diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 8451398b3b8b..c6974fc922c3 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md @@ -1,11 +1,15 @@ # 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 - Fixed a resource leak where replica clients that were no longer part of the auto-failover set were not closed during client refresh. @@ -17,6 +21,7 @@ ### 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..a0a727c2307e 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("id") == "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("id") == "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/assets.json b/sdk/appconfiguration/azure-appconfiguration-provider/assets.json index b9e5f6a80d69..c79d03040b0b 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_d3ca89ab22" } 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 a481238c616f..e13a31bce6bb 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, ) @@ -95,6 +96,95 @@ def __init__(self, **kwargs: Any) -> None: self._on_refresh_error: Optional[Callable[[Exception], None]] = kwargs.pop("on_refresh_error", None) self._configuration_mapper: Optional[Callable] = kwargs.pop("configuration_mapper", None) + def _refresh_configuration_settings( + self, client: ConfigurationClient, headers: Mapping[str, str], **kwargs + ) -> Tuple[List[ConfigurationSetting], List[List[str]], bool, bool, Mapping[Tuple[str, str], Optional[str]]]: + """ + Refreshes configuration settings (excluding feature flags) using a single client, if a refresh is due. + + :param client: The configuration client to attempt the refresh against. + :type client: ~azure.appconfiguration.provider.ConfigurationClient + :param headers: The correlation-context headers to include with the requests. + :type headers: Mapping[str, str] + :return: A tuple of (configuration_settings, page_etags, settings_refreshed, refresh_attempted, + updated_watched_settings). + :rtype: Tuple[List[ConfigurationSetting], List[List[str]], bool, bool, + Mapping[Tuple[str, str], Optional[str]]] + """ + configuration_settings: List[ConfigurationSetting] = [] + page_etags: List[List[str]] = [] + settings_refreshed = False + refresh_attempted = False + updated_watched_settings: Mapping[Tuple[str, str], Optional[str]] = {} + + if self._refresh_enabled and not self._watched_settings and self._refresh_timer.needs_refresh(): + refresh_attempted = True + + if client.check_page_etags(self._selects, self._page_etags, headers=headers, **kwargs): + configuration_settings, page_etags = client.load_configuration_settings( + self._selects, headers=headers, **kwargs + ) + settings_refreshed = True + + elif self._refresh_enabled and self._watched_settings and self._refresh_timer.needs_refresh(): + refresh_attempted = True + + updated_watched_settings = client.get_updated_watched_settings( + self._watched_settings, headers=headers, **kwargs + ) + + if len(updated_watched_settings) > 0: + configuration_settings, _ = client.load_configuration_settings(self._selects, headers=headers, **kwargs) + settings_refreshed = True + + return configuration_settings, page_etags, settings_refreshed, refresh_attempted, updated_watched_settings + + def _refresh_feature_flags( + self, client: ConfigurationClient, headers: Mapping[str, str], **kwargs + ) -> Tuple[ + Optional[List[FeatureFlagConfigurationSetting]], List[List[str]], Optional[List[FeatureFlag]], List[List[str]] + ]: + """ + Refreshes key-value based and enhanced feature flags using a single client, if a refresh is due. + + Key-value based feature flags and enhanced feature flags are separate resource types with their own + change-detection state, but if either indicates a change, both are reloaded and re-merged together to + guarantee the merged result is always correct and internally consistent. + + :param client: The configuration client to attempt the refresh against. + :type client: ~azure.appconfiguration.provider.ConfigurationClient + :param headers: The correlation-context headers to include with the requests. + :type headers: Mapping[str, str] + :return: A tuple of (feature_flags, feature_flag_page_etags, enhanced_feature_flags, + enhanced_feature_flag_etags). + :rtype: Tuple[Optional[List[FeatureFlagConfigurationSetting]], List[List[str]], + Optional[List[FeatureFlag]], List[List[str]]] + """ + feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None + feature_flag_page_etags: List[List[str]] = [] + enhanced_feature_flags: Optional[List[FeatureFlag]] = None + enhanced_feature_flag_etags: List[List[str]] = [] + + feature_flags_changed = not self._feature_flag_page_etags or client.check_feature_flag_page_etags( + self._feature_flag_selectors, self._feature_flag_page_etags, headers=headers, **kwargs + ) + enhanced_feature_flags_changed = ( + 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 + ) + ) + + if feature_flags_changed or enhanced_feature_flags_changed: + feature_flags, feature_flag_page_etags = client.load_feature_flags( + self._feature_flag_selectors, headers=headers, **kwargs + ) + enhanced_feature_flags, enhanced_feature_flag_etags = client.load_enhanced_feature_flags( + self._enhanced_feature_flag_selectors, headers=headers, **kwargs + ) + + return feature_flags, feature_flag_page_etags, enhanced_feature_flags, enhanced_feature_flag_etags + def _attempt_refresh( self, client: ConfigurationClient, replica_count: int, is_failover_request: bool, **kwargs ) -> None: @@ -108,7 +198,6 @@ def _attempt_refresh( :param is_failover_request: Whether this attempt is a failover from a previously failed client. :type is_failover_request: bool """ - settings_refreshed = False headers = self._update_correlation_context_header( kwargs.pop("headers", {}), "Watch", @@ -116,48 +205,32 @@ def _attempt_refresh( self._secret_provider.uses_key_vault, is_failover_request, ) - 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 feature_flag_refresh_attempted = False - updated_watched_settings: Mapping[Tuple[str, str], Optional[str]] = {} 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 - - if client.check_page_etags(self._selects, self._page_etags, headers=headers, **kwargs): - configuration_settings, page_etags = client.load_configuration_settings( - self._selects, headers=headers, **kwargs - ) - settings_refreshed = True - - elif self._refresh_enabled and self._watched_settings and self._refresh_timer.needs_refresh(): - configuration_refresh_attempted = True - - updated_watched_settings = client.get_updated_watched_settings( - self._watched_settings, headers=headers, **kwargs - ) - - if len(updated_watched_settings) > 0: - configuration_settings, _ = client.load_configuration_settings( - self._selects, headers=headers, **kwargs - ) - settings_refreshed = True + ( + configuration_settings, + page_etags, + settings_refreshed, + configuration_refresh_attempted, + updated_watched_settings, + ) = self._refresh_configuration_settings(client, headers, **kwargs) if self._feature_flag_refresh_enabled and self._feature_flag_refresh_timer.needs_refresh(): feature_flag_refresh_attempted = True - if not self._feature_flag_page_etags or client.check_feature_flag_page_etags( - self._feature_flag_selectors, self._feature_flag_page_etags, headers=headers, **kwargs - ): - feature_flags, feature_flag_page_etags = client.load_feature_flags( - self._feature_flag_selectors, headers=headers, **kwargs - ) + ( + feature_flags, + feature_flag_page_etags, + enhanced_feature_flags, + enhanced_feature_flag_etags, + ) = self._refresh_feature_flags(client, headers, **kwargs) # Default to existing settings if no refresh occurred processed_settings = self._dict @@ -168,7 +241,9 @@ def _attempt_refresh( # 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 @@ -176,12 +251,16 @@ 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 is not None or enhanced_feature_flags is not None + ) and self._on_refresh_success: self._on_refresh_success() except AzureError as e: logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint) @@ -294,6 +373,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( @@ -301,7 +381,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: @@ -326,6 +413,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) @@ -391,7 +479,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 a2ced46b3d1b..4b9b1183e87b 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,10 @@ 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, + REQUIRED_API_VERSION, ) from ._refresh_timer import _RefreshTimer from ._request_tracing_context import _RequestTracingContext @@ -82,6 +88,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 @@ -104,19 +170,27 @@ 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="*")] + api_version = kwargs.get("api_version") + if self._feature_flag_enabled and api_version is not None and api_version != REQUIRED_API_VERSION: + raise ValueError( + f"Unsupported api_version '{api_version}'. When feature_flag_enabled is True, api_version must be " + f"'{REQUIRED_API_VERSION}', as enhanced feature flags require this service API version." + ) + 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() @@ -134,7 +208,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 @@ -143,6 +217,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] = {} @@ -150,15 +282,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) @@ -242,10 +374,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] @@ -366,23 +497,72 @@ 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 is not None 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 is not None: + # Only overwrite the cached key-value feature flags when a refresh actually occurred. This preserves + # the previous state (including an intentional empty list) when this source wasn't refreshed. + self._processed_kv_feature_flags = [self._process_kv_feature_flag(ff) for ff in feature_flags] + + if enhanced_feature_flags is not None: + # Only overwrite the cached enhanced feature flags when a refresh actually occurred, so the previous + # state is carried over when this source wasn't refreshed. + 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 is not None 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) @@ -392,6 +572,147 @@ def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) - # Feature flag value is not a valid JSON return {} + @staticmethod + def _parse_variant_value(value: Optional[str], content_type: Optional[str]) -> Any: + """ + Parses an enhanced feature flag variant's raw string value, similar to how a regular key-value setting's + value is processed. If the variant's content type indicates JSON, the value is parsed and returned as a + JSON object; otherwise the raw string value is returned unchanged. + + :param value: The variant's raw value, as returned by the enhanced feature flag endpoint. + :type value: Optional[str] + :param content_type: The variant's content type. + :type content_type: Optional[str] + :return: The parsed JSON object if the content type is JSON, otherwise the original raw value. + :rtype: Any + :raises json.JSONDecodeError: If the content type indicates JSON but the value is not valid JSON. + """ + if not isinstance(value, str) or not is_json_content_type(content_type or ""): + return value + return json.loads(value) + + @staticmethod + def _parse_filter_parameter_value(value: Optional[str]) -> Any: + """ + Parses a single enhanced feature flag filter parameter value as a best-effort attempt. + + :param value: The filter parameter's raw string value. + :type value: Optional[str] + :return: The parsed JSON object/array if the value looks like JSON and parses successfully, otherwise + the original raw value. + :rtype: Any + """ + if not isinstance(value, str): + return value + trimmed = value.strip() + if not trimmed or trimmed[0] not in "{[": + return value + try: + return json.loads(value) + except json.JSONDecodeError: + # Not valid JSON after all: fall back to the original string, since the customer may have + # actually intended a literal string value. + return value + + 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.filters: + conditions_value["client_filters"] = [ + { + "name": client_filter.name, + "parameters": ( + { + key: self._parse_filter_parameter_value(value) + for key, value in client_filter.parameters.items() + } + if client_filter.parameters + else client_filter.parameters + ), + } + for client_filter in feature_flag.conditions.filters + ] + filter_names = [client_filter.name for client_filter in feature_flag.conditions.filters] + if conditions_value: + feature_flag_value["conditions"] = conditions_value + + if feature_flag.variants: + try: + feature_flag_value["variants"] = [ + { + "name": variant.name, + "configuration_value": self._parse_variant_value(variant.value, variant.content_type), + "content_type": variant.content_type, + "status_override": variant.status_override, + } + for variant in feature_flag.variants + ] + except json.JSONDecodeError as e: + raise ValueError(f"Enhanced feature flag '{feature_flag.name}' has an invalid variant value.") from e + + 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 2c7de888427b..f7f2bb1f4fe2 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: Mapping[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 da34535a7329..a8ebf753fc57 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,15 @@ 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" +# The minimum service API version required to use provider. +REQUIRED_API_VERSION = "2026-05-01-preview" + # ------------------------------------------------------------------------ # 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 f763c626a512..6146f7d61066 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py @@ -11,13 +11,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, @@ -48,7 +49,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, @@ -86,9 +87,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 @@ -123,7 +126,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, @@ -160,9 +163,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 9419cb2685ae..9306cf145918 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 484a63ad3394..7ba6ccec5e79 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 @@ -35,6 +35,7 @@ LOAD_BALANCING_FEATURE = "LB" AI_CONFIGURATION_FEATURE = "AI" AI_CHAT_COMPLETION_FEATURE = "AICC" +ENHANCED_FEATURE_FLAG_TAG = "EnhFF" # Correlation context constants FEATUREMANAGEMENT_PACKAGE = "featuremanagement" @@ -82,6 +83,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 @@ -239,15 +241,26 @@ def update_feature_filter_telemetry(self, feature_flag) -> None: :type feature_flag: FeatureFlagConfigurationSetting """ if feature_flag.filters: - for feature_filter in feature_flag.filters: - if feature_filter.get("name") in PERCENTAGE_FILTER_NAMES: - self.feature_filter_usage[PERCENTAGE_FILTER_KEY] = True - elif feature_filter.get("name") in TIME_WINDOW_FILTER_NAMES: - self.feature_filter_usage[TIME_WINDOW_FILTER_KEY] = True - elif feature_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.""" @@ -270,6 +283,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 65c681b632a6..a4085f26038f 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: Mapping[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 bf2f36732906..1340ae4772e3 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, @@ -161,9 +164,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 0fca605ab02b..5871f5da539d 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, ) @@ -106,6 +107,97 @@ def __init__(self, **kwargs: Any) -> None: "configuration_mapper", None ) + async def _refresh_configuration_settings( + self, client: ConfigurationClient, headers: Mapping[str, str], **kwargs + ) -> Tuple[List[ConfigurationSetting], List[List[str]], bool, bool, Mapping[Tuple[str, str], Optional[str]]]: + """ + Refreshes configuration settings (excluding feature flags) using a single client, if a refresh is due. + + :param client: The configuration client to attempt the refresh against. + :type client: ~azure.appconfiguration.provider.aio.ConfigurationClient + :param headers: The correlation-context headers to include with the requests. + :type headers: Mapping[str, str] + :return: A tuple of (configuration_settings, page_etags, settings_refreshed, refresh_attempted, + updated_watched_settings). + :rtype: Tuple[List[ConfigurationSetting], List[List[str]], bool, bool, + Mapping[Tuple[str, str], Optional[str]]] + """ + configuration_settings: List[ConfigurationSetting] = [] + page_etags: List[List[str]] = [] + settings_refreshed = False + refresh_attempted = False + updated_watched_settings: Mapping[Tuple[str, str], Optional[str]] = {} + + if self._refresh_enabled and not self._watched_settings and self._refresh_timer.needs_refresh(): + refresh_attempted = True + + if await client.check_page_etags(self._selects, self._page_etags, headers=headers, **kwargs): + configuration_settings, page_etags = await client.load_configuration_settings( + self._selects, headers=headers, **kwargs + ) + settings_refreshed = True + + elif self._refresh_enabled and self._watched_settings and self._refresh_timer.needs_refresh(): + refresh_attempted = True + + updated_watched_settings = await client.get_updated_watched_settings( + self._watched_settings, headers=headers, **kwargs + ) + + if len(updated_watched_settings) > 0: + configuration_settings, _ = await client.load_configuration_settings( + self._selects, headers=headers, **kwargs + ) + settings_refreshed = True + + return configuration_settings, page_etags, settings_refreshed, refresh_attempted, updated_watched_settings + + async def _refresh_feature_flags( + self, client: ConfigurationClient, headers: Mapping[str, str], **kwargs + ) -> Tuple[ + Optional[List[FeatureFlagConfigurationSetting]], List[List[str]], Optional[List[FeatureFlag]], List[List[str]] + ]: + """ + Refreshes key-value based and enhanced feature flags using a single client, if a refresh is due. + + Key-value based feature flags and enhanced feature flags are separate resource types with their own + change-detection state, but if either indicates a change, both are reloaded and re-merged together to + guarantee the merged result is always correct and internally consistent. + + :param client: The configuration client to attempt the refresh against. + :type client: ~azure.appconfiguration.provider.aio.ConfigurationClient + :param headers: The correlation-context headers to include with the requests. + :type headers: Mapping[str, str] + :return: A tuple of (feature_flags, feature_flag_page_etags, enhanced_feature_flags, + enhanced_feature_flag_etags). + :rtype: Tuple[Optional[List[FeatureFlagConfigurationSetting]], List[List[str]], + Optional[List[FeatureFlag]], List[List[str]]] + """ + feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None + feature_flag_page_etags: List[List[str]] = [] + enhanced_feature_flags: Optional[List[FeatureFlag]] = None + enhanced_feature_flag_etags: List[List[str]] = [] + + feature_flags_changed = not self._feature_flag_page_etags or await client.check_feature_flag_page_etags( + self._feature_flag_selectors, self._feature_flag_page_etags, headers=headers, **kwargs + ) + enhanced_feature_flags_changed = ( + 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 + ) + ) + + if feature_flags_changed or enhanced_feature_flags_changed: + feature_flags, feature_flag_page_etags = await client.load_feature_flags( + self._feature_flag_selectors, headers=headers, **kwargs + ) + enhanced_feature_flags, enhanced_feature_flag_etags = await client.load_enhanced_feature_flags( + self._enhanced_feature_flag_selectors, headers=headers, **kwargs + ) + + return feature_flags, feature_flag_page_etags, enhanced_feature_flags, enhanced_feature_flag_etags + async def _attempt_refresh( self, client: ConfigurationClient, replica_count: int, is_failover_request: bool, **kwargs ): @@ -119,7 +211,6 @@ async def _attempt_refresh( :param is_failover_request: Whether this attempt is a failover from a previously failed client. :type is_failover_request: bool """ - settings_refreshed = False headers = self._update_correlation_context_header( kwargs.pop("headers", {}), "Watch", @@ -127,48 +218,33 @@ async def _attempt_refresh( self._secret_provider.uses_key_vault, is_failover_request, ) - 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 feature_flag_refresh_attempted = False - updated_watched_settings: Mapping[Tuple[str, str], Optional[str]] = {} 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 - - if await client.check_page_etags(self._selects, self._page_etags, headers=headers, **kwargs): - configuration_settings, page_etags = await client.load_configuration_settings( - self._selects, headers=headers, **kwargs - ) - settings_refreshed = True - - elif self._refresh_enabled and self._watched_settings and self._refresh_timer.needs_refresh(): - configuration_refresh_attempted = True - - updated_watched_settings = await client.get_updated_watched_settings( - self._watched_settings, headers=headers, **kwargs - ) - - if len(updated_watched_settings) > 0: - configuration_settings, _ = await client.load_configuration_settings( - self._selects, headers=headers, **kwargs - ) - settings_refreshed = True + ( + configuration_settings, + page_etags, + settings_refreshed, + configuration_refresh_attempted, + updated_watched_settings, + ) = await self._refresh_configuration_settings(client, headers, **kwargs) if self._feature_flag_refresh_enabled and self._feature_flag_refresh_timer.needs_refresh(): feature_flag_refresh_attempted = True - if not self._feature_flag_page_etags or await client.check_feature_flag_page_etags( - self._feature_flag_selectors, self._feature_flag_page_etags, headers=headers, **kwargs - ): - feature_flags, feature_flag_page_etags = await client.load_feature_flags( - self._feature_flag_selectors, headers=headers, **kwargs - ) + ( + feature_flags, + feature_flag_page_etags, + enhanced_feature_flags, + enhanced_feature_flag_etags, + ) = await self._refresh_feature_flags(client, headers, **kwargs) + # Default to existing settings if no refresh occurred processed_settings = self._dict @@ -178,7 +254,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 @@ -186,12 +264,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) @@ -304,6 +384,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( @@ -311,7 +392,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: @@ -338,6 +426,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) @@ -404,7 +493,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..7ae2fbcf758c --- /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 # type:ignore +from azure.appconfiguration import FeatureFlag # type:ignore +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..a1aeeedc6e03 --- /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 # type:ignore +from azure.appconfiguration.provider import load, SettingSelector + +endpoint = os.environ["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..b93b8961b324 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/setup.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/setup.py @@ -57,23 +57,20 @@ url="https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/appconfiguration/azure-appconfiguration-provider", keywords="azure, azure sdk", classifiers=[ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "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/conftest.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py index 6dc7e45229eb..ab0b2c653c79 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py @@ -74,7 +74,8 @@ def add_sanitizers(test_proxy): # Remove the following sanitizers since certain fields are needed in tests and are non-sensitive: # - AZSDK3430: $..id # - AZSDK3447: $.key - remove_batch_sanitizers(["AZSDK3430", "AZSDK3447"]) + # - AZSDK3493: $..name (feature flag names, e.g. from the /ff resource, are not sensitive and are asserted on) + remove_batch_sanitizers(["AZSDK3430", "AZSDK3447", "AZSDK3493"]) @pytest.fixture(autouse=True) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py index 8a13a686a322..13dbbdb4731d 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -11,18 +11,32 @@ from typing import Dict, Any from azure.appconfiguration import FeatureFlagConfigurationSetting +from azure.appconfiguration import ( + FeatureFlag, + FeatureFlagAllocation, + FeatureFlagConditions, + FeatureFilter, + 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, METADATA_KEY, ETAG_KEY, FEATURE_FLAG_REFERENCE_KEY, + FEATURE_MANAGEMENT_KEY, + FEATURE_FLAG_KEY, + REQUIRED_API_VERSION, ) from azure.appconfiguration.provider._refresh_timer import _RefreshTimer @@ -206,6 +220,80 @@ 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_feature_flag_enabled_with_required_api_version_succeeds(self): + provider = AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + feature_flag_enabled=True, + api_version=REQUIRED_API_VERSION, + ) + + self.assertTrue(provider._feature_flag_enabled) + + def test_feature_flag_enabled_with_no_api_version_succeeds(self): + # No explicit api_version means the SDK client's own default will be used, which already supports enhanced + # feature flags, so no validation error should be raised. + provider = AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + feature_flag_enabled=True, + ) + + self.assertTrue(provider._feature_flag_enabled) + + def test_feature_flag_enabled_with_outdated_api_version_raises_error(self): + with self.assertRaises(ValueError) as context: + AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + feature_flag_enabled=True, + api_version="2023-11-01", + ) + + self.assertIn("2023-11-01", str(context.exception)) + self.assertIn(REQUIRED_API_VERSION, str(context.exception)) + + def test_feature_flag_disabled_with_outdated_api_version_does_not_raise(self): + # api_version validation only applies when feature_flag_enabled is True, since enhanced feature flags are + # only loaded in that case. + provider = AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + feature_flag_enabled=False, + api_version="2023-11-01", + ) + + self.assertFalse(provider._feature_flag_enabled) + def test_process_key_name_with_no_prefix(self): """Test key name processing with no matching prefix.""" config = Mock() @@ -403,3 +491,466 @@ 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", + filters=[FeatureFilter(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_filter_parameter_json_object_is_parsed(self): + """Test that a filter parameter value that looks like a JSON object is parsed as JSON.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + conditions=FeatureFlagConditions( + filters=[FeatureFilter(name="Audience", parameters={"Users": '{"a": 1}'})], + ), + ) + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + self.assertEqual(result["conditions"]["client_filters"][0]["parameters"], {"Users": {"a": 1}}) + + def test_process_enhanced_feature_flag_filter_parameter_json_array_is_parsed(self): + """Test that a filter parameter value that looks like a JSON array is parsed as JSON.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + conditions=FeatureFlagConditions( + filters=[FeatureFilter(name="Audience", parameters={"Groups": "[1, 2, 3]"})], + ), + ) + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + self.assertEqual(result["conditions"]["client_filters"][0]["parameters"], {"Groups": [1, 2, 3]}) + + def test_process_enhanced_feature_flag_filter_parameter_invalid_json_falls_back_to_string(self): + """Test that a filter parameter value that looks like JSON but fails to parse falls back to the raw + string, rather than raising, since the customer may have intended a literal string value.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + conditions=FeatureFlagConditions( + filters=[FeatureFilter(name="Audience", parameters={"Users": "{ invalid json"})], + ), + ) + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + self.assertEqual(result["conditions"]["client_filters"][0]["parameters"], {"Users": "{ invalid json"}) + + def test_process_enhanced_feature_flag_filter_parameter_plain_string_is_not_parsed(self): + """Test that a plain string filter parameter value (not starting with '{' or '[') is left unchanged.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + conditions=FeatureFlagConditions( + filters=[FeatureFilter(name="Audience", parameters={"Region": "US"})], + ), + ) + + result = self.provider._process_enhanced_feature_flag(feature_flag) + + self.assertEqual(result["conditions"]["client_filters"][0]["parameters"], {"Region": "US"}) + + def test_process_enhanced_feature_flag_with_variants_and_allocation(self): + """Test processing an enhanced feature flag with variants and allocation. Variant values, like regular + key-value settings, are raw strings on the wire: a variant with a JSON content type is parsed into a JSON + object, while a variant with no (or non-JSON) content type is kept as the raw string.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + variants=[ + FeatureFlagVariantDefinition(name="Control", value="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") + # No content type: falls back to the raw string value, unparsed. + self.assertEqual(result["variants"][0]["configuration_value"], "control_value") + self.assertEqual(result["variants"][1]["content_type"], "application/json") + # JSON content type: the raw string value is parsed into a JSON object. + self.assertEqual(result["variants"][1]["configuration_value"], {"key": "test_value"}) + + 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_invalid_variant_json_raises_value_error(self): + """If a variant's content type claims JSON but its value is invalid JSON, processing the enhanced feature + flag should raise a clear ValueError identifying the offending flag, chained from the underlying + JSONDecodeError.""" + feature_flag = FeatureFlag( + name="InvalidVariant", + enabled=True, + variants=[ + FeatureFlagVariantDefinition(name="Variant", value="{ invalid json", content_type="application/json") + ], + ) + + with self.assertRaises(ValueError) as context: + self.provider._process_enhanced_feature_flag(feature_flag) + + self.assertIn("InvalidVariant", str(context.exception)) + self.assertIsInstance(context.exception.__cause__, json.JSONDecodeError) + + 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 TestParseVariantValue(unittest.TestCase): + """Test the _parse_variant_value static method, used to interpret an enhanced feature flag variant's raw + string value based on its content type, mirroring how regular key-value settings are processed.""" + + def test_json_content_type_parses_object(self): + result = AzureAppConfigurationProviderBase._parse_variant_value('{"key": "value"}', "application/json") + self.assertEqual(result, {"key": "value"}) + + def test_json_content_type_parses_array(self): + result = AzureAppConfigurationProviderBase._parse_variant_value("[1, 2, 3]", "application/json") + self.assertEqual(result, [1, 2, 3]) + + def test_json_content_type_with_charset_parses_object(self): + result = AzureAppConfigurationProviderBase._parse_variant_value( + '{"key": "value"}', "application/json; charset=utf-8" + ) + self.assertEqual(result, {"key": "value"}) + + def test_json_content_type_with_structured_suffix_parses_object(self): + """Content types using the '+json' structured syntax suffix (e.g. a vendor-specific media type) are + also treated as JSON.""" + result = AzureAppConfigurationProviderBase._parse_variant_value( + '{"key": "value"}', "application/vnd.microsoft.appconfig.ff+json" + ) + self.assertEqual(result, {"key": "value"}) + + def test_no_content_type_falls_back_to_raw_string(self): + result = AzureAppConfigurationProviderBase._parse_variant_value("plain_value", None) + self.assertEqual(result, "plain_value") + + def test_non_json_content_type_falls_back_to_raw_string(self): + result = AzureAppConfigurationProviderBase._parse_variant_value("plain_value", "text/plain") + self.assertEqual(result, "plain_value") + + def test_text_json_content_type_is_not_treated_as_json(self): + """'text/json' is not an 'application/*' JSON media type, so it should be treated as a raw string, even + though it contains the substring 'json'.""" + result = AzureAppConfigurationProviderBase._parse_variant_value("{ invalid json", "text/json") + self.assertEqual(result, "{ invalid json") + + +class TestParseFilterParameterValue(unittest.TestCase): + """Tests for _parse_filter_parameter_value, which parses enhanced feature flag filter parameter values as a + best-effort attempt, always falling back to the raw string on failure since filter parameters have no + content type to declare JSON intent (unlike variant values).""" + + def test_json_object_is_parsed(self): + result = AzureAppConfigurationProviderBase._parse_filter_parameter_value('{"a": 1}') + self.assertEqual(result, {"a": 1}) + + def test_json_array_is_parsed(self): + result = AzureAppConfigurationProviderBase._parse_filter_parameter_value("[1, 2, 3]") + self.assertEqual(result, [1, 2, 3]) + + def test_invalid_json_object_falls_back_to_string(self): + result = AzureAppConfigurationProviderBase._parse_filter_parameter_value("{ invalid json") + self.assertEqual(result, "{ invalid json") + + def test_invalid_json_array_falls_back_to_string(self): + result = AzureAppConfigurationProviderBase._parse_filter_parameter_value("[ invalid json") + self.assertEqual(result, "[ invalid json") + + def test_plain_string_is_not_parsed(self): + result = AzureAppConfigurationProviderBase._parse_filter_parameter_value("US") + self.assertEqual(result, "US") + + def test_empty_string_is_not_parsed(self): + result = AzureAppConfigurationProviderBase._parse_filter_parameter_value("") + self.assertEqual(result, "") + + def test_json_literal_null_string_is_not_parsed(self): + """The literal string 'null' doesn't start with '{' or '[', so it should be left as a raw string, not + parsed into JSON null.""" + result = AzureAppConfigurationProviderBase._parse_filter_parameter_value("null") + self.assertEqual(result, "null") + + def test_non_string_value_is_returned_unchanged(self): + result = AzureAppConfigurationProviderBase._parse_filter_parameter_value(None) + self.assertIsNone(result) + + def test_json_content_type_with_invalid_json_raises(self): + """If the content type claims JSON but the value is not valid JSON, parsing should raise instead of + silently falling back to the raw string.""" + with self.assertRaises(json.JSONDecodeError): + AzureAppConfigurationProviderBase._parse_variant_value("not valid json", "application/json") + + def test_none_value_returns_none(self): + result = AzureAppConfigurationProviderBase._parse_variant_value(None, "application/json") + self.assertIsNone(result) + + +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", feature_flag_enabled=True + ) + + 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, []) + + def test_kv_feature_flags_none_preserves_previous_processed_flags(self): + """Same as the enhanced-flag case above, but for key-value based feature flags: passing None (not + refreshed this round) must not clear or overwrite the previously cached key-value feature flags.""" + kv_flag = FeatureFlagConfigurationSetting(feature_id="MyFeature", enabled=True, label=NULL_CHAR) + self.provider._process_and_merge_feature_flags({}, [], [kv_flag], None) + self.assertEqual(len(self.provider._processed_kv_feature_flags), 1) + + # Passing None again should leave the previously processed key-value feature flags untouched. + self.provider._process_and_merge_feature_flags({}, [], None, None) + self.assertEqual(len(self.provider._processed_kv_feature_flags), 1) + + def test_kv_feature_flags_explicit_empty_list_clears_previous_processed_flags(self): + """An explicitly empty list for key-value feature flags means the store was queried and returned zero + feature flags, so the previously cached key-value feature flags should be cleared.""" + kv_flag = FeatureFlagConfigurationSetting(feature_id="MyFeature", enabled=True, label=NULL_CHAR) + self.provider._process_and_merge_feature_flags({}, [], [kv_flag], None) + self.assertEqual(len(self.provider._processed_kv_feature_flags), 1) + + self.provider._process_and_merge_feature_flags({}, [], [], None) + self.assertEqual(self.provider._processed_kv_feature_flags, []) + + def test_both_none_preserves_merged_result_unchanged(self): + """When neither source was refreshed (both None), the merge step should be skipped entirely and the + previously merged/processed feature flag list should be returned untouched.""" + kv_flag = FeatureFlagConfigurationSetting(feature_id="KvFeature", enabled=True, label=NULL_CHAR) + enhanced_flag = FeatureFlag(name="EnhancedFeature", enabled=False) + + settings = self.provider._process_and_merge_feature_flags({}, [], [kv_flag], [enhanced_flag]) + previous_merged = settings[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY] + self.assertEqual(len(previous_merged), 2) + + # Neither source refreshed this round: the previously merged list is passed through as + # processed_feature_flags and should come back unchanged. + settings = self.provider._process_and_merge_feature_flags({}, previous_merged, None, None) + self.assertEqual(settings[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY], previous_merged) + + def test_only_kv_refreshed_still_merges_with_cached_enhanced_flags(self): + """If only key-value feature flags are refreshed (enhanced is None), the merge should still combine the + newly refreshed kv flags with the previously cached enhanced feature flags.""" + enhanced_flag = FeatureFlag(name="EnhancedFeature", enabled=True) + self.provider._process_and_merge_feature_flags({}, [], [], [enhanced_flag]) + self.assertEqual(len(self.provider._processed_enhanced_feature_flags), 1) + + kv_flag = FeatureFlagConfigurationSetting(feature_id="KvFeature", enabled=True, label=NULL_CHAR) + settings = self.provider._process_and_merge_feature_flags({}, [], [kv_flag], None) + merged_ids = {ff["id"] for ff in settings[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY]} + self.assertEqual(merged_ids, {"KvFeature", "EnhancedFeature"}) + + +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 423322f41e3b..018e7e091675 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 @@ -12,7 +12,7 @@ FALLBACK_CLIENT_REFRESH_EXPIRED_INTERVAL, MINIMAL_CLIENT_REFRESH_INTERVAL, ) -from azure.appconfiguration.provider._models import SettingSelector +from azure.appconfiguration.provider._models import SettingSelector, FeatureFlagSelector def _create_mock_credential(): @@ -36,6 +36,22 @@ def close(self): self.closed = True +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): @@ -473,3 +489,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_provider_feature_flag_refresh_unit.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_refresh_unit.py new file mode 100644 index 000000000000..a8b31873fd9b --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_refresh_unit.py @@ -0,0 +1,131 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +import unittest +from unittest.mock import Mock + +from azure.appconfiguration.provider._azureappconfigurationprovider import AzureAppConfigurationProvider +from azure.appconfiguration.provider._azureappconfigurationproviderbase import AzureAppConfigurationProviderBase + + +def _make_provider() -> AzureAppConfigurationProvider: + """ + Builds an AzureAppConfigurationProvider instance for unit testing ``_attempt_refresh`` without creating any + real network clients. ``AzureAppConfigurationProviderBase.__init__`` is invoked directly (it does not create + any network resources), and the subclass-specific attributes normally set up by + ``AzureAppConfigurationProvider.__init__`` (which does create real clients) are stubbed out instead. + """ + provider = AzureAppConfigurationProvider.__new__(AzureAppConfigurationProvider) + AzureAppConfigurationProviderBase.__init__( + provider, + endpoint="https://test.azconfig.io", + feature_flag_enabled=True, + feature_flag_refresh_enabled=True, + refresh_enabled=False, + ) + provider._secret_provider = Mock(uses_key_vault=False) + provider._on_refresh_success = None + provider._on_refresh_error = None + provider._configuration_mapper = None + provider._replica_client_manager = Mock() + # Force the feature flag refresh timer to be due immediately. + provider._feature_flag_refresh_timer._next_refresh_time = 0 + return provider + + +def _make_client(kv_changed: bool, enhanced_changed: bool) -> Mock: + client = Mock() + client.endpoint = "https://test.azconfig.io" + client.check_feature_flag_page_etags.return_value = kv_changed + client.check_enhanced_feature_flag_etags.return_value = enhanced_changed + client.load_feature_flags.return_value = ([], [["kv_etag"]]) + client.load_enhanced_feature_flags.return_value = ([], [["enhanced_etag"]]) + return client + + +class TestAttemptRefreshReloadsBothFeatureFlagSources(unittest.TestCase): + """Test that ``_attempt_refresh`` always reloads and re-merges both the key-value based feature flags and + the enhanced feature flags together whenever either source's change-detection indicates a change, so the + merged result is always internally consistent.""" + + def test_only_kv_changed_reloads_both(self): + provider = _make_provider() + # Seed existing etag state so change-detection is actually exercised instead of short-circuiting on + # empty state. + provider._feature_flag_page_etags = [["old_kv_etag"]] + provider._enhanced_feature_flag_etags = [["old_enhanced_etag"]] + + client = _make_client(kv_changed=True, enhanced_changed=False) + + provider._attempt_refresh(client, replica_count=0, is_failover_request=False) + + client.load_feature_flags.assert_called_once() + client.load_enhanced_feature_flags.assert_called_once() + + def test_only_enhanced_changed_reloads_both(self): + provider = _make_provider() + provider._feature_flag_page_etags = [["old_kv_etag"]] + provider._enhanced_feature_flag_etags = [["old_enhanced_etag"]] + + client = _make_client(kv_changed=False, enhanced_changed=True) + + provider._attempt_refresh(client, replica_count=0, is_failover_request=False) + + client.load_feature_flags.assert_called_once() + client.load_enhanced_feature_flags.assert_called_once() + + def test_neither_changed_reloads_neither(self): + provider = _make_provider() + provider._feature_flag_page_etags = [["old_kv_etag"]] + provider._enhanced_feature_flag_etags = [["old_enhanced_etag"]] + + client = _make_client(kv_changed=False, enhanced_changed=False) + + provider._attempt_refresh(client, replica_count=0, is_failover_request=False) + + client.load_feature_flags.assert_not_called() + client.load_enhanced_feature_flags.assert_not_called() + + def test_both_changed_reloads_both(self): + provider = _make_provider() + provider._feature_flag_page_etags = [["old_kv_etag"]] + provider._enhanced_feature_flag_etags = [["old_enhanced_etag"]] + + client = _make_client(kv_changed=True, enhanced_changed=True) + + provider._attempt_refresh(client, replica_count=0, is_failover_request=False) + + client.load_feature_flags.assert_called_once() + client.load_enhanced_feature_flags.assert_called_once() + + def test_no_previous_etag_state_reloads_both_without_checking(self): + """When there is no previous etag state at all (first refresh attempt), both sources should be loaded + without needing to call the etag-check methods.""" + provider = _make_provider() + provider._feature_flag_page_etags = [] + provider._enhanced_feature_flag_etags = [] + + client = _make_client(kv_changed=False, enhanced_changed=False) + + provider._attempt_refresh(client, replica_count=0, is_failover_request=False) + + client.load_feature_flags.assert_called_once() + client.load_enhanced_feature_flags.assert_called_once() + + def test_reload_updates_etag_state_for_both_sources(self): + provider = _make_provider() + provider._feature_flag_page_etags = [["old_kv_etag"]] + provider._enhanced_feature_flag_etags = [["old_enhanced_etag"]] + + client = _make_client(kv_changed=True, enhanced_changed=False) + + provider._attempt_refresh(client, replica_count=0, is_failover_request=False) + + self.assertEqual(provider._feature_flag_page_etags, [["kv_etag"]]) + self.assertEqual(provider._enhanced_feature_flag_etags, [["enhanced_etag"]]) + + +if __name__ == "__main__": + unittest.main() 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 97084765b698..3dc1b216a603 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..a83ed9a1f93f --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/tests.md @@ -0,0 +1,89 @@ +# Azure App Configuration Python Provider tests + +(This content is for `azure-appconfiguration-provider` package developer only) + +For general repo-wide testing guidance, see [doc/dev/tests.md](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/tests.md). + +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. + +## Before opening a PR: what to run, and in what order + +If your change adds or modifies any integration tests, run through the following steps in order before opening or updating a PR: + +1. **Run unit tests.** No setup required; these should always pass. +2. **Run the affected integration tests live**, with `AZURE_TEST_RUN_LIVE=true` (and without `AZURE_SKIP_LIVE_RECORDING`), so the test proxy records real interactions to local recording files. See [Live tests vs. recorded (playback) tests](#live-tests-vs-recorded-playback-tests) below. +3. **Run the same integration tests again in playback mode** (unset `AZURE_TEST_RUN_LIVE`, or set it to `false`) to confirm the newly generated recordings replay correctly. Do this *before* pushing the recordings — the test proxy uses your local recording files in playback mode, so this catches recording/sanitization issues before they're published. +4. **Push the new/updated recordings** to the assets repo and commit the updated `assets.json` — see [Publishing new recordings](#publishing-new-recordings) below. Only re-record and push tests you added or intentionally changed; unrelated existing recordings don't need to be regenerated. +5. **Run the pre-PR static/build checks** (sdist, mypy, pylint, black, update_snippet) — see [Pre-PR validation checks](#pre-pr-validation-checks-sdist-mypy-pylint-black-snippets) below. + +If your change only touches unit tests (no integration test changes), you can skip straight to steps 1 and 5. + +## 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: + +### Publishing new recordings + +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. Run the same tests again in playback mode (unset `AZURE_TEST_RUN_LIVE`) to confirm the new recordings replay correctly before publishing them. +3. From the repo root, push the new/updated recordings to the assets repo: + + ```bash + python scripts/manage_recordings.py push -p sdk/appconfiguration/azure-appconfiguration-provider/assets.json + ``` + + This uploads the changed recordings and updates the `Tag` field in `assets.json`. +4. 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) + +By this point you should have already run unit tests, live tests, and playback tests against updated recordings (see [Before opening a PR: what to run, and in what order](#before-opening-a-pr-what-to-run-and-in-what-order) above). + +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.