Skip to content

Commit 2227417

Browse files
mrm9084Copilotrossgrambo
authored
App Config Provider - Load Order Change (#42905)
* Changed to load all then process * Moving async tests to aio folder * updated with refresh * fixing type hint * Update sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refresh re-work * Updating kv processing * reducing code reuse * small updates * Update assets.json * Adding more tests and fixing validation * Copilot review comments * Update _azureappconfigurationproviderbase.py * fixing backoff test * review comments * Review change + rename vars * Simplifying Change * simplifying change * formatting fix * Apply suggestions from code review Co-authored-by: Ross Grambo <rossgrambo@microsoft.com> * review comments * rename _update_feature_filter_telemetry * review comments * split checking watch keys from refresh pull * Moving sentinel key processing to it's own method * Updated to a single return * updated method name * review comments * fixed formatting * fixed filter usage reset * updating imports * Updated Watch name * comments * more renaming * Update _async_client_manager.py * more renaming * copilot suggestions * Update _azureappconfigurationproviderbase.py --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ross Grambo <rossgrambo@microsoft.com>
1 parent 09e64c5 commit 2227417

File tree

9 files changed

+1228
-561
lines changed

9 files changed

+1228
-561
lines changed

sdk/appconfiguration/azure-appconfiguration-provider/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "python",
44
"TagPrefix": "python/appconfiguration/azure-appconfiguration-provider",
5-
"Tag": "python/appconfiguration/azure-appconfiguration-provider_b91db477af"
5+
"Tag": "python/appconfiguration/azure-appconfiguration-provider_8a72ac47e0"
66
}

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py

Lines changed: 136 additions & 114 deletions
Large diffs are not rendered by default.

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py

Lines changed: 233 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
# Licensed under the MIT License. See License.txt in the project root for
44
# license information.
55
# -------------------------------------------------------------------------
6+
import base64
7+
import hashlib
8+
import json
69
import os
710
import random
811
import time
@@ -25,6 +28,10 @@
2528
ValuesView,
2629
TypeVar,
2730
)
31+
from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
32+
ConfigurationSetting,
33+
FeatureFlagConfigurationSetting,
34+
)
2835
from ._models import SettingSelector
2936
from ._constants import (
3037
REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE,
@@ -38,6 +45,16 @@
3845
PERCENTAGE_FILTER_KEY,
3946
TIME_WINDOW_FILTER_KEY,
4047
TARGETING_FILTER_KEY,
48+
PERCENTAGE_FILTER_NAMES,
49+
TIME_WINDOW_FILTER_NAMES,
50+
TARGETING_FILTER_NAMES,
51+
TELEMETRY_KEY,
52+
METADATA_KEY,
53+
ETAG_KEY,
54+
FEATURE_FLAG_REFERENCE_KEY,
55+
ALLOCATION_ID_KEY,
56+
APP_CONFIG_AI_MIME_PROFILE,
57+
APP_CONFIG_AICC_MIME_PROFILE,
4158
)
4259

4360
JSON = Mapping[str, Any]
@@ -48,8 +65,13 @@
4865

4966

5067
def delay_failure(start_time: datetime.datetime) -> None:
51-
# We want to make sure we are up a minimum amount of time before we kill the process. Otherwise, we could get stuck
52-
# in a quick restart loop.
68+
"""
69+
We want to make sure we are up a minimum amount of time before we kill the process.
70+
Otherwise, we could get stuck in a quick restart loop.
71+
72+
:param start_time: The time when the process started.
73+
:type start_time: datetime.datetime
74+
"""
5375
min_time = datetime.timedelta(seconds=min_uptime)
5476
current_time = datetime.datetime.now()
5577
if current_time - start_time < min_time:
@@ -161,11 +183,11 @@ def is_json_content_type(content_type: str) -> bool:
161183
return False
162184

163185

