diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 8451398b3b8b..f5733963400b 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 (2026-08-25) ### Features Added +- Enhanced 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..aecf87fd9c1b 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/README.md @@ -158,13 +158,11 @@ The provider can be configured to refresh configurations from the store on a set import os from azure.appconfiguration.provider import load, WatchKey -connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"] - config = load( endpoint=endpoint, credential=credential, refresh_on=[WatchKey("Sentinel")], - refresh_interval=60, + refresh_interval=30, **kwargs, ) ``` @@ -361,14 +359,12 @@ To enable refresh for feature flags you need to enable refresh. This will allow import os from azure.appconfiguration.provider import load, WatchKey -connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"] - config = load( endpoint=endpoint, credential=credential, refresh_on=[WatchKey("message")], refresh_on_feature_flags=True, - refresh_interval=60, + refresh_interval=30, feature_flag_enabled=True, feature_flag_refresh_enabled=True, **kwargs, @@ -377,6 +373,47 @@ 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 (`id` for feature flag). + + + +```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"]) +``` + + + ## 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. @@ -405,8 +442,10 @@ from azure.appconfiguration.provider import load def my_mapper(setting): - # Transform the setting as needed - setting.value = setting.value.strip() + # Transform the setting as needed. Some settings may have a None value, so guard against that + # before calling string methods on it. + if setting.value is not None: + setting.value = setting.value.strip() config = load(endpoint=endpoint, credential=credential, configuration_mapper=my_mapper, **kwargs) @@ -469,6 +508,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..384e8f683f97 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_MIN_API_VERSION, ) from ._refresh_timer import _RefreshTimer from ._request_tracing_context import _RequestTracingContext @@ -82,6 +88,70 @@ 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) + + kv_selectors = [] + enhanced_selectors = [] + for select in [first_selector, *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_select = cast(FeatureFlagSelector, select) + kv_selectors.append( + SettingSelector( + key_filter=feature_flag_select.name_filter, + label_filter=feature_flag_select.label_filter, + tag_filters=feature_flag_select.tag_filters, + ) + ) + # FeatureFlagSelector has no snapshot_name, so every selector is used for enhanced feature flags. + enhanced_selectors.append(feature_flag_select) + else: + setting_select = cast(SettingSelector, select) + kv_selectors.append(setting_select) + if setting_select.snapshot_name is None: + enhanced_selectors.append( + FeatureFlagSelector( + name_filter=setting_select.key_filter, + label_filter=setting_select.label_filter, + tag_filters=setting_select.tag_filters, + ) + ) + + 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 +174,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 api_version is not None and api_version < REQUIRED_MIN_API_VERSION: + raise ValueError( + f"Unsupported api_version '{api_version}'. This version of the provider requires api_version " + f"'{REQUIRED_MIN_API_VERSION}' or newer." + ) + 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 +212,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 +221,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 +286,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 +378,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 +501,73 @@ 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(self._processed_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 +577,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..08e77bf87e32 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,19 @@ 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" + +# ------------------------------------------------------------------------ +# API Version Constants +# ------------------------------------------------------------------------ +# The minimum service API version required to use this version of the provider. +REQUIRED_MIN_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..819d2d3280a2 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,16 @@ 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 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) @@ -304,6 +386,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 +394,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 +428,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 +495,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..130efd99631b --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_enhanced_feature_flag_sample.py @@ -0,0 +1,76 @@ +# ------------------------------------------------------------------------ +# 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. Use ``FeatureFlagSelector`` to filter which enhanced feature + flags are loaded. 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 + + +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. + 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 + + 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_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..76002298b0dd --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py @@ -0,0 +1,62 @@ +# ------------------------------------------------------------------------ +# 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. Use ``FeatureFlagSelector`` to filter which enhanced feature + flags are loaded. +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 + +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. +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 + + 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_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/samples/entra_id_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/entra_id_sample.py index 6acca29b3182..82f8d8b80cf4 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/entra_id_sample.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/entra_id_sample.py @@ -115,8 +115,10 @@ def my_mapper(setting): - # Transform the setting as needed - setting.value = setting.value.strip() + # Transform the setting as needed. Some settings may have a None value, so guard against that + # before calling string methods on it. + if setting.value is not None: + setting.value = setting.value.strip() config = load(endpoint=endpoint, credential=credential, configuration_mapper=my_mapper, **kwargs) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/refresh_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/refresh_sample.py index 5ff4f06212e5..429cf89e6e4f 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/refresh_sample.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/refresh_sample.py @@ -5,7 +5,6 @@ # ------------------------------------------------------------------------- import os import time -import random from sample_utilities import get_authority, get_credential, get_client_modifications from azure.appconfiguration import ( # type:ignore AzureAppConfigurationClient, @@ -30,20 +29,20 @@ def my_callback_on_fail(_): print("Refresh failed!") -rand = random.random() -watch_key = WatchKey("message" + str(rand)) +# Setting the sentinel key that will be watched for changes. Whenever any monitored configuration is +# updated, the sentinel key should also be updated to signal that a refresh is needed. +sentinel_setting = ConfigurationSetting(key="Sentinel", value="1") +client.set_configuration_setting(configuration_setting=sentinel_setting) # [START refresh_provider] import os from azure.appconfiguration.provider import load, WatchKey -connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"] - config = load( endpoint=endpoint, credential=credential, refresh_on=[WatchKey("Sentinel")], - refresh_interval=60, + refresh_interval=30, **kwargs, ) # [END refresh_provider] @@ -60,14 +59,14 @@ def my_callback_on_fail(_): # Updating the configuration setting configuration_setting.value = "Hello World Updated!" -configuration_setting2 = ConfigurationSetting(key="message" + str(rand), value="2") - -client.set_configuration_setting(configuration_setting=configuration_setting2) - client.set_configuration_setting(configuration_setting=configuration_setting) +# Updating the sentinel key so that the watched key change is detected on the next refresh +sentinel_setting.value = "2" +client.set_configuration_setting(configuration_setting=sentinel_setting) + # Waiting for the refresh interval to pass -time.sleep(2) +time.sleep(35) # Refreshing the configuration setting config.refresh() @@ -77,7 +76,7 @@ def my_callback_on_fail(_): print(config["my_json"]["key"]) # Waiting for the refresh interval to pass -time.sleep(2) +time.sleep(35) # Refreshing the configuration setting with no changes config.refresh() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/refresh_sample_feature_flags.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/refresh_sample_feature_flags.py index 56f7dea16295..714c7d410107 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/refresh_sample_feature_flags.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/refresh_sample_feature_flags.py @@ -22,12 +22,21 @@ client = AzureAppConfigurationClient(endpoint, credential) configuration_setting = ConfigurationSetting(key="message", value="Hello World!") +json_setting = ConfigurationSetting(key="my_json", value='{"key": "value"}', content_type="application/json") feature_flag_setting = FeatureFlagConfigurationSetting("Beta", enabled=True) client.set_configuration_setting(configuration_setting=configuration_setting) +client.set_configuration_setting(configuration_setting=json_setting) client.set_configuration_setting(configuration_setting=feature_flag_setting) +def get_feature_flag(config, flag_id): + for flag in config["feature_management"]["feature_flags"]: + if flag["id"] == flag_id: + return flag + raise KeyError(flag_id) + + def my_callback_on_fail(_): print("Refresh failed!") @@ -36,14 +45,12 @@ def my_callback_on_fail(_): import os from azure.appconfiguration.provider import load, WatchKey -connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"] - config = load( endpoint=endpoint, credential=credential, refresh_on=[WatchKey("message")], refresh_on_feature_flags=True, - refresh_interval=60, + refresh_interval=30, feature_flag_enabled=True, feature_flag_refresh_enabled=True, **kwargs, @@ -54,7 +61,7 @@ def my_callback_on_fail(_): print(config["message"]) print(config["my_json"]["key"]) -print(config["feature_management"]["feature_flags"][1]) +print(get_feature_flag(config, "Beta")) # Updating the configuration setting feature_flag_setting.enabled = False @@ -62,7 +69,7 @@ def my_callback_on_fail(_): client.set_configuration_setting(configuration_setting=feature_flag_setting) # Waiting for the refresh interval to pass -time.sleep(2) +time.sleep(35) # Refreshing the configuration setting config.refresh() @@ -70,10 +77,10 @@ def my_callback_on_fail(_): # Printing the updated value print(config["message"]) print(config["my_json"]["key"]) -print(config["feature_management"]["feature_flags"][1]) +print(get_feature_flag(config, "Beta")) # Waiting for the refresh interval to pass -time.sleep(2) +time.sleep(35) # Refreshing the configuration setting with no changes config.refresh() @@ -81,4 +88,4 @@ def my_callback_on_fail(_): # Printing the updated value print(config["message"]) print(config["my_json"]["key"]) -print(config["feature_management"]["feature_flags"][1]) +print(get_feature_flag(config, "Beta")) 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/aio/test_async_provider_feature_flag_refresh_unit.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_refresh_unit.py new file mode 100644 index 000000000000..f7607389ddb1 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_refresh_unit.py @@ -0,0 +1,147 @@ +# ------------------------------------------------------------------------ +# 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 AsyncMock, Mock + +from azure.appconfiguration.provider.aio._azureappconfigurationproviderasync import AzureAppConfigurationProvider +from azure.appconfiguration.provider._azureappconfigurationproviderbase import AzureAppConfigurationProviderBase + + +def _make_provider() -> AzureAppConfigurationProvider: + """ + Builds an AzureAppConfigurationProvider (async) 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 = AsyncMock(return_value=kv_changed) + client.check_enhanced_feature_flag_etags = AsyncMock(return_value=enhanced_changed) + client.load_feature_flags = AsyncMock(return_value=([], [["kv_etag"]])) + client.load_enhanced_feature_flags = AsyncMock(return_value=([], [["enhanced_etag"]])) + return client + + +class TestAsyncAttemptRefreshReloadsBothFeatureFlagSources(unittest.IsolatedAsyncioTestCase): + """Test that the async ``_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. Mirrors the sync test suite in + ``test_provider_feature_flag_refresh_unit.py``.""" + + async def test_only_kv_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=False) + + await 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() + + async 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) + + await 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() + + async 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) + + await 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() + + async 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) + + await 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() + + async 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) + + await 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() + + async 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) + + await 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"]]) + + async def test_on_refresh_success_called_when_feature_flags_refresh_to_empty_list(self): + """Regression test: even when a feature flag refresh occurs but yields an empty list (a falsy but + non-None value), ``_on_refresh_success`` must still be invoked because a refresh was in fact attempted + and completed successfully.""" + provider = _make_provider() + provider._feature_flag_page_etags = [["old_kv_etag"]] + provider._enhanced_feature_flag_etags = [["old_enhanced_etag"]] + on_refresh_success = Mock() + provider._on_refresh_success = on_refresh_success + + # Both loads return an empty list, which is falsy but not None. + client = _make_client(kv_changed=True, enhanced_changed=False) + + await provider._attempt_refresh(client, replica_count=0, is_failover_request=False) + + on_refresh_success.assert_called_once() + + +if __name__ == "__main__": + unittest.main() 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..31cb48970a42 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -11,18 +11,22 @@ from typing import Dict, Any from azure.appconfiguration import FeatureFlagConfigurationSetting +from azure.appconfiguration import FeatureFlag 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_MIN_API_VERSION, ) from azure.appconfiguration.provider._refresh_timer import _RefreshTimer @@ -206,6 +210,90 @@ 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_required_min_api_version_succeeds(self): + provider = AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + api_version=REQUIRED_MIN_API_VERSION, + ) + + self.assertEqual(provider._origin_endpoint, "https://test.azconfig.io") + + def test_newer_api_version_succeeds(self): + # A future service API version (newer than REQUIRED_MIN_API_VERSION) should also be accepted, since newer + # versions are expected to remain a superset of the current provider's minimum requirements. + provider = AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + api_version="2099-01-01", + ) + + self.assertEqual(provider._origin_endpoint, "https://test.azconfig.io") + + def test_no_api_version_succeeds(self): + # No explicit api_version means the SDK client's own default will be used, which already meets the + # provider's minimum required api_version, so no validation error should be raised. + provider = AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + ) + + self.assertEqual(provider._origin_endpoint, "https://test.azconfig.io") + + def test_outdated_api_version_raises_error(self): + # api_version validation applies regardless of feature_flag_enabled, since this provider version requires + # a minimum service api_version to function correctly in general, not only for enhanced feature flags. + with self.assertRaises(ValueError) as context: + AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + api_version="2023-11-01", + ) + + self.assertIn("2023-11-01", str(context.exception)) + self.assertIn(REQUIRED_MIN_API_VERSION, str(context.exception)) + + def test_feature_flag_disabled_with_outdated_api_version_raises_error(self): + # The minimum api_version check is independent of feature_flag_enabled. + with self.assertRaises(ValueError) as context: + AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + feature_flag_enabled=False, + api_version="2023-11-01", + ) + + self.assertIn("2023-11-01", str(context.exception)) + self.assertIn(REQUIRED_MIN_API_VERSION, str(context.exception)) + def test_process_key_name_with_no_prefix(self): """Test key name processing with no matching prefix.""" config = Mock() @@ -403,3 +491,244 @@ 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 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_process_enhanced_feature_flag.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_process_enhanced_feature_flag.py new file mode 100644 index 000000000000..5f586b49fa9d --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_process_enhanced_feature_flag.py @@ -0,0 +1,261 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +import unittest +import json + +from azure.appconfiguration import ( + FeatureFlag, + FeatureFlagAllocation, + FeatureFlagConditions, + FeatureFilter, + FeatureFlagTelemetryConfiguration, + FeatureFlagVariantDefinition, + GroupAllocation, + PercentileAllocation, + UserAllocation, +) +from azure.appconfiguration.provider._azureappconfigurationproviderbase import AzureAppConfigurationProviderBase +from azure.appconfiguration.provider._constants import ( + METADATA_KEY, + ETAG_KEY, + FEATURE_FLAG_REFERENCE_KEY, +) + + +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_uses_enhanced_feature_flags_tracing_not_reset_when_enhanced_flags_not_refreshed(self): + """Regression test: if a refresh only touches key-value feature flags (enhanced_feature_flags=None, + meaning the enhanced feature flag source was not refreshed this time), the tracing context must not be + reset to False when enhanced feature flags are already loaded from a previous refresh.""" + 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) + + # Simulate a refresh where only key-value feature flags were refreshed; enhanced_feature_flags=None + # indicates that source wasn't touched this time. + self.provider._process_and_merge_feature_flags({}, [], [], None) + + self.assertTrue(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]) 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..a403b8b934b4 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py @@ -11,13 +11,15 @@ AzureAppConfigurationClient, ConfigurationSetting, ConfigurationSettingsFilter, + FeatureFlag, + FeatureFlagClient, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, SnapshotComposition, SnapshotStatus, ) from azure.appconfiguration.provider import load, AzureAppConfigurationKeyVaultOptions -from azure.appconfiguration.provider._constants import NULL_CHAR +from azure.appconfiguration.provider._constants import NULL_CHAR, FEATURE_FLAG_ID_FIELD class AppConfigTestCase(AzureRecordedTestCase): @@ -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[FEATURE_FLAG_ID_FIELD] == 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..941fd33a89e9 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/tests.md @@ -0,0 +1,102 @@ +# Azure App Configuration Python Provider tests + +(This content is for `azure-appconfiguration-provider` package developers only) + +For general repo-wide testing setup and concepts (environment setup, the test proxy, live vs. playback +mode, recordings, sanitizers), see [doc/dev/tests.md](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/tests.md). +This page only covers what's specific to this package, plus a checklist of what to run — and in what +order — before opening a PR. + +## Running tests locally (Windows) + +Use a single virtual environment for the whole repo, created at the **repo root** (`azure-sdk-for-python/.venv`) +per [doc/dev/tests.md § Set up your development environment](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/tests.md#set-up-your-development-environment): + +```powershell +# From the repo root +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install --upgrade pip +``` + +Then install this package's `dev_requirements.txt` from *this* package directory, since it contains paths +relative to it (e.g. `../azure-appconfiguration`), and install this package itself as an editable install: + +```powershell +# From sdk/appconfiguration/azure-appconfiguration-provider +..\..\..\.venv\Scripts\python.exe -m pip install -r dev_requirements.txt +..\..\..\.venv\Scripts\python.exe -m pip install -e . +``` + +Run tests from this package directory: + +```powershell +..\..\..\.venv\Scripts\python.exe -P -m pytest tests +``` + +Run a single file or test the same way, e.g.: + +```powershell +..\..\..\.venv\Scripts\python.exe -P -m pytest tests\aio\test_async_provider_refresh.py +``` + +## Environment variables for local integration testing + +To run the integration tests (those using `@recorded_by_proxy` / `@recorded_by_proxy_async`) locally in live mode (interacting with app config store through network call), create a `.env` file at the repository root with the following content: + +``` +AZURE_TEST_RUN_LIVE=true +APPCONFIGURATION_ENDPOINT_STRING=.azconfig.io> +APPCONFIGURATION_KEY_VAULT_REFERENCE= +APPCONFIGURATION_KEY_VAULT_REFERENCE2= +APPCONFIGURATION_KEYVAULT_SECRET_URL= +APPCONFIGURATION_KEYVAULT_SECRET_URL2= +``` + +* `APPCONFIGURATION_KEY_VAULT_REFERENCE` and `APPCONFIGURATION_KEYVAULT_SECRET_URL` can be the same value. `APPCONFIGURATION_KEY_VAULT_REFERENCE2` and `APPCONFIGURATION_KEYVAULT_SECRET_URL2` can be the same value. They are referencing the `Secret Identifier`. +* See [Microsoft Learn: Set and retrieve a secret from Azure Key Vault using the Azure portal](https://learn.microsoft.com/en-us/azure/key-vault/secrets/quick-create-portal) for how to create a secret. + +With `AZURE_TEST_RUN_LIVE=true` set in your `.env` file, run the integration test(s) live the same way as any +other test: + +```powershell +..\..\..\.venv\Scripts\python.exe -P -m pytest tests\test_provider.py +``` + +See [doc/dev/tests.md § Configure live or playback testing mode](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/tests.md#configure-live-or-playback-testing-mode) +and [§ Run and record tests](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/tests.md#run-and-record-tests) +for how live/record/playback modes work generally. + +## Before opening a PR: what to run, and in what order + +1. **Run the full test suite** + ```powershell + ..\..\..\.venv\Scripts\python.exe -P -m pytest tests + ``` + These should always pass. +2. **If your change adds or modifies integration tests** (those using `@recorded_by_proxy` / `@recorded_by_proxy_async`), run just those test file(s)/method(s) live + (`AZURE_TEST_RUN_LIVE=true`) so the test proxy records real interactions + to local recording files. +3. **Run the same integration tests again in playback mode** (unset `AZURE_TEST_RUN_LIVE`) to confirm the + new recordings replay correctly — do this *before* pushing recordings, since it catches + recording/sanitization issues early. +4. **Push the new/updated recordings** and commit the updated `assets.json`: + ```bash + python scripts/manage_recordings.py push -p sdk/appconfiguration/azure-appconfiguration-provider/assets.json + ``` + Only re-record and push tests you added or intentionally changed. See + [doc/dev/tests.md § Update test recordings](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/tests.md#update-test-recordings) + for background on the assets repo/`assets.json` mechanism. +5. **Run the pre-PR static/build checks**, from this package's directory: + ```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; re-run after other fixes) + azpysdk update_snippet . # regenerates README code snippets; run after changing any samples/*.py file + ``` + 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 `azpysdk` checks, and [doc/dev/pylint_checking.md](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/pylint_checking.md) + / [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 fixing pylint/mypy issues. + +If your change only touches unit tests (no integration test changes), skip straight to steps 1 and 5.