Skip to content

Commit c270319

Browse files
mrm9084Copilotavanigupta
authored
App Configuration - Snapshot references (#44116)
* basic snapshot references * async + testsing * fixed request tracing * Update test_request_tracing_context.py * Update test_snapshot_references.py * Update test_snapshot_references_integration.py * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test_async_snapshot_references_integration.py * Update test_request_tracing_context.py * Update test_request_tracing_context.py * review comment * Apply suggestions from code review Co-authored-by: Avani Gupta <avanigupta@users.noreply.github.com> * code review changes * Update _snapshot_reference_parser.py * Update _snapshot_reference_parser.py * review comments * rename settings to setting * Moving SNAPSHOT_REFERENCE_TAG * fixing tests * Update assets.json * Update assets * Updating assets and fixing sample * Update assets.json * fixing test * Update README.md * Review comments * format updates * review comment * Update assets.json * Update assets.json * Update assets.json * review comments * review comments * review comments * Adding 404 check * adding 404 snapshot tests * try change * review comments * format fix * fixing text --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Avani Gupta <avanigupta@users.noreply.github.com>
1 parent f605066 commit c270319

19 files changed

Lines changed: 1323 additions & 174 deletions

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_25357bbd75"
5+
"Tag": "python/appconfiguration/azure-appconfiguration-provider_847d5f6456"
66
}

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

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ._constants import (
2828
FEATURE_MANAGEMENT_KEY,
2929
FEATURE_FLAG_KEY,
30+
SNAPSHOT_REF_CONTENT_TYPE,
3031
)
3132
from ._azureappconfigurationproviderbase import (
3233
AzureAppConfigurationProviderBase,
@@ -299,7 +300,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f
299300

300301
if settings_refreshed:
301302
# Configuration Settings have been refreshed
302-
processed_settings = self._process_configurations(configuration_settings)
303+
processed_settings = self._process_configurations(configuration_settings, client)
303304

304305
processed_settings = self._process_feature_flags(processed_settings, processed_feature_flags, feature_flags)
305306
self._dict = processed_settings
@@ -383,7 +384,7 @@ def _load_all(self, **kwargs: Any) -> None:
383384
try:
384385
configuration_settings = client.load_configuration_settings(self._selects, headers=headers, **kwargs)
385386
watched_settings = self._update_watched_settings(configuration_settings)
386-
processed_settings = self._process_configurations(configuration_settings)
387+
processed_settings = self._process_configurations(configuration_settings, client)
387388

388389
if self._feature_flag_enabled:
389390
feature_flags: List[FeatureFlagConfigurationSetting] = client.load_feature_flags(
@@ -422,27 +423,69 @@ def _load_all(self, **kwargs: Any) -> None:
422423
is_failover_request = True
423424
raise exception
424425

425-
def _process_configurations(self, configuration_settings: List[ConfigurationSetting]) -> Dict[str, Any]:
426+
def _expand_snapshot_references(
427+
self, configuration_settings: List[ConfigurationSetting], client: ConfigurationClient
428+
) -> List[ConfigurationSetting]:
429+
"""
430+
Expands snapshot references in configuration settings to their actual settings.
431+
432+
:param configuration_settings: List of configuration settings that may contain snapshot references.
433+
:type configuration_settings: List[~azure.appconfiguration.ConfigurationSetting]
434+
:param client: The configuration client used to resolve snapshot references.
435+
:type client: ~azure.appconfiguration.provider.ConfigurationClient
436+
:return: List of configuration settings with snapshot references expanded.
437+
:rtype: List[~azure.appconfiguration.ConfigurationSetting]
438+
"""
439+
expanded_settings: List[ConfigurationSetting] = []
440+
441+
for setting in configuration_settings:
442+
if SNAPSHOT_REF_CONTENT_TYPE == setting.content_type:
443+
# Check if this is a snapshot reference
444+
445+
# Track snapshot reference usage for telemetry
446+
self._tracing_context.uses_snapshot_reference = True
447+
try:
448+
# Resolve the snapshot reference to actual settings
449+
expanded_settings.extend(client.resolve_snapshot_reference(setting))
450+
except AzureError as e:
451+
logger.warning(
452+
"Failed to resolve snapshot reference for key '%s' (label: '%s'): %s",
453+
setting.key,
454+
setting.label,
455+
str(e),
456+
)
457+
raise e
458+
else:
459+
expanded_settings.append(setting)
460+
461+
return expanded_settings
462+
463+
def _process_configurations(
464+
self, configuration_settings: List[ConfigurationSetting], client: ConfigurationClient
465+
) -> Dict[str, Any]:
466+
# Snapshot references must be expanded in place to support override by key ordering.
467+
expanded_settings = self._expand_snapshot_references(configuration_settings, client)
468+
426469
# configuration_settings can contain duplicate keys, but they are in priority order, i.e. later settings take
427470
# precedence. Only process the settings with the highest priority (i.e. the last one in the list).
428-
unique_settings = self._deduplicate_settings(configuration_settings)
471+
unique_settings = self._deduplicate_settings(expanded_settings)
429472

430473
configuration_settings_processed = {}
431474
feature_flags_processed = []
432-
for settings in unique_settings.values():
475+
for setting in unique_settings:
433476
if self._configuration_mapper:
434477
# If a map function is provided, use it to process the configuration setting
435-
self._configuration_mapper(settings)
436-
if isinstance(settings, FeatureFlagConfigurationSetting):
478+
self._configuration_mapper(setting)
479+
if isinstance(setting, FeatureFlagConfigurationSetting):
437480
# Feature flags are not processed like other settings
438-
feature_flag_value = self._process_feature_flag(settings)
481+
feature_flag_value = self._process_feature_flag(setting)
439482
feature_flags_processed.append(feature_flag_value)
440483

441484
if self._feature_flag_refresh_enabled:
442-
self._watched_feature_flags[(settings.key, settings.label)] = settings.etag
485+
self._watched_feature_flags[(setting.key, setting.label)] = setting.etag
443486
else:
444-
key = self._process_key_name(settings)
445-
value = self._process_key_value(settings)
487+
key = self._process_key_name(setting)
488+
value = self._process_key_value(setting)
446489
configuration_settings_processed[key] = value
447490
return configuration_settings_processed
448491

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -494,10 +494,14 @@ def _process_feature_flags(
494494
return processed_settings
495495

496496
def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) -> Dict[str, Any]:
497-
feature_flag_value = json.loads(feature_flag.value)
498-
self._update_ff_telemetry_metadata(self._origin_endpoint, feature_flag, feature_flag_value)
499-
self._tracing_context.update_feature_filter_telemetry(feature_flag)
500-
return feature_flag_value
497+
try:
498+
feature_flag_value = json.loads(feature_flag.value)
499+
self._update_ff_telemetry_metadata(self._origin_endpoint, feature_flag, feature_flag_value)
500+
self._tracing_context.update_feature_filter_telemetry(feature_flag)
501+
return feature_flag_value
502+
except json.JSONDecodeError:
503+
# Feature flag value is not a valid JSON
504+
return {}
501505

502506
def _update_watched_settings(
503507
self, configuration_settings: List[ConfigurationSetting]
@@ -561,17 +565,15 @@ def _update_correlation_context_header(
561565
is_failover_request=is_failover_request,
562566
)
563567

564-
def _deduplicate_settings(
565-
self, configuration_settings: List[ConfigurationSetting]
566-
) -> Dict[str, ConfigurationSetting]:
568+
def _deduplicate_settings(self, configuration_settings: List[ConfigurationSetting]) -> List[ConfigurationSetting]:
567569
"""
568570
Deduplicates configuration settings by key.
569571
570572
:param List[ConfigurationSetting] configuration_settings: The list of configuration settings to deduplicate
571-
:return: A dictionary mapping keys to their unique configuration settings
572-
:rtype: Dict[str, ConfigurationSetting]
573+
:return: A list of unique configuration settings
574+
:rtype: List[ConfigurationSetting]
573575
"""
574576
unique_settings: Dict[str, ConfigurationSetting] = {}
575577
for settings in configuration_settings:
576578
unique_settings[settings.key] = settings
577-
return unique_settings
579+
return list(unique_settings.values())

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

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from ._models import SettingSelector
2929
from ._constants import FEATURE_FLAG_PREFIX
3030
from ._discovery import find_auto_failover_endpoints
31+
from ._snapshot_reference_parser import SnapshotReferenceParser
32+
from ._constants import SNAPSHOT_REF_CONTENT_TYPE
3133

3234

3335
@dataclass
@@ -135,14 +137,13 @@ def _check_configuration_setting(
135137

136138
@distributed_trace
137139
def load_configuration_settings(self, selects: List[SettingSelector], **kwargs) -> List[ConfigurationSetting]:
138-
configuration_settings = []
140+
configuration_settings: List[ConfigurationSetting] = []
139141
for select in selects:
140-
configurations = []
142+
configurations: List[ConfigurationSetting] = []
141143
if select.snapshot_name is not None:
142144
# When loading from a snapshot, ignore key_filter, label_filter, and tag_filters
143-
snapshot = self._client.get_snapshot(select.snapshot_name)
144-
if snapshot.composition_type != SnapshotComposition.KEY:
145-
raise ValueError(f"Snapshot '{select.snapshot_name}' is not a key snapshot.")
145+
if not self._validate_snapshot(select.snapshot_name):
146+
return []
146147
configurations = self._client.list_configuration_settings(snapshot_name=select.snapshot_name, **kwargs)
147148
else:
148149
# Use traditional filtering when not loading from a snapshot
@@ -152,11 +153,10 @@ def load_configuration_settings(self, selects: List[SettingSelector], **kwargs)
152153
tags_filter=select.tag_filters,
153154
**kwargs,
154155
)
155-
for config in configurations:
156-
if not isinstance(config, FeatureFlagConfigurationSetting):
157-
# Feature flags are ignored when loaded by Selects, as they are selected from
158-
# `feature_flag_selectors`
159-
configuration_settings.append(config)
156+
# Feature flags are ignored when loaded by Selects, as they are selected from `feature_flag_selectors`
157+
configuration_settings.extend(
158+
config for config in configurations if not isinstance(config, FeatureFlagConfigurationSetting)
159+
)
160160
return configuration_settings
161161

162162
@distributed_trace
@@ -170,9 +170,8 @@ def load_feature_flags(
170170
feature_flags = []
171171
if select.snapshot_name is not None:
172172
# When loading from a snapshot, ignore key_filter, label_filter, and tag_filters
173-
snapshot = self._client.get_snapshot(select.snapshot_name)
174-
if snapshot.composition_type != SnapshotComposition.KEY:
175-
raise ValueError(f"Composition type for '{select.snapshot_name}' must be 'key'.")
173+
if not self._validate_snapshot(select.snapshot_name):
174+
return []
176175
feature_flags = self._client.list_configuration_settings(snapshot_name=select.snapshot_name, **kwargs)
177176
else:
178177
# Handle None key_filter by converting to empty string
@@ -248,6 +247,30 @@ def get_configuration_setting(self, key: str, label: str, **kwargs) -> Optional[
248247
"""
249248
return self._client.get_configuration_setting(key=key, label=label, **kwargs)
250249

250+
def _validate_snapshot(self, snapshot_name: str) -> bool:
251+
"""Gets and validates a snapshot by name.
252+
253+
Returns True if the snapshot is found and has a valid composition type,
254+
or False if the snapshot does not exist (404).
255+
256+
:param str snapshot_name: The name of the snapshot to retrieve
257+
:return: True if the snapshot is valid, False if not found
258+
:rtype: bool
259+
:raises HttpResponseError: If the error is not a 404
260+
:raises ValueError: If the snapshot composition type is not 'key'
261+
"""
262+
snapshot = None
263+
try:
264+
snapshot = self._client.get_snapshot(snapshot_name)
265+
except HttpResponseError as e:
266+
if e.status_code == 404:
267+
self.LOGGER.warning("Snapshot '%s' not found when resolving snapshot.", snapshot_name)
268+
return False
269+
raise e
270+
if snapshot.composition_type != SnapshotComposition.KEY:
271+
raise ValueError(f"Composition type for '{snapshot_name}' must be 'key'.")
272+
return True
273+
251274
def is_active(self) -> bool:
252275
"""
253276
Checks if the client is active and can be used.
@@ -270,6 +293,31 @@ def __enter__(self):
270293
def __exit__(self, *args):
271294
self._client.__exit__(*args)
272295

296+
def resolve_snapshot_reference(self, setting: ConfigurationSetting, **kwargs) -> List[ConfigurationSetting]:
297+
"""
298+
Resolve a snapshot reference configuration setting to the actual snapshot data.
299+
300+
:param ConfigurationSetting setting: The snapshot reference configuration setting
301+
:return: A list of resolved configuration settings from the snapshot
302+
:rtype: List[ConfigurationSetting]
303+
:raises ValueError: When the setting is not a valid snapshot reference
304+
"""
305+
if SNAPSHOT_REF_CONTENT_TYPE != setting.content_type:
306+
raise ValueError("Setting is not a snapshot reference")
307+
308+
# Parse the snapshot reference
309+
snapshot_name = SnapshotReferenceParser.parse(setting)
310+
if not self._validate_snapshot(snapshot_name):
311+
return []
312+
313+
# Create a selector for the snapshot
314+
snapshot_selector = SettingSelector(snapshot_name=snapshot_name)
315+
316+
# Use existing load_configuration_settings to load from snapshot
317+
configurations = self.load_configuration_settings([snapshot_selector], **kwargs)
318+
319+
return configurations
320+
273321

274322
class ConfigurationClientManager(ConfigurationClientManagerBase): # pylint:disable=too-many-instance-attributes
275323
def __init__(

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

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,47 @@
44
# license information.
55
# -------------------------------------------------------------------------
66

7+
8+
# ------------------------------------------------------------------------
9+
# Feature Management Constants
10+
# ------------------------------------------------------------------------
711
FEATURE_MANAGEMENT_KEY = "feature_management"
812
FEATURE_FLAG_KEY = "feature_flags"
913
FEATURE_FLAG_PREFIX = ".appconfig.featureflag/"
14+
FEATURE_FLAG_REFERENCE_KEY = "FeatureFlagReference"
15+
ALLOCATION_ID_KEY = "AllocationId"
16+
ETAG_KEY = "ETag"
1017

11-
NULL_CHAR = "\0"
12-
18+
# ------------------------------------------------------------------------
19+
# Environment Variable Constants
20+
# ------------------------------------------------------------------------
1321
REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE = "AZURE_APP_CONFIGURATION_TRACING_DISABLED"
1422
AzureFunctionEnvironmentVariable = "FUNCTIONS_EXTENSION_VERSION"
1523
AzureWebAppEnvironmentVariable = "WEBSITE_SITE_NAME"
1624
ContainerAppEnvironmentVariable = "CONTAINER_APP_NAME"
1725
KubernetesEnvironmentVariable = "KUBERNETES_PORT"
1826
ServiceFabricEnvironmentVariable = "Fabric_NodeName" # cspell:disable-line
1927

28+
# ------------------------------------------------------------------------
29+
# Telemetry and Tracing Constants
30+
# ------------------------------------------------------------------------
2031
TELEMETRY_KEY = "telemetry"
2132
METADATA_KEY = "metadata"
33+
SNAPSHOT_REFERENCE_TAG = "SnapshotRef"
2234

23-
ALLOCATION_ID_KEY = "AllocationId"
24-
ETAG_KEY = "ETag"
25-
FEATURE_FLAG_REFERENCE_KEY = "FeatureFlagReference"
26-
27-
# Mime profiles
35+
# ------------------------------------------------------------------------
36+
# Content Type and Mime Profile Constants
37+
# ------------------------------------------------------------------------
2838
APP_CONFIG_AI_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai/"
2939
APP_CONFIG_AICC_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai/chat-completion"
40+
SNAPSHOT_REF_CONTENT_TYPE = 'application/json; profile="https://azconfig.io/mime-profiles/snapshot-ref"; charset=utf-8'
41+
42+
# ------------------------------------------------------------------------
43+
# Snapshot Reference Constants
44+
# ------------------------------------------------------------------------
45+
SNAPSHOT_NAME_FIELD = "snapshot_name"
46+
47+
# ------------------------------------------------------------------------
48+
# Miscellaneous Constants
49+
# ------------------------------------------------------------------------
50+
NULL_CHAR = "\0"

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
LOAD_BALANCING_FEATURE = "LB"
4040
AI_CONFIGURATION_FEATURE = "AI"
4141
AI_CHAT_COMPLETION_FEATURE = "AICC"
42+
SNAPSHOT_REFERENCE_TAG = "SnapshotRef"
4243

4344
# Correlation context constants
4445
FEATUREMANAGEMENT_PACKAGE = "featuremanagement"
@@ -82,6 +83,7 @@ def __init__(self, load_balancing_enabled: bool = False) -> None:
8283
self.uses_load_balancing = load_balancing_enabled
8384
self.uses_ai_configuration = False
8485
self.uses_aicc_configuration = False # AI Chat Completion
86+
self.uses_snapshot_reference = False
8587
self.uses_telemetry = False
8688
self.uses_seed = False
8789
self.max_variants: Optional[int] = None
@@ -270,6 +272,8 @@ def _create_features_string(self) -> str:
270272
features_list.append(AI_CONFIGURATION_FEATURE)
271273
if self.uses_aicc_configuration:
272274
features_list.append(AI_CHAT_COMPLETION_FEATURE)
275+
if self.uses_snapshot_reference:
276+
features_list.append(SNAPSHOT_REFERENCE_TAG)
273277

274278
return Delimiter.join(features_list)
275279

0 commit comments

Comments
 (0)