diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 54481723b4d3..b2e72a63ba26 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md @@ -1,16 +1,21 @@ # Release History -## 2.5.1 (Unreleased) +## 2.6.0b1 (Unreleased) ### Features Added +- Feature flags created via the dedicated feature flag resource endpoint (`FeatureFlagClient`/`FeatureFlag` in `azure-appconfiguration`) are now loaded automatically alongside key-value based feature flags whenever `feature_flag_enabled=True`. Both kinds are merged into the same `feature_management.feature_flags` list, with resource-based feature flags taking precedence over key-value based ones when they share the same name. No new `load()` options are required to opt in, and existing `feature_flag_selectors` filter both kinds. + ### Breaking Changes +- Raised the minimum supported Python version to 3.10, matching the minimum required by `azure-appconfiguration>=1.10.0b1`. Dropped support for Python 3.7, 3.8, and 3.9. + ### Bugs Fixed ### Other Changes - Bumped minimum dependency on `azure-core` to `>=1.31.0`. +- Bumped minimum dependency on `azure-appconfiguration` to `>=1.10.0b1` for `FeatureFlagClient`/`FeatureFlag` support. ## 2.5.0 (2026-05-22) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/README.md index 8500a5be6a22..767ac1d2dba4 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/README.md @@ -377,6 +377,72 @@ config = load( +### Loading Enhanced Feature Flags + +Feature flags can also be created using the dedicated feature flag endpoint (via `FeatureFlagClient`/`FeatureFlag` in `azure-appconfiguration`), instead of as key-value configuration settings. These are referred to as enhanced feature flags. No additional `load()` options are required to load them — it happens automatically whenever `feature_flag_enabled=True`, and they are merged into the same `feature_management.feature_flags` list as key-value based feature flags, with enhanced feature flags taking precedence when both share the same name. + + + +```python +from azure.appconfiguration.provider import load + +# Feature flags loaded from the enhanced feature flag endpoint are merged into the same +# feature_management.feature_flags list as key-value based feature flags. +config = load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, **kwargs) +feature_flags = config["feature_management"]["feature_flags"] +enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("name") == "EnhancedFeatureBeta") +print(enhanced_flag_beta["enabled"]) +``` + + + +`FeatureFlagSelector` is the dedicated selector type for filtering enhanced feature flags by name, label, or tags, and is the recommended way to select enhanced feature flags. + + + +```python +from azure.appconfiguration.provider import load, FeatureFlagSelector + +# FeatureFlagSelector is the dedicated selector type for filtering enhanced feature flags. +config = load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[FeatureFlagSelector(name_filter="Enhanced*")], + **kwargs, +) +feature_flags = config["feature_management"]["feature_flags"] +enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") +print(enhanced_flag_beta["enabled"]) +``` + + + +The same `SettingSelector` used to filter key-value based feature flags also filters enhanced feature flags, by name, label, or tags. A list of `feature_flag_selectors` must contain either `SettingSelector` or `FeatureFlagSelector` instances, but not both. Note that selectors with a `snapshot_name` are not currently supported for enhanced feature flags and are skipped when loading them. + + + +```python +from azure.appconfiguration.provider import load, SettingSelector + +# The same SettingSelector used to filter key-value based feature flags also filters enhanced feature +# flags, by name/label/tags. +config = load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Enhanced*")], + **kwargs, +) +feature_flags = config["feature_management"]["feature_flags"] +enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("name") == "EnhancedFeatureBeta") +print(enhanced_flag_beta["enabled"]) +``` + + + +Existing customers using key-value based feature flags do not need to make any code changes to benefit from this feature. If enhanced feature flags are created in the same App Configuration store, the provider will automatically load and merge them alongside the existing key-value based feature flags whenever `feature_flag_enabled=True`. + ## JSON Content Type Configuration settings with a JSON content type (e.g., `application/json`) are automatically deserialized into their corresponding Python objects when loaded by the provider. @@ -469,6 +535,12 @@ This library uses the standard [logging](https://docs.python.org/3/library/loggi * **Configuration not refreshing** — Make sure you are calling `config.refresh()` periodically (e.g., before each request in a web app). The provider does not auto-refresh in the background. * **Startup failures** — If the store is unreachable during startup, the provider will retry until `startup_timeout` (default 100 seconds) is exceeded. Increase this value if your store is expected to have high latency. +## Testing + +(This content is for `azure-appconfiguration-provider` package developer only) + +See [tests/tests.md](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/appconfiguration/azure-appconfiguration-provider/tests/tests.md) for instructions on running unit and integration tests, working with recordings, and setting up environment variables for local testing. + ## Next steps Check out our Django and Flask examples to see how to use the provider in a web application. diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/api.md b/sdk/appconfiguration/azure-appconfiguration-provider/api.md index 97959ac187a1..8c4676874192 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/api.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/api.md @@ -8,7 +8,7 @@ namespace azure.appconfiguration.provider *, feature_flag_enabled: bool = False, feature_flag_refresh_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = ..., + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = ..., key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = ..., keyvault_client_configs: Optional[Mapping[str, JSON]] = ..., keyvault_credential: Optional[TokenCredential] = ..., @@ -31,7 +31,7 @@ namespace azure.appconfiguration.provider connection_string: str, feature_flag_enabled: bool = False, feature_flag_refresh_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = ..., + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = ..., key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = ..., keyvault_client_configs: Optional[Mapping[str, JSON]] = ..., keyvault_credential: Optional[TokenCredential] = ..., @@ -68,6 +68,17 @@ namespace azure.appconfiguration.provider def refresh(self, **kwargs) -> None: ... + class azure.appconfiguration.provider.FeatureFlagSelector: + + def __init__( + self, + *, + label_filter: Optional[str] = NULL_CHAR, + name_filter: Optional[str] = ..., + tag_filters: Optional[List[str]] = ... + ): ... + + class azure.appconfiguration.provider.SettingSelector: def __init__( @@ -94,7 +105,7 @@ namespace azure.appconfiguration.provider.aio *, feature_flag_enabled: bool = False, feature_flag_refresh_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = ..., + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = ..., key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = ..., keyvault_client_configs: Optional[Mapping[str, JSON]] = ..., keyvault_credential: Optional[AsyncTokenCredential] = ..., @@ -117,7 +128,7 @@ namespace azure.appconfiguration.provider.aio connection_string: str, feature_flag_enabled: bool = False, feature_flag_refresh_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = ..., + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = ..., key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = ..., keyvault_client_configs: Optional[Mapping[str, JSON]] = ..., keyvault_credential: Optional[AsyncTokenCredential] = ..., diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/api.metadata.yml b/sdk/appconfiguration/azure-appconfiguration-provider/api.metadata.yml index 54ff9f5af349..3ea31f2567ee 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/api.metadata.yml +++ b/sdk/appconfiguration/azure-appconfiguration-provider/api.metadata.yml @@ -1,3 +1,3 @@ -apiMdSha256: 1b252a78094b95bf515ebe5ec67fc2de12ce5b595bad25438a58241ca7153b84 +apiMdSha256: 808ba824eedfb7fc2365479c13867983ee557982cd635995a9617e140c1919dc parserVersion: 0.3.28 -pythonVersion: 3.14.3 +pythonVersion: 3.12.10 diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/assets.json b/sdk/appconfiguration/azure-appconfiguration-provider/assets.json index b9e5f6a80d69..ab4ac3e8fb87 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/assets.json +++ b/sdk/appconfiguration/azure-appconfiguration-provider/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/appconfiguration/azure-appconfiguration-provider", - "Tag": "python/appconfiguration/azure-appconfiguration-provider_34a63910b7" + "Tag": "python/appconfiguration/azure-appconfiguration-provider_fb87386e95" } diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/__init__.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/__init__.py index 66fe656d692c..b8fd9df2ffe0 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/__init__.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/__init__.py @@ -7,6 +7,7 @@ from ._azureappconfigurationprovider import AzureAppConfigurationProvider from ._models import ( AzureAppConfigurationKeyVaultOptions, + FeatureFlagSelector, SettingSelector, WatchKey, ) @@ -18,6 +19,7 @@ "load", "AzureAppConfigurationProvider", "AzureAppConfigurationKeyVaultOptions", + "FeatureFlagSelector", "SettingSelector", "WatchKey", ] diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py index a3f8f826679c..d6c01d6ddad8 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py @@ -17,6 +17,7 @@ ) from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, + FeatureFlag, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, ) @@ -107,6 +108,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f ) configuration_settings: List[ConfigurationSetting] = [] feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None + enhanced_feature_flags: Optional[List[FeatureFlag]] = None # Timer needs to be reset even if no refresh happened if time had passed configuration_refresh_attempted = False @@ -115,6 +117,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f existing_feature_flag_usage = self._tracing_context.feature_filter_usage.copy() page_etags: List[List[str]] = [] feature_flag_page_etags: List[List[str]] = [] + enhanced_feature_flag_etags: List[List[str]] = [] try: if self._refresh_enabled and not self._watched_settings and self._refresh_timer.needs_refresh(): configuration_refresh_attempted = True @@ -148,6 +151,16 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f self._feature_flag_selectors, headers=headers, **kwargs ) + # Enhanced feature flags are loaded independently of the key-value based feature flags, using their + # own page-level etag state, since they are a separate resource type with a separate + # change-detection mechanism. + if not self._enhanced_feature_flag_etags or client.check_enhanced_feature_flag_etags( + self._enhanced_feature_flag_selectors, self._enhanced_feature_flag_etags, headers=headers, **kwargs + ): + enhanced_feature_flags, enhanced_feature_flag_etags = client.load_enhanced_feature_flags( + self._enhanced_feature_flag_selectors, headers=headers, **kwargs + ) + # Default to existing settings if no refresh occurred processed_settings = self._dict @@ -157,7 +170,9 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f # Configuration Settings have been refreshed processed_settings = self._process_configurations(configuration_settings, client) - processed_settings = self._process_feature_flags(processed_settings, processed_feature_flags, feature_flags) + processed_settings = self._process_and_merge_feature_flags( + processed_settings, processed_feature_flags, feature_flags, enhanced_feature_flags + ) self._dict = processed_settings if settings_refreshed: self._page_etags = page_etags @@ -165,12 +180,14 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f self._watched_settings.update(updated_watched_settings) if feature_flags is not None: self._feature_flag_page_etags = feature_flag_page_etags + if enhanced_feature_flags is not None: + self._enhanced_feature_flag_etags = enhanced_feature_flag_etags # Reset timers at the same time as they should load from the same store. if configuration_refresh_attempted: self._refresh_timer.reset() if self._feature_flag_refresh_enabled and feature_flag_refresh_attempted: self._feature_flag_refresh_timer.reset() - if (settings_refreshed or feature_flags) and self._on_refresh_success: + if (settings_refreshed or feature_flags or enhanced_feature_flags) and self._on_refresh_success: self._on_refresh_success() except AzureError as e: logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint) @@ -278,6 +295,7 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> processed_settings = self._process_configurations(configuration_settings, client) feature_flag_page_etags: List[List[str]] = [] + enhanced_feature_flag_etags: List[List[str]] = [] if self._feature_flag_enabled: feature_flags: List[FeatureFlagConfigurationSetting] feature_flags, feature_flag_page_etags = client.load_feature_flags( @@ -285,7 +303,14 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> headers=headers, **kwargs, ) - processed_settings = self._process_feature_flags(processed_settings, [], feature_flags) + enhanced_feature_flags, enhanced_feature_flag_etags = client.load_enhanced_feature_flags( + self._enhanced_feature_flag_selectors, + headers=headers, + **kwargs, + ) + processed_settings = self._process_and_merge_feature_flags( + processed_settings, [], feature_flags, enhanced_feature_flags + ) for (key, label), etag in self._watched_settings.items(): if not etag: try: @@ -310,6 +335,7 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> self._dict = processed_settings self._page_etags = page_etags self._feature_flag_page_etags = feature_flag_page_etags + self._enhanced_feature_flag_etags = enhanced_feature_flag_etags return True except AzureError as e: logger.warning("Failed to load configurations from endpoint %s.\n %s", client.endpoint, e.message) @@ -375,7 +401,7 @@ def _process_configurations( self._configuration_mapper(setting) if isinstance(setting, FeatureFlagConfigurationSetting): # Feature flags are not processed like other settings - feature_flag_value = self._process_feature_flag(setting) + feature_flag_value = self._process_kv_feature_flag(setting) feature_flags_processed.append(feature_flag_value) else: key = self._process_key_name(setting) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py index e5b6240d74e6..c42c252b2739 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -21,12 +21,14 @@ ItemsView, ValuesView, TypeVar, + cast, ) from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, FeatureFlagConfigurationSetting, + FeatureFlag, ) -from ._models import SettingSelector +from ._models import FeatureFlagSelector, SettingSelector from ._constants import ( NULL_CHAR, TELEMETRY_KEY, @@ -38,6 +40,9 @@ APP_CONFIG_AICC_MIME_PROFILE, FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY, + FEATURE_FLAG_ID_FIELD, + FEATURE_FLAG_KV_REFERENCE_SEGMENT, + ENHANCED_FEATURE_FLAG_REFERENCE_SEGMENT, ) from ._refresh_timer import _RefreshTimer from ._request_tracing_context import _RequestTracingContext @@ -80,6 +85,66 @@ def _build_watched_setting(setting: Union[str, Tuple[str, str]]) -> Tuple[str, s return key, label +def _normalize_feature_flag_selectors( + selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] +) -> Tuple[List[SettingSelector], List[FeatureFlagSelector]]: + """ + Normalizes the customer-provided ``feature_flag_selectors``, which may be either a ``List[SettingSelector]`` + or a ``List[FeatureFlagSelector]`` (the two types cannot be mixed in the same list), into the two selector + lists used internally to load both kinds of feature flags: + + - kv_selectors: Used to load key-value based feature flags (``SettingSelector.key_filter`` is used as the key + filter). + - enhanced_selectors: Used to load enhanced feature flags from the dedicated feature flag resource endpoint + (``FeatureFlagSelector.name_filter`` is used as the name filter). + + :param selectors: The customer-provided feature flag selectors, or None to use the default (all feature flags + without a label). + :type selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] + :return: A tuple of (kv_selectors, enhanced_selectors). + :rtype: Tuple[List[SettingSelector], List[FeatureFlagSelector]] + """ + if selectors is None: + return [SettingSelector(key_filter="*")], [FeatureFlagSelector(name_filter="*")] + if not selectors: + # An explicitly empty collection of selectors means no feature flags should be loaded, unlike None + # which falls back to the default of loading all unlabeled feature flags. + return [], [] + + selectors_iter = iter(selectors) + first_selector = next(selectors_iter) + is_feature_flag_selector = isinstance(first_selector, FeatureFlagSelector) + for select in selectors_iter: + if isinstance(select, FeatureFlagSelector) != is_feature_flag_selector: + raise TypeError( + "feature_flag_selectors must be either a list of SettingSelector or a list of FeatureFlagSelector, " + "not a mix of both." + ) + + if is_feature_flag_selector: + feature_flag_selectors = cast(List[FeatureFlagSelector], selectors) + kv_selectors = [ + SettingSelector( + key_filter=select.name_filter, label_filter=select.label_filter, tag_filters=select.tag_filters + ) + for select in feature_flag_selectors + ] + # FeatureFlagSelector has no snapshot_name, so every selector is used for enhanced feature flags. + enhanced_selectors = list(feature_flag_selectors) + return kv_selectors, enhanced_selectors + + setting_selectors = cast(List[SettingSelector], selectors) + kv_selectors = list(setting_selectors) + enhanced_selectors = [ + FeatureFlagSelector( + name_filter=select.key_filter, label_filter=select.label_filter, tag_filters=select.tag_filters + ) + for select in setting_selectors + if select.snapshot_name is None + ] + return kv_selectors, enhanced_selectors + + class AzureAppConfigurationProviderBase(Mapping[str, Union[str, JSON]]): # pylint: disable=too-many-instance-attributes """ Provides a dictionary-like interface to Azure App Configuration settings. Enables loading of sets of configuration @@ -102,19 +167,21 @@ def __init__(self, **kwargs: Any) -> None: } self._refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs) self._feature_flag_enabled = kwargs.pop("feature_flag_enabled", False) - self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", None) - if self._feature_flag_selectors is None: - self._feature_flag_selectors = [SettingSelector(key_filter="*")] + self._feature_flag_selectors, self._enhanced_feature_flag_selectors = _normalize_feature_flag_selectors( + kwargs.pop("feature_flag_selectors", None) + ) self._feature_flag_refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs) self._feature_flag_refresh_enabled = kwargs.pop("feature_flag_refresh_enabled", False) refresh_enabled = kwargs.pop("refresh_enabled", None) if refresh_enabled is None and len(refresh_on) > 0: # If refresh_enabled is not explicitly set, enable refresh if there are settings to refresh on - # This make sure we don't break existing users. refresh_enabled = True self._refresh_enabled = refresh_enabled self._page_etags: List[List[str]] = [] self._feature_flag_page_etags: List[List[str]] = [] + self._enhanced_feature_flag_etags: List[List[str]] = [] + self._processed_kv_feature_flags: List[Dict[str, Any]] = [] + self._processed_enhanced_feature_flags: List[Dict[str, Any]] = [] self._tracing_context = _RequestTracingContext(kwargs.pop("load_balancing_enabled", False)) self._update_lock = Lock() self._refresh_lock = Lock() @@ -132,7 +199,7 @@ def _update_ff_telemetry_metadata( self, endpoint: str, feature_flag: FeatureFlagConfigurationSetting, feature_flag_value: Dict ): """ - Add telemetry metadata to feature flag values. + Add telemetry metadata to feature flag values loaded from the key-value store. :param endpoint: The App Configuration endpoint URL. :type endpoint: str @@ -141,6 +208,64 @@ def _update_ff_telemetry_metadata( :param feature_flag_value: The feature flag value dictionary to update. :type feature_flag_value: Dict[str, Any] """ + self._update_ff_telemetry_metadata_common( + endpoint, + feature_flag.key, + feature_flag.label, + feature_flag.etag, + feature_flag_value, + FEATURE_FLAG_KV_REFERENCE_SEGMENT, + ) + + def _update_enhanced_feature_flag_telemetry_metadata( + self, endpoint: str, feature_flag: FeatureFlag, feature_flag_value: Dict + ): + """ + Add telemetry metadata to enhanced feature flag values loaded from the enhanced feature flag endpoint. + + :param endpoint: The App Configuration endpoint URL. + :type endpoint: str + :param feature_flag: The enhanced feature flag. + :type feature_flag: ~azure.appconfiguration.FeatureFlag + :param feature_flag_value: The feature flag value dictionary to update. + :type feature_flag_value: Dict[str, Any] + """ + self._update_ff_telemetry_metadata_common( + endpoint, + feature_flag.name, + feature_flag.label, + feature_flag.etag, + feature_flag_value, + ENHANCED_FEATURE_FLAG_REFERENCE_SEGMENT, + ) + + def _update_ff_telemetry_metadata_common( # pylint: disable=too-many-positional-arguments + self, + endpoint: str, + identifier: str, + label: Optional[str], + etag: Optional[str], + feature_flag_value: Dict, + reference_path_segment: str, + ): + """ + Add telemetry metadata to a feature flag value dictionary, regardless of which endpoint it was loaded from. + + :param endpoint: The App Configuration endpoint URL. + :type endpoint: str + :param identifier: The identifier of the feature flag (key for key-value based, name for enhanced feature + flags). + :type identifier: str + :param label: The label of the feature flag. + :type label: Optional[str] + :param etag: The etag of the feature flag. + :type etag: Optional[str] + :param feature_flag_value: The feature flag value dictionary to update. + :type feature_flag_value: Dict[str, Any] + :param reference_path_segment: The path segment to use when building the feature flag reference URL, e.g. + "kv" for key-value based feature flags or "ff" for enhanced feature flags. + :type reference_path_segment: str + """ if TELEMETRY_KEY not in feature_flag_value: # Initialize telemetry dictionary if not present feature_flag_value[TELEMETRY_KEY] = {} @@ -148,15 +273,15 @@ def _update_ff_telemetry_metadata( # Update telemetry metadata for application insights/logging in feature management if METADATA_KEY not in feature_flag_value[TELEMETRY_KEY]: feature_flag_value[TELEMETRY_KEY][METADATA_KEY] = {} - feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ETAG_KEY] = feature_flag.etag + feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ETAG_KEY] = etag if feature_flag_value[TELEMETRY_KEY].get("enabled"): self._tracing_context.uses_telemetry = True if not endpoint.endswith("/"): endpoint += "/" - feature_flag_reference = f"{endpoint}kv/{feature_flag.key}" - if feature_flag.label and not feature_flag.label.isspace(): - feature_flag_reference += f"?label={feature_flag.label}" + feature_flag_reference = f"{endpoint}{reference_path_segment}/{identifier}" + if label: + feature_flag_reference += f"?label={label}" feature_flag_value[TELEMETRY_KEY][METADATA_KEY][FEATURE_FLAG_REFERENCE_KEY] = feature_flag_reference allocation_id = self._generate_allocation_id(feature_flag_value) @@ -240,10 +365,9 @@ def _generate_allocation_id(feature_flag_value: Dict[str, JSON]) -> Optional[str for v in sorted_variants: allocation_id += f"{base64.b64encode(v.get('name', '').encode()).decode()}," - if "configuration_value" in v: - allocation_id += ( - f"{json.dumps(v.get('configuration_value', ''), separators=(',', ':'), sort_keys=True)}" - ) + allocation_id += ( + f"{json.dumps(v.get('configuration_value', ''), separators=(',', ':'), sort_keys=True)}" + ) allocation_id += ";" if sorted_variants: allocation_id = allocation_id[:-1] @@ -364,23 +488,68 @@ def _process_key_value_base(self, config: ConfigurationSetting) -> Union[str, Di return config.value return config.value - def _process_feature_flags( + def _process_and_merge_feature_flags( self, processed_settings: Dict[str, Any], processed_feature_flags: List[Dict[str, Any]], feature_flags: Optional[List[FeatureFlagConfigurationSetting]], + enhanced_feature_flags: Optional[List[FeatureFlag]] = None, ) -> Dict[str, Any]: - if feature_flags: + if feature_flags or enhanced_feature_flags is not None: # Reset feature flag usage self._tracing_context.reset_feature_filter_usage() - processed_feature_flags = [self._process_feature_flag(ff) for ff in feature_flags] + + if feature_flags: + self._processed_kv_feature_flags = [self._process_kv_feature_flag(ff) for ff in feature_flags] + + if enhanced_feature_flags is not None: + self._processed_enhanced_feature_flags = [ + self._process_enhanced_feature_flag(ff) for ff in enhanced_feature_flags + ] + self._tracing_context.uses_enhanced_feature_flags = bool(enhanced_feature_flags) + + if feature_flags or enhanced_feature_flags is not None: + processed_feature_flags = self._merge_feature_flags( + self._processed_kv_feature_flags, self._processed_enhanced_feature_flags + ) if self._feature_flag_enabled: processed_settings[FEATURE_MANAGEMENT_KEY] = {} processed_settings[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY] = processed_feature_flags return processed_settings - def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) -> Dict[str, Any]: + @staticmethod + def _merge_feature_flags( + kv_feature_flags: List[Dict[str, Any]], enhanced_feature_flags: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """ + Merge feature flags loaded from the key-value store with enhanced feature flags loaded from the + enhanced feature flag endpoint. Both sources populate the ``id`` field using the feature management + library's schema (for enhanced feature flags, the enhanced feature flag's name is used as the ``id``). + Feature flags are matched by their ``id`` field. When both sources contain a feature flag with the + same identifier, the enhanced feature flag takes precedence. + + :param kv_feature_flags: The feature flags loaded from the key-value store. + :type kv_feature_flags: List[Dict[str, Any]] + :param enhanced_feature_flags: The enhanced feature flags loaded from the enhanced feature flag endpoint. + :type enhanced_feature_flags: List[Dict[str, Any]] + :return: The merged list of feature flags. + :rtype: List[Dict[str, Any]] + """ + merged: Dict[str, Dict[str, Any]] = {} + for ff in kv_feature_flags: + identifier = ff.get(FEATURE_FLAG_ID_FIELD) + if identifier is None: + continue + merged[identifier] = ff + for ff in enhanced_feature_flags: + identifier = ff.get(FEATURE_FLAG_ID_FIELD) + if identifier is None: + continue + merged[identifier] = ff + return list(merged.values()) + + def _process_kv_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) -> Dict[str, Any]: try: feature_flag_value = json.loads(feature_flag.value) self._update_ff_telemetry_metadata(self._origin_endpoint, feature_flag, feature_flag_value) @@ -390,6 +559,92 @@ def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) - # Feature flag value is not a valid JSON return {} + def _process_enhanced_feature_flag(self, feature_flag: FeatureFlag) -> Dict[str, Any]: + """ + Convert an enhanced feature flag, loaded from the enhanced feature flag endpoint, into a dictionary that + matches the feature management library's schema. + Ref: https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureFlag.v2.0.0.schema.json + + :param feature_flag: The enhanced feature flag. + :type feature_flag: ~azure.appconfiguration.FeatureFlag + :return: The feature flag as a dictionary. + :rtype: Dict[str, Any] + """ + feature_flag_value: Dict[str, Any] = { + FEATURE_FLAG_ID_FIELD: feature_flag.name, + "enabled": feature_flag.enabled, + } + if feature_flag.label: + feature_flag_value["label"] = feature_flag.label + if feature_flag.description: + feature_flag_value["description"] = feature_flag.description + + filter_names: List[Optional[str]] = [] + if feature_flag.conditions: + conditions_value: Dict[str, Any] = {} + if feature_flag.conditions.requirement_type: + conditions_value["requirement_type"] = feature_flag.conditions.requirement_type + if feature_flag.conditions.client_filters: + conditions_value["client_filters"] = [ + {"name": client_filter.name, "parameters": client_filter.parameters} + for client_filter in feature_flag.conditions.client_filters + ] + filter_names = [client_filter.name for client_filter in feature_flag.conditions.client_filters] + if conditions_value: + feature_flag_value["conditions"] = conditions_value + + if feature_flag.variants: + feature_flag_value["variants"] = [ + { + "name": variant.name, + "configuration_value": variant.value, + "content_type": variant.content_type, + "status_override": variant.status_override, + } + for variant in feature_flag.variants + ] + + if feature_flag.allocation: + allocation_value: Dict[str, Any] = {} + if feature_flag.allocation.default_when_disabled: + allocation_value["default_when_disabled"] = feature_flag.allocation.default_when_disabled + if feature_flag.allocation.default_when_enabled: + allocation_value["default_when_enabled"] = feature_flag.allocation.default_when_enabled + if feature_flag.allocation.percentile: + allocation_value["percentile"] = [ + { + "variant": percentile.variant, + "from": percentile.percentile_from, + "to": percentile.percentile_to, + } + for percentile in feature_flag.allocation.percentile + ] + if feature_flag.allocation.user: + allocation_value["user"] = [ + {"variant": user.variant, "users": user.users} for user in feature_flag.allocation.user + ] + if feature_flag.allocation.group: + allocation_value["group"] = [ + {"variant": group.variant, "groups": group.groups} for group in feature_flag.allocation.group + ] + if feature_flag.allocation.seed: + allocation_value["seed"] = feature_flag.allocation.seed + if allocation_value: + feature_flag_value["allocation"] = allocation_value + + if feature_flag.telemetry: + feature_flag_value["telemetry"] = { + "enabled": feature_flag.telemetry.enabled, + "metadata": dict(feature_flag.telemetry.metadata) if feature_flag.telemetry.metadata else {}, + } + + if feature_flag.tags: + feature_flag_value["tags"] = dict(feature_flag.tags) + + self._update_enhanced_feature_flag_telemetry_metadata(self._origin_endpoint, feature_flag, feature_flag_value) + self._tracing_context.update_feature_filter_telemetry_by_names(filter_names) + return feature_flag_value + def _update_watched_settings( self, configuration_settings: List[ConfigurationSetting] ) -> Dict[Tuple[str, str], Optional[str]]: diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py index 651a55577222..f42aecb8e027 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py @@ -17,6 +17,8 @@ ConfigurationSetting, AzureAppConfigurationClient, FeatureFlagConfigurationSetting, + FeatureFlag, + FeatureFlagClient, SnapshotComposition, ) from ._client_manager_base import ( @@ -25,7 +27,7 @@ FALLBACK_CLIENT_REFRESH_EXPIRED_INTERVAL, MINIMAL_CLIENT_REFRESH_INTERVAL, ) -from ._models import SettingSelector +from ._models import FeatureFlagSelector, SettingSelector from ._constants import FEATURE_FLAG_PREFIX from ._discovery import find_auto_failover_endpoints from ._snapshot_reference_parser import SnapshotReferenceParser @@ -35,6 +37,7 @@ @dataclass class _ConfigurationClientWrapper(_ConfigurationClientWrapperBase): _client: AzureAppConfigurationClient + _enhanced_feature_flag_client: Optional[FeatureFlagClient] = None backoff_end_time: float = 0 failed_attempts: int = 0 LOGGER = getLogger(__name__) @@ -61,6 +64,7 @@ def from_credential( :return: A new instance of the _ConfigurationClientWrapper class :rtype: _ConfigurationClientWrapper """ + feature_flag_enabled = kwargs.pop("feature_flag_enabled", False) return cls( endpoint, AzureAppConfigurationClient( @@ -71,6 +75,18 @@ def from_credential( retry_backoff_max=retry_backoff_max, **kwargs, ), + ( + FeatureFlagClient( + endpoint, + credential, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ) + if feature_flag_enabled + else None + ), ) @classmethod @@ -89,6 +105,7 @@ def from_connection_string( :return: A new instance of the _ConfigurationClientWrapper class :rtype: _ConfigurationClientWrapper """ + feature_flag_enabled = kwargs.pop("feature_flag_enabled", False) return cls( endpoint, AzureAppConfigurationClient.from_connection_string( @@ -98,6 +115,17 @@ def from_connection_string( retry_backoff_max=retry_backoff_max, **kwargs, ), + ( + FeatureFlagClient.from_connection_string( + connection_string, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ) + if feature_flag_enabled + else None + ), ) def _check_configuration_setting( @@ -218,8 +246,6 @@ def load_feature_flags( """ loaded_feature_flags: List[FeatureFlagConfigurationSetting] = [] page_etags: List[List[str]] = [] - # Needs to be removed unknown keyword argument for list_configuration_settings - kwargs.pop("sentinel_keys", None) for select in feature_flag_selectors: selector_etags: List[str] = [] if select.snapshot_name is not None: @@ -282,6 +308,71 @@ def check_feature_flag_page_etags( return True return False + @distributed_trace + def load_enhanced_feature_flags( + self, feature_flag_selectors: List[FeatureFlagSelector], **kwargs + ) -> Tuple[List[FeatureFlag], List[List[str]]]: + """ + Loads enhanced feature flags from the enhanced feature flag endpoint using page-based iteration. + + :param feature_flag_selectors: List of feature flag selectors to filter feature flags + :type feature_flag_selectors: List[FeatureFlagSelector] + :return: A tuple of (feature_flags, page_etags_per_selector), with one page etags entry per selector, in the + same relative order as ``feature_flag_selectors``. + :rtype: Tuple[List[~azure.appconfiguration.FeatureFlag], List[List[str]]] + """ + loaded_feature_flags: List[FeatureFlag] = [] + if self._enhanced_feature_flag_client is None: + return loaded_feature_flags, [[] for _ in feature_flag_selectors] + page_etags: List[List[str]] = [] + for select in feature_flag_selectors: + selector_etags: List[str] = [] + feature_flags = self._enhanced_feature_flag_client.list_feature_flags( + name_filter=select.name_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + iterator = feature_flags.by_page() + for page in iterator: + loaded_feature_flags.extend(page) + selector_etags.append(iterator.etag) + page_etags.append(selector_etags) + return loaded_feature_flags, page_etags + + @distributed_trace + def check_enhanced_feature_flag_etags( + self, feature_flag_selectors: List[FeatureFlagSelector], page_etags: List[List[str]], **kwargs + ) -> bool: + """ + Checks if any enhanced feature flag page has changed using page etags. + + :param feature_flag_selectors: List of feature flag selectors for feature flags + :type feature_flag_selectors: List[FeatureFlagSelector] + :param page_etags: The page etags from the last load, one entry per selector, in the same relative order as + ``feature_flag_selectors``. + :type page_etags: List[List[str]] + :return: True if any page has changed, False otherwise + :rtype: bool + """ + if self._enhanced_feature_flag_client is None: + return False + for i, select in enumerate(feature_flag_selectors): + if i >= len(page_etags): + # Missing or stale etag state should trigger a refresh instead of failing. + return True + selector_etags = page_etags[i] + feature_flags = self._enhanced_feature_flag_client.list_feature_flags( + name_filter=select.name_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + for _ in feature_flags.by_page(match_conditions=selector_etags): + # If any page is returned, it means that page has changed + return True + return False + @distributed_trace def get_updated_watched_settings( self, watched_settings: Mapping[Tuple[str, str], Optional[str]], headers: Dict[str, str], **kwargs @@ -362,13 +453,19 @@ def close(self) -> None: Closes the connection to Azure App Configuration. """ self._client.close() + if self._enhanced_feature_flag_client is not None: + self._enhanced_feature_flag_client.close() def __enter__(self): self._client.__enter__() + if self._enhanced_feature_flag_client is not None: + self._enhanced_feature_flag_client.__enter__() return self def __exit__(self, *args): self._client.__exit__(*args) + if self._enhanced_feature_flag_client is not None: + self._enhanced_feature_flag_client.__exit__(*args) def resolve_snapshot_reference(self, setting: ConfigurationSetting, **kwargs) -> List[ConfigurationSetting]: """ diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py index 3e68591bb46c..f368eff8c525 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py @@ -15,6 +15,13 @@ ALLOCATION_ID_KEY = "AllocationId" ETAG_KEY = "ETag" +# Identifier field required by the feature management library's schema for every feature flag entry. +FEATURE_FLAG_ID_FIELD = "id" +# Path segment used to build the feature flag reference URL for feature flags loaded from the key-value store. +FEATURE_FLAG_KV_REFERENCE_SEGMENT = "kv" +# Path segment used to build the feature flag reference URL for enhanced feature flags. +ENHANCED_FEATURE_FLAG_REFERENCE_SEGMENT = "ff" + # ------------------------------------------------------------------------ # Environment Variable Constants # ------------------------------------------------------------------------ diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py index 6660ba2a6786..4624ddc71ed5 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py @@ -10,13 +10,14 @@ Mapping, Optional, Tuple, + Union, overload, ) from azure.core.credentials import TokenCredential from ._constants import ( DEFAULT_STARTUP_TIMEOUT, ) -from ._models import AzureAppConfigurationKeyVaultOptions, SettingSelector +from ._models import AzureAppConfigurationKeyVaultOptions, FeatureFlagSelector, SettingSelector from ._utils import ( delay_failure, process_load_parameters, @@ -46,7 +47,7 @@ def load( # pylint: disable=docstring-keyword-should-match-keyword-only on_refresh_success: Optional[Callable] = None, on_refresh_error: Optional[Callable[[Exception], None]] = None, feature_flag_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = None, + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = None, feature_flag_refresh_enabled: bool = False, startup_timeout: int = DEFAULT_STARTUP_TIMEOUT, **kwargs, @@ -84,9 +85,11 @@ def load( # pylint: disable=docstring-keyword-should-match-keyword-only :paramtype on_refresh_error: Optional[Callable[[Exception], None]] :keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False. :paramtype feature_flag_enabled: bool - :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all - feature flags without a label. - :paramtype feature_flag_selectors: List[SettingSelector] + :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. Either a list of + ~azure.appconfiguration.provider.SettingSelector or a list of + ~azure.appconfiguration.provider.FeatureFlagSelector (the two types cannot be mixed in the same list). + By default will load all feature flags without a label. + :paramtype feature_flag_selectors: Union[List[SettingSelector], List[FeatureFlagSelector]] :keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is False. :paramtype feature_flag_refresh_enabled: bool @@ -121,7 +124,7 @@ def load( # pylint: disable=docstring-keyword-should-match-keyword-only on_refresh_success: Optional[Callable] = None, on_refresh_error: Optional[Callable[[Exception], None]] = None, feature_flag_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = None, + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = None, feature_flag_refresh_enabled: bool = False, startup_timeout: int = DEFAULT_STARTUP_TIMEOUT, **kwargs, @@ -161,9 +164,11 @@ def load( # pylint: disable=docstring-keyword-should-match-keyword-only :paramtype on_refresh_error: Optional[Callable[[Exception], None]] :keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False. :paramtype feature_flag_enabled: bool - :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all - feature flags without a label. - :paramtype feature_flag_selectors: List[SettingSelector] + :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. Either a list of + ~azure.appconfiguration.provider.SettingSelector or a list of + ~azure.appconfiguration.provider.FeatureFlagSelector (the two types cannot be mixed in the same list). + By default will load all feature flags without a label. + :paramtype feature_flag_selectors: Union[List[SettingSelector], List[FeatureFlagSelector]] :keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is False. :paramtype feature_flag_refresh_enabled: bool diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_models.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_models.py index c43d3cdc91c0..1e5f3f593141 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_models.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_models.py @@ -103,6 +103,46 @@ def __init__( self.snapshot_name = snapshot_name +class FeatureFlagSelector: + """ + Selects a set of feature flags from the dedicated enhanced feature flag endpoint. + + :keyword name_filter: A filter to select feature flags based on their name. + :type name_filter: str + :keyword label_filter: A filter to select feature flags based on their labels. Default + value is \0 i.e. (No Label) as seen in the portal. + :type label_filter: Optional[str] + :keyword tag_filters: A filter to select feature flags based on their tags. This is a + list of strings that will be used to match tags on the feature flags. Reserved characters (\\*, \\, ,) + must be escaped with backslash if they are part of the value. Tag filters must follow the format + "tagName=tagValue", for empty values use "tagName=" and for null values use "tagName=\\0". + :type tag_filters: Optional[List[str]] + """ + + def __init__( + self, + *, + name_filter: Optional[str] = None, + label_filter: Optional[str] = NULL_CHAR, + tag_filters: Optional[List[str]] = None, + ): + if name_filter is None: + raise ValueError("name_filter must be specified.") + + if tag_filters is not None: + if not isinstance(tag_filters, list): + raise TypeError("tag_filters must be a list of strings.") + for tag in tag_filters: + if not tag: + raise ValueError("Tag filter cannot be an empty string or None.") + if not isinstance(tag, str) or "=" not in tag or tag.startswith("="): + raise ValueError("Tag filter " + tag + ' does not follow the format "tagName=tagValue".') + + self.name_filter = name_filter + self.label_filter = label_filter + self.tag_filters = tag_filters + + class WatchKey(NamedTuple): key: str label: str = NULL_CHAR diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py index bc308d0bf1ac..a4aa3e974aae 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py @@ -40,6 +40,7 @@ AI_CONFIGURATION_FEATURE = "AI" AI_CHAT_COMPLETION_FEATURE = "AICC" SNAPSHOT_REFERENCE_TAG = "SnapshotRef" +ENHANCED_FEATURE_FLAG_TAG = "EnhFF" # Correlation context constants FEATUREMANAGEMENT_PACKAGE = "featuremanagement" @@ -84,6 +85,7 @@ def __init__(self, load_balancing_enabled: bool = False) -> None: self.uses_ai_configuration = False self.uses_aicc_configuration = False # AI Chat Completion self.uses_snapshot_reference = False + self.uses_enhanced_feature_flags = False self.uses_telemetry = False self.uses_seed = False self.max_variants: Optional[int] = None @@ -243,15 +245,26 @@ def update_feature_filter_telemetry(self, feature_flag) -> None: # Constants are already imported at module level if feature_flag.filters: - for filter in feature_flag.filters: - if filter.get("name") in PERCENTAGE_FILTER_NAMES: - self.feature_filter_usage[PERCENTAGE_FILTER_KEY] = True - elif filter.get("name") in TIME_WINDOW_FILTER_NAMES: - self.feature_filter_usage[TIME_WINDOW_FILTER_KEY] = True - elif filter.get("name") in TARGETING_FILTER_NAMES: - self.feature_filter_usage[TARGETING_FILTER_KEY] = True - else: - self.feature_filter_usage[CUSTOM_FILTER_KEY] = True + self.update_feature_filter_telemetry_by_names(filter.get("name") for filter in feature_flag.filters) + + def update_feature_filter_telemetry_by_names(self, filter_names) -> None: + """ + Track feature filter usage for App Configuration telemetry, given the filter names directly. Used for feature + flags that don't expose their filters as dictionaries, e.g. feature flags loaded from the feature flag + resource endpoint. + + :param filter_names: The names of the filters used by a feature flag. + :type filter_names: Iterable[Optional[str]] + """ + for name in filter_names: + if name in PERCENTAGE_FILTER_NAMES: + self.feature_filter_usage[PERCENTAGE_FILTER_KEY] = True + elif name in TIME_WINDOW_FILTER_NAMES: + self.feature_filter_usage[TIME_WINDOW_FILTER_KEY] = True + elif name in TARGETING_FILTER_NAMES: + self.feature_filter_usage[TARGETING_FILTER_KEY] = True + else: + self.feature_filter_usage[CUSTOM_FILTER_KEY] = True def reset_feature_filter_usage(self) -> None: """Reset the feature filter usage tracking.""" @@ -274,6 +287,8 @@ def _create_features_string(self) -> str: features_list.append(AI_CHAT_COMPLETION_FEATURE) if self.uses_snapshot_reference: features_list.append(SNAPSHOT_REFERENCE_TAG) + if self.uses_enhanced_feature_flags: + features_list.append(ENHANCED_FEATURE_FLAG_TAG) return Delimiter.join(features_list) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py index 41676d00c483..0f4ca7972c61 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py @@ -4,4 +4,4 @@ # license information. # ------------------------------------------------------------------------- -VERSION = "2.5.1" +VERSION = "2.6.0b1" diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py index abcb6233e9a1..2e578dbfa1a6 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py @@ -15,16 +15,17 @@ from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, FeatureFlagConfigurationSetting, + FeatureFlag, SnapshotComposition, ) -from azure.appconfiguration.aio import AzureAppConfigurationClient +from azure.appconfiguration.aio import AzureAppConfigurationClient, FeatureFlagClient from .._client_manager_base import ( _ConfigurationClientWrapperBase, ConfigurationClientManagerBase, FALLBACK_CLIENT_REFRESH_EXPIRED_INTERVAL, MINIMAL_CLIENT_REFRESH_INTERVAL, ) -from .._models import SettingSelector +from .._models import FeatureFlagSelector, SettingSelector from .._constants import FEATURE_FLAG_PREFIX from .._snapshot_reference_parser import SnapshotReferenceParser from .._constants import SNAPSHOT_REF_CONTENT_TYPE @@ -37,6 +38,7 @@ @dataclass class _AsyncConfigurationClientWrapper(_ConfigurationClientWrapperBase): _client: AzureAppConfigurationClient + _enhanced_feature_flag_client: Optional[FeatureFlagClient] = None backoff_end_time: float = 0 failed_attempts: int = 0 LOGGER = getLogger(__name__) @@ -63,6 +65,7 @@ def from_credential( :return: A new instance of the _AsyncConfigurationClientWrapper class :rtype: _AsyncConfigurationClientWrapper """ + feature_flag_enabled = kwargs.pop("feature_flag_enabled", False) return cls( endpoint, AzureAppConfigurationClient( @@ -73,6 +76,18 @@ def from_credential( retry_backoff_max=retry_backoff_max, **kwargs, ), + ( + FeatureFlagClient( + endpoint, + credential, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ) + if feature_flag_enabled + else None + ), ) @classmethod @@ -91,6 +106,7 @@ def from_connection_string( :return: A new instance of the _AsyncConfigurationClientWrapper class :rtype: _AsyncConfigurationClientWrapper """ + feature_flag_enabled = kwargs.pop("feature_flag_enabled", False) return cls( endpoint, AzureAppConfigurationClient.from_connection_string( @@ -100,6 +116,17 @@ def from_connection_string( retry_backoff_max=retry_backoff_max, **kwargs, ), + ( + FeatureFlagClient.from_connection_string( + connection_string, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ) + if feature_flag_enabled + else None + ), ) async def _check_configuration_setting( @@ -172,7 +199,7 @@ async def load_configuration_settings( async for config in page: if not isinstance(config, FeatureFlagConfigurationSetting): configuration_settings.append(config) - selector_etags.append(iterator.etag) # type: ignore[attr-defined] + selector_etags.append(iterator.etag) page_etags.append(selector_etags) return configuration_settings, page_etags @@ -220,8 +247,6 @@ async def load_feature_flags( """ loaded_feature_flags: List[FeatureFlagConfigurationSetting] = [] page_etags: List[List[str]] = [] - # Needs to be removed unknown keyword argument for list_configuration_settings - kwargs.pop("sentinel_keys", None) for select in feature_flag_selectors: selector_etags: List[str] = [] if select.snapshot_name is not None: @@ -247,7 +272,7 @@ async def load_feature_flags( async for ff in page: if isinstance(ff, FeatureFlagConfigurationSetting): loaded_feature_flags.append(ff) - selector_etags.append(iterator.etag) # type: ignore[attr-defined] + selector_etags.append(iterator.etag) page_etags.append(selector_etags) return loaded_feature_flags, page_etags @@ -284,6 +309,72 @@ async def check_feature_flag_page_etags( return True return False + @distributed_trace + async def load_enhanced_feature_flags( + self, feature_flag_selectors: List[FeatureFlagSelector], **kwargs + ) -> Tuple[List[FeatureFlag], List[List[str]]]: + """ + Loads enhanced feature flags from the enhanced feature flag endpoint using page-based iteration. + + :param feature_flag_selectors: List of feature flag selectors to filter feature flags + :type feature_flag_selectors: List[FeatureFlagSelector] + :return: A tuple of (feature_flags, page_etags_per_selector), with one page etags entry per selector, in the + same relative order as ``feature_flag_selectors``. + :rtype: Tuple[List[~azure.appconfiguration.FeatureFlag], List[List[str]]] + """ + loaded_feature_flags: List[FeatureFlag] = [] + if self._enhanced_feature_flag_client is None: + return loaded_feature_flags, [[] for _ in feature_flag_selectors] + page_etags: List[List[str]] = [] + for select in feature_flag_selectors: + selector_etags: List[str] = [] + feature_flags = self._enhanced_feature_flag_client.list_feature_flags( + name_filter=select.name_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + iterator = feature_flags.by_page() + async for page in iterator: + async for ff in page: + loaded_feature_flags.append(ff) + selector_etags.append(iterator.etag) + page_etags.append(selector_etags) + return loaded_feature_flags, page_etags + + @distributed_trace + async def check_enhanced_feature_flag_etags( + self, feature_flag_selectors: List[FeatureFlagSelector], page_etags: List[List[str]], **kwargs + ) -> bool: + """ + Checks if any enhanced feature flag page has changed using page etags. + + :param feature_flag_selectors: List of feature flag selectors for feature flags + :type feature_flag_selectors: List[FeatureFlagSelector] + :param page_etags: The page etags from the last load, one entry per selector, in the same relative order as + ``feature_flag_selectors``. + :type page_etags: List[List[str]] + :return: True if any page has changed, False otherwise + :rtype: bool + """ + if self._enhanced_feature_flag_client is None: + return False + for i, select in enumerate(feature_flag_selectors): + if i >= len(page_etags): + # Missing or stale etag state should trigger a refresh instead of failing. + return True + selector_etags = page_etags[i] + feature_flags = self._enhanced_feature_flag_client.list_feature_flags( + name_filter=select.name_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + async for _ in feature_flags.by_page(match_conditions=selector_etags): # type: ignore[call-arg] + # If any page is returned, it means that page has changed + return True + return False + @distributed_trace async def get_updated_watched_settings( self, watched_settings: Mapping[Tuple[str, str], Optional[str]], headers: Dict[str, str], **kwargs @@ -364,13 +455,19 @@ async def close(self) -> None: Closes the connection to Azure App Configuration. """ await self._client.close() + if self._enhanced_feature_flag_client is not None: + await self._enhanced_feature_flag_client.close() async def __aenter__(self): await self._client.__aenter__() + if self._enhanced_feature_flag_client is not None: + await self._enhanced_feature_flag_client.__aenter__() return self async def __aexit__(self, *args): await self._client.__aexit__(*args) + if self._enhanced_feature_flag_client is not None: + await self._enhanced_feature_flag_client.__aexit__(*args) async def resolve_snapshot_reference(self, setting: ConfigurationSetting, **kwargs) -> List[ConfigurationSetting]: """ diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_load.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_load.py index 81a8854340aa..4a857b9986df 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_load.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_load.py @@ -13,12 +13,13 @@ overload, List, Tuple, + Union, ) from azure.core.credentials_async import AsyncTokenCredential from .._constants import ( DEFAULT_STARTUP_TIMEOUT, ) -from .._models import AzureAppConfigurationKeyVaultOptions, SettingSelector +from .._models import AzureAppConfigurationKeyVaultOptions, FeatureFlagSelector, SettingSelector from .._utils import ( delay_failure, process_load_parameters, @@ -49,7 +50,7 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only on_refresh_success: Optional[Callable] = None, on_refresh_error: Optional[Callable[[Exception], Awaitable[None]]] = None, feature_flag_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = None, + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = None, feature_flag_refresh_enabled: bool = False, startup_timeout: int = DEFAULT_STARTUP_TIMEOUT, **kwargs, @@ -87,9 +88,11 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only :paramtype on_refresh_error: Optional[Callable[[Exception], Awaitable[None]]] :keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False. :paramtype feature_flag_enabled: bool - :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all - feature flags without a label. - :paramtype feature_flag_selectors: List[SettingSelector] + :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. Either a list of + ~azure.appconfiguration.provider.SettingSelector or a list of + ~azure.appconfiguration.provider.FeatureFlagSelector (the two types cannot be mixed in the same list). + By default will load all feature flags without a label. + :paramtype feature_flag_selectors: Union[List[SettingSelector], List[FeatureFlagSelector]] :keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is False. :paramtype feature_flag_refresh_enabled: bool @@ -124,7 +127,7 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only on_refresh_success: Optional[Callable] = None, on_refresh_error: Optional[Callable[[Exception], Awaitable[None]]] = None, feature_flag_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = None, + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = None, feature_flag_refresh_enabled: bool = False, startup_timeout: int = DEFAULT_STARTUP_TIMEOUT, **kwargs, @@ -164,9 +167,11 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only :paramtype on_refresh_error: Optional[Callable[[Exception], Awaitable[None]]] :keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False. :paramtype feature_flag_enabled: bool - :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all - feature flags without a label. - :paramtype feature_flag_selectors: List[SettingSelector] + :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. Either a list of + ~azure.appconfiguration.provider.SettingSelector or a list of + ~azure.appconfiguration.provider.FeatureFlagSelector (the two types cannot be mixed in the same list). + By default will load all feature flags without a label. + :paramtype feature_flag_selectors: Union[List[SettingSelector], List[FeatureFlagSelector]] :keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is False. :paramtype feature_flag_refresh_enabled: bool diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py index 458bd10bcce8..a12289af0086 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py @@ -19,6 +19,7 @@ ) from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, + FeatureFlag, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, ) @@ -120,6 +121,7 @@ async def _attempt_refresh( ) configuration_settings: List[ConfigurationSetting] = [] feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None + enhanced_feature_flags: Optional[List[FeatureFlag]] = None # Timer needs to be reset even if no refresh happened if time had passed configuration_refresh_attempted = False @@ -128,6 +130,7 @@ async def _attempt_refresh( existing_feature_flag_usage = self._tracing_context.feature_filter_usage.copy() page_etags: List[List[str]] = [] feature_flag_page_etags: List[List[str]] = [] + enhanced_feature_flag_etags: List[List[str]] = [] try: if self._refresh_enabled and not self._watched_settings and self._refresh_timer.needs_refresh(): configuration_refresh_attempted = True @@ -160,6 +163,16 @@ async def _attempt_refresh( feature_flags, feature_flag_page_etags = await client.load_feature_flags( self._feature_flag_selectors, headers=headers, **kwargs ) + + # Enhanced feature flags are loaded independently of the key-value based feature flags, using their + # own page-level etag state, since they are a separate resource type with a separate + # change-detection mechanism. + if not self._enhanced_feature_flag_etags or await client.check_enhanced_feature_flag_etags( + self._enhanced_feature_flag_selectors, self._enhanced_feature_flag_etags, headers=headers, **kwargs + ): + enhanced_feature_flags, enhanced_feature_flag_etags = await client.load_enhanced_feature_flags( + self._enhanced_feature_flag_selectors, headers=headers, **kwargs + ) # Default to existing settings if no refresh occurred processed_settings = self._dict @@ -169,7 +182,9 @@ async def _attempt_refresh( # Configuration Settings have been refreshed processed_settings = await self._process_configurations(configuration_settings, client) - processed_settings = self._process_feature_flags(processed_settings, processed_feature_flags, feature_flags) + processed_settings = self._process_and_merge_feature_flags( + processed_settings, processed_feature_flags, feature_flags, enhanced_feature_flags + ) self._dict = processed_settings if settings_refreshed: self._page_etags = page_etags @@ -177,12 +192,14 @@ async def _attempt_refresh( self._watched_settings.update(updated_watched_settings) if feature_flags is not None: self._feature_flag_page_etags = feature_flag_page_etags + if enhanced_feature_flags is not None: + self._enhanced_feature_flag_etags = enhanced_feature_flag_etags # Reset timers at the same time as they should load from the same store. if configuration_refresh_attempted: self._refresh_timer.reset() if self._feature_flag_refresh_enabled and feature_flag_refresh_attempted: self._feature_flag_refresh_timer.reset() - if (settings_refreshed or feature_flags) and self._on_refresh_success: + if (settings_refreshed or feature_flags or enhanced_feature_flags) and self._on_refresh_success: self._on_refresh_success() except AzureError as e: logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint) @@ -290,6 +307,7 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A processed_settings = await self._process_configurations(configuration_settings, client) feature_flag_page_etags: List[List[str]] = [] + enhanced_feature_flag_etags: List[List[str]] = [] if self._feature_flag_enabled: feature_flags: List[FeatureFlagConfigurationSetting] feature_flags, feature_flag_page_etags = await client.load_feature_flags( @@ -297,7 +315,14 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A headers=headers, **kwargs, ) - processed_settings = self._process_feature_flags(processed_settings, [], feature_flags) + enhanced_feature_flags, enhanced_feature_flag_etags = await client.load_enhanced_feature_flags( + self._enhanced_feature_flag_selectors, + headers=headers, + **kwargs, + ) + processed_settings = self._process_and_merge_feature_flags( + processed_settings, [], feature_flags, enhanced_feature_flags + ) for (key, label), etag in self._watched_settings.items(): if not etag: try: @@ -324,6 +349,7 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A self._dict = processed_settings self._page_etags = page_etags self._feature_flag_page_etags = feature_flag_page_etags + self._enhanced_feature_flag_etags = enhanced_feature_flag_etags return True except AzureError as e: logger.warning("Failed to load configurations from endpoint %s.\n %s", client.endpoint, e.message) @@ -390,7 +416,7 @@ async def _process_configurations( await self._configuration_mapper(setting) if isinstance(setting, FeatureFlagConfigurationSetting): # Feature flags are not processed like other settings - feature_flag_value = self._process_feature_flag(setting) + feature_flag_value = self._process_kv_feature_flag(setting) feature_flags_processed.append(feature_flag_value) else: key = self._process_key_name(setting) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md index 90b21ea17f95..83138f21b560 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md @@ -49,6 +49,8 @@ pip install azure.appconfiguration.provider | entra_id_sample.py | demos connecting to app configuration with Entra ID | | connection_string_sample.py | demos connecting to app configuration with a Connection String | | key_vault_reference_sample.py | demos resolving key vault references with App Configuration | +| enhanced_feature_flag_sample.py | demos loading feature flags created via the dedicated enhanced feature flag endpoint | +| async_enhanced_feature_flag_sample.py | async version of enhanced_feature_flag_sample.py | ## Next steps diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_enhanced_feature_flag_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_enhanced_feature_flag_sample.py new file mode 100644 index 000000000000..d4dd51215445 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_enhanced_feature_flag_sample.py @@ -0,0 +1,99 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +""" +FILE: async_enhanced_feature_flag_sample.py +DESCRIPTION: + This sample demonstrates loading feature flags that were created using the dedicated enhanced feature flag + endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the key-value + based feature flags stored as configuration settings. The provider loads both kinds of feature + flags side by side into the same ``feature_management.feature_flags`` list, so no additional + ``load()`` options are required to opt in. This is the async version of enhanced_feature_flag_sample.py. +USAGE: python async_enhanced_feature_flag_sample.py + Set the environment variable APPCONFIGURATION_ENDPOINT_STRING with your App Configuration + connection endpoint before running the sample. +""" +import os +import asyncio +from sample_utilities import get_authority, get_credential, get_client_modifications +from azure.appconfiguration.aio import FeatureFlagClient +from azure.appconfiguration import FeatureFlag +from azure.appconfiguration.provider.aio import load +from azure.appconfiguration.provider import SettingSelector + + +async def main(): + endpoint = os.environ["APPCONFIGURATION_ENDPOINT_STRING"] + authority = get_authority(endpoint) + credential = get_credential(authority, is_async=True) + kwargs = get_client_modifications() + + # Creating a feature flag using the dedicated enhanced feature flag endpoint. This is a separate + # resource type from the key-value based feature flags, and is managed via FeatureFlagClient + # instead of AzureAppConfigurationClient. + feature_flag_client = FeatureFlagClient(endpoint, credential, **kwargs) + await feature_flag_client.set_feature_flag(FeatureFlag(name="EnhancedFeatureBeta", enabled=True)) + + try: + # [START enhanced_feature_flag_loading_async] + from azure.appconfiguration.provider.aio import load + + # Feature flags loaded from the enhanced feature flag endpoint are merged into the same + # feature_management.feature_flags list as key-value based feature flags. + config = await load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, **kwargs) + feature_flags = config["feature_management"]["feature_flags"] + enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") + print(enhanced_flag_beta["enabled"]) + + await config.close() + # [END enhanced_feature_flag_loading_async] + + # [START enhanced_feature_flag_selector_async] + from azure.appconfiguration.provider.aio import load + from azure.appconfiguration.provider import SettingSelector + + # The same SettingSelector used to filter key-value based feature flags also filters enhanced feature + # flags, by name/label/tags. + config = await load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Enhanced*")], + **kwargs, + ) + feature_flags = config["feature_management"]["feature_flags"] + enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") + print(enhanced_flag_beta["enabled"]) + + await config.close() + # [END enhanced_feature_flag_selector_async] + + # [START enhanced_feature_flag_selector_with_feature_flag_selector_async] + from azure.appconfiguration.provider.aio import load + from azure.appconfiguration.provider import FeatureFlagSelector + + # FeatureFlagSelector is the dedicated selector type for filtering enhanced feature flags. + config = await load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[FeatureFlagSelector(name_filter="Enhanced*")], + **kwargs, + ) + feature_flags = config["feature_management"]["feature_flags"] + enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") + print(enhanced_flag_beta["enabled"]) + + await config.close() + # [END enhanced_feature_flag_selector_with_feature_flag_selector_async] + finally: + # Cleaning up the enhanced feature flag created for this sample. + await feature_flag_client.delete_feature_flag("EnhancedFeatureBeta") + await feature_flag_client.close() + await credential.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py new file mode 100644 index 000000000000..58bd72e141df --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py @@ -0,0 +1,81 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +""" +FILE: enhanced_feature_flag_sample.py +DESCRIPTION: + This sample demonstrates loading feature flags that were created using the dedicated enhanced feature flag + endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the key-value + based feature flags stored as configuration settings. The provider loads both kinds of feature + flags side by side into the same ``feature_management.feature_flags`` list, so no additional + ``load()`` options are required to opt in. +USAGE: python enhanced_feature_flag_sample.py + Set the environment variable APPCONFIGURATION_ENDPOINT_STRING with your App Configuration + connection endpoint before running the sample. +""" +import os +from sample_utilities import get_authority, get_credential, get_client_modifications +from azure.appconfiguration import FeatureFlag, FeatureFlagClient +from azure.appconfiguration.provider import load, SettingSelector + +endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING") +authority = get_authority(endpoint) +credential = get_credential(authority) +kwargs = get_client_modifications() + +# Creating a feature flag using the dedicated enhanced feature flag endpoint. This is a separate resource +# type from the key-value based feature flags, and is managed via FeatureFlagClient instead of +# AzureAppConfigurationClient. +feature_flag_client = FeatureFlagClient(endpoint, credential, **kwargs) +feature_flag_client.set_feature_flag(FeatureFlag(name="EnhancedFeatureBeta", enabled=True)) + +try: + # [START enhanced_feature_flag_loading] + from azure.appconfiguration.provider import load + + # Feature flags loaded from the enhanced feature flag endpoint are merged into the same + # feature_management.feature_flags list as key-value based feature flags. + config = load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, **kwargs) + feature_flags = config["feature_management"]["feature_flags"] + enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") + print(enhanced_flag_beta["enabled"]) + # [END enhanced_feature_flag_loading] + + # [START enhanced_feature_flag_selector] + from azure.appconfiguration.provider import load, SettingSelector + + # The same SettingSelector used to filter key-value based feature flags also filters enhanced feature + # flags, by name/label/tags. + config = load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Enhanced*")], + **kwargs, + ) + feature_flags = config["feature_management"]["feature_flags"] + enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") + print(enhanced_flag_beta["enabled"]) + # [END enhanced_feature_flag_selector] + + # [START enhanced_feature_flag_selector_with_feature_flag_selector] + from azure.appconfiguration.provider import load, FeatureFlagSelector + + # FeatureFlagSelector is the dedicated selector type for filtering enhanced feature flags. + config = load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[FeatureFlagSelector(name_filter="Enhanced*")], + **kwargs, + ) + feature_flags = config["feature_management"]["feature_flags"] + enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") + print(enhanced_flag_beta["enabled"]) + # [END enhanced_feature_flag_selector_with_feature_flag_selector] +finally: + # Cleaning up the enhanced feature flag created for this sample. + feature_flag_client.delete_feature_flag("EnhancedFeatureBeta") + feature_flag_client.close() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/setup.py b/sdk/appconfiguration/azure-appconfiguration-provider/setup.py index d756d6d66783..8d01796d2530 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/setup.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/setup.py @@ -61,19 +61,16 @@ "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "License :: OSI Approved :: MIT License", ], zip_safe=False, packages=find_packages(exclude=exclude_packages), - python_requires=">=3.6", + python_requires=">=3.10", install_requires=[ "azure-core>=1.31.0", - "azure-appconfiguration>=1.8.0", + "azure-appconfiguration>=1.10.0b1", "azure-keyvault-secrets>=4.3.0", "dnspython>=2.6.1", ], diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_enhanced_feature_flags.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_enhanced_feature_flags.py new file mode 100644 index 000000000000..051ad70ad882 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_enhanced_feature_flags.py @@ -0,0 +1,153 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +""" +Tests for loading feature flags from the dedicated enhanced feature flag endpoint +(``FeatureFlagClient``/``FeatureFlag``), as opposed to the key-value based +``FeatureFlagConfigurationSetting`` stored via ``AzureAppConfigurationClient`` (async version). +""" +import functools +from devtools_testutils import EnvironmentVariableLoader +from devtools_testutils.aio import recorded_by_proxy_async +from testcase import has_feature_flag, get_feature_flag +from asynctestcase import AppConfigTestCase +from test_constants import APPCONFIGURATION_ENDPOINT_STRING, FEATURE_MANAGEMENT_KEY +from azure.appconfiguration import FeatureFlag, FeatureFlagConfigurationSetting +from azure.appconfiguration.provider import SettingSelector +from azure.appconfiguration.provider._constants import NULL_CHAR + +AppConfigProviderPreparer = functools.partial( + EnvironmentVariableLoader, + "appconfiguration", + appconfiguration_endpoint_string=APPCONFIGURATION_ENDPOINT_STRING, +) + + +class TestAppConfigurationProviderEnhancedFeatureFlags(AppConfigTestCase): + """Tests for the provider loading feature flags from the dedicated enhanced feature flag endpoint (async).""" + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_load_enhanced_feature_flag(self, appconfiguration_endpoint_string): + """A feature flag created via the enhanced feature flag endpoint should be loaded by the provider.""" + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceOnlyFeature", enabled=True) + await feature_flag_client.set_feature_flag(feature_flag) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="ResourceOnlyFeature")], + ) as client: + assert FEATURE_MANAGEMENT_KEY in client + assert has_feature_flag(client, "ResourceOnlyFeature", enabled=True) + finally: + await feature_flag_client.delete_feature_flag("ResourceOnlyFeature") + await feature_flag_client.close() + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_load_enhanced_feature_flag_disabled(self, appconfiguration_endpoint_string): + """A disabled enhanced feature flag should be loaded with enabled set to False.""" + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceDisabledFeature", enabled=False) + await feature_flag_client.set_feature_flag(feature_flag) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="ResourceDisabledFeature")], + ) as client: + assert has_feature_flag(client, "ResourceDisabledFeature", enabled=False) + finally: + await feature_flag_client.delete_feature_flag("ResourceDisabledFeature") + await feature_flag_client.close() + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_load_enhanced_feature_flag_with_label(self, appconfiguration_endpoint_string): + """An enhanced feature flag with a label should be loaded when the label filter matches.""" + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceLabeledFeature", enabled=True, label="test_label") + await feature_flag_client.set_feature_flag(feature_flag) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[ + SettingSelector(key_filter="ResourceLabeledFeature", label_filter="test_label") + ], + ) as client: + assert has_feature_flag(client, "ResourceLabeledFeature", enabled=True) + finally: + await feature_flag_client.delete_feature_flag("ResourceLabeledFeature", label="test_label") + await feature_flag_client.close() + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_enhanced_feature_flag_selector_filters_by_name(self, appconfiguration_endpoint_string): + """The feature_flag_selectors key_filter should scope which enhanced feature flags are loaded.""" + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + included_flag = FeatureFlag(name="IncludedResourceFeature", enabled=True) + excluded_flag = FeatureFlag(name="ExcludedResourceFeature", enabled=True) + await feature_flag_client.set_feature_flag(included_flag) + await feature_flag_client.set_feature_flag(excluded_flag) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Included*")], + ) as client: + assert has_feature_flag(client, "IncludedResourceFeature", enabled=True) + assert not has_feature_flag(client, "ExcludedResourceFeature") + finally: + await feature_flag_client.delete_feature_flag("IncludedResourceFeature") + await feature_flag_client.delete_feature_flag("ExcludedResourceFeature") + await feature_flag_client.close() + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_enhanced_feature_flag_overrides_key_value(self, appconfiguration_endpoint_string): + """An enhanced feature flag should take precedence over a key-value based feature flag with the + same identifier when both are loaded.""" + appconfig_client = self.create_appconfig_client(appconfiguration_endpoint_string) + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + + kv_feature_flag = FeatureFlagConfigurationSetting(feature_id="OverlapFeature", enabled=False, label=NULL_CHAR) + await appconfig_client.set_configuration_setting(kv_feature_flag) + enhanced_feature_flag_obj = FeatureFlag(name="OverlapFeature", enabled=True) + await feature_flag_client.set_feature_flag(enhanced_feature_flag_obj) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="OverlapFeature")], + ) as client: + # The enhanced feature flag (enabled=True) should win over the key-value based one + # (enabled=False) since they share the same identifier. + assert has_feature_flag(client, "OverlapFeature", enabled=True) + feature_flag = get_feature_flag(client, "OverlapFeature") + assert feature_flag is not None + assert "id" in feature_flag + finally: + await appconfig_client.delete_configuration_setting(key=kv_feature_flag.key, label=kv_feature_flag.label) + await feature_flag_client.delete_feature_flag("OverlapFeature") + await appconfig_client.close() + await feature_flag_client.close() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py index a433b81ce018..65d389d35186 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py @@ -6,7 +6,7 @@ # -------------------------------------------------------------------------- from devtools_testutils import AzureRecordedTestCase from testcase import get_configs -from azure.appconfiguration.aio import AzureAppConfigurationClient +from azure.appconfiguration.aio import AzureAppConfigurationClient, FeatureFlagClient from azure.appconfiguration.provider import AzureAppConfigurationKeyVaultOptions from azure.appconfiguration.provider.aio import load @@ -35,6 +35,10 @@ def create_appconfig_client(self, appconfiguration_endpoint_string): cred = self.get_credential(AzureAppConfigurationClient, is_async=True) return AzureAppConfigurationClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") + def create_enhanced_feature_flag_client(self, appconfiguration_endpoint_string): + cred = self.get_credential(FeatureFlagClient, is_async=True) + return FeatureFlagClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") + async def setup_configs(client, keyvault_secret_url, keyvault_secret_url2): async with client: @@ -82,6 +86,24 @@ async def set_test_settings_async(client, settings): await client.set_configuration_setting(setting) +async def cleanup_enhanced_feature_flags_async(feature_flag_client, feature_flags): + """ + Delete enhanced feature flags created via the dedicated enhanced feature flag endpoint (async version). + + :param feature_flag_client: The async FeatureFlagClient to use for cleanup. + :param feature_flags: List of FeatureFlag objects (or (name, label) tuples) to delete. + """ + for feature_flag in feature_flags: + if isinstance(feature_flag, tuple): + name, label = feature_flag + else: + name, label = feature_flag.name, feature_flag.label + try: + await feature_flag_client.delete_feature_flag(name, label=label) + except Exception: # pylint: disable=broad-except + pass + + async def create_snapshot_async(client, snapshot_name, key_filters, composition_type=None, retention_period=3600): """ Create a snapshot in Azure App Configuration and verify it was created successfully (async version). diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py index 5073f2afad31..551257e60ac4 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -11,12 +11,23 @@ from typing import Dict, Any from azure.appconfiguration import FeatureFlagConfigurationSetting +from azure.appconfiguration import ( + FeatureFlag, + FeatureFlagAllocation, + FeatureFlagConditions, + FeatureFlagFilter, + FeatureFlagTelemetryConfiguration, + FeatureFlagVariantDefinition, + GroupAllocation, + PercentileAllocation, + UserAllocation, +) from azure.appconfiguration.provider._azureappconfigurationproviderbase import ( is_json_content_type, _build_watched_setting, AzureAppConfigurationProviderBase, ) -from azure.appconfiguration.provider._models import SettingSelector +from azure.appconfiguration.provider._models import SettingSelector, FeatureFlagSelector from azure.appconfiguration.provider._constants import ( NULL_CHAR, TELEMETRY_KEY, @@ -201,6 +212,39 @@ def test_initialization_with_custom_values(self): self.assertTrue(provider._feature_flag_enabled) self.assertEqual(provider._refresh_timer._interval, 60) + def test_enhanced_feature_flag_selectors_excludes_snapshot_selectors(self): + key_select = SettingSelector(key_filter="app:*") + snapshot_select = SettingSelector(snapshot_name="my-snapshot") + feature_flag_selectors = [snapshot_select, key_select] + + provider = AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + feature_flag_selectors=feature_flag_selectors, + ) + + self.assertEqual(provider._feature_flag_selectors, feature_flag_selectors) + self.assertEqual(len(provider._enhanced_feature_flag_selectors), 1) + self.assertIsInstance(provider._enhanced_feature_flag_selectors[0], FeatureFlagSelector) + self.assertEqual(provider._enhanced_feature_flag_selectors[0].name_filter, key_select.key_filter) + self.assertEqual(provider._enhanced_feature_flag_selectors[0].label_filter, key_select.label_filter) + + def test_feature_flag_selectors_none_defaults_to_all_unlabeled_flags(self): + provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + self.assertEqual(len(provider._feature_flag_selectors), 1) + self.assertEqual(provider._feature_flag_selectors[0].key_filter, "*") + self.assertEqual(len(provider._enhanced_feature_flag_selectors), 1) + self.assertEqual(provider._enhanced_feature_flag_selectors[0].name_filter, "*") + + def test_feature_flag_selectors_explicit_empty_list_loads_none(self): + provider = AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + feature_flag_selectors=[], + ) + + self.assertEqual(provider._feature_flag_selectors, []) + self.assertEqual(provider._enhanced_feature_flag_selectors, []) + def test_process_key_name_with_no_prefix(self): """Test key name processing with no matching prefix.""" config = Mock() @@ -398,3 +442,243 @@ def test_generate_allocation_id_truly_empty(self): result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) # This should return None because allocated_variants is empty and no seed self.assertIsNone(result) + + +class TestProcessEnhancedFeatureFlag(unittest.TestCase): + """Test processing of feature flags loaded from the dedicated enhanced feature flag endpoint.""" + + def setUp(self): + self.provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + def test_process_enhanced_feature_flag_minimal(self): + """Test processing a minimal enhanced feature flag.""" + feature_flag = FeatureFlag(name="MyFeature", enabled=True) + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + self.assertEqual(result["id"], "MyFeature") + self.assertTrue(result["enabled"]) + self.assertNotIn("label", result) + self.assertNotIn("description", result) + self.assertNotIn("conditions", result) + self.assertNotIn("variants", result) + self.assertNotIn("allocation", result) + self.assertNotIn("tags", result) + # Telemetry metadata (ETag) is always attached during processing, even without an explicit + # telemetry configuration on the enhanced feature flag. + self.assertIn("telemetry", result) + self.assertNotIn("enabled", result["telemetry"]) + + def test_process_enhanced_feature_flag_sets_uses_enhanced_feature_flags_tracing(self): + """Processing and merging enhanced feature flags should mark the tracing context as having used the + enhanced feature flag endpoint, for the Correlation-Context telemetry header. The flag should reset to + False if a subsequent refresh returns no enhanced feature flags.""" + self.assertFalse(self.provider._tracing_context.uses_enhanced_feature_flags) + + feature_flag = FeatureFlag(name="MyFeature", enabled=True) + self.provider._process_and_merge_feature_flags({}, [], [], [feature_flag]) + + self.assertTrue(self.provider._tracing_context.uses_enhanced_feature_flags) + + self.provider._process_and_merge_feature_flags({}, [], [], []) + + self.assertFalse(self.provider._tracing_context.uses_enhanced_feature_flags) + + def test_process_enhanced_feature_flag_with_label_and_description(self): + """Test processing an enhanced feature flag with label and description.""" + feature_flag = FeatureFlag(name="MyFeature", enabled=False, label="prod", description="A test feature") + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + self.assertEqual(result["id"], "MyFeature") + self.assertFalse(result["enabled"]) + self.assertEqual(result["label"], "prod") + self.assertEqual(result["description"], "A test feature") + + def test_process_enhanced_feature_flag_with_conditions(self): + """Test processing an enhanced feature flag with conditions/client filters.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + conditions=FeatureFlagConditions( + requirement_type="All", + client_filters=[FeatureFlagFilter(name="Percentage", parameters={"Value": "50"})], + ), + ) + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + self.assertEqual(result["conditions"]["requirement_type"], "All") + self.assertEqual(len(result["conditions"]["client_filters"]), 1) + self.assertEqual(result["conditions"]["client_filters"][0]["name"], "Percentage") + self.assertEqual(result["conditions"]["client_filters"][0]["parameters"], {"Value": "50"}) + + def test_process_enhanced_feature_flag_with_variants_and_allocation(self): + """Test processing an enhanced feature flag with variants and allocation.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + variants=[ + FeatureFlagVariantDefinition(name="Control", value={"key": "control_value"}), + FeatureFlagVariantDefinition(name="Test", value={"key": "test_value"}, content_type="application/json"), + ], + allocation=FeatureFlagAllocation( + default_when_disabled="Control", + default_when_enabled="Test", + percentile=[PercentileAllocation(variant="Control", percentile_from=0, percentile_to=50)], + user=[UserAllocation(variant="Test", users=["user1"])], + group=[GroupAllocation(variant="Test", groups=["group1"])], + seed="1234", + ), + ) + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + self.assertEqual(len(result["variants"]), 2) + self.assertEqual(result["variants"][0]["name"], "Control") + self.assertEqual(result["variants"][0]["configuration_value"], {"key": "control_value"}) + self.assertEqual(result["variants"][1]["content_type"], "application/json") + + allocation = result["allocation"] + self.assertEqual(allocation["default_when_disabled"], "Control") + self.assertEqual(allocation["default_when_enabled"], "Test") + self.assertEqual(allocation["percentile"], [{"variant": "Control", "from": 0, "to": 50}]) + self.assertEqual(allocation["user"], [{"variant": "Test", "users": ["user1"]}]) + self.assertEqual(allocation["group"], [{"variant": "Test", "groups": ["group1"]}]) + self.assertEqual(allocation["seed"], "1234") + + def test_process_enhanced_feature_flag_with_telemetry_and_tags(self): + """Test processing an enhanced feature flag with telemetry settings and tags.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + telemetry=FeatureFlagTelemetryConfiguration(enabled=True, metadata={"custom": "value"}), + tags={"team": "infra"}, + ) + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + # Telemetry metadata gets ETag/FeatureFlagReference metadata appended by + # _update_enhanced_feature_flag_telemetry_metadata as part of processing. + self.assertTrue(result["telemetry"]["enabled"]) + self.assertEqual(result["telemetry"]["metadata"]["custom"], "value") + self.assertEqual(result["tags"], {"team": "infra"}) + + def test_process_enhanced_feature_flag_updates_telemetry_metadata(self): + """Test that processing an enhanced feature flag adds ETag/FeatureFlagReference telemetry metadata.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + label="prod", + telemetry=FeatureFlagTelemetryConfiguration(enabled=True), + ) + feature_flag.etag = "enhanced_etag" + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + metadata = result["telemetry"][METADATA_KEY] + self.assertEqual(metadata[ETAG_KEY], "enhanced_etag") + self.assertIn(FEATURE_FLAG_REFERENCE_KEY, metadata) + # The enhanced feature flag reference uses the "ff" path segment, not "kv". + self.assertIn("/ff/MyFeature", metadata[FEATURE_FLAG_REFERENCE_KEY]) + self.assertIn("?label=prod", metadata[FEATURE_FLAG_REFERENCE_KEY]) + + +class TestUpdateEnhancedFeatureFlagTelemetryMetadata(unittest.TestCase): + """Test the _update_enhanced_feature_flag_telemetry_metadata method.""" + + def setUp(self): + self.provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + def test_update_enhanced_feature_flag_telemetry_metadata(self): + """Test enhanced feature flag telemetry processing uses the 'ff' reference segment.""" + feature_flag = FeatureFlag(name="test_feature", enabled=True, label="test_label") + feature_flag.etag = "test_etag" + + feature_flag_value: Dict[str, Any] = {TELEMETRY_KEY: {"enabled": True}} + endpoint = "https://test.azconfig.io" + + self.provider._update_enhanced_feature_flag_telemetry_metadata(endpoint, feature_flag, feature_flag_value) + + metadata = feature_flag_value[TELEMETRY_KEY][METADATA_KEY] + self.assertEqual(metadata[ETAG_KEY], "test_etag") + self.assertIn(FEATURE_FLAG_REFERENCE_KEY, metadata) + self.assertIn("/ff/test_feature", metadata[FEATURE_FLAG_REFERENCE_KEY]) + self.assertIn("?label=test_label", metadata[FEATURE_FLAG_REFERENCE_KEY]) + + +class TestMergeFeatureFlags(unittest.TestCase): + """Test the _merge_feature_flags static method.""" + + def test_merge_no_overlap(self): + """Test merging when there is no identifier overlap between the two sources.""" + kv_flags = [{"id": "KvFeature", "enabled": True}] + enhanced_flags = [{"id": "EnhancedFeature", "enabled": False}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, enhanced_flags) + + self.assertEqual(len(merged), 2) + self.assertIn({"id": "KvFeature", "enabled": True}, merged) + self.assertIn({"id": "EnhancedFeature", "enabled": False}, merged) + + def test_merge_enhanced_takes_precedence_on_collision(self): + """Test that an enhanced feature flag overrides a key-value one with the same identifier.""" + kv_flags = [{"id": "SharedFeature", "enabled": False, "source": "kv"}] + enhanced_flags = [{"id": "SharedFeature", "enabled": True, "source": "enhanced"}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, enhanced_flags) + + self.assertEqual(len(merged), 1) + self.assertEqual(merged[0]["source"], "enhanced") + self.assertTrue(merged[0]["enabled"]) + + def test_merge_empty_lists(self): + """Test merging two empty lists returns an empty list.""" + merged = AzureAppConfigurationProviderBase._merge_feature_flags([], []) + self.assertEqual(merged, []) + + def test_merge_only_kv_flags(self): + """Test merging when only key-value based feature flags are present.""" + kv_flags = [{"id": "Feature1", "enabled": True}, {"id": "Feature2", "enabled": False}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, []) + + self.assertEqual(len(merged), 2) + + def test_merge_only_enhanced_flags(self): + """Test merging when only enhanced feature flags are present.""" + enhanced_flags = [{"id": "Feature1", "enabled": True}, {"id": "Feature2", "enabled": False}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags([], enhanced_flags) + + self.assertEqual(len(merged), 2) + + +class TestProcessAndMergeFeatureFlags(unittest.TestCase): + """Test _process_and_merge_feature_flags distinguishes None (not loaded this round) from an explicitly + empty list (loaded this round, zero found).""" + + def setUp(self): + self.provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + def test_enhanced_feature_flags_none_preserves_previous_processed_flags(self): + feature_flag = FeatureFlag(name="MyFeature", enabled=True) + self.provider._process_and_merge_feature_flags({}, [], None, [feature_flag]) + self.assertEqual(len(self.provider._processed_enhanced_feature_flags), 1) + + # Passing None again should leave the previously processed enhanced feature flags untouched. + self.provider._process_and_merge_feature_flags({}, [], None, None) + self.assertEqual(len(self.provider._processed_enhanced_feature_flags), 1) + + def test_enhanced_feature_flags_explicit_empty_list_clears_previous_processed_flags(self): + feature_flag = FeatureFlag(name="MyFeature", enabled=True) + self.provider._process_and_merge_feature_flags({}, [], None, [feature_flag]) + self.assertEqual(len(self.provider._processed_enhanced_feature_flags), 1) + + # An explicitly empty list means the endpoint was queried and returned zero feature flags. + self.provider._process_and_merge_feature_flags({}, [], None, []) + self.assertEqual(self.provider._processed_enhanced_feature_flags, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py index 5edaab158bf4..986239f61dfc 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py @@ -7,7 +7,7 @@ from unittest.mock import patch, call, Mock, MagicMock import pytest from azure.appconfiguration.provider._client_manager import ConfigurationClientManager, _ConfigurationClientWrapper -from azure.appconfiguration.provider._models import SettingSelector +from azure.appconfiguration.provider._models import SettingSelector, FeatureFlagSelector def _create_mock_credential(): @@ -27,6 +27,22 @@ def __init__(self, endpoint, connection_string, credential, retry_total, retry_b self.retry_backoff = retry_backoff +class _FakePagedIterator: + """Mimics an ItemPaged page iterator, exposing a mutable ``etag`` reflecting the last-yielded page.""" + + def __init__(self, pages): + self._pages = iter(pages) + self.etag = None + + def __iter__(self): + return self + + def __next__(self): + page, etag = next(self._pages) + self.etag = etag + return page + + @pytest.mark.usefixtures("caplog") class TestConfigurationClientManager(unittest.TestCase): @@ -371,3 +387,167 @@ def test_check_page_etags_keys_first_then_snapshot(): mock_client.list_configuration_settings.assert_called_once_with( key_filter="app/*", label_filter="\0", tags_filter=None ) + + +def test_load_enhanced_feature_flags_no_feature_flag_client(): + """When no feature flag client is configured, no service calls are made.""" + mock_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client) + + selects = [SettingSelector(key_filter="app/*"), SettingSelector(key_filter="other/*")] + + feature_flags, page_etags = wrapper.load_enhanced_feature_flags(selects) + + assert feature_flags == [] + assert page_etags == [[], []] + + +def test_load_enhanced_feature_flags_assumes_pre_filtered_selectors(): + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [FeatureFlagSelector(name_filter="app/*")] + + flag1 = Mock(name="flag1") + mock_response = Mock() + mock_response.by_page.return_value = _FakePagedIterator([([flag1], "etag1")]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + + feature_flags, page_etags = wrapper.load_enhanced_feature_flags(selects) + + assert feature_flags == [flag1] + assert page_etags == [["etag1"]] + mock_feature_flag_client.list_feature_flags.assert_called_once_with( + name_filter="app/*", label_filter="\0", tags_filter=None + ) + + +def test_load_enhanced_feature_flags_multiple_pages(): + """Multiple pages should be aggregated and each page's etag collected.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [FeatureFlagSelector(name_filter="app/*")] + + flag1 = Mock(name="flag1") + flag2 = Mock(name="flag2") + + class FakeIterator: + """Mimics an ItemPaged iterator, exposing a mutable ``etag`` reflecting the last-yielded page.""" + + def __init__(self, pages): + self._pages = iter(pages) + self.etag = None + + def __iter__(self): + return self + + def __next__(self): + page, etag = next(self._pages) + self.etag = etag + return page + + mock_response = Mock() + mock_response.by_page.return_value = FakeIterator([([flag1], "etag1"), ([flag2], "etag2")]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + + feature_flags, page_etags = wrapper.load_enhanced_feature_flags(selects) + + assert feature_flags == [flag1, flag2] + assert page_etags == [["etag1", "etag2"]] + + +def test_check_enhanced_feature_flag_etags_no_feature_flag_client(): + """When no feature flag client is configured, no changes are reported.""" + mock_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client) + + selects = [SettingSelector(key_filter="app/*")] + + result = wrapper.check_enhanced_feature_flag_etags(selects, [["etag1"]]) + + assert result is False + + +def test_check_enhanced_feature_flag_etags_no_change(): + """When the returned pages are empty, no changes are reported.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [FeatureFlagSelector(name_filter="app/*")] + page_etags = [["etag1"]] + + mock_response = Mock() + mock_response.by_page.return_value = iter([]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + + result = wrapper.check_enhanced_feature_flag_etags(selects, page_etags) + + assert result is False + mock_feature_flag_client.list_feature_flags.assert_called_once_with( + name_filter="app/*", label_filter="\0", tags_filter=None + ) + mock_response.by_page.assert_called_once_with(match_conditions=["etag1"]) + + +def test_check_enhanced_feature_flag_etags_change_detected(): + """When a page is returned, a change should be reported.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [FeatureFlagSelector(name_filter="app/*")] + page_etags = [["etag1"]] + + mock_response = Mock() + mock_response.by_page.return_value = iter([[Mock()]]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + + result = wrapper.check_enhanced_feature_flag_etags(selects, page_etags) + + assert result is True + + +def test_check_enhanced_feature_flag_etags_assumes_pre_filtered_selectors(): + """The enhanced feature flag endpoint does not support snapshots. Filtering out selectors with a + snapshot_name is the caller's responsibility (done once at startup via + ConfigurationProviderBase._enhanced_feature_flag_selectors), so this method should simply process whatever + selectors it is given.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [FeatureFlagSelector(name_filter="app/*")] + page_etags = [["etag1"]] + + mock_response = Mock() + mock_response.by_page.return_value = iter([]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + + result = wrapper.check_enhanced_feature_flag_etags(selects, page_etags) + + assert result is False + mock_feature_flag_client.list_feature_flags.assert_called_once_with( + name_filter="app/*", label_filter="\0", tags_filter=None + ) + + +def test_check_enhanced_feature_flag_etags_missing_page_etags_triggers_refresh(): + """Missing etag state for a selector should trigger a refresh instead of failing.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [FeatureFlagSelector(name_filter="app/*"), FeatureFlagSelector(name_filter="other/*")] + # Only one entry provided for two selectors; the first selector's page hasn't changed. + mock_response = Mock() + mock_response.by_page.return_value = iter([]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + page_etags = [["etag1"]] + + result = wrapper.check_enhanced_feature_flag_etags(selects, page_etags) + + assert result is True diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_enhanced_feature_flags.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_enhanced_feature_flags.py new file mode 100644 index 000000000000..f2685adfef0b --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_enhanced_feature_flags.py @@ -0,0 +1,150 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +""" +Tests for loading feature flags from the dedicated enhanced feature flag endpoint +(``FeatureFlagClient``/``FeatureFlag``), as opposed to the key-value based +``FeatureFlagConfigurationSetting`` stored via ``AzureAppConfigurationClient``. +""" +import functools +from devtools_testutils import EnvironmentVariableLoader, recorded_by_proxy +from testcase import AppConfigTestCase, has_feature_flag, get_feature_flag +from test_constants import APPCONFIGURATION_ENDPOINT_STRING, FEATURE_MANAGEMENT_KEY +from azure.appconfiguration import FeatureFlag, FeatureFlagConfigurationSetting +from azure.appconfiguration.provider import SettingSelector +from azure.appconfiguration.provider._constants import NULL_CHAR + +AppConfigProviderPreparer = functools.partial( + EnvironmentVariableLoader, + "appconfiguration", + appconfiguration_endpoint_string=APPCONFIGURATION_ENDPOINT_STRING, +) + + +class TestAppConfigurationProviderEnhancedFeatureFlags(AppConfigTestCase): + """Tests for the provider loading feature flags from the dedicated enhanced feature flag endpoint.""" + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_load_enhanced_feature_flag(self, appconfiguration_endpoint_string): + """A feature flag created via the enhanced feature flag endpoint should be loaded by the provider.""" + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceOnlyFeature", enabled=True) + feature_flag_client.set_feature_flag(feature_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="ResourceOnlyFeature")], + ) + + assert FEATURE_MANAGEMENT_KEY in client + assert has_feature_flag(client, "ResourceOnlyFeature", enabled=True) + finally: + feature_flag_client.delete_feature_flag("ResourceOnlyFeature") + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_load_enhanced_feature_flag_disabled(self, appconfiguration_endpoint_string): + """A disabled enhanced feature flag should be loaded with enabled set to False.""" + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceDisabledFeature", enabled=False) + feature_flag_client.set_feature_flag(feature_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="ResourceDisabledFeature")], + ) + + assert has_feature_flag(client, "ResourceDisabledFeature", enabled=False) + finally: + feature_flag_client.delete_feature_flag("ResourceDisabledFeature") + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_load_enhanced_feature_flag_with_label(self, appconfiguration_endpoint_string): + """An enhanced feature flag with a label should be loaded when the label filter matches.""" + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceLabeledFeature", enabled=True, label="test_label") + feature_flag_client.set_feature_flag(feature_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[ + SettingSelector(key_filter="ResourceLabeledFeature", label_filter="test_label") + ], + ) + + assert has_feature_flag(client, "ResourceLabeledFeature", enabled=True) + finally: + feature_flag_client.delete_feature_flag("ResourceLabeledFeature", label="test_label") + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_enhanced_feature_flag_selector_filters_by_name(self, appconfiguration_endpoint_string): + """The feature_flag_selectors key_filter should scope which enhanced feature flags are loaded.""" + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + included_flag = FeatureFlag(name="IncludedResourceFeature", enabled=True) + excluded_flag = FeatureFlag(name="ExcludedResourceFeature", enabled=True) + feature_flag_client.set_feature_flag(included_flag) + feature_flag_client.set_feature_flag(excluded_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Included*")], + ) + + assert has_feature_flag(client, "IncludedResourceFeature", enabled=True) + assert not has_feature_flag(client, "ExcludedResourceFeature") + finally: + feature_flag_client.delete_feature_flag("IncludedResourceFeature") + feature_flag_client.delete_feature_flag("ExcludedResourceFeature") + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_enhanced_feature_flag_overrides_key_value(self, appconfiguration_endpoint_string): + """An enhanced feature flag should take precedence over a key-value based feature flag with the + same identifier when both are loaded.""" + appconfig_client = self.create_appconfig_client(appconfiguration_endpoint_string) + feature_flag_client = self.create_enhanced_feature_flag_client(appconfiguration_endpoint_string) + + kv_feature_flag = FeatureFlagConfigurationSetting(feature_id="OverlapFeature", enabled=False, label=NULL_CHAR) + appconfig_client.set_configuration_setting(kv_feature_flag) + enhanced_feature_flag_obj = FeatureFlag(name="OverlapFeature", enabled=True) + feature_flag_client.set_feature_flag(enhanced_feature_flag_obj) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="OverlapFeature")], + ) + + # The enhanced feature flag (enabled=True) should win over the key-value based one + # (enabled=False) since they share the same identifier. + assert has_feature_flag(client, "OverlapFeature", enabled=True) + feature_flag = get_feature_flag(client, "OverlapFeature") + assert feature_flag is not None + assert "id" in feature_flag + finally: + appconfig_client.delete_configuration_setting(key=kv_feature_flag.key, label=kv_feature_flag.label) + feature_flag_client.delete_feature_flag("OverlapFeature") diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py index ad3cf6285d8b..3393a2866463 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py @@ -21,6 +21,7 @@ TARGETING_FILTER_NAMES, FEATURE_FLAG_USES_SEED_TAG, FEATURE_FLAG_USES_TELEMETRY_TAG, + ENHANCED_FEATURE_FLAG_TAG, ) from azure.appconfiguration.provider._constants import ( REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE, @@ -513,3 +514,90 @@ def test_correlation_context_with_multiple_tags(self): self.assertIn("UsesKeyVault", correlation_header) self.assertIn("Failover", correlation_header) self.assertIn(SNAPSHOT_REFERENCE_TAG, correlation_header) + + +class TestEnhancedFeatureFlagTracking(unittest.TestCase): + """Test enhanced feature flag usage tracking in request tracing context.""" + + def test_enhanced_feature_flag_tag_constant(self): + """Test that the enhanced feature flag tag constant has the expected value.""" + self.assertEqual(ENHANCED_FEATURE_FLAG_TAG, "EnhFF") + + def test_initialization(self): + """Test that request tracing context initializes enhanced feature flag tracking to False.""" + context = _RequestTracingContext() + self.assertFalse(context.uses_enhanced_feature_flags) + + def test_set_enhanced_feature_flag_usage(self): + """Test setting enhanced feature flag usage in tracing context.""" + context = _RequestTracingContext() + + # Initially false + self.assertFalse(context.uses_enhanced_feature_flags) + + # Set to true + context.uses_enhanced_feature_flags = True + self.assertTrue(context.uses_enhanced_feature_flags) + + # Set back to false + context.uses_enhanced_feature_flags = False + self.assertFalse(context.uses_enhanced_feature_flags) + + def test_correlation_context_without_enhanced_feature_flags(self): + """Test correlation context header when not using the enhanced feature flag endpoint.""" + context = _RequestTracingContext() + context.uses_enhanced_feature_flags = False + + headers = {} + updated_headers = context.update_correlation_context_header( + headers=headers, + request_type="Startup", + replica_count=0, + uses_key_vault=False, + feature_flag_enabled=False, + is_failover_request=False, + ) + + correlation_header = updated_headers.get("Correlation-Context", "") + self.assertIn("RequestType=Startup", correlation_header) + self.assertNotIn(ENHANCED_FEATURE_FLAG_TAG, correlation_header) + + def test_correlation_context_with_enhanced_feature_flags(self): + """Test correlation context header when using the enhanced feature flag endpoint.""" + context = _RequestTracingContext() + context.uses_enhanced_feature_flags = True + + headers = {} + updated_headers = context.update_correlation_context_header( + headers=headers, + request_type="Startup", + replica_count=0, + uses_key_vault=False, + feature_flag_enabled=False, + is_failover_request=False, + ) + + correlation_header = updated_headers.get("Correlation-Context", "") + self.assertIn("RequestType=Startup", correlation_header) + self.assertIn(f"Features={ENHANCED_FEATURE_FLAG_TAG}", correlation_header) + + def test_correlation_context_with_enhanced_feature_flags_and_snapshot_reference(self): + """Test correlation context header format when both enhanced feature flags and snapshot references are + used, verifying both feature tags are joined by the delimiter in the Features segment.""" + context = _RequestTracingContext() + context.uses_enhanced_feature_flags = True + context.uses_snapshot_reference = True + + headers = {} + updated_headers = context.update_correlation_context_header( + headers=headers, + request_type="Startup", + replica_count=0, + uses_key_vault=False, + feature_flag_enabled=False, + is_failover_request=False, + ) + + correlation_header = updated_headers.get("Correlation-Context", "") + self.assertIn(SNAPSHOT_REFERENCE_TAG, correlation_header) + self.assertIn(ENHANCED_FEATURE_FLAG_TAG, correlation_header) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py index 99074621b628..7314dc389213 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py @@ -11,6 +11,8 @@ AzureAppConfigurationClient, ConfigurationSetting, ConfigurationSettingsFilter, + FeatureFlag, + FeatureFlagClient, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, SnapshotComposition, @@ -44,6 +46,10 @@ def create_appconfig_client(self, appconfiguration_endpoint_string): cred = self.get_credential(AzureAppConfigurationClient) return AzureAppConfigurationClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") + def create_enhanced_feature_flag_client(self, appconfiguration_endpoint_string): + cred = self.get_credential(FeatureFlagClient) + return FeatureFlagClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") + def setup_configs(client, keyvault_secret_url, keyvault_secret_url2): """Set up all test configs and create snapshots. Returns (snapshot_name, ff_snapshot_name).""" @@ -164,6 +170,38 @@ def create_feature_flag_config_setting(key, label, enabled, tags=None): return FeatureFlagConfigurationSetting(feature_id=key, label=label, enabled=enabled, tags=tags) +def create_enhanced_feature_flag(name, enabled, label=None, **kwargs): + """ + Create a FeatureFlag object for use with the dedicated enhanced feature flag endpoint + (``FeatureFlagClient``), as opposed to the key-value based ``FeatureFlagConfigurationSetting``. + + :param name: The name/identifier of the feature flag. + :param enabled: Whether the feature flag is enabled. + :param label: The label of the feature flag. + :return: A FeatureFlag object. + :rtype: ~azure.appconfiguration.FeatureFlag + """ + return FeatureFlag(name=name, enabled=enabled, label=label, **kwargs) + + +def cleanup_enhanced_feature_flags(feature_flag_client, feature_flags): + """ + Delete enhanced feature flags created via the dedicated enhanced feature flag endpoint. + + :param feature_flag_client: The FeatureFlagClient to use for cleanup. + :param feature_flags: List of FeatureFlag objects (or (name, label) tuples) to delete. + """ + for feature_flag in feature_flags: + if isinstance(feature_flag, tuple): + name, label = feature_flag + else: + name, label = feature_flag.name, feature_flag.label + try: + feature_flag_client.delete_feature_flag(name, label=label) + except Exception: # pylint: disable=broad-except + pass + + def cleanup_test_resources( client, settings=None, @@ -245,7 +283,7 @@ def create_snapshot(client, snapshot_name, key_filters, composition_type=None, r def get_feature_flag(client, feature_id): for feature_flag in client[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY]: - if feature_flag["id"] == feature_id: + if feature_flag.get("id", feature_flag.get("name")) == feature_id: return feature_flag return None diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/tests.md b/sdk/appconfiguration/azure-appconfiguration-provider/tests/tests.md new file mode 100644 index 000000000000..af064b22e021 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/tests.md @@ -0,0 +1,72 @@ +# Azure App Configuration Python Provider tests + +(This content is for `azure-appconfiguration-provider` package developer only) + +The tests for this package are under the `tests/` directory and are split into two categories: + +* **Unit tests** (e.g. `tests/test_azureappconfigurationproviderbase.py`, `tests/test_configuration_client_manager.py`) — exercise internal logic in isolation using mocked clients. These do not require any App Configuration store, network access, or environment variables, and can be run at any time with no setup. +* **Integration tests** (e.g. `tests/test_provider.py`, `tests/test_provider_enhanced_feature_flags.py`, and their `tests/aio/` async equivalents) — exercise the provider end-to-end against an Azure App Configuration store. These tests are built on [`devtools_testutils`](https://github.com/Azure/azure-sdk-for-python/tree/main/eng/tools/azure-sdk-tools/devtools_testutils) and each test method is decorated with `@recorded_by_proxy` / `@recorded_by_proxy_async`, which route the test's HTTP traffic through the [test proxy](https://github.com/Azure/azure-sdk-tools/tree/main/tools/test-proxy) tool. + +## Live tests vs. recorded (playback) tests + +Whether an integration test makes a real network call or replays a recording is controlled entirely by the `AZURE_TEST_RUN_LIVE` environment variable, not by anything in this package's code: + +* `AZURE_TEST_RUN_LIVE=true` — Tests run in **live/record mode**. The test proxy forwards requests to the real endpoint configured via your environment variables (see below), and (unless `AZURE_SKIP_LIVE_RECORDING=true` is also set) records the request/response pairs as new recording files for use in future playback runs. +* `AZURE_TEST_RUN_LIVE` unset or `false` (the default, and what CI uses) — Tests run in **playback mode**. The test proxy replays the existing recordings instead of contacting the real service, so **no network calls are made** and no live App Configuration store is required. + +Recordings themselves are not stored directly in this repository — they live in the separate [`Azure/azure-sdk-assets`](https://github.com/Azure/azure-sdk-assets) repo, and this package's `assets.json` file pins the exact recordings revision (`Tag`) that CI uses. If you add or change integration tests, you need to generate new recordings and publish them: + +1. Run the affected tests with `AZURE_TEST_RUN_LIVE=true` (and without `AZURE_SKIP_LIVE_RECORDING`) so the test proxy records real interactions to local recording files. +2. From the repo root, push the new/updated recordings to the assets repo: + + ```bash + dotnet tool run test-proxy push -a sdk/appconfiguration/azure-appconfiguration-provider/assets.json + ``` + + This uploads the changed recordings and updates the `Tag` field in `assets.json`. +3. Commit the updated `assets.json` as part of your PR — this is what allows CI (which always runs in playback mode) to pick up the new recordings. + +Only re-record tests you added or intentionally changed; unrelated existing recordings don't need to be regenerated. + +## Environment variables for local testing + +To run the integration tests locally in live mode, create a `.env` file at the repository root (it is automatically loaded by `devtools_testutils`) with the following variables: + +``` +AZURE_TEST_RUN_LIVE=true +APPCONFIGURATION_CONNECTION_STRING= +APPCONFIGURATION_ENDPOINT_STRING=.azconfig.io> +APPCONFIGURATION_KEY_VAULT_REFERENCE= +APPCONFIGURATION_KEY_VAULT_REFERENCE2= +APPCONFIGURATION_KEYVAULT_SECRET_URL= +APPCONFIGURATION_KEYVAULT_SECRET_URL2= +``` + +Notes: + +* For key vault URI, you can create a secret in Azure Key Vault service. The key vault URI is the *Secret Identifier*, without the final version number. For example, if the secret identifier is `https://some_secret.vault.azure.net/secrets/fake-secret/30d8830ec5ed4a428d311292a826f452`, the key vault URI should be `https://some_secret.vault.azure.net/secrets/fake-secret/`. +* Authentication for Entra ID-based tests relies on your local Azure CLI login (`az login`); make sure you're signed in to the subscription that contains your App Configuration store. +* Add `AZURE_SKIP_LIVE_RECORDING=true` if you want to run tests live against the real store without generating/overwriting recording files (useful for a quick sanity check). +* Omit `AZURE_TEST_RUN_LIVE` (or set it to `false`) to run the same tests in playback mode against existing recordings — this does not require any of the App Configuration environment variables above. + +## Pre-PR validation checks (sdist, mypy, pylint, black, snippets) + +Make sure all unit tests and live tests passed, test recodings updated, all tests against recordings also passed. + +Before opening or updating a PR, run the same static/build checks that CI enforces, using the `azpysdk` entrypoint from `eng/tools/azure-sdk-tools`. See [doc/tool_usage_guide.md](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/tool_usage_guide.md) for the full list of available checks and options (e.g. `--isolate`). + +From this package's directory (`sdk/appconfiguration/azure-appconfiguration-provider`): + +```bash +azpysdk sdist . # builds the sdist and runs the full test suite against it +azpysdk mypy . # static type checking +azpysdk pylint . # lint checks +azpysdk black . # formatting check (auto-reformats files in place) +azpysdk update_snippet . # regenerates README code snippets from sample files +``` + +Notes: + +* Run `black` again after making any other fixes, since it may reformat files you just edited. +* Run `update_snippet` after changing any `samples/*.py` file, then diff to confirm the regenerated snippets in `README.md` match what you expect. +* See [doc/dev/pylint_checking.md](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/pylint_checking.md) and [doc/dev/static_type_checking_cheat_sheet.md](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/static_type_checking_cheat_sheet.md) for guidance on fixing pylint/mypy issues.