Skip to content

Commit 62e9e46

Browse files
mrm9084Copilot
andauthored
Snapshots python - Azure App Configuration Provider (#43690)
* Adding Snapshots * fixing formatting * Snapshot fixes + tests * attempting to fix tests * Update test_snapshots.py * fixing recording and async tests * Update assets.json * format fixes * fix live issue * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update snapshot_sample.py * fix sample and test --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent e9e0c48 commit 62e9e46

File tree

10 files changed

+841
-32
lines changed

10 files changed

+841
-32
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_ccc89e9eaa"
5+
"Tag": "python/appconfiguration/azure-appconfiguration-provider_18bb5889fc"
66
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,16 @@ def process_load_parameters(*args, **kwargs: Any) -> Dict[str, Any]:
127127
if kwargs.get("keyvault_credential") is not None and kwargs.get("secret_resolver") is not None:
128128
raise ValueError("A keyvault credential and secret resolver can't both be configured.")
129129

130+
# Validate feature flag selectors don't use snapshots
131+
feature_flag_selectors = kwargs.get("feature_flag_selectors")
132+
if feature_flag_selectors:
133+
for selector in feature_flag_selectors:
134+
if hasattr(selector, "snapshot_name") and selector.snapshot_name is not None:
135+
raise ValueError(
136+
"snapshot_name cannot be used with feature_flag_selectors. "
137+
"Use snapshot_name with regular selects instead to load feature flags from snapshots."
138+
)
139+
130140
# Determine Key Vault usage
131141
uses_key_vault = (
132142
"keyvault_credential" in kwargs

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

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
ConfigurationSetting,
1818
AzureAppConfigurationClient,
1919
FeatureFlagConfigurationSetting,
20+
SnapshotComposition,
2021
)
2122
from ._client_manager_base import (
2223
_ConfigurationClientWrapperBase,
@@ -44,7 +45,7 @@ def from_credential(
4445
user_agent: str,
4546
retry_total: int,
4647
retry_backoff_max: int,
47-
**kwargs
48+
**kwargs,
4849
) -> Self:
4950
"""
5051
Creates a new instance of the _ConfigurationClientWrapper class, using the provided credential to authenticate
@@ -66,7 +67,7 @@ def from_credential(
6667
user_agent=user_agent,
6768
retry_total=retry_total,
6869
retry_backoff_max=retry_backoff_max,
69-
**kwargs
70+
**kwargs,
7071
),
7172
)
7273

@@ -93,7 +94,7 @@ def from_connection_string(
9394
user_agent=user_agent,
9495
retry_total=retry_total,
9596
retry_backoff_max=retry_backoff_max,
96-
**kwargs
97+
**kwargs,
9798
),
9899
)
99100

@@ -136,9 +137,20 @@ def _check_configuration_setting(
136137
def load_configuration_settings(self, selects: List[SettingSelector], **kwargs) -> List[ConfigurationSetting]:
137138
configuration_settings = []
138139
for select in selects:
139-
configurations = self._client.list_configuration_settings(
140-
key_filter=select.key_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, **kwargs
141-
)
140+
if select.snapshot_name is not None:
141+
# When loading from a snapshot, ignore key_filter, label_filter, and tag_filters
142+
snapshot = self._client.get_snapshot(select.snapshot_name)
143+
if snapshot.composition_type != SnapshotComposition.KEY:
144+
raise ValueError(f"Snapshot '{select.snapshot_name}' is not a key snapshot.")
145+
configurations = self._client.list_configuration_settings(snapshot_name=select.snapshot_name, **kwargs)
146+
else:
147+
# Use traditional filtering when not loading from a snapshot
148+
configurations = self._client.list_configuration_settings(
149+
key_filter=select.key_filter,
150+
label_filter=select.label_filter,
151+
tags_filter=select.tag_filters,
152+
**kwargs,
153+
)
142154
for config in configurations:
143155
if not isinstance(config, FeatureFlagConfigurationSetting):
144156
# Feature flags are ignored when loaded by Selects, as they are selected from
@@ -154,11 +166,13 @@ def load_feature_flags(
154166
# Needs to be removed unknown keyword argument for list_configuration_settings
155167
kwargs.pop("sentinel_keys", None)
156168
for select in feature_flag_selectors:
169+
# Handle None key_filter by converting to empty string
170+
key_filter = select.key_filter if select.key_filter is not None else ""
157171
feature_flags = self._client.list_configuration_settings(
158-
key_filter=FEATURE_FLAG_PREFIX + select.key_filter,
172+
key_filter=FEATURE_FLAG_PREFIX + key_filter,
159173
label_filter=select.label_filter,
160174
tags_filter=select.tag_filters,
161-
**kwargs
175+
**kwargs,
162176
)
163177
for feature_flag in feature_flags:
164178
if not isinstance(feature_flag, FeatureFlagConfigurationSetting):
@@ -261,7 +275,7 @@ def __init__(
261275
min_backoff_sec,
262276
max_backoff_sec,
263277
load_balancing_enabled,
264-
**kwargs
278+
**kwargs,
265279
):
266280
super(ConfigurationClientManager, self).__init__(
267281
endpoint,
@@ -272,7 +286,7 @@ def __init__(
272286
min_backoff_sec,
273287
max_backoff_sec,
274288
load_balancing_enabled,
275-
**kwargs
289+
**kwargs,
276290
)
277291
self._original_connection_string = connection_string
278292
self._credential = credential
@@ -367,7 +381,7 @@ def refresh_clients(self):
367381
self._user_agent,
368382
self._retry_total,
369383
self._retry_backoff_max,
370-
**self._args
384+
**self._args,
371385
)
372386
)
373387
elif self._credential:
@@ -378,7 +392,7 @@ def refresh_clients(self):
378392
self._user_agent,
379393
self._retry_total,
380394
self._retry_backoff_max,
381-
**self._args
395+
**self._args,
382396
)
383397
)
384398
self._next_update_time = time.time() + MINIMAL_CLIENT_REFRESH_INTERVAL

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

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
from typing import Optional, Callable, TYPE_CHECKING, Union, Awaitable, Mapping, Any, NamedTuple, List
77
from ._constants import NULL_CHAR
88

9+
# Error message constants for SettingSelector validation
10+
_SNAPSHOT_PARAMETER_CONFLICT_ERROR = (
11+
"Cannot specify both snapshot_name and {parameter}. "
12+
"When using snapshots, all other filtering parameters are ignored."
13+
)
14+
_SELECTOR_REQUIRED_ERROR = "Either key_filter or snapshot_name must be specified."
15+
916
if TYPE_CHECKING:
1017
from azure.core.credentials import TokenCredential
1118
from azure.core.credentials_async import AsyncTokenCredential
@@ -43,21 +50,44 @@ class SettingSelector:
4350
"""
4451
Selects a set of configuration settings from Azure App Configuration.
4552
46-
:keyword key_filter:A filter to select configuration settings and feature flags based on their keys.
53+
:keyword key_filter: A filter to select configuration settings and feature flags based on their keys.
54+
Cannot be used with snapshot_name.
4755
:type key_filter: str
4856
:keyword label_filter: A filter to select configuration settings and feature flags based on their labels. Default
49-
is value is \0 i.e. (No Label) as seen in the portal.
57+
value is \0 i.e. (No Label) as seen in the portal. Cannot be used with snapshot_name.
5058
:type label_filter: Optional[str]
5159
:keyword tag_filters: A filter to select configuration settings and feature flags based on their tags. This is a
52-
list of strings that will be used to match tags on the configuration settings. Reserved characters (\\*, \\, ,)
53-
must be escaped with backslash if they are part of the value. Tag filters must follow the format
54-
"tagName=tagValue", for empty values use "tagName=" and for null values use "tagName=\\0".
60+
list of strings that will be used to match tags on the configuration settings. Reserved characters (\\*, \\, ,)
61+
must be escaped with backslash if they are part of the value. Tag filters must follow the format
62+
"tagName=tagValue", for empty values use "tagName=" and for null values use "tagName=\\0".
63+
Cannot be used with snapshot_name.
5564
:type tag_filters: Optional[List[str]]
65+
:keyword snapshot_name: The name of the snapshot to load configuration settings from. When specified,
66+
all configuration settings from the snapshot will be loaded. Cannot be used with key_filter, label_filter,
67+
or tag_filters.
68+
:type snapshot_name: Optional[str]
5669
"""
5770

5871
def __init__(
59-
self, *, key_filter: str, label_filter: Optional[str] = NULL_CHAR, tag_filters: Optional[List[str]] = None
72+
self,
73+
*,
74+
key_filter: Optional[str] = None,
75+
label_filter: Optional[str] = NULL_CHAR,
76+
tag_filters: Optional[List[str]] = None,
77+
snapshot_name: Optional[str] = None,
6078
):
79+
if snapshot_name is not None:
80+
# When using snapshots, no other filtering parameters should be specified
81+
if key_filter is not None:
82+
raise ValueError(_SNAPSHOT_PARAMETER_CONFLICT_ERROR.format(parameter="key_filter"))
83+
if label_filter != NULL_CHAR:
84+
raise ValueError(_SNAPSHOT_PARAMETER_CONFLICT_ERROR.format(parameter="label_filter"))
85+
if tag_filters is not None:
86+
raise ValueError(_SNAPSHOT_PARAMETER_CONFLICT_ERROR.format(parameter="tag_filters"))
87+
88+
if snapshot_name is None and key_filter is None:
89+
raise ValueError(_SELECTOR_REQUIRED_ERROR)
90+
6191
if tag_filters is not None:
6292
if not isinstance(tag_filters, list):
6393
raise TypeError("tag_filters must be a list of strings.")
@@ -70,6 +100,7 @@ def __init__(
70100
self.key_filter = key_filter
71101
self.label_filter = label_filter
72102
self.tag_filters = tag_filters
103+
self.snapshot_name = snapshot_name
73104

74105

75106
class WatchKey(NamedTuple):

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
1616
ConfigurationSetting,
1717
FeatureFlagConfigurationSetting,
18+
SnapshotComposition,
1819
)
1920
from azure.appconfiguration.aio import AzureAppConfigurationClient
2021
from .._client_manager_base import (
@@ -46,7 +47,7 @@ def from_credential(
4647
user_agent: str,
4748
retry_total: int,
4849
retry_backoff_max: int,
49-
**kwargs
50+
**kwargs,
5051
) -> Self:
5152
"""
5253
Creates a new instance of the _AsyncConfigurationClientWrapper class, using the provided credential to
@@ -68,7 +69,7 @@ def from_credential(
6869
user_agent=user_agent,
6970
retry_total=retry_total,
7071
retry_backoff_max=retry_backoff_max,
71-
**kwargs
72+
**kwargs,
7273
),
7374
)
7475

@@ -95,7 +96,7 @@ def from_connection_string(
9596
user_agent=user_agent,
9697
retry_total=retry_total,
9798
retry_backoff_max=retry_backoff_max,
98-
**kwargs
99+
**kwargs,
99100
),
100101
)
101102