164-
def _build_sentinel(setting: Union[str, Tuple[str, str]]) -> Tuple[str, str]:
186+
def _build_watched_setting(setting: Union[str, Tuple[str, str]]) -> Tuple[str, str]:
165187
try:
166188
key, label = setting # type:ignore
167-
except IndexError:
168-
key = setting
189+
except (IndexError, ValueError):
190+
key = str(setting) # Ensure key is a string
169191
label = NULL_CHAR
170192
if "*" in key or "*" in label:
171193
raise ValueError("Wildcard key or label filters are not supported for refresh.")
@@ -260,7 +282,7 @@ class AzureAppConfigurationProviderBase(Mapping[str, Union[str, JSON]]): # pyli
260282
"""
261283

262284
def __init__(self, **kwargs: Any) -> None:
263-
self._origin_endpoint = kwargs.get("endpoint", None)
285+
self._origin_endpoint: str = kwargs.get("endpoint", "")
264286
self._dict: Dict[str, Any] = {}
265287
self._selects: List[SettingSelector] = kwargs.pop(
266288
"selects", [SettingSelector(key_filter="*", label_filter=NULL_CHAR)]
@@ -270,7 +292,9 @@ def __init__(self, **kwargs: Any) -> None:
270292
self._trim_prefixes: List[str] = sorted(trim_prefixes, key=len, reverse=True)
271293

272294
refresh_on: List[Tuple[str, str]] = kwargs.pop("refresh_on", None) or []
273-
self._refresh_on: Mapping[Tuple[str, str], Optional[str]] = {_build_sentinel(s): None for s in refresh_on}
295+
self._watched_settings: Dict[Tuple[str, str], Optional[str]] = {
296+
_build_watched_setting(s): None for s in refresh_on
297+
}
274298
self._refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs)
275299
self._keyvault_credential = kwargs.pop("keyvault_credential", None)
276300
self._secret_resolver = kwargs.pop("secret_resolver", None)
@@ -282,10 +306,10 @@ def __init__(self, **kwargs: Any) -> None:
282306
)
283307
self._feature_flag_enabled = kwargs.pop("feature_flag_enabled", False)
284308
self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", [SettingSelector(key_filter="*")])
285-
self._refresh_on_feature_flags: Mapping[Tuple[str, str], Optional[str]] = {}
309+
self._watched_feature_flags: Dict[Tuple[str, str], Optional[str]] = {}
286310
self._feature_flag_refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs)
287311
self._feature_flag_refresh_enabled = kwargs.pop("feature_flag_refresh_enabled", False)
288-
self._feature_filter_usage: Mapping[str, bool] = {}
312+
self._feature_filter_usage: Dict[str, bool] = {}
289313
self._uses_load_balancing = kwargs.pop("load_balancing_enabled", False)
290314
self._uses_ai_configuration = False
291315
self._uses_aicc_configuration = False # AI Chat Completion
@@ -301,6 +325,136 @@ def _process_key_name(self, config):
301325
break
302326
return trimmed_key
303327

328+
def _update_ff_telemetry_metadata(
329+
self, endpoint: str, feature_flag: FeatureFlagConfigurationSetting, feature_flag_value: Dict
330+
):
331+
"""
332+
Add telemetry metadata to feature flag values.
333+
334+
:param endpoint: The App Configuration endpoint URL.
335+
:type endpoint: str
336+
:param feature_flag: The feature flag configuration setting.
337+
:type feature_flag: FeatureFlagConfigurationSetting
338+
:param feature_flag_value: The feature flag value dictionary to update.
339+
:type feature_flag_value: Dict[str, Any]
340+
"""
341+
if TELEMETRY_KEY in feature_flag_value:
342+
if METADATA_KEY not in feature_flag_value[TELEMETRY_KEY]:
343+
feature_flag_value[TELEMETRY_KEY][METADATA_KEY] = {}
344+
feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ETAG_KEY] = feature_flag.etag
345+
346+
if not endpoint.endswith("/"):
347+
endpoint += "/"
348+
feature_flag_reference = f"{endpoint}kv/{feature_flag.key}"
349+
if feature_flag.label and not feature_flag.label.isspace():
350+
feature_flag_reference += f"?label={feature_flag.label}"
351+
if feature_flag_value[TELEMETRY_KEY].get("enabled"):
352+
feature_flag_value[TELEMETRY_KEY][METADATA_KEY][FEATURE_FLAG_REFERENCE_KEY] = feature_flag_reference
353+
allocation_id = self._generate_allocation_id(feature_flag_value)
354+
if allocation_id:
355+
feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ALLOCATION_ID_KEY] = allocation_id
356+
357+
def _update_feature_filter_telemetry(self, feature_flag: FeatureFlagConfigurationSetting):
358+
"""
359+
Track feature filter usage for App Configuration telemetry.
360+
361+
:param feature_flag: The feature flag to analyze for filter usage.
362+
:type feature_flag: FeatureFlagConfigurationSetting
363+
"""
364+
if feature_flag.filters:
365+
for filter in feature_flag.filters:
366+
if filter.get("name") in PERCENTAGE_FILTER_NAMES:
367+
self._feature_filter_usage[PERCENTAGE_FILTER_KEY] = True
368+
elif filter.get("name") in TIME_WINDOW_FILTER_NAMES:
369+
self._feature_filter_usage[TIME_WINDOW_FILTER_KEY] = True
370+
elif filter.get("name") in TARGETING_FILTER_NAMES:
371+
self._feature_filter_usage[TARGETING_FILTER_KEY] = True
372+
else:
373+
self._feature_filter_usage[CUSTOM_FILTER_KEY] = True
374+
375+
@staticmethod
376+
def _generate_allocation_id(feature_flag_value: Dict[str, JSON]) -> Optional[str]:
377+
"""
378+
Generates an allocation ID for the specified feature.
379+
seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Control,20;20,Test,100\nvariants=Control,standard;Test,special # pylint:disable=line-too-long
380+
381+
:param Dict[str, JSON] feature_flag_value: The feature to generate an allocation ID for.
382+
:rtype: str
383+
:return: The allocation ID.
384+
"""
385+
386+
allocation_id = ""
387+
allocated_variants = []
388+
389+
allocation: Optional[JSON] = feature_flag_value.get("allocation")
390+
391+
if not allocation:
392+
return None
393+
394+
# Seed
395+
allocation_id = f"seed={allocation.get('seed', '')}"
396+
397+
# DefaultWhenEnabled
398+
if "default_when_enabled" in allocation:
399+
allocated_variants.append(allocation.get("default_when_enabled"))
400+
401+
allocation_id += f"\ndefault_when_enabled={allocation.get('default_when_enabled', '')}"
402+
403+
# Percentile
404+
allocation_id += "\npercentiles="
405+
406+
percentile = allocation.get("percentile")
407+
408+
if percentile:
409+
percentile_allocations = sorted(
410+
(x for x in percentile if x.get("from") != x.get("to")),
411+
key=lambda x: x.get("from"),
412+
)
413+
414+
for percentile_allocation in percentile_allocations:
415+
if "variant" in percentile_allocation:
416+
allocated_variants.append(percentile_allocation.get("variant"))
417+
418+
allocation_id += ";".join(
419+
f"{pa.get('from')}," f"{base64.b64encode(pa.get('variant').encode()).decode()}," f"{pa.get('to')}"
420+
for pa in percentile_allocations
421+
)
422+
423+
if not allocated_variants and not allocation.get("seed"):
424+
return None
425+
426+
# Variants
427+
allocation_id += "\nvariants="
428+
429+
variants_value = feature_flag_value.get("variants")
430+
if variants_value and (isinstance(variants_value, list) or all(isinstance(v, dict) for v in variants_value)):
431+
if (
432+
allocated_variants
433+
and isinstance(variants_value, list)
434+
and all(isinstance(v, dict) for v in variants_value)
435+
):
436+
sorted_variants: List[Dict[str, Any]] = sorted(
437+
(v for v in variants_value if v.get("name") in allocated_variants),
438+
key=lambda v: v.get("name"),
439+
)
440+
441+
for v in sorted_variants:
442+
allocation_id += f"{base64.b64encode(v.get('name', '').encode()).decode()},"
443+
if "configuration_value" in v:
444+
allocation_id += (
445+
f"{json.dumps(v.get('configuration_value', ''), separators=(',', ':'), sort_keys=True)}"
446+
)
447+
allocation_id += ";"
448+
if sorted_variants:
449+
allocation_id = allocation_id[:-1]
450+
451+
# Create a sha256 hash of the allocation_id
452+
hash_object = hashlib.sha256(allocation_id.encode())
453+
hash_digest = hash_object.digest()
454+
455+
# Encode the first 15 bytes in base64 url
456+
return base64.urlsafe_b64encode(hash_digest[:15]).decode()
457+
304458
def __getitem__(self, key: str) -> Any:
305459
# pylint:disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype
306460
"""
@@ -366,14 +520,81 @@ def get(self, key: str, default: Optional[Union[str, JSON, _T]] = None) -> Union
366520
"""
367521
Returns the value of the specified key. If the key does not exist, returns the default value.
368522
369-
:param str key: The key of the value to get.
523+
:param key: The key of the value to get.
524+
:type key: str
370525
:param default: The default value to return.
371-
:type: str or None
526+
:type default: Optional[Union[str, JSON, _T]]
372527
:return: The value of the specified key.
373-
:rtype: Union[str, JSON]
528+
:rtype: Union[str, JSON, _T, None]
374529
"""
375530
with self._update_lock:
376531
return self._dict.get(key, default)
377532