@@ -138,9 +139,20 @@ async def _check_configuration_setting(
138139
async def load_configuration_settings(self, selects: List[SettingSelector], **kwargs) -> List[ConfigurationSetting]:
139140
configuration_settings = []
140141
for select in selects:
141-
configurations = self._client.list_configuration_settings(
142-
key_filter=select.key_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, **kwargs
143-
)
142+
if select.snapshot_name is not None:
143+
# When loading from a snapshot, ignore key_filter, label_filter, and tag_filters
144+
snapshot = await self._client.get_snapshot(select.snapshot_name)
145+
if snapshot.composition_type != SnapshotComposition.KEY:
146+
raise ValueError(f"Snapshot '{select.snapshot_name}' is not a key snapshot.")
147+
configurations = self._client.list_configuration_settings(snapshot_name=select.snapshot_name, **kwargs)
148+
else:
149+
# Use traditional filtering when not loading from a snapshot
150+
configurations = self._client.list_configuration_settings(
151+
key_filter=select.key_filter,
152+
label_filter=select.label_filter,
153+
tags_filter=select.tag_filters,
154+
**kwargs,
155+
)
144156
async for config in configurations:
145157
if not isinstance(config, FeatureFlagConfigurationSetting):
146158
# Feature flags are ignored when loaded by Selects, as they are selected from
@@ -156,11 +168,13 @@ async def load_feature_flags(
156168
# Needs to be removed unknown keyword argument for list_configuration_settings
157169
kwargs.pop("sentinel_keys", None)
158170
for select in feature_flag_selectors:
171+
# Handle None key_filter by converting to empty string
172+
key_filter = select.key_filter if select.key_filter is not None else ""
159173
feature_flags = self._client.list_configuration_settings(
160-
key_filter=FEATURE_FLAG_PREFIX + select.key_filter,
174+
key_filter=FEATURE_FLAG_PREFIX + key_filter,
161175
label_filter=select.label_filter,
162176
tags_filter=select.tag_filters,
163-
**kwargs
177+
**kwargs,
164178
)
165179
async for feature_flag in feature_flags:
166180
if not isinstance(feature_flag, FeatureFlagConfigurationSetting):
@@ -265,7 +279,7 @@ def __init__(
265279
min_backoff_sec,
266280
max_backoff_sec,
267281
load_balancing_enabled,
268-
**kwargs
282+
**kwargs,
269283
):
270284
super(AsyncConfigurationClientManager, self).__init__(
271285
endpoint,
@@ -276,7 +290,7 @@ def __init__(
276290
min_backoff_sec,
277291
max_backoff_sec,
278292
load_balancing_enabled,
279-
**kwargs
293+
**kwargs,
280294
)
281295
self._original_connection_string = connection_string
282296
self._credential = credential
@@ -373,7 +387,7 @@ async def refresh_clients(self):
373387
self._user_agent,
374388
self._retry_total,
375389
self._retry_backoff_max,
376-
**self._args
390+
**self._args,
377391
)
378392
)
379393
elif self._credential:
@@ -384,7 +398,7 @@ async def refresh_clients(self):
384398
self._user_agent,
385399
self._retry_total,
386400
self._retry_backoff_max,
387-
**self._args
401+
**self._args,
388402
)
389403
)
390404
self._next_update_time = time.time() + MINIMAL_CLIENT_REFRESH_INTERVAL
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
7+
import asyncio
8+
from azure.appconfiguration.provider.aio import load
9+
from azure.appconfiguration.provider import SettingSelector
10+
from sample_utilities import get_client_modifications
11+
import os
12+
13+
14+
async def main():
15+
kwargs = get_client_modifications()
16+
connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"]
17+
18+
# Loading configuration settings from a snapshot
19+
# Note: The snapshot must already exist in your App Configuration store
20+
snapshot_name = "my-snapshot-name"
21+
snapshot_selects = [SettingSelector(snapshot_name=snapshot_name)]
22+
config = await load(connection_string=connection_string, selects=snapshot_selects, **kwargs)
23+
24+
print("Configuration settings from snapshot:")
25+
for key, value in config.items():
26+
print(f"{key}: {value}")
27+
28+
# You can also combine snapshot-based selectors with regular selectors
29+
# The snapshot settings and filtered settings will be merged, with later selectors taking precedence
30+
mixed_selects = [
31+
SettingSelector(snapshot_name=snapshot_name), # Load all settings from snapshot
32+
SettingSelector(key_filter="override.*", label_filter="prod"), # Also load specific override settings
33+
]
34+
config_mixed = await load(connection_string=connection_string, selects=mixed_selects, **kwargs)
35+
36+
print("\nMixed configuration (snapshot + filtered settings):")
37+
for key, value in config_mixed.items():
38+
print(f"{key}: {value}")
39+
40+
# Loading feature flags from a snapshot
41+
# To load feature flags from a snapshot, include the snapshot selector in the 'selects' parameter and set feature_flag_enabled=True.
42+
feature_flag_selects = [SettingSelector(snapshot_name=snapshot_name)]
43+
config_with_flags = await load(
44+
connection_string=connection_string,
45+
selects=feature_flag_selects,
46+
feature_flag_enabled=True,
47+
**kwargs,
48+
)
49+
print(
50+
f"\nConfiguration includes feature flags: {any(key.startswith('.appconfig.featureflag/') for key in config_with_flags.keys())}"
51+
)
52+
53+
54+
if __name__ == "__main__":
55+
asyncio.run(main())

0 commit comments

Comments
 (0)