378533
def __ne__(self, other: Any) -> bool:
379534
return not self == other
535+
536+
def _process_key_value_base(self, config: ConfigurationSetting) -> Union[str, Dict[str, Any]]:
537+
"""
538+
Process configuration values that are not KeyVault references. If the content type is None, the value is
539+
returned as-is.
540+
541+
:param config: The configuration setting to process.
542+
:type config: ConfigurationSetting
543+
:return: The processed configuration value (JSON object if JSON content type, string otherwise).
544+
:rtype: Union[str, Dict[str, Any]]
545+
"""
546+
if config.content_type is None:
547+
return config.value
548+
if is_json_content_type(config.content_type) and not isinstance(config, FeatureFlagConfigurationSetting):
549+
# Feature flags are of type json, but don't treat them as such
550+
try:
551+
if APP_CONFIG_AI_MIME_PROFILE in config.content_type:
552+
self._uses_ai_configuration = True
553+
if APP_CONFIG_AICC_MIME_PROFILE in config.content_type:
554+
self._uses_aicc_configuration = True
555+
return json.loads(config.value)
556+
except json.JSONDecodeError:
557+
try:
558+
# If the value is not a valid JSON, check if it has comments and remove them
559+
from ._json import remove_json_comments
560+
561+
return json.loads(remove_json_comments(config.value))
562+
except (json.JSONDecodeError, ValueError):
563+
# If the value is not a valid JSON, treat it like regular string value
564+
return config.value
565+
return config.value
566+
567+
def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) -> Dict[str, Any]:
568+
feature_flag_value = json.loads(feature_flag.value)
569+
self._update_ff_telemetry_metadata(self._origin_endpoint, feature_flag, feature_flag_value)
570+
self._update_feature_filter_telemetry(feature_flag)
571+
return feature_flag_value
572+
573+
def _update_watched_settings(
574+
self, configuration_settings: List[ConfigurationSetting]
575+
) -> Dict[Tuple[str, str], Optional[str]]:
576+
"""
577+
Updates the etags of watched settings that are part of the configuration
578+
:param List[ConfigurationSetting] configuration_settings: The list of configuration settings to update
579+
:return: A dictionary mapping (key, label) tuples to their updated etags
580+
:rtype: Dict[Tuple[str, str], Optional[str]]
581+
"""
582+
watched_settings: Dict[Tuple[str, str], Optional[str]] = {}
583+
for config in configuration_settings:
584+
if (config.key, config.label) in self._watched_settings:
585+
watched_settings[(config.key, config.label)] = config.etag
586+
return watched_settings
587+
588+
def _update_watched_feature_flags(
589+
self, feature_flags: List[FeatureFlagConfigurationSetting]
590+
) -> Dict[Tuple[str, str], Optional[str]]:
591+
"""
592+
Updates the etags of watched feature flags that are part of the configuration
593+
:param List[FeatureFlagConfigurationSetting] feature_flags: The list of feature flags to update
594+
:return: A dictionary mapping (key, label) tuples to their updated etags
595+
:rtype: Dict[Tuple[str, str], Optional[str]]
596+
"""
597+
watched_feature_flags: Dict[Tuple[str, str], Optional[str]] = {}
598+
for feature_flag in feature_flags:
599+
watched_feature_flags[(feature_flag.key, feature_flag.label)] = feature_flag.etag
600+
return watched_feature_flags

0 commit comments

Comments
 (0)