From 2907d04e316093855db6041968c4a6bdcd0c739d Mon Sep 17 00:00:00 2001 From: Yuan Qu Date: Wed, 22 Jul 2026 14:02:55 -0700 Subject: [PATCH 1/8] Load feature flags from the dedicated feature flag resource endpoint in the provider Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHANGELOG.md | 5 +- .../azure-appconfiguration-provider/README.md | 93 ++++++++ .../assets.json | 2 +- .../_azureappconfigurationprovider.py | 32 ++- .../_azureappconfigurationproviderbase.py | 212 ++++++++++++++++- .../provider/_client_manager.py | 98 ++++++++ .../appconfiguration/provider/_constants.py | 9 + .../provider/_request_tracing_context.py | 29 ++- .../appconfiguration/provider/_version.py | 2 +- .../provider/aio/_async_client_manager.py | 101 +++++++- .../_azureappconfigurationproviderasync.py | 32 ++- .../samples/README.md | 2 + .../async_feature_flag_resource_sample.py | 80 +++++++ .../samples/feature_flag_resource_sample.py | 65 ++++++ .../azure-appconfiguration-provider/setup.py | 2 +- ...t_async_provider_feature_flag_resources.py | 153 ++++++++++++ .../tests/asynctestcase.py | 24 +- .../test_azureappconfigurationproviderbase.py | 218 ++++++++++++++++++ .../test_configuration_client_manager.py | 176 ++++++++++++++ .../test_provider_feature_flag_resources.py | 150 ++++++++++++ .../tests/testcase.py | 40 +++- 21 files changed, 1496 insertions(+), 29 deletions(-) create mode 100644 sdk/appconfiguration/azure-appconfiguration-provider/samples/async_feature_flag_resource_sample.py create mode 100644 sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py create mode 100644 sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_resources.py create mode 100644 sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.py diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 54481723b4d3..5d36e017dc8a 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md @@ -1,9 +1,11 @@ # Release History -## 2.5.1 (Unreleased) +## 2.6.0b1 (Unreleased) ### Features Added +- Feature flags created via the dedicated feature flag resource endpoint (`FeatureFlagClient`/`FeatureFlag` in `azure-appconfiguration`) are now loaded automatically alongside key-value based feature flags whenever `feature_flag_enabled=True`. Both kinds are merged into the same `feature_management.feature_flags` list, with resource-based feature flags taking precedence over key-value based ones when they share the same name. No new `load()` options are required to opt in, and existing `feature_flag_selectors` filter both kinds. + ### Breaking Changes ### Bugs Fixed @@ -11,6 +13,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..7780b20300a2 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/README.md @@ -377,6 +377,48 @@ config = load( +### Loading Feature Flags as Resources + +Feature flags can also be created using the dedicated feature flag resource endpoint (via `FeatureFlagClient`/`FeatureFlag` in `azure-appconfiguration`), instead of as classic key-value configuration settings. The provider loads both kinds side by side into the same `feature_management.feature_flags` list, with feature flag resources taking precedence over key-value based feature flags when they share the same name. No additional `load()` options are required to enable this — it happens automatically whenever `feature_flag_enabled=True`, using the same `feature_flag_selectors`. + + + +```python +from azure.appconfiguration.provider import load + +# Feature flags loaded from the feature flag resource 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"] +resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") +print(resource_beta["enabled"]) +``` + + + +The same `SettingSelector` used to filter key-value based feature flags also filters feature flag resources, by name, label, or tags. Note that selectors with a `snapshot_name` are not currently supported by the feature flag resource endpoint and are skipped when loading feature flag resources. + + + +```python +from azure.appconfiguration.provider import load, SettingSelector + +# The same SettingSelector used to filter key-value based feature flags also filters feature flag +# resources, by name/label/tags. +config = load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Resource*")], + **kwargs, +) +feature_flags = config["feature_management"]["feature_flags"] +resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") +print(resource_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. @@ -469,6 +511,57 @@ 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) + +The tests for this package are under the `tests/` directory and are split into two categories: + +* **Unit tests** (e.g. `tests/test_azureappconfigurationproviderbase.py`, `tests/test_configuration_client_manager.py`) — exercise internal logic in isolation using mocked clients. These do not require any App Configuration store, network access, or environment variables, and can be run at any time with no setup. +* **Integration tests** (e.g. `tests/test_provider.py`, `tests/test_provider_feature_flag_resources.py`, and their `tests/aio/` async equivalents) — exercise the provider end-to-end against an Azure App Configuration store. These tests are built on [`devtools_testutils`](https://github.com/Azure/azure-sdk-for-python/tree/main/eng/tools/azure-sdk-tools/devtools_testutils) and each test method is decorated with `@recorded_by_proxy` / `@recorded_by_proxy_async`, which route the test's HTTP traffic through the [test proxy](https://github.com/Azure/azure-sdk-tools/tree/main/tools/test-proxy) tool. + +### Live tests vs. recorded (playback) tests + +Whether an integration test makes a real network call or replays a recording is controlled entirely by the `AZURE_TEST_RUN_LIVE` environment variable, not by anything in this package's code: + +* `AZURE_TEST_RUN_LIVE=true` — Tests run in **live/record mode**. The test proxy forwards requests to the real endpoint configured via your environment variables (see below), and (unless `AZURE_SKIP_LIVE_RECORDING=true` is also set) records the request/response pairs as new recording files for use in future playback runs. +* `AZURE_TEST_RUN_LIVE` unset or `false` (the default, and what CI uses) — Tests run in **playback mode**. The test proxy replays the existing recordings instead of contacting the real service, so **no network calls are made** and no live App Configuration store is required. + +Recordings themselves are not stored directly in this repository — they live in the separate [`Azure/azure-sdk-assets`](https://github.com/Azure/azure-sdk-assets) repo, and this package's `assets.json` file pins the exact recordings revision (`Tag`) that CI uses. If you add or change integration tests, you need to generate new recordings and publish them: + +1. Run the affected tests with `AZURE_TEST_RUN_LIVE=true` (and without `AZURE_SKIP_LIVE_RECORDING`) so the test proxy records real interactions to local recording files. +2. From the repo root, push the new/updated recordings to the assets repo: + + ```bash + dotnet tool run test-proxy push -a sdk/appconfiguration/azure-appconfiguration-provider/assets.json + ``` + + This uploads the changed recordings and updates the `Tag` field in `assets.json`. +3. Commit the updated `assets.json` as part of your PR — this is what allows CI (which always runs in playback mode) to pick up the new recordings. + +Only re-record tests you added or intentionally changed; unrelated existing recordings don't need to be regenerated. + +### Environment variables for local testing + +To run the integration tests locally in live mode, create a `.env` file at the repository root (it is automatically loaded by `devtools_testutils`) with the following variables: + +``` +AZURE_TEST_RUN_LIVE=true +APPCONFIGURATION_CONNECTION_STRING= +APPCONFIGURATION_ENDPOINT_STRING=.azconfig.io> +APPCONFIGURATION_KEY_VAULT_REFERENCE= +APPCONFIGURATION_KEY_VAULT_REFERENCE2= +APPCONFIGURATION_KEYVAULT_SECRET_URL= +APPCONFIGURATION_KEYVAULT_SECRET_URL2= +``` + +Notes: + +* For key vault URI, you can create a secret in Azure Key Vault service. The key vault URI is the *Secret Identifier*, without the final version number. For example, if the secret identifier is `https://some_secret.vault.azure.net/secrets/fake-secret/30d8830ec5ed4a428d311292a826f452`, the key vault URI should be `https://some_secret.vault.azure.net/secrets/fake-secret/`. +* Authentication for Entra ID-based tests relies on your local Azure CLI login (`az login`); make sure you're signed in to the subscription that contains your App Configuration store. +* Add `AZURE_SKIP_LIVE_RECORDING=true` if you want to run tests live against the real store without generating/overwriting recording files (useful for a quick sanity check). +* Omit `AZURE_TEST_RUN_LIVE` (or set it to `false`) to run the same tests in playback mode against existing recordings — this does not require any of the App Configuration environment variables above. + ## 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/assets.json b/sdk/appconfiguration/azure-appconfiguration-provider/assets.json index b9e5f6a80d69..ab4ac3e8fb87 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/assets.json +++ b/sdk/appconfiguration/azure-appconfiguration-provider/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/appconfiguration/azure-appconfiguration-provider", - "Tag": "python/appconfiguration/azure-appconfiguration-provider_34a63910b7" + "Tag": "python/appconfiguration/azure-appconfiguration-provider_fb87386e95" } diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py index a3f8f826679c..6161477e40b2 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py @@ -17,6 +17,7 @@ ) from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, + FeatureFlag, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, ) @@ -107,6 +108,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f ) configuration_settings: List[ConfigurationSetting] = [] feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None + feature_flag_resources: Optional[List[FeatureFlag]] = None # Timer needs to be reset even if no refresh happened if time had passed configuration_refresh_attempted = False @@ -115,6 +117,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f existing_feature_flag_usage = self._tracing_context.feature_filter_usage.copy() page_etags: List[List[str]] = [] feature_flag_page_etags: List[List[str]] = [] + feature_flag_resource_etags: List[List[str]] = [] try: if self._refresh_enabled and not self._watched_settings and self._refresh_timer.needs_refresh(): configuration_refresh_attempted = True @@ -148,6 +151,16 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f self._feature_flag_selectors, headers=headers, **kwargs ) + # Feature flag resources are loaded independently of the key-value based feature flags, using their + # own page-level etag state, since they are a separate resource type with a separate + # change-detection mechanism. + if not self._feature_flag_resource_etags or client.check_feature_flag_resource_etags( + self._feature_flag_selectors, self._feature_flag_resource_etags, headers=headers, **kwargs + ): + feature_flag_resources, feature_flag_resource_etags = client.load_feature_flag_resources( + self._feature_flag_selectors, headers=headers, **kwargs + ) + # Default to existing settings if no refresh occurred processed_settings = self._dict @@ -157,7 +170,9 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f # Configuration Settings have been refreshed processed_settings = self._process_configurations(configuration_settings, client) - processed_settings = self._process_feature_flags(processed_settings, processed_feature_flags, feature_flags) + processed_settings = self._process_feature_flags( + processed_settings, processed_feature_flags, feature_flags, feature_flag_resources + ) self._dict = processed_settings if settings_refreshed: self._page_etags = page_etags @@ -165,12 +180,14 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f self._watched_settings.update(updated_watched_settings) if feature_flags is not None: self._feature_flag_page_etags = feature_flag_page_etags + if feature_flag_resources is not None: + self._feature_flag_resource_etags = feature_flag_resource_etags # Reset timers at the same time as they should load from the same store. if configuration_refresh_attempted: self._refresh_timer.reset() if self._feature_flag_refresh_enabled and feature_flag_refresh_attempted: self._feature_flag_refresh_timer.reset() - if (settings_refreshed or feature_flags) and self._on_refresh_success: + if (settings_refreshed or feature_flags or feature_flag_resources) and self._on_refresh_success: self._on_refresh_success() except AzureError as e: logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint) @@ -278,6 +295,7 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> processed_settings = self._process_configurations(configuration_settings, client) feature_flag_page_etags: List[List[str]] = [] + feature_flag_resource_etags: List[List[str]] = [] if self._feature_flag_enabled: feature_flags: List[FeatureFlagConfigurationSetting] feature_flags, feature_flag_page_etags = client.load_feature_flags( @@ -285,7 +303,14 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> headers=headers, **kwargs, ) - processed_settings = self._process_feature_flags(processed_settings, [], feature_flags) + feature_flag_resources, feature_flag_resource_etags = client.load_feature_flag_resources( + self._feature_flag_selectors, + headers=headers, + **kwargs, + ) + processed_settings = self._process_feature_flags( + processed_settings, [], feature_flags, feature_flag_resources + ) for (key, label), etag in self._watched_settings.items(): if not etag: try: @@ -310,6 +335,7 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> self._dict = processed_settings self._page_etags = page_etags self._feature_flag_page_etags = feature_flag_page_etags + self._feature_flag_resource_etags = feature_flag_resource_etags return True except AzureError as e: logger.warning("Failed to load configurations from endpoint %s.\n %s", client.endpoint, e.message) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py index e5b6240d74e6..d2cc796cdd5c 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -25,6 +25,7 @@ from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, FeatureFlagConfigurationSetting, + FeatureFlag, ) from ._models import SettingSelector from ._constants import ( @@ -38,6 +39,10 @@ APP_CONFIG_AICC_MIME_PROFILE, FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY, + FEATURE_FLAG_ID_FIELD, + FEATURE_FLAG_NAME_FIELD, + FEATURE_FLAG_KV_REFERENCE_SEGMENT, + FEATURE_FLAG_RESOURCE_REFERENCE_SEGMENT, ) from ._refresh_timer import _RefreshTimer from ._request_tracing_context import _RequestTracingContext @@ -115,6 +120,16 @@ def __init__(self, **kwargs: Any) -> None: self._refresh_enabled = refresh_enabled self._page_etags: List[List[str]] = [] self._feature_flag_page_etags: List[List[str]] = [] + # Per-selector collection ETags for feature flags loaded from the feature flag resource endpoint. This is + # independent of the key-value based feature_flag_page_etags, since the resource endpoint is a separate + # resource type with its own change-detection mechanism. + self._feature_flag_resource_etags: List[List[str]] = [] + # Feature flags are loaded from two independent sources: the classic key-value store, and the newer + # dedicated feature flag resource endpoint. Each source's processed output is cached separately so that a + # refresh of one source does not require re-processing or discarding the other source's data. The two are + # merged (resource-based feature flags take precedence on identifier collision) whenever either changes. + self._processed_kv_feature_flags: List[Dict[str, Any]] = [] + self._processed_resource_feature_flags: List[Dict[str, Any]] = [] self._tracing_context = _RequestTracingContext(kwargs.pop("load_balancing_enabled", False)) self._update_lock = Lock() self._refresh_lock = Lock() @@ -132,7 +147,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 classic key-value store. :param endpoint: The App Configuration endpoint URL. :type endpoint: str @@ -141,6 +156,61 @@ 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_ff_resource_telemetry_metadata(self, endpoint: str, feature_flag: FeatureFlag, feature_flag_value: Dict): + """ + Add telemetry metadata to feature flag values loaded from the feature flag resource endpoint. + + :param endpoint: The App Configuration endpoint URL. + :type endpoint: str + :param feature_flag: The feature flag resource. + :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, + FEATURE_FLAG_RESOURCE_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 resource-based). + :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 resource-based feature flags. + :type reference_path_segment: str + """ if TELEMETRY_KEY not in feature_flag_value: # Initialize telemetry dictionary if not present feature_flag_value[TELEMETRY_KEY] = {} @@ -148,15 +218,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 and not label.isspace(): + feature_flag_reference += f"?label={label}" feature_flag_value[TELEMETRY_KEY][METADATA_KEY][FEATURE_FLAG_REFERENCE_KEY] = feature_flag_reference allocation_id = self._generate_allocation_id(feature_flag_value) @@ -240,10 +310,14 @@ 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()}," + # Key-value based feature flags store the variant value under "configuration_value". Feature + # flags loaded from the feature flag resource endpoint store it under "value" instead. if "configuration_value" in v: allocation_id += ( f"{json.dumps(v.get('configuration_value', ''), separators=(',', ':'), sort_keys=True)}" ) + elif "value" in v: + allocation_id += f"{json.dumps(v.get('value', ''), separators=(',', ':'), sort_keys=True)}" allocation_id += ";" if sorted_variants: allocation_id = allocation_id[:-1] @@ -369,17 +443,56 @@ def _process_feature_flags( processed_settings: Dict[str, Any], processed_feature_flags: List[Dict[str, Any]], feature_flags: Optional[List[FeatureFlagConfigurationSetting]], + feature_flag_resources: Optional[List[FeatureFlag]] = None, ) -> Dict[str, Any]: - if feature_flags: + if feature_flags or feature_flag_resources: # Reset feature flag usage self._tracing_context.reset_feature_filter_usage() - processed_feature_flags = [self._process_feature_flag(ff) for ff in feature_flags] + + if feature_flags: + self._processed_kv_feature_flags = [self._process_feature_flag(ff) for ff in feature_flags] + + if feature_flag_resources: + self._processed_resource_feature_flags = [ + self._process_feature_flag_resource(ff) for ff in feature_flag_resources + ] + + if feature_flags or feature_flag_resources: + processed_feature_flags = self._merge_feature_flags( + self._processed_kv_feature_flags, self._processed_resource_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 + @staticmethod + def _merge_feature_flags( + kv_feature_flags: List[Dict[str, Any]], resource_feature_flags: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """ + Merge feature flags loaded from the classic key-value store with feature flags loaded from the feature + flag resource endpoint. Feature flags are matched by their identifier (``id`` for key-value based feature + flags, ``name`` for resource-based feature flags). When both sources contain a feature flag with the same + identifier, the resource-based feature flag takes precedence. + + :param kv_feature_flags: The feature flags loaded from the classic key-value store. + :type kv_feature_flags: List[Dict[str, Any]] + :param resource_feature_flags: The feature flags loaded from the feature flag resource endpoint. + :type resource_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) + merged[identifier] = ff + for ff in resource_feature_flags: + identifier = ff.get(FEATURE_FLAG_NAME_FIELD) + merged[identifier] = ff + return list(merged.values()) + def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) -> Dict[str, Any]: try: feature_flag_value = json.loads(feature_flag.value) @@ -390,6 +503,91 @@ def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) - # Feature flag value is not a valid JSON return {} + def _process_feature_flag_resource(self, feature_flag: FeatureFlag) -> Dict[str, Any]: + """ + Convert a feature flag resource, loaded from the feature flag resource endpoint, into a dictionary using + the feature flag resource's native field names. + + :param feature_flag: The feature flag resource. + :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_NAME_FIELD: feature_flag.name, + "enabled": feature_flag.enabled, + } + if feature_flag.label and not feature_flag.label.isspace(): + feature_flag_value["label"] = feature_flag.label + if feature_flag.description: + feature_flag_value["description"] = feature_flag.description + + filter_names: List[Optional[str]] = [] + if feature_flag.conditions: + conditions_value: Dict[str, Any] = {} + if feature_flag.conditions.requirement_type: + conditions_value["requirement_type"] = feature_flag.conditions.requirement_type + if feature_flag.conditions.client_filters: + conditions_value["client_filters"] = [ + {"name": client_filter.name, "parameters": client_filter.parameters} + for client_filter in feature_flag.conditions.client_filters + ] + filter_names = [client_filter.name for client_filter in feature_flag.conditions.client_filters] + if conditions_value: + feature_flag_value["conditions"] = conditions_value + + if feature_flag.variants: + feature_flag_value["variants"] = [ + { + "name": variant.name, + "value": variant.value, + "content_type": variant.content_type, + "status_override": variant.status_override, + } + for variant in feature_flag.variants + ] + + if feature_flag.allocation: + allocation_value: Dict[str, Any] = {} + if feature_flag.allocation.default_when_disabled: + allocation_value["default_when_disabled"] = feature_flag.allocation.default_when_disabled + if feature_flag.allocation.default_when_enabled: + allocation_value["default_when_enabled"] = feature_flag.allocation.default_when_enabled + if feature_flag.allocation.percentile: + allocation_value["percentile"] = [ + { + "variant": percentile.variant, + "percentile_from": percentile.percentile_from, + "percentile_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_ff_resource_telemetry_metadata(self._origin_endpoint, feature_flag, feature_flag_value) + self._tracing_context.update_feature_filter_telemetry_by_names(filter_names) + return feature_flag_value + def _update_watched_settings( self, configuration_settings: List[ConfigurationSetting] ) -> Dict[Tuple[str, str], Optional[str]]: diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py index 651a55577222..056e375ab7de 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 ( @@ -35,6 +37,7 @@ @dataclass class _ConfigurationClientWrapper(_ConfigurationClientWrapperBase): _client: AzureAppConfigurationClient + _feature_flag_client: Optional[FeatureFlagClient] = None backoff_end_time: float = 0 failed_attempts: int = 0 LOGGER = getLogger(__name__) @@ -71,6 +74,14 @@ 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, + ), ) @classmethod @@ -98,6 +109,13 @@ 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, + ), ) def _check_configuration_setting( @@ -282,6 +300,80 @@ def check_feature_flag_page_etags( return True return False + @distributed_trace + def load_feature_flag_resources( + self, feature_flag_selectors: List[SettingSelector], **kwargs + ) -> Tuple[List[FeatureFlag], List[List[str]]]: + """ + Loads feature flags from the feature flag resource endpoint using page-based iteration, collecting page + etags for each selector. Selectors with a ``snapshot_name`` are currently not supported by the feature flag + resource endpoint and are skipped. + + :param feature_flag_selectors: List of setting selectors to filter feature flags + :type feature_flag_selectors: List[SettingSelector] + :return: A tuple of (feature_flags, page_etags_per_selector) + :rtype: Tuple[List[~azure.appconfiguration.FeatureFlag], List[List[str]]] + """ + loaded_feature_flags: List[FeatureFlag] = [] + page_etags: List[List[str]] = [] + # Needs to be removed unknown keyword argument for the feature flag client + kwargs.pop("sentinel_keys", None) + if self._feature_flag_client is None: + return loaded_feature_flags, [[] for _ in feature_flag_selectors] + for select in feature_flag_selectors: + selector_etags: List[str] = [] + if select.snapshot_name is not None: + # Snapshots are not supported by the feature flag resource endpoint as of now + page_etags.append(selector_etags) + continue + feature_flags = self._feature_flag_client.list_feature_flags( + name_filter=select.key_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_feature_flag_resource_etags( + self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs + ) -> bool: + """ + Checks if any feature flag resource page has changed using page etags. + + :param feature_flag_selectors: List of setting selectors for feature flags + :type feature_flag_selectors: List[SettingSelector] + :param page_etags: The page etags from the last load, one list per selector + :type page_etags: List[List[str]] + :return: True if any page has changed, False otherwise + :rtype: bool + """ + if self._feature_flag_client is None: + return False + for i, select in enumerate(feature_flag_selectors): + if select.snapshot_name is not None: + # Snapshots are not supported by the feature flag resource endpoint + continue + 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._feature_flag_client.list_feature_flags( + name_filter=select.key_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + for _ in feature_flags.by_page(match_conditions=selector_etags): + # If any page is returned, it means that page has changed + return True + return False + @distributed_trace def get_updated_watched_settings( self, watched_settings: Mapping[Tuple[str, str], Optional[str]], headers: Dict[str, str], **kwargs @@ -362,13 +454,19 @@ def close(self) -> None: Closes the connection to Azure App Configuration. """ self._client.close() + if self._feature_flag_client is not None: + self._feature_flag_client.close() def __enter__(self): self._client.__enter__() + if self._feature_flag_client is not None: + self._feature_flag_client.__enter__() return self def __exit__(self, *args): self._client.__exit__(*args) + if self._feature_flag_client is not None: + self._feature_flag_client.__exit__(*args) def resolve_snapshot_reference(self, setting: ConfigurationSetting, **kwargs) -> List[ConfigurationSetting]: """ diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py index 3e68591bb46c..fc916cebff3a 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py @@ -15,6 +15,15 @@ ALLOCATION_ID_KEY = "AllocationId" ETAG_KEY = "ETag" +# Identifier field used by feature flags loaded from the classic key-value store. +FEATURE_FLAG_ID_FIELD = "id" +# Identifier field used by feature flags loaded from the dedicated feature flag resource endpoint. +FEATURE_FLAG_NAME_FIELD = "name" +# 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 feature flags loaded from the resource endpoint. +FEATURE_FLAG_RESOURCE_REFERENCE_SEGMENT = "ff" + # ------------------------------------------------------------------------ # Environment Variable Constants # ------------------------------------------------------------------------ diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py index bc308d0bf1ac..330a719613a6 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 @@ -243,15 +243,26 @@ def update_feature_filter_telemetry(self, feature_flag) -> None: # Constants are already imported at module level if feature_flag.filters: - for filter in feature_flag.filters: - if filter.get("name") in PERCENTAGE_FILTER_NAMES: - self.feature_filter_usage[PERCENTAGE_FILTER_KEY] = True - elif filter.get("name") in TIME_WINDOW_FILTER_NAMES: - self.feature_filter_usage[TIME_WINDOW_FILTER_KEY] = True - elif filter.get("name") in TARGETING_FILTER_NAMES: - self.feature_filter_usage[TARGETING_FILTER_KEY] = True - else: - self.feature_filter_usage[CUSTOM_FILTER_KEY] = True + self.update_feature_filter_telemetry_by_names(filter.get("name") for filter in feature_flag.filters) + + def update_feature_filter_telemetry_by_names(self, filter_names) -> None: + """ + Track feature filter usage for App Configuration telemetry, given the filter names directly. Used for feature + flags that don't expose their filters as dictionaries, e.g. feature flags loaded from the feature flag + resource endpoint. + + :param filter_names: The names of the filters used by a feature flag. + :type filter_names: Iterable[Optional[str]] + """ + for name in filter_names: + if name in PERCENTAGE_FILTER_NAMES: + self.feature_filter_usage[PERCENTAGE_FILTER_KEY] = True + elif name in TIME_WINDOW_FILTER_NAMES: + self.feature_filter_usage[TIME_WINDOW_FILTER_KEY] = True + elif name in TARGETING_FILTER_NAMES: + self.feature_filter_usage[TARGETING_FILTER_KEY] = True + else: + self.feature_filter_usage[CUSTOM_FILTER_KEY] = True def reset_feature_filter_usage(self) -> None: """Reset the feature filter usage tracking.""" diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py index 41676d00c483..0f4ca7972c61 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py @@ -4,4 +4,4 @@ # license information. # ------------------------------------------------------------------------- -VERSION = "2.5.1" +VERSION = "2.6.0b1" diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py index abcb6233e9a1..c2bde9f89586 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,9 +15,10 @@ 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, @@ -37,6 +38,7 @@ @dataclass class _AsyncConfigurationClientWrapper(_ConfigurationClientWrapperBase): _client: AzureAppConfigurationClient + _feature_flag_client: Optional[FeatureFlagClient] = None backoff_end_time: float = 0 failed_attempts: int = 0 LOGGER = getLogger(__name__) @@ -73,6 +75,14 @@ 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, + ), ) @classmethod @@ -100,6 +110,13 @@ 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, + ), ) async def _check_configuration_setting( @@ -284,6 +301,82 @@ async def check_feature_flag_page_etags( return True return False + @distributed_trace + @distributed_trace + async def load_feature_flag_resources( + self, feature_flag_selectors: List[SettingSelector], **kwargs + ) -> Tuple[List[FeatureFlag], List[List[str]]]: + """ + Loads feature flags from the feature flag resource endpoint using page-based iteration, collecting page + etags for each selector. Selectors with a ``snapshot_name`` are not supported by the feature flag resource + endpoint and are skipped. + + :param feature_flag_selectors: List of setting selectors to filter feature flags + :type feature_flag_selectors: List[SettingSelector] + :return: A tuple of (feature_flags, page_etags_per_selector) + :rtype: Tuple[List[~azure.appconfiguration.FeatureFlag], List[List[str]]] + """ + loaded_feature_flags: List[FeatureFlag] = [] + page_etags: List[List[str]] = [] + # Needs to be removed unknown keyword argument for the feature flag client + kwargs.pop("sentinel_keys", None) + if self._feature_flag_client is None: + return loaded_feature_flags, [[] for _ in feature_flag_selectors] + for select in feature_flag_selectors: + selector_etags: List[str] = [] + if select.snapshot_name is not None: + # Snapshots are not supported by the feature flag resource endpoint + page_etags.append(selector_etags) + continue + feature_flags = self._feature_flag_client.list_feature_flags( + name_filter=select.key_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) # type: ignore[attr-defined] + page_etags.append(selector_etags) + return loaded_feature_flags, page_etags + + @distributed_trace + async def check_feature_flag_resource_etags( + self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs + ) -> bool: + """ + Checks if any feature flag resource page has changed using page etags. + + :param feature_flag_selectors: List of setting selectors for feature flags + :type feature_flag_selectors: List[SettingSelector] + :param page_etags: The page etags from the last load, one list per selector + :type page_etags: List[List[str]] + :return: True if any page has changed, False otherwise + :rtype: bool + """ + if self._feature_flag_client is None: + return False + for i, select in enumerate(feature_flag_selectors): + if select.snapshot_name is not None: + # Snapshots are not supported by the feature flag resource endpoint + continue + 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._feature_flag_client.list_feature_flags( + name_filter=select.key_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + async for _ in feature_flags.by_page(match_conditions=selector_etags): # type: ignore[call-arg] + # If any page is returned, it means that page has changed + return True + return False + @distributed_trace async def get_updated_watched_settings( self, watched_settings: Mapping[Tuple[str, str], Optional[str]], headers: Dict[str, str], **kwargs @@ -364,13 +457,19 @@ async def close(self) -> None: Closes the connection to Azure App Configuration. """ await self._client.close() + if self._feature_flag_client is not None: + await self._feature_flag_client.close() async def __aenter__(self): await self._client.__aenter__() + if self._feature_flag_client is not None: + await self._feature_flag_client.__aenter__() return self async def __aexit__(self, *args): await self._client.__aexit__(*args) + if self._feature_flag_client is not None: + await self._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/_azureappconfigurationproviderasync.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py index 458bd10bcce8..819102ad63ea 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py @@ -19,6 +19,7 @@ ) from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, + FeatureFlag, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, ) @@ -120,6 +121,7 @@ async def _attempt_refresh( ) configuration_settings: List[ConfigurationSetting] = [] feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None + feature_flag_resources: Optional[List[FeatureFlag]] = None # Timer needs to be reset even if no refresh happened if time had passed configuration_refresh_attempted = False @@ -128,6 +130,7 @@ async def _attempt_refresh( existing_feature_flag_usage = self._tracing_context.feature_filter_usage.copy() page_etags: List[List[str]] = [] feature_flag_page_etags: List[List[str]] = [] + feature_flag_resource_etags: List[List[str]] = [] try: if self._refresh_enabled and not self._watched_settings and self._refresh_timer.needs_refresh(): configuration_refresh_attempted = True @@ -160,6 +163,16 @@ async def _attempt_refresh( feature_flags, feature_flag_page_etags = await client.load_feature_flags( self._feature_flag_selectors, headers=headers, **kwargs ) + + # Feature flag resources are loaded independently of the key-value based feature flags, using their + # own page-level etag state, since they are a separate resource type with a separate + # change-detection mechanism. + if not self._feature_flag_resource_etags or await client.check_feature_flag_resource_etags( + self._feature_flag_selectors, self._feature_flag_resource_etags, headers=headers, **kwargs + ): + feature_flag_resources, feature_flag_resource_etags = await client.load_feature_flag_resources( + self._feature_flag_selectors, headers=headers, **kwargs + ) # Default to existing settings if no refresh occurred processed_settings = self._dict @@ -169,7 +182,9 @@ async def _attempt_refresh( # Configuration Settings have been refreshed processed_settings = await self._process_configurations(configuration_settings, client) - processed_settings = self._process_feature_flags(processed_settings, processed_feature_flags, feature_flags) + processed_settings = self._process_feature_flags( + processed_settings, processed_feature_flags, feature_flags, feature_flag_resources + ) self._dict = processed_settings if settings_refreshed: self._page_etags = page_etags @@ -177,12 +192,14 @@ async def _attempt_refresh( self._watched_settings.update(updated_watched_settings) if feature_flags is not None: self._feature_flag_page_etags = feature_flag_page_etags + if feature_flag_resources is not None: + self._feature_flag_resource_etags = feature_flag_resource_etags # Reset timers at the same time as they should load from the same store. if configuration_refresh_attempted: self._refresh_timer.reset() if self._feature_flag_refresh_enabled and feature_flag_refresh_attempted: self._feature_flag_refresh_timer.reset() - if (settings_refreshed or feature_flags) and self._on_refresh_success: + if (settings_refreshed or feature_flags or feature_flag_resources) and self._on_refresh_success: self._on_refresh_success() except AzureError as e: logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint) @@ -290,6 +307,7 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A processed_settings = await self._process_configurations(configuration_settings, client) feature_flag_page_etags: List[List[str]] = [] + feature_flag_resource_etags: List[List[str]] = [] if self._feature_flag_enabled: feature_flags: List[FeatureFlagConfigurationSetting] feature_flags, feature_flag_page_etags = await client.load_feature_flags( @@ -297,7 +315,14 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A headers=headers, **kwargs, ) - processed_settings = self._process_feature_flags(processed_settings, [], feature_flags) + feature_flag_resources, feature_flag_resource_etags = await client.load_feature_flag_resources( + self._feature_flag_selectors, + headers=headers, + **kwargs, + ) + processed_settings = self._process_feature_flags( + processed_settings, [], feature_flags, feature_flag_resources + ) for (key, label), etag in self._watched_settings.items(): if not etag: try: @@ -324,6 +349,7 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A self._dict = processed_settings self._page_etags = page_etags self._feature_flag_page_etags = feature_flag_page_etags + self._feature_flag_resource_etags = feature_flag_resource_etags return True except AzureError as e: logger.warning("Failed to load configurations from endpoint %s.\n %s", client.endpoint, e.message) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md index 90b21ea17f95..e15d0f6b1d6e 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 | +| feature_flag_resource_sample.py | demos loading feature flags created via the dedicated feature flag resource endpoint | +| async_feature_flag_resource_sample.py | async version of feature_flag_resource_sample.py | ## Next steps diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_feature_flag_resource_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_feature_flag_resource_sample.py new file mode 100644 index 000000000000..2b11ecf1ba1f --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_feature_flag_resource_sample.py @@ -0,0 +1,80 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +""" +FILE: async_feature_flag_resource_sample.py +DESCRIPTION: + This sample demonstrates loading feature flags that were created using the dedicated feature flag + resource endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value + based feature flags stored as configuration settings. The provider loads both kinds of feature + flags side by side into the same ``feature_management.feature_flags`` list, so no additional + ``load()`` options are required to opt in. This is the async version of feature_flag_resource_sample.py. +USAGE: python async_feature_flag_resource_sample.py + Set the environment variable APPCONFIGURATION_ENDPOINT_STRING with your App Configuration + connection endpoint before running the sample. +""" +import os +import asyncio +from sample_utilities import get_authority, get_credential, get_client_modifications +from azure.appconfiguration.aio import FeatureFlagClient +from azure.appconfiguration import FeatureFlag +from azure.appconfiguration.provider.aio import load +from azure.appconfiguration.provider import SettingSelector + + +async def main(): + endpoint = os.environ["APPCONFIGURATION_ENDPOINT_STRING"] + authority = get_authority(endpoint) + credential = get_credential(authority, is_async=True) + kwargs = get_client_modifications() + + # Creating a feature flag using the dedicated feature flag resource endpoint. This is a separate + # resource type from the classic key-value based feature flags, and is managed via FeatureFlagClient + # instead of AzureAppConfigurationClient. + feature_flag_client = FeatureFlagClient(endpoint, credential, **kwargs) + await feature_flag_client.set_feature_flag(FeatureFlag(name="ResourceBeta", enabled=True)) + + try: + # [START feature_flag_resource_loading_async] + from azure.appconfiguration.provider.aio import load + + # Feature flags loaded from the feature flag resource endpoint are merged into the same + # feature_management.feature_flags list as key-value based feature flags. + config = await load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, **kwargs) + feature_flags = config["feature_management"]["feature_flags"] + resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") + print(resource_beta["enabled"]) + + await config.close() + # [END feature_flag_resource_loading_async] + + # [START feature_flag_resource_selector_async] + from azure.appconfiguration.provider.aio import load + from azure.appconfiguration.provider import SettingSelector + + # The same SettingSelector used to filter key-value based feature flags also filters feature flag + # resources, by name/label/tags. + config = await load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Resource*")], + **kwargs, + ) + feature_flags = config["feature_management"]["feature_flags"] + resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") + print(resource_beta["enabled"]) + + await config.close() + # [END feature_flag_resource_selector_async] + finally: + # Cleaning up the feature flag resource created for this sample. + await feature_flag_client.delete_feature_flag("ResourceBeta") + await feature_flag_client.close() + await credential.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py new file mode 100644 index 000000000000..0545d534f1a6 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py @@ -0,0 +1,65 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +""" +FILE: feature_flag_resource_sample.py +DESCRIPTION: + This sample demonstrates loading feature flags that were created using the dedicated feature flag + resource endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value + based feature flags stored as configuration settings. The provider loads both kinds of feature + flags side by side into the same ``feature_management.feature_flags`` list, so no additional + ``load()`` options are required to opt in. +USAGE: python feature_flag_resource_sample.py + Set the environment variable APPCONFIGURATION_ENDPOINT_STRING with your App Configuration + connection endpoint before running the sample. +""" +import os +from sample_utilities import get_authority, get_credential, get_client_modifications +from azure.appconfiguration import FeatureFlag, FeatureFlagClient +from azure.appconfiguration.provider import load, SettingSelector + +endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING") +authority = get_authority(endpoint) +credential = get_credential(authority) +kwargs = get_client_modifications() + +# Creating a feature flag using the dedicated feature flag resource endpoint. This is a separate resource +# type from the classic key-value based feature flags, and is managed via FeatureFlagClient instead of +# AzureAppConfigurationClient. +feature_flag_client = FeatureFlagClient(endpoint, credential, **kwargs) +feature_flag_client.set_feature_flag(FeatureFlag(name="ResourceBeta", enabled=True)) + +try: + # [START feature_flag_resource_loading] + from azure.appconfiguration.provider import load + + # Feature flags loaded from the feature flag resource 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"] + resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") + print(resource_beta["enabled"]) + # [END feature_flag_resource_loading] + + # [START feature_flag_resource_selector] + from azure.appconfiguration.provider import load, SettingSelector + + # The same SettingSelector used to filter key-value based feature flags also filters feature flag + # resources, by name/label/tags. + config = load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Resource*")], + **kwargs, + ) + feature_flags = config["feature_management"]["feature_flags"] + resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") + print(resource_beta["enabled"]) + # [END feature_flag_resource_selector] +finally: + # Cleaning up the feature flag resource created for this sample. + feature_flag_client.delete_feature_flag("ResourceBeta") + feature_flag_client.close() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/setup.py b/sdk/appconfiguration/azure-appconfiguration-provider/setup.py index d756d6d66783..495014bbd097 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/setup.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/setup.py @@ -73,7 +73,7 @@ python_requires=">=3.6", 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_feature_flag_resources.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_resources.py new file mode 100644 index 000000000000..538eddbcb1f7 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_resources.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 feature flag resource endpoint +(``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic 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 TestAppConfigurationProviderFeatureFlagResources(AppConfigTestCase): + """Tests for the provider loading feature flags from the dedicated feature flag resource endpoint (async).""" + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_load_feature_flag_resource(self, appconfiguration_endpoint_string): + """A feature flag created via the feature flag resource endpoint should be loaded by the provider.""" + feature_flag_client = self.create_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_feature_flag_resource_disabled(self, appconfiguration_endpoint_string): + """A disabled feature flag resource should be loaded with enabled set to False.""" + feature_flag_client = self.create_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_feature_flag_resource_with_label(self, appconfiguration_endpoint_string): + """A feature flag resource with a label should be loaded when the label filter matches.""" + feature_flag_client = self.create_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_feature_flag_resource_selector_filters_by_name(self, appconfiguration_endpoint_string): + """The feature_flag_selectors key_filter should scope which feature flag resources are loaded.""" + feature_flag_client = self.create_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_feature_flag_resource_overrides_key_value(self, appconfiguration_endpoint_string): + """A feature flag resource 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_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) + resource_feature_flag = FeatureFlag(name="OverlapFeature", enabled=True) + await feature_flag_client.set_feature_flag(resource_feature_flag) + + 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 resource-based 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 "name" in feature_flag + finally: + await appconfig_client.delete_configuration_setting(key=kv_feature_flag.key, label=kv_feature_flag.label) + await feature_flag_client.delete_feature_flag("OverlapFeature") + await appconfig_client.close() + await feature_flag_client.close() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py index a433b81ce018..9a84248e5c4b 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_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_feature_flag_resources_async(feature_flag_client, feature_flags): + """ + Delete feature flag resources created via the dedicated feature flag resource endpoint (async version). + + :param feature_flag_client: The async FeatureFlagClient to use for cleanup. + :param feature_flags: List of FeatureFlag objects (or (name, label) tuples) to delete. + """ + for feature_flag in feature_flags: + if isinstance(feature_flag, tuple): + name, label = feature_flag + else: + name, label = feature_flag.name, feature_flag.label + try: + await feature_flag_client.delete_feature_flag(name, label=label) + except Exception: # pylint: disable=broad-except + pass + + async def create_snapshot_async(client, snapshot_name, key_filters, composition_type=None, retention_period=3600): """ Create a snapshot in Azure App Configuration and verify it was created successfully (async version). diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py index 5073f2afad31..c4801f992526 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -11,6 +11,17 @@ from typing import Dict, Any from azure.appconfiguration import FeatureFlagConfigurationSetting +from azure.appconfiguration import ( + FeatureFlag, + FeatureFlagAllocation, + FeatureFlagConditions, + FeatureFlagFilter, + FeatureFlagTelemetryConfiguration, + FeatureFlagVariantDefinition, + GroupAllocation, + PercentileAllocation, + UserAllocation, +) from azure.appconfiguration.provider._azureappconfigurationproviderbase import ( is_json_content_type, _build_watched_setting, @@ -398,3 +409,210 @@ 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 TestProcessFeatureFlagResource(unittest.TestCase): + """Test processing of feature flags loaded from the dedicated feature flag resource endpoint.""" + + def setUp(self): + self.provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + def test_process_feature_flag_resource_minimal(self): + """Test processing a minimal feature flag resource.""" + feature_flag = FeatureFlag(name="MyFeature", enabled=True) + + result = self.provider._process_feature_flag_resource(feature_flag) + + self.assertEqual(result["name"], "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 feature flag resource. + self.assertIn("telemetry", result) + self.assertNotIn("enabled", result["telemetry"]) + + def test_process_feature_flag_resource_with_label_and_description(self): + """Test processing a feature flag resource with label and description.""" + feature_flag = FeatureFlag(name="MyFeature", enabled=False, label="prod", description="A test feature") + + result = self.provider._process_feature_flag_resource(feature_flag) + + self.assertEqual(result["name"], "MyFeature") + self.assertFalse(result["enabled"]) + self.assertEqual(result["label"], "prod") + self.assertEqual(result["description"], "A test feature") + + def test_process_feature_flag_resource_whitespace_label_omitted(self): + """Test that a whitespace-only label is not included in the processed output.""" + feature_flag = FeatureFlag(name="MyFeature", enabled=True, label=" ") + + result = self.provider._process_feature_flag_resource(feature_flag) + + self.assertNotIn("label", result) + + def test_process_feature_flag_resource_with_conditions(self): + """Test processing a feature flag resource with conditions/client filters.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + conditions=FeatureFlagConditions( + requirement_type="All", + client_filters=[FeatureFlagFilter(name="Percentage", parameters={"Value": "50"})], + ), + ) + + result = self.provider._process_feature_flag_resource(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_feature_flag_resource_with_variants_and_allocation(self): + """Test processing a feature flag resource with variants and allocation.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + variants=[ + FeatureFlagVariantDefinition(name="Control", value={"key": "control_value"}), + FeatureFlagVariantDefinition(name="Test", value={"key": "test_value"}, content_type="application/json"), + ], + allocation=FeatureFlagAllocation( + default_when_disabled="Control", + default_when_enabled="Test", + percentile=[PercentileAllocation(variant="Control", percentile_from=0, percentile_to=50)], + user=[UserAllocation(variant="Test", users=["user1"])], + group=[GroupAllocation(variant="Test", groups=["group1"])], + seed="1234", + ), + ) + + result = self.provider._process_feature_flag_resource(feature_flag) + + self.assertEqual(len(result["variants"]), 2) + self.assertEqual(result["variants"][0]["name"], "Control") + self.assertEqual(result["variants"][0]["value"], {"key": "control_value"}) + self.assertEqual(result["variants"][1]["content_type"], "application/json") + + allocation = result["allocation"] + self.assertEqual(allocation["default_when_disabled"], "Control") + self.assertEqual(allocation["default_when_enabled"], "Test") + self.assertEqual(allocation["percentile"], [{"variant": "Control", "percentile_from": 0, "percentile_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_feature_flag_resource_with_telemetry_and_tags(self): + """Test processing a feature flag resource 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_feature_flag_resource(feature_flag) + + # Telemetry metadata gets ETag/FeatureFlagReference metadata appended by + # _update_ff_resource_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_feature_flag_resource_updates_telemetry_metadata(self): + """Test that processing a feature flag resource adds ETag/FeatureFlagReference telemetry metadata.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + label="prod", + telemetry=FeatureFlagTelemetryConfiguration(enabled=True), + ) + feature_flag.etag = "resource_etag" + + result = self.provider._process_feature_flag_resource(feature_flag) + + metadata = result["telemetry"][METADATA_KEY] + self.assertEqual(metadata[ETAG_KEY], "resource_etag") + self.assertIn(FEATURE_FLAG_REFERENCE_KEY, metadata) + # The resource-based feature flag reference uses the "ff" path segment, not "kv". + self.assertIn("/ff/MyFeature", metadata[FEATURE_FLAG_REFERENCE_KEY]) + self.assertIn("?label=prod", metadata[FEATURE_FLAG_REFERENCE_KEY]) + + +class TestUpdateFfResourceTelemetryMetadata(unittest.TestCase): + """Test the _update_ff_resource_telemetry_metadata method.""" + + def setUp(self): + self.provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + def test_update_ff_resource_telemetry_metadata(self): + """Test resource-based 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_ff_resource_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}] + resource_flags = [{"name": "ResourceFeature", "enabled": False}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, resource_flags) + + self.assertEqual(len(merged), 2) + self.assertIn({"id": "KvFeature", "enabled": True}, merged) + self.assertIn({"name": "ResourceFeature", "enabled": False}, merged) + + def test_merge_resource_takes_precedence_on_collision(self): + """Test that a resource-based feature flag overrides a key-value one with the same identifier.""" + kv_flags = [{"id": "SharedFeature", "enabled": False, "source": "kv"}] + resource_flags = [{"name": "SharedFeature", "enabled": True, "source": "resource"}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, resource_flags) + + self.assertEqual(len(merged), 1) + self.assertEqual(merged[0]["source"], "resource") + 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_resource_flags(self): + """Test merging when only resource-based feature flags are present.""" + resource_flags = [{"name": "Feature1", "enabled": True}, {"name": "Feature2", "enabled": False}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags([], resource_flags) + + self.assertEqual(len(merged), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py index 5edaab158bf4..85d13144c746 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 @@ -27,6 +27,22 @@ def __init__(self, endpoint, connection_string, credential, retry_total, retry_b self.retry_backoff = retry_backoff +class _FakePagedIterator: + """Mimics an ItemPaged page iterator, exposing a mutable ``etag`` reflecting the last-yielded page.""" + + def __init__(self, pages): + self._pages = iter(pages) + self.etag = None + + def __iter__(self): + return self + + def __next__(self): + page, etag = next(self._pages) + self.etag = etag + return page + + @pytest.mark.usefixtures("caplog") class TestConfigurationClientManager(unittest.TestCase): @@ -371,3 +387,163 @@ 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_feature_flag_resources_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_feature_flag_resources(selects) + + assert feature_flags == [] + assert page_etags == [[], []] + + +def test_load_feature_flag_resources_skips_snapshot_selectors(): + """Selectors with a snapshot_name are not supported by the feature flag resource endpoint and are skipped.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [ + SettingSelector(snapshot_name="my-snapshot"), + SettingSelector(key_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_feature_flag_resources(selects) + + assert feature_flags == [flag1] + assert page_etags == [[], ["etag1"]] + # Only the non-snapshot selector should trigger a service call + mock_feature_flag_client.list_feature_flags.assert_called_once_with( + name_filter="app/*", label_filter="\0", tags_filter=None + ) + + +def test_load_feature_flag_resources_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 = [SettingSelector(key_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_feature_flag_resources(selects) + + assert feature_flags == [flag1, flag2] + assert page_etags == [["etag1", "etag2"]] + + +def test_check_feature_flag_resource_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_feature_flag_resource_etags(selects, [["etag1"]]) + + assert result is False + + +def test_check_feature_flag_resource_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 = [SettingSelector(key_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_feature_flag_resource_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_feature_flag_resource_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 = [SettingSelector(key_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_feature_flag_resource_etags(selects, page_etags) + + assert result is True + + +def test_check_feature_flag_resource_etags_skips_snapshot_selectors(): + """Selectors with a snapshot_name are not supported and should be skipped without a service call.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [SettingSelector(snapshot_name="my-snapshot")] + page_etags = [[]] + + result = wrapper.check_feature_flag_resource_etags(selects, page_etags) + + assert result is False + mock_feature_flag_client.list_feature_flags.assert_not_called() + + +def test_check_feature_flag_resource_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 = [SettingSelector(key_filter="app/*"), SettingSelector(key_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_feature_flag_resource_etags(selects, page_etags) + + assert result is True diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.py new file mode 100644 index 000000000000..e9b82ec7457c --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.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 feature flag resource endpoint +(``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic 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 TestAppConfigurationProviderFeatureFlagResources(AppConfigTestCase): + """Tests for the provider loading feature flags from the dedicated feature flag resource endpoint.""" + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_load_feature_flag_resource(self, appconfiguration_endpoint_string): + """A feature flag created via the feature flag resource endpoint should be loaded by the provider.""" + feature_flag_client = self.create_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_feature_flag_resource_disabled(self, appconfiguration_endpoint_string): + """A disabled feature flag resource should be loaded with enabled set to False.""" + feature_flag_client = self.create_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_feature_flag_resource_with_label(self, appconfiguration_endpoint_string): + """A feature flag resource with a label should be loaded when the label filter matches.""" + feature_flag_client = self.create_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_feature_flag_resource_selector_filters_by_name(self, appconfiguration_endpoint_string): + """The feature_flag_selectors key_filter should scope which feature flag resources are loaded.""" + feature_flag_client = self.create_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_feature_flag_resource_overrides_key_value(self, appconfiguration_endpoint_string): + """A feature flag resource 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_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) + resource_feature_flag = FeatureFlag(name="OverlapFeature", enabled=True) + feature_flag_client.set_feature_flag(resource_feature_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="OverlapFeature")], + ) + + # The resource-based 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 "name" 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/testcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py index 99074621b628..a2cdedb6310e 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py @@ -11,6 +11,8 @@ AzureAppConfigurationClient, ConfigurationSetting, ConfigurationSettingsFilter, + FeatureFlag, + FeatureFlagClient, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, SnapshotComposition, @@ -44,6 +46,10 @@ def create_appconfig_client(self, appconfiguration_endpoint_string): cred = self.get_credential(AzureAppConfigurationClient) return AzureAppConfigurationClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") + def create_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_feature_flag_resource(name, enabled, label=None, **kwargs): + """ + Create a FeatureFlag resource object for use with the dedicated feature flag resource endpoint + (``FeatureFlagClient``), as opposed to the classic 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 resource object. + :rtype: ~azure.appconfiguration.FeatureFlag + """ + return FeatureFlag(name=name, enabled=enabled, label=label, **kwargs) + + +def cleanup_feature_flag_resources(feature_flag_client, feature_flags): + """ + Delete feature flag resources created via the dedicated feature flag resource endpoint. + + :param feature_flag_client: The FeatureFlagClient to use for cleanup. + :param feature_flags: List of FeatureFlag objects (or (name, label) tuples) to delete. + """ + for feature_flag in feature_flags: + if isinstance(feature_flag, tuple): + name, label = feature_flag + else: + name, label = feature_flag.name, feature_flag.label + try: + feature_flag_client.delete_feature_flag(name, label=label) + except Exception: # pylint: disable=broad-except + pass + + def cleanup_test_resources( client, settings=None, @@ -245,7 +283,7 @@ def create_snapshot(client, snapshot_name, key_filters, composition_type=None, r def get_feature_flag(client, feature_id): for feature_flag in client[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY]: - if feature_flag["id"] == feature_id: + if feature_flag.get("id", feature_flag.get("name")) == feature_id: return feature_flag return None From 0e66fcaae8e9a73d47e4807f88359c56268642ac Mon Sep 17 00:00:00 2001 From: Yuan Qu Date: Wed, 29 Jul 2026 11:48:27 -0700 Subject: [PATCH 2/8] Rename to enhanced feature flag terminology, fix id/name schema bug, and address review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-appconfiguration-provider/README.md | 75 ++-------- .../_azureappconfigurationprovider.py | 38 +++--- .../_azureappconfigurationproviderbase.py | 116 ++++++++-------- .../provider/_client_manager.py | 88 ++++++------ .../appconfiguration/provider/_constants.py | 11 +- .../provider/aio/_async_client_manager.py | 97 ++++++------- .../_azureappconfigurationproviderasync.py | 39 +++--- .../samples/README.md | 4 +- ... => async_enhanced_feature_flag_sample.py} | 44 +++--- ...ple.py => enhanced_feature_flag_sample.py} | 42 +++--- .../tests/README.md | 50 +++++++ ..._async_provider_enhanced_feature_flags.py} | 46 +++---- .../tests/asynctestcase.py | 6 +- .../test_azureappconfigurationproviderbase.py | 129 ++++++++++-------- .../test_configuration_client_manager.py | 62 +++++---- ...> test_provider_enhanced_feature_flags.py} | 46 +++---- .../tests/testcase.py | 14 +- 17 files changed, 471 insertions(+), 436 deletions(-) rename sdk/appconfiguration/azure-appconfiguration-provider/samples/{async_feature_flag_resource_sample.py => async_enhanced_feature_flag_sample.py} (66%) rename sdk/appconfiguration/azure-appconfiguration-provider/samples/{feature_flag_resource_sample.py => enhanced_feature_flag_sample.py} (60%) create mode 100644 sdk/appconfiguration/azure-appconfiguration-provider/tests/README.md rename sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/{test_async_provider_feature_flag_resources.py => test_async_provider_enhanced_feature_flags.py} (77%) rename sdk/appconfiguration/azure-appconfiguration-provider/tests/{test_provider_feature_flag_resources.py => test_provider_enhanced_feature_flags.py} (76%) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/README.md index 7780b20300a2..31ae14598326 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/README.md @@ -377,44 +377,44 @@ config = load( -### Loading Feature Flags as Resources +### Loading Enhanced Feature Flags -Feature flags can also be created using the dedicated feature flag resource endpoint (via `FeatureFlagClient`/`FeatureFlag` in `azure-appconfiguration`), instead of as classic key-value configuration settings. The provider loads both kinds side by side into the same `feature_management.feature_flags` list, with feature flag resources taking precedence over key-value based feature flags when they share the same name. No additional `load()` options are required to enable this — it happens automatically whenever `feature_flag_enabled=True`, using the same `feature_flag_selectors`. +Feature flags can also be created using the dedicated enhanced feature flag endpoint (via `FeatureFlagClient`/`FeatureFlag` in `azure-appconfiguration`), instead of as key-value configuration settings. The provider loads both kinds side by side into the same `feature_management.feature_flags` list, with enhanced feature flags taking precedence over key-value based feature flags when they share the same name. No additional `load()` options are required to enable this — it happens automatically whenever `feature_flag_enabled=True`, using the same `feature_flag_selectors`. - + ```python from azure.appconfiguration.provider import load -# Feature flags loaded from the feature flag resource endpoint are merged into the same +# 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"] -resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") -print(resource_beta["enabled"]) +enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("name") == "EnhancedFeatureBeta") +print(enhanced_flag_beta["enabled"]) ``` -The same `SettingSelector` used to filter key-value based feature flags also filters feature flag resources, by name, label, or tags. Note that selectors with a `snapshot_name` are not currently supported by the feature flag resource endpoint and are skipped when loading feature flag resources. +The same `SettingSelector` used to filter key-value based feature flags also filters enhanced feature flags, by name, label, or tags. Note that selectors with a `snapshot_name` are not currently supported by the enhanced feature flag endpoint and are skipped when loading enhanced feature flags. - + ```python from azure.appconfiguration.provider import load, SettingSelector -# The same SettingSelector used to filter key-value based feature flags also filters feature flag -# resources, by name/label/tags. +# The same SettingSelector used to filter key-value based feature flags also filters enhanced feature +# flags, by name/label/tags. config = load( endpoint=endpoint, credential=credential, feature_flag_enabled=True, - feature_flag_selectors=[SettingSelector(key_filter="Resource*")], + feature_flag_selectors=[SettingSelector(key_filter="Enhanced*")], **kwargs, ) feature_flags = config["feature_management"]["feature_flags"] -resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") -print(resource_beta["enabled"]) +enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("name") == "EnhancedFeatureBeta") +print(enhanced_flag_beta["enabled"]) ``` @@ -511,56 +511,11 @@ 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 +## Testing (This content is for `azure-appconfiguration-provider` package developer only) -The tests for this package are under the `tests/` directory and are split into two categories: - -* **Unit tests** (e.g. `tests/test_azureappconfigurationproviderbase.py`, `tests/test_configuration_client_manager.py`) — exercise internal logic in isolation using mocked clients. These do not require any App Configuration store, network access, or environment variables, and can be run at any time with no setup. -* **Integration tests** (e.g. `tests/test_provider.py`, `tests/test_provider_feature_flag_resources.py`, and their `tests/aio/` async equivalents) — exercise the provider end-to-end against an Azure App Configuration store. These tests are built on [`devtools_testutils`](https://github.com/Azure/azure-sdk-for-python/tree/main/eng/tools/azure-sdk-tools/devtools_testutils) and each test method is decorated with `@recorded_by_proxy` / `@recorded_by_proxy_async`, which route the test's HTTP traffic through the [test proxy](https://github.com/Azure/azure-sdk-tools/tree/main/tools/test-proxy) tool. - -### Live tests vs. recorded (playback) tests - -Whether an integration test makes a real network call or replays a recording is controlled entirely by the `AZURE_TEST_RUN_LIVE` environment variable, not by anything in this package's code: - -* `AZURE_TEST_RUN_LIVE=true` — Tests run in **live/record mode**. The test proxy forwards requests to the real endpoint configured via your environment variables (see below), and (unless `AZURE_SKIP_LIVE_RECORDING=true` is also set) records the request/response pairs as new recording files for use in future playback runs. -* `AZURE_TEST_RUN_LIVE` unset or `false` (the default, and what CI uses) — Tests run in **playback mode**. The test proxy replays the existing recordings instead of contacting the real service, so **no network calls are made** and no live App Configuration store is required. - -Recordings themselves are not stored directly in this repository — they live in the separate [`Azure/azure-sdk-assets`](https://github.com/Azure/azure-sdk-assets) repo, and this package's `assets.json` file pins the exact recordings revision (`Tag`) that CI uses. If you add or change integration tests, you need to generate new recordings and publish them: - -1. Run the affected tests with `AZURE_TEST_RUN_LIVE=true` (and without `AZURE_SKIP_LIVE_RECORDING`) so the test proxy records real interactions to local recording files. -2. From the repo root, push the new/updated recordings to the assets repo: - - ```bash - dotnet tool run test-proxy push -a sdk/appconfiguration/azure-appconfiguration-provider/assets.json - ``` - - This uploads the changed recordings and updates the `Tag` field in `assets.json`. -3. Commit the updated `assets.json` as part of your PR — this is what allows CI (which always runs in playback mode) to pick up the new recordings. - -Only re-record tests you added or intentionally changed; unrelated existing recordings don't need to be regenerated. - -### Environment variables for local testing - -To run the integration tests locally in live mode, create a `.env` file at the repository root (it is automatically loaded by `devtools_testutils`) with the following variables: - -``` -AZURE_TEST_RUN_LIVE=true -APPCONFIGURATION_CONNECTION_STRING= -APPCONFIGURATION_ENDPOINT_STRING=.azconfig.io> -APPCONFIGURATION_KEY_VAULT_REFERENCE= -APPCONFIGURATION_KEY_VAULT_REFERENCE2= -APPCONFIGURATION_KEYVAULT_SECRET_URL= -APPCONFIGURATION_KEYVAULT_SECRET_URL2= -``` - -Notes: - -* For key vault URI, you can create a secret in Azure Key Vault service. The key vault URI is the *Secret Identifier*, without the final version number. For example, if the secret identifier is `https://some_secret.vault.azure.net/secrets/fake-secret/30d8830ec5ed4a428d311292a826f452`, the key vault URI should be `https://some_secret.vault.azure.net/secrets/fake-secret/`. -* Authentication for Entra ID-based tests relies on your local Azure CLI login (`az login`); make sure you're signed in to the subscription that contains your App Configuration store. -* Add `AZURE_SKIP_LIVE_RECORDING=true` if you want to run tests live against the real store without generating/overwriting recording files (useful for a quick sanity check). -* Omit `AZURE_TEST_RUN_LIVE` (or set it to `false`) to run the same tests in playback mode against existing recordings — this does not require any of the App Configuration environment variables above. +See [tests/README.md](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/appconfiguration/azure-appconfiguration-provider/tests/README.md) for instructions on running unit and integration tests, working with recordings, and setting up environment variables for local testing. ## Next steps 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 6161477e40b2..d6c01d6ddad8 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py @@ -108,7 +108,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f ) configuration_settings: List[ConfigurationSetting] = [] feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None - feature_flag_resources: Optional[List[FeatureFlag]] = 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 @@ -117,7 +117,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f existing_feature_flag_usage = self._tracing_context.feature_filter_usage.copy() page_etags: List[List[str]] = [] feature_flag_page_etags: List[List[str]] = [] - feature_flag_resource_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 @@ -151,14 +151,14 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f self._feature_flag_selectors, headers=headers, **kwargs ) - # Feature flag resources are loaded independently of the key-value based feature flags, using their + # Enhanced feature flags are loaded independently of the key-value based feature flags, using their # own page-level etag state, since they are a separate resource type with a separate # change-detection mechanism. - if not self._feature_flag_resource_etags or client.check_feature_flag_resource_etags( - self._feature_flag_selectors, self._feature_flag_resource_etags, headers=headers, **kwargs + if not self._enhanced_feature_flag_etags or client.check_enhanced_feature_flag_etags( + self._enhanced_feature_flag_selectors, self._enhanced_feature_flag_etags, headers=headers, **kwargs ): - feature_flag_resources, feature_flag_resource_etags = client.load_feature_flag_resources( - 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 ) # Default to existing settings if no refresh occurred @@ -170,8 +170,8 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f # Configuration Settings have been refreshed processed_settings = self._process_configurations(configuration_settings, client) - processed_settings = self._process_feature_flags( - processed_settings, processed_feature_flags, feature_flags, feature_flag_resources + 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: @@ -180,14 +180,14 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f self._watched_settings.update(updated_watched_settings) if feature_flags is not None: self._feature_flag_page_etags = feature_flag_page_etags - if feature_flag_resources is not None: - self._feature_flag_resource_etags = feature_flag_resource_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 or feature_flag_resources) and self._on_refresh_success: + if (settings_refreshed or feature_flags or enhanced_feature_flags) and self._on_refresh_success: self._on_refresh_success() except AzureError as e: logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint) @@ -295,7 +295,7 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> processed_settings = self._process_configurations(configuration_settings, client) feature_flag_page_etags: List[List[str]] = [] - feature_flag_resource_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( @@ -303,13 +303,13 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> headers=headers, **kwargs, ) - feature_flag_resources, feature_flag_resource_etags = client.load_feature_flag_resources( - self._feature_flag_selectors, + enhanced_feature_flags, enhanced_feature_flag_etags = client.load_enhanced_feature_flags( + self._enhanced_feature_flag_selectors, headers=headers, **kwargs, ) - processed_settings = self._process_feature_flags( - processed_settings, [], feature_flags, feature_flag_resources + 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: @@ -335,7 +335,7 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> self._dict = processed_settings self._page_etags = page_etags self._feature_flag_page_etags = feature_flag_page_etags - self._feature_flag_resource_etags = feature_flag_resource_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) @@ -401,7 +401,7 @@ def _process_configurations( self._configuration_mapper(setting) if isinstance(setting, FeatureFlagConfigurationSetting): # Feature flags are not processed like other settings - feature_flag_value = self._process_feature_flag(setting) + feature_flag_value = self._process_kv_feature_flag(setting) feature_flags_processed.append(feature_flag_value) else: key = self._process_key_name(setting) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py index d2cc796cdd5c..b6bf97f28e2b 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -40,9 +40,8 @@ FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY, FEATURE_FLAG_ID_FIELD, - FEATURE_FLAG_NAME_FIELD, FEATURE_FLAG_KV_REFERENCE_SEGMENT, - FEATURE_FLAG_RESOURCE_REFERENCE_SEGMENT, + ENHANCED_FEATURE_FLAG_REFERENCE_SEGMENT, ) from ._refresh_timer import _RefreshTimer from ._request_tracing_context import _RequestTracingContext @@ -110,26 +109,23 @@ def __init__(self, **kwargs: Any) -> None: self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", None) if self._feature_flag_selectors is None: self._feature_flag_selectors = [SettingSelector(key_filter="*")] + # The enhanced feature flag currently does not support snapshots, so selectors with a snapshot_name are + # filtered out. + self._enhanced_feature_flag_selectors = [ + select for select in self._feature_flag_selectors if select.snapshot_name is 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]] = [] - # Per-selector collection ETags for feature flags loaded from the feature flag resource endpoint. This is - # independent of the key-value based feature_flag_page_etags, since the resource endpoint is a separate - # resource type with its own change-detection mechanism. - self._feature_flag_resource_etags: List[List[str]] = [] - # Feature flags are loaded from two independent sources: the classic key-value store, and the newer - # dedicated feature flag resource endpoint. Each source's processed output is cached separately so that a - # refresh of one source does not require re-processing or discarding the other source's data. The two are - # merged (resource-based feature flags take precedence on identifier collision) whenever either changes. + self._enhanced_feature_flag_etags: List[List[str]] = [] self._processed_kv_feature_flags: List[Dict[str, Any]] = [] - self._processed_resource_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() @@ -147,7 +143,7 @@ def _update_ff_telemetry_metadata( self, endpoint: str, feature_flag: FeatureFlagConfigurationSetting, feature_flag_value: Dict ): """ - Add telemetry metadata to feature flag values loaded from the classic key-value store. + Add telemetry metadata to feature flag values loaded from the key-value store. :param endpoint: The App Configuration endpoint URL. :type endpoint: str @@ -165,13 +161,15 @@ def _update_ff_telemetry_metadata( FEATURE_FLAG_KV_REFERENCE_SEGMENT, ) - def _update_ff_resource_telemetry_metadata(self, endpoint: str, feature_flag: FeatureFlag, feature_flag_value: Dict): + def _update_enhanced_feature_flag_telemetry_metadata( + self, endpoint: str, feature_flag: FeatureFlag, feature_flag_value: Dict + ): """ - Add telemetry metadata to feature flag values loaded from the feature flag resource endpoint. + 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 feature flag resource. + :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] @@ -182,7 +180,7 @@ def _update_ff_resource_telemetry_metadata(self, endpoint: str, feature_flag: Fe feature_flag.label, feature_flag.etag, feature_flag_value, - FEATURE_FLAG_RESOURCE_REFERENCE_SEGMENT, + ENHANCED_FEATURE_FLAG_REFERENCE_SEGMENT, ) def _update_ff_telemetry_metadata_common( # pylint: disable=too-many-positional-arguments @@ -199,7 +197,8 @@ def _update_ff_telemetry_metadata_common( # pylint: disable=too-many-positional :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 resource-based). + :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] @@ -208,7 +207,7 @@ def _update_ff_telemetry_metadata_common( # pylint: disable=too-many-positional :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 resource-based feature flags. + "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: @@ -225,11 +224,11 @@ def _update_ff_telemetry_metadata_common( # pylint: disable=too-many-positional if not endpoint.endswith("/"): endpoint += "/" feature_flag_reference = f"{endpoint}{reference_path_segment}/{identifier}" - if label and not label.isspace(): + 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) + allocation_id = self._generate_allocation_id(feature_flag_value, reference_path_segment) if allocation_id: feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ALLOCATION_ID_KEY] = allocation_id @@ -243,12 +242,14 @@ def _update_ff_telemetry_metadata_common( # pylint: disable=too-many-positional self._tracing_context.update_max_variants(len(variants)) @staticmethod - def _generate_allocation_id(feature_flag_value: Dict[str, JSON]) -> Optional[str]: + def _generate_allocation_id(feature_flag_value: Dict[str, JSON], reference_path_segment: str) -> Optional[str]: """ Generates an allocation ID for the specified feature. seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Control,20;20,Test,100\nvariants=Control,standard;Test,special # pylint:disable=line-too-long :param Dict[str, JSON] feature_flag_value: The feature to generate an allocation ID for. + :param str reference_path_segment: The path segment identifying which source the feature flag was loaded + from, e.g. "kv" for key-value based feature flags or "ff" for enhanced feature flags. :rtype: str :return: The allocation ID. """ @@ -310,14 +311,13 @@ 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()}," - # Key-value based feature flags store the variant value under "configuration_value". Feature - # flags loaded from the feature flag resource endpoint store it under "value" instead. - if "configuration_value" in v: - allocation_id += ( - f"{json.dumps(v.get('configuration_value', ''), separators=(',', ':'), sort_keys=True)}" - ) - elif "value" in v: - allocation_id += f"{json.dumps(v.get('value', ''), separators=(',', ':'), sort_keys=True)}" + # Key-value based feature flags store the variant value under "configuration_value". Enhanced + # feature flags store it under "value" instead. + if reference_path_segment == FEATURE_FLAG_KV_REFERENCE_SEGMENT: + value_key = "configuration_value" + else: + value_key = "value" + allocation_id += f"{json.dumps(v.get(value_key, ''), separators=(',', ':'), sort_keys=True)}" allocation_id += ";" if sorted_variants: allocation_id = allocation_id[:-1] @@ -438,28 +438,28 @@ 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]], - feature_flag_resources: Optional[List[FeatureFlag]] = None, + enhanced_feature_flags: Optional[List[FeatureFlag]] = None, ) -> Dict[str, Any]: - if feature_flags or feature_flag_resources: + if feature_flags or enhanced_feature_flags: # Reset feature flag usage self._tracing_context.reset_feature_filter_usage() if feature_flags: - self._processed_kv_feature_flags = [self._process_feature_flag(ff) for ff in feature_flags] + self._processed_kv_feature_flags = [self._process_kv_feature_flag(ff) for ff in feature_flags] - if feature_flag_resources: - self._processed_resource_feature_flags = [ - self._process_feature_flag_resource(ff) for ff in feature_flag_resources + if enhanced_feature_flags: + self._processed_enhanced_feature_flags = [ + self._process_enhanced_feature_flag(ff) for ff in enhanced_feature_flags ] - if feature_flags or feature_flag_resources: + if feature_flags or enhanced_feature_flags: processed_feature_flags = self._merge_feature_flags( - self._processed_kv_feature_flags, self._processed_resource_feature_flags + self._processed_kv_feature_flags, self._processed_enhanced_feature_flags ) if self._feature_flag_enabled: @@ -469,18 +469,19 @@ def _process_feature_flags( @staticmethod def _merge_feature_flags( - kv_feature_flags: List[Dict[str, Any]], resource_feature_flags: List[Dict[str, Any]] + kv_feature_flags: List[Dict[str, Any]], enhanced_feature_flags: List[Dict[str, Any]] ) -> List[Dict[str, Any]]: """ - Merge feature flags loaded from the classic key-value store with feature flags loaded from the feature - flag resource endpoint. Feature flags are matched by their identifier (``id`` for key-value based feature - flags, ``name`` for resource-based feature flags). When both sources contain a feature flag with the same - identifier, the resource-based feature flag takes precedence. + 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 classic key-value store. + :param kv_feature_flags: The feature flags loaded from the key-value store. :type kv_feature_flags: List[Dict[str, Any]] - :param resource_feature_flags: The feature flags loaded from the feature flag resource endpoint. - :type resource_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]] """ @@ -488,12 +489,12 @@ def _merge_feature_flags( for ff in kv_feature_flags: identifier = ff.get(FEATURE_FLAG_ID_FIELD) merged[identifier] = ff - for ff in resource_feature_flags: - identifier = ff.get(FEATURE_FLAG_NAME_FIELD) + for ff in enhanced_feature_flags: + identifier = ff.get(FEATURE_FLAG_ID_FIELD) merged[identifier] = ff return list(merged.values()) - def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) -> Dict[str, Any]: + 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) @@ -503,21 +504,22 @@ def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) - # Feature flag value is not a valid JSON return {} - def _process_feature_flag_resource(self, feature_flag: FeatureFlag) -> Dict[str, Any]: + def _process_enhanced_feature_flag(self, feature_flag: FeatureFlag) -> Dict[str, Any]: """ - Convert a feature flag resource, loaded from the feature flag resource endpoint, into a dictionary using - the feature flag resource's native field names. + 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 feature flag resource. + :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_NAME_FIELD: feature_flag.name, + FEATURE_FLAG_ID_FIELD: feature_flag.name, "enabled": feature_flag.enabled, } - if feature_flag.label and not feature_flag.label.isspace(): + if feature_flag.label: feature_flag_value["label"] = feature_flag.label if feature_flag.description: feature_flag_value["description"] = feature_flag.description @@ -584,7 +586,7 @@ def _process_feature_flag_resource(self, feature_flag: FeatureFlag) -> Dict[str, if feature_flag.tags: feature_flag_value["tags"] = dict(feature_flag.tags) - self._update_ff_resource_telemetry_metadata(self._origin_endpoint, feature_flag, feature_flag_value) + 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 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 056e375ab7de..184ac5a3afc3 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 @@ -37,7 +37,7 @@ @dataclass class _ConfigurationClientWrapper(_ConfigurationClientWrapperBase): _client: AzureAppConfigurationClient - _feature_flag_client: Optional[FeatureFlagClient] = None + _enhanced_feature_flag_client: Optional[FeatureFlagClient] = None backoff_end_time: float = 0 failed_attempts: int = 0 LOGGER = getLogger(__name__) @@ -64,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( @@ -74,13 +75,17 @@ 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, + ( + FeatureFlagClient( + endpoint, + credential, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ) + if feature_flag_enabled + else None ), ) @@ -100,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( @@ -109,12 +115,16 @@ 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, + ( + 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 ), ) @@ -236,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: @@ -301,32 +309,26 @@ def check_feature_flag_page_etags( return False @distributed_trace - def load_feature_flag_resources( + def load_enhanced_feature_flags( self, feature_flag_selectors: List[SettingSelector], **kwargs ) -> Tuple[List[FeatureFlag], List[List[str]]]: """ - Loads feature flags from the feature flag resource endpoint using page-based iteration, collecting page - etags for each selector. Selectors with a ``snapshot_name`` are currently not supported by the feature flag - resource endpoint and are skipped. + Loads enhanced feature flags from the enhanced feature flag endpoint using page-based iteration. + The enhanced feature flag endpoint currently does not support snapshots. :param feature_flag_selectors: List of setting selectors to filter feature flags :type feature_flag_selectors: List[SettingSelector] - :return: A tuple of (feature_flags, page_etags_per_selector) + :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] = [] - page_etags: List[List[str]] = [] - # Needs to be removed unknown keyword argument for the feature flag client - kwargs.pop("sentinel_keys", None) - if self._feature_flag_client is None: + 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] = [] - if select.snapshot_name is not None: - # Snapshots are not supported by the feature flag resource endpoint as of now - page_etags.append(selector_etags) - continue - feature_flags = self._feature_flag_client.list_feature_flags( + feature_flags = self._enhanced_feature_flag_client.list_feature_flags( name_filter=select.key_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, @@ -340,30 +342,28 @@ def load_feature_flag_resources( return loaded_feature_flags, page_etags @distributed_trace - def check_feature_flag_resource_etags( + def check_enhanced_feature_flag_etags( self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs ) -> bool: """ - Checks if any feature flag resource page has changed using page etags. + Checks if any enhanced feature flag page has changed using page etags. :param feature_flag_selectors: List of setting selectors for feature flags :type feature_flag_selectors: List[SettingSelector] - :param page_etags: The page etags from the last load, one list per selector + :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._feature_flag_client is None: + if self._enhanced_feature_flag_client is None: return False for i, select in enumerate(feature_flag_selectors): - if select.snapshot_name is not None: - # Snapshots are not supported by the feature flag resource endpoint - continue 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._feature_flag_client.list_feature_flags( + feature_flags = self._enhanced_feature_flag_client.list_feature_flags( name_filter=select.key_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, @@ -454,19 +454,19 @@ def close(self) -> None: Closes the connection to Azure App Configuration. """ self._client.close() - if self._feature_flag_client is not None: - self._feature_flag_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._feature_flag_client is not None: - self._feature_flag_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._feature_flag_client is not None: - self._feature_flag_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 fc916cebff3a..2ed2e07053f3 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py @@ -15,14 +15,15 @@ ALLOCATION_ID_KEY = "AllocationId" ETAG_KEY = "ETag" -# Identifier field used by feature flags loaded from the classic key-value store. +# Identifier field required by the feature management library's schema for every feature flag entry. For +# enhanced feature flags, which do not have their own "id" concept, the enhanced feature flag's name is used +# as the value of this field. FEATURE_FLAG_ID_FIELD = "id" -# Identifier field used by feature flags loaded from the dedicated feature flag resource endpoint. -FEATURE_FLAG_NAME_FIELD = "name" # 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 feature flags loaded from the resource endpoint. -FEATURE_FLAG_RESOURCE_REFERENCE_SEGMENT = "ff" +# Path segment used to build the feature flag reference URL for enhanced feature flags loaded from the enhanced +# feature flag endpoint. +ENHANCED_FEATURE_FLAG_REFERENCE_SEGMENT = "ff" # ------------------------------------------------------------------------ # Environment Variable Constants 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 c2bde9f89586..fd7c20e5ef0c 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 @@ -38,7 +38,7 @@ @dataclass class _AsyncConfigurationClientWrapper(_ConfigurationClientWrapperBase): _client: AzureAppConfigurationClient - _feature_flag_client: Optional[FeatureFlagClient] = None + _enhanced_feature_flag_client: Optional[FeatureFlagClient] = None backoff_end_time: float = 0 failed_attempts: int = 0 LOGGER = getLogger(__name__) @@ -65,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( @@ -75,13 +76,17 @@ 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, + ( + FeatureFlagClient( + endpoint, + credential, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ) + if feature_flag_enabled + else None ), ) @@ -101,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( @@ -110,12 +116,16 @@ 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, + ( + 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 ), ) @@ -189,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 @@ -237,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: @@ -264,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 @@ -302,33 +310,28 @@ async def check_feature_flag_page_etags( return False @distributed_trace - @distributed_trace - async def load_feature_flag_resources( + async def load_enhanced_feature_flags( self, feature_flag_selectors: List[SettingSelector], **kwargs ) -> Tuple[List[FeatureFlag], List[List[str]]]: """ - Loads feature flags from the feature flag resource endpoint using page-based iteration, collecting page - etags for each selector. Selectors with a ``snapshot_name`` are not supported by the feature flag resource - endpoint and are skipped. + Loads enhanced feature flags from the enhanced feature flag endpoint using page-based iteration, collecting + page etags for each selector. The enhanced feature flag endpoint currently does not support snapshots, so + ``feature_flag_selectors`` is expected to already be filtered to exclude selectors with a + ``snapshot_name`` (see ``ConfigurationProviderBase._enhanced_feature_flag_selectors``). :param feature_flag_selectors: List of setting selectors to filter feature flags :type feature_flag_selectors: List[SettingSelector] - :return: A tuple of (feature_flags, page_etags_per_selector) + :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] = [] - page_etags: List[List[str]] = [] - # Needs to be removed unknown keyword argument for the feature flag client - kwargs.pop("sentinel_keys", None) - if self._feature_flag_client is None: + 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] = [] - if select.snapshot_name is not None: - # Snapshots are not supported by the feature flag resource endpoint - page_etags.append(selector_etags) - continue - feature_flags = self._feature_flag_client.list_feature_flags( + feature_flags = self._enhanced_feature_flag_client.list_feature_flags( name_filter=select.key_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, @@ -338,35 +341,33 @@ async def load_feature_flag_resources( async for page in iterator: async for ff in page: 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 @distributed_trace - async def check_feature_flag_resource_etags( + async def check_enhanced_feature_flag_etags( self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs ) -> bool: """ - Checks if any feature flag resource page has changed using page etags. + Checks if any enhanced feature flag page has changed using page etags. :param feature_flag_selectors: List of setting selectors for feature flags :type feature_flag_selectors: List[SettingSelector] - :param page_etags: The page etags from the last load, one list per selector + :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._feature_flag_client is None: + if self._enhanced_feature_flag_client is None: return False for i, select in enumerate(feature_flag_selectors): - if select.snapshot_name is not None: - # Snapshots are not supported by the feature flag resource endpoint - continue 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._feature_flag_client.list_feature_flags( + feature_flags = self._enhanced_feature_flag_client.list_feature_flags( name_filter=select.key_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, @@ -457,19 +458,19 @@ async def close(self) -> None: Closes the connection to Azure App Configuration. """ await self._client.close() - if self._feature_flag_client is not None: - await self._feature_flag_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._feature_flag_client is not None: - await self._feature_flag_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._feature_flag_client is not None: - await self._feature_flag_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/_azureappconfigurationproviderasync.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py index 819102ad63ea..b06e5d536a80 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 @@ -121,7 +121,7 @@ async def _attempt_refresh( ) configuration_settings: List[ConfigurationSetting] = [] feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None - feature_flag_resources: Optional[List[FeatureFlag]] = 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 @@ -130,7 +130,7 @@ async def _attempt_refresh( existing_feature_flag_usage = self._tracing_context.feature_filter_usage.copy() page_etags: List[List[str]] = [] feature_flag_page_etags: List[List[str]] = [] - feature_flag_resource_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 @@ -164,14 +164,15 @@ async def _attempt_refresh( self._feature_flag_selectors, headers=headers, **kwargs ) - # Feature flag resources are loaded independently of the key-value based feature flags, using their + # Enhanced feature flags are loaded independently of the key-value based feature flags, using their # own page-level etag state, since they are a separate resource type with a separate # change-detection mechanism. - if not self._feature_flag_resource_etags or await client.check_feature_flag_resource_etags( - self._feature_flag_selectors, self._feature_flag_resource_etags, headers=headers, **kwargs + if not self._enhanced_feature_flag_etags or await client.check_enhanced_feature_flag_etags( + self._enhanced_feature_flag_selectors, self._enhanced_feature_flag_etags, headers=headers, + **kwargs ): - feature_flag_resources, feature_flag_resource_etags = await client.load_feature_flag_resources( - 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 ) # Default to existing settings if no refresh occurred processed_settings = self._dict @@ -182,8 +183,8 @@ 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, feature_flag_resources + 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: @@ -192,14 +193,14 @@ async def _attempt_refresh( self._watched_settings.update(updated_watched_settings) if feature_flags is not None: self._feature_flag_page_etags = feature_flag_page_etags - if feature_flag_resources is not None: - self._feature_flag_resource_etags = feature_flag_resource_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 or feature_flag_resources) and self._on_refresh_success: + if (settings_refreshed or feature_flags or enhanced_feature_flags) and self._on_refresh_success: self._on_refresh_success() except AzureError as e: logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint) @@ -307,7 +308,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]] = [] - feature_flag_resource_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( @@ -315,13 +316,13 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A headers=headers, **kwargs, ) - feature_flag_resources, feature_flag_resource_etags = await client.load_feature_flag_resources( - self._feature_flag_selectors, + 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_feature_flags( - processed_settings, [], feature_flags, feature_flag_resources + 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: @@ -349,7 +350,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._feature_flag_resource_etags = feature_flag_resource_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) @@ -416,7 +417,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 e15d0f6b1d6e..83138f21b560 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md @@ -49,8 +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 | -| feature_flag_resource_sample.py | demos loading feature flags created via the dedicated feature flag resource endpoint | -| async_feature_flag_resource_sample.py | async version of feature_flag_resource_sample.py | +| 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_feature_flag_resource_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_enhanced_feature_flag_sample.py similarity index 66% rename from sdk/appconfiguration/azure-appconfiguration-provider/samples/async_feature_flag_resource_sample.py rename to sdk/appconfiguration/azure-appconfiguration-provider/samples/async_enhanced_feature_flag_sample.py index 2b11ecf1ba1f..3b61b6275241 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_feature_flag_resource_sample.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_enhanced_feature_flag_sample.py @@ -4,14 +4,14 @@ # license information. # ------------------------------------------------------------------------- """ -FILE: async_feature_flag_resource_sample.py +FILE: async_enhanced_feature_flag_sample.py DESCRIPTION: - This sample demonstrates loading feature flags that were created using the dedicated feature flag - resource endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value + This sample demonstrates loading feature flags that were created using the dedicated enhanced feature flag + endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the key-value based feature flags stored as configuration settings. The provider loads both kinds of feature flags side by side into the same ``feature_management.feature_flags`` list, so no additional - ``load()`` options are required to opt in. This is the async version of feature_flag_resource_sample.py. -USAGE: python async_feature_flag_resource_sample.py + ``load()`` options are required to opt in. This is the async version of enhanced_feature_flag_sample.py. +USAGE: python async_enhanced_feature_flag_sample.py Set the environment variable APPCONFIGURATION_ENDPOINT_STRING with your App Configuration connection endpoint before running the sample. """ @@ -30,48 +30,48 @@ async def main(): credential = get_credential(authority, is_async=True) kwargs = get_client_modifications() - # Creating a feature flag using the dedicated feature flag resource endpoint. This is a separate - # resource type from the classic key-value based feature flags, and is managed via FeatureFlagClient + # Creating a feature flag using the dedicated enhanced feature flag endpoint. This is a separate + # resource type from the key-value based feature flags, and is managed via FeatureFlagClient # instead of AzureAppConfigurationClient. feature_flag_client = FeatureFlagClient(endpoint, credential, **kwargs) - await feature_flag_client.set_feature_flag(FeatureFlag(name="ResourceBeta", enabled=True)) + await feature_flag_client.set_feature_flag(FeatureFlag(name="EnhancedFeatureBeta", enabled=True)) try: - # [START feature_flag_resource_loading_async] + # [START enhanced_feature_flag_loading_async] from azure.appconfiguration.provider.aio import load - # Feature flags loaded from the feature flag resource endpoint are merged into the same + # Feature flags loaded from the enhanced feature flag endpoint are merged into the same # feature_management.feature_flags list as key-value based feature flags. config = await load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, **kwargs) feature_flags = config["feature_management"]["feature_flags"] - resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") - print(resource_beta["enabled"]) + enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") + print(enhanced_flag_beta["enabled"]) await config.close() - # [END feature_flag_resource_loading_async] + # [END enhanced_feature_flag_loading_async] - # [START feature_flag_resource_selector_async] + # [START enhanced_feature_flag_selector_async] from azure.appconfiguration.provider.aio import load from azure.appconfiguration.provider import SettingSelector - # The same SettingSelector used to filter key-value based feature flags also filters feature flag - # resources, by name/label/tags. + # The same SettingSelector used to filter key-value based feature flags also filters enhanced feature + # flags, by name/label/tags. config = await load( endpoint=endpoint, credential=credential, feature_flag_enabled=True, - feature_flag_selectors=[SettingSelector(key_filter="Resource*")], + feature_flag_selectors=[SettingSelector(key_filter="Enhanced*")], **kwargs, ) feature_flags = config["feature_management"]["feature_flags"] - resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") - print(resource_beta["enabled"]) + enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") + print(enhanced_flag_beta["enabled"]) await config.close() - # [END feature_flag_resource_selector_async] + # [END enhanced_feature_flag_selector_async] finally: - # Cleaning up the feature flag resource created for this sample. - await feature_flag_client.delete_feature_flag("ResourceBeta") + # 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() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py similarity index 60% rename from sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py rename to sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py index 0545d534f1a6..815a3696d0db 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py @@ -4,14 +4,14 @@ # license information. # ------------------------------------------------------------------------- """ -FILE: feature_flag_resource_sample.py +FILE: enhanced_feature_flag_sample.py DESCRIPTION: - This sample demonstrates loading feature flags that were created using the dedicated feature flag - resource endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value + This sample demonstrates loading feature flags that were created using the dedicated enhanced feature flag + endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the key-value based feature flags stored as configuration settings. The provider loads both kinds of feature flags side by side into the same ``feature_management.feature_flags`` list, so no additional ``load()`` options are required to opt in. -USAGE: python feature_flag_resource_sample.py +USAGE: python enhanced_feature_flag_sample.py Set the environment variable APPCONFIGURATION_ENDPOINT_STRING with your App Configuration connection endpoint before running the sample. """ @@ -25,41 +25,41 @@ credential = get_credential(authority) kwargs = get_client_modifications() -# Creating a feature flag using the dedicated feature flag resource endpoint. This is a separate resource -# type from the classic key-value based feature flags, and is managed via FeatureFlagClient instead of +# Creating a feature flag using the dedicated enhanced feature flag endpoint. This is a separate resource +# type from the key-value based feature flags, and is managed via FeatureFlagClient instead of # AzureAppConfigurationClient. feature_flag_client = FeatureFlagClient(endpoint, credential, **kwargs) -feature_flag_client.set_feature_flag(FeatureFlag(name="ResourceBeta", enabled=True)) +feature_flag_client.set_feature_flag(FeatureFlag(name="EnhancedFeatureBeta", enabled=True)) try: - # [START feature_flag_resource_loading] + # [START enhanced_feature_flag_loading] from azure.appconfiguration.provider import load - # Feature flags loaded from the feature flag resource endpoint are merged into the same + # 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"] - resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") - print(resource_beta["enabled"]) - # [END feature_flag_resource_loading] + 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 feature_flag_resource_selector] + # [START enhanced_feature_flag_selector] from azure.appconfiguration.provider import load, SettingSelector - # The same SettingSelector used to filter key-value based feature flags also filters feature flag - # resources, by name/label/tags. + # The same SettingSelector used to filter key-value based feature flags also filters enhanced feature + # flags, by name/label/tags. config = load( endpoint=endpoint, credential=credential, feature_flag_enabled=True, - feature_flag_selectors=[SettingSelector(key_filter="Resource*")], + feature_flag_selectors=[SettingSelector(key_filter="Enhanced*")], **kwargs, ) feature_flags = config["feature_management"]["feature_flags"] - resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") - print(resource_beta["enabled"]) - # [END feature_flag_resource_selector] + 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] finally: - # Cleaning up the feature flag resource created for this sample. - feature_flag_client.delete_feature_flag("ResourceBeta") + # 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/tests/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/tests/README.md new file mode 100644 index 000000000000..85f257fc1fce --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/README.md @@ -0,0 +1,50 @@ +# Azure App Configuration Python Provider tests + +(This content is for `azure-appconfiguration-provider` package developer only) + +The tests for this package are under the `tests/` directory and are split into two categories: + +* **Unit tests** (e.g. `tests/test_azureappconfigurationproviderbase.py`, `tests/test_configuration_client_manager.py`) — exercise internal logic in isolation using mocked clients. These do not require any App Configuration store, network access, or environment variables, and can be run at any time with no setup. +* **Integration tests** (e.g. `tests/test_provider.py`, `tests/test_provider_enhanced_feature_flags.py`, and their `tests/aio/` async equivalents) — exercise the provider end-to-end against an Azure App Configuration store. These tests are built on [`devtools_testutils`](https://github.com/Azure/azure-sdk-for-python/tree/main/eng/tools/azure-sdk-tools/devtools_testutils) and each test method is decorated with `@recorded_by_proxy` / `@recorded_by_proxy_async`, which route the test's HTTP traffic through the [test proxy](https://github.com/Azure/azure-sdk-tools/tree/main/tools/test-proxy) tool. + +## Live tests vs. recorded (playback) tests + +Whether an integration test makes a real network call or replays a recording is controlled entirely by the `AZURE_TEST_RUN_LIVE` environment variable, not by anything in this package's code: + +* `AZURE_TEST_RUN_LIVE=true` — Tests run in **live/record mode**. The test proxy forwards requests to the real endpoint configured via your environment variables (see below), and (unless `AZURE_SKIP_LIVE_RECORDING=true` is also set) records the request/response pairs as new recording files for use in future playback runs. +* `AZURE_TEST_RUN_LIVE` unset or `false` (the default, and what CI uses) — Tests run in **playback mode**. The test proxy replays the existing recordings instead of contacting the real service, so **no network calls are made** and no live App Configuration store is required. + +Recordings themselves are not stored directly in this repository — they live in the separate [`Azure/azure-sdk-assets`](https://github.com/Azure/azure-sdk-assets) repo, and this package's `assets.json` file pins the exact recordings revision (`Tag`) that CI uses. If you add or change integration tests, you need to generate new recordings and publish them: + +1. Run the affected tests with `AZURE_TEST_RUN_LIVE=true` (and without `AZURE_SKIP_LIVE_RECORDING`) so the test proxy records real interactions to local recording files. +2. From the repo root, push the new/updated recordings to the assets repo: + + ```bash + dotnet tool run test-proxy push -a sdk/appconfiguration/azure-appconfiguration-provider/assets.json + ``` + + This uploads the changed recordings and updates the `Tag` field in `assets.json`. +3. Commit the updated `assets.json` as part of your PR — this is what allows CI (which always runs in playback mode) to pick up the new recordings. + +Only re-record tests you added or intentionally changed; unrelated existing recordings don't need to be regenerated. + +## Environment variables for local testing + +To run the integration tests locally in live mode, create a `.env` file at the repository root (it is automatically loaded by `devtools_testutils`) with the following variables: + +``` +AZURE_TEST_RUN_LIVE=true +APPCONFIGURATION_CONNECTION_STRING= +APPCONFIGURATION_ENDPOINT_STRING=.azconfig.io> +APPCONFIGURATION_KEY_VAULT_REFERENCE= +APPCONFIGURATION_KEY_VAULT_REFERENCE2= +APPCONFIGURATION_KEYVAULT_SECRET_URL= +APPCONFIGURATION_KEYVAULT_SECRET_URL2= +``` + +Notes: + +* For key vault URI, you can create a secret in Azure Key Vault service. The key vault URI is the *Secret Identifier*, without the final version number. For example, if the secret identifier is `https://some_secret.vault.azure.net/secrets/fake-secret/30d8830ec5ed4a428d311292a826f452`, the key vault URI should be `https://some_secret.vault.azure.net/secrets/fake-secret/`. +* Authentication for Entra ID-based tests relies on your local Azure CLI login (`az login`); make sure you're signed in to the subscription that contains your App Configuration store. +* Add `AZURE_SKIP_LIVE_RECORDING=true` if you want to run tests live against the real store without generating/overwriting recording files (useful for a quick sanity check). +* Omit `AZURE_TEST_RUN_LIVE` (or set it to `false`) to run the same tests in playback mode against existing recordings — this does not require any of the App Configuration environment variables above. diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_resources.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_enhanced_feature_flags.py similarity index 77% rename from sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_resources.py rename to sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_enhanced_feature_flags.py index 538eddbcb1f7..051ad70ad882 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_resources.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_enhanced_feature_flags.py @@ -4,8 +4,8 @@ # license information. # -------------------------------------------------------------------------- """ -Tests for loading feature flags from the dedicated feature flag resource endpoint -(``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value based +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 @@ -25,15 +25,15 @@ ) -class TestAppConfigurationProviderFeatureFlagResources(AppConfigTestCase): - """Tests for the provider loading feature flags from the dedicated feature flag resource endpoint (async).""" +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_feature_flag_resource(self, appconfiguration_endpoint_string): - """A feature flag created via the feature flag resource endpoint should be loaded by the provider.""" - feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + 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) @@ -53,9 +53,9 @@ async def test_load_feature_flag_resource(self, appconfiguration_endpoint_string # method: load @AppConfigProviderPreparer() @recorded_by_proxy_async - async def test_load_feature_flag_resource_disabled(self, appconfiguration_endpoint_string): - """A disabled feature flag resource should be loaded with enabled set to False.""" - feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + 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) @@ -74,9 +74,9 @@ async def test_load_feature_flag_resource_disabled(self, appconfiguration_endpoi # method: load @AppConfigProviderPreparer() @recorded_by_proxy_async - async def test_load_feature_flag_resource_with_label(self, appconfiguration_endpoint_string): - """A feature flag resource with a label should be loaded when the label filter matches.""" - feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + 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) @@ -97,9 +97,9 @@ async def test_load_feature_flag_resource_with_label(self, appconfiguration_endp # method: load @AppConfigProviderPreparer() @recorded_by_proxy_async - async def test_feature_flag_resource_selector_filters_by_name(self, appconfiguration_endpoint_string): - """The feature_flag_selectors key_filter should scope which feature flag resources are loaded.""" - feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + 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) @@ -122,16 +122,16 @@ async def test_feature_flag_resource_selector_filters_by_name(self, appconfigura # method: load @AppConfigProviderPreparer() @recorded_by_proxy_async - async def test_feature_flag_resource_overrides_key_value(self, appconfiguration_endpoint_string): - """A feature flag resource should take precedence over a key-value based feature flag with the + 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_feature_flag_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) - resource_feature_flag = FeatureFlag(name="OverlapFeature", enabled=True) - await feature_flag_client.set_feature_flag(resource_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( @@ -140,12 +140,12 @@ async def test_feature_flag_resource_overrides_key_value(self, appconfiguration_ feature_flag_enabled=True, feature_flag_selectors=[SettingSelector(key_filter="OverlapFeature")], ) as client: - # The resource-based feature flag (enabled=True) should win over the key-value based one + # 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 "name" in feature_flag + 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") diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py index 9a84248e5c4b..65d389d35186 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py @@ -35,7 +35,7 @@ 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_feature_flag_client(self, appconfiguration_endpoint_string): + 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") @@ -86,9 +86,9 @@ async def set_test_settings_async(client, settings): await client.set_configuration_setting(setting) -async def cleanup_feature_flag_resources_async(feature_flag_client, feature_flags): +async def cleanup_enhanced_feature_flags_async(feature_flag_client, feature_flags): """ - Delete feature flag resources created via the dedicated feature flag resource endpoint (async version). + 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. diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py index c4801f992526..792b02ed2440 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -34,6 +34,7 @@ METADATA_KEY, ETAG_KEY, FEATURE_FLAG_REFERENCE_KEY, + FEATURE_FLAG_KV_REFERENCE_SEGMENT, ) from azure.appconfiguration.provider._refresh_timer import _RefreshTimer @@ -212,6 +213,22 @@ 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): + """The enhanced feature flag endpoint doesn't support snapshots, so snapshot-name selectors should be + filtered out once at startup, while the original selector list (used for the key-value store, which does + support snapshots) is left untouched.""" + 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(provider._enhanced_feature_flag_selectors, [key_select]) + def test_process_key_name_with_no_prefix(self): """Test key name processing with no matching prefix.""" config = Mock() @@ -357,7 +374,9 @@ def test_update_ff_telemetry_metadata_max_variants(self): def test_generate_allocation_id_no_allocation(self): """Test allocation ID generation with no allocation.""" feature_flag_value: Dict[str, Any] = {"no_allocation": "here"} - result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) + result = AzureAppConfigurationProviderBase._generate_allocation_id( + feature_flag_value, FEATURE_FLAG_KV_REFERENCE_SEGMENT + ) self.assertIsNone(result) def test_generate_allocation_id_with_allocation(self): @@ -374,7 +393,9 @@ def test_generate_allocation_id_with_allocation(self): ], } - result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) + result = AzureAppConfigurationProviderBase._generate_allocation_id( + feature_flag_value, FEATURE_FLAG_KV_REFERENCE_SEGMENT + ) self.assertIsNotNone(result) self.assertIsInstance(result, str) # Should be a base64 encoded string @@ -393,7 +414,9 @@ def test_generate_allocation_id_no_variants_no_seed(self): "default_when_enabled": "Control" } } - result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) + result = AzureAppConfigurationProviderBase._generate_allocation_id( + feature_flag_value, FEATURE_FLAG_KV_REFERENCE_SEGMENT + ) # Since default_when_enabled is provided, allocated_variants won't be empty # so this should return a valid allocation ID self.assertIsNotNone(result) @@ -406,24 +429,26 @@ def test_generate_allocation_id_truly_empty(self): # No seed and no default_when_enabled } } - result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) + result = AzureAppConfigurationProviderBase._generate_allocation_id( + feature_flag_value, FEATURE_FLAG_KV_REFERENCE_SEGMENT + ) # This should return None because allocated_variants is empty and no seed self.assertIsNone(result) -class TestProcessFeatureFlagResource(unittest.TestCase): - """Test processing of feature flags loaded from the dedicated feature flag resource endpoint.""" +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_feature_flag_resource_minimal(self): - """Test processing a minimal feature flag resource.""" + 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_feature_flag_resource(feature_flag) + result = self.provider._process_enhanced_feature_flag(feature_flag) - self.assertEqual(result["name"], "MyFeature") + self.assertEqual(result["id"], "MyFeature") self.assertTrue(result["enabled"]) self.assertNotIn("label", result) self.assertNotIn("description", result) @@ -432,31 +457,23 @@ def test_process_feature_flag_resource_minimal(self): self.assertNotIn("allocation", result) self.assertNotIn("tags", result) # Telemetry metadata (ETag) is always attached during processing, even without an explicit - # telemetry configuration on the feature flag resource. + # telemetry configuration on the enhanced feature flag. self.assertIn("telemetry", result) self.assertNotIn("enabled", result["telemetry"]) - def test_process_feature_flag_resource_with_label_and_description(self): - """Test processing a feature flag resource with label and description.""" + 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_feature_flag_resource(feature_flag) + result = self.provider._process_enhanced_feature_flag(feature_flag) - self.assertEqual(result["name"], "MyFeature") + self.assertEqual(result["id"], "MyFeature") self.assertFalse(result["enabled"]) self.assertEqual(result["label"], "prod") self.assertEqual(result["description"], "A test feature") - def test_process_feature_flag_resource_whitespace_label_omitted(self): - """Test that a whitespace-only label is not included in the processed output.""" - feature_flag = FeatureFlag(name="MyFeature", enabled=True, label=" ") - - result = self.provider._process_feature_flag_resource(feature_flag) - - self.assertNotIn("label", result) - - def test_process_feature_flag_resource_with_conditions(self): - """Test processing a feature flag resource with conditions/client filters.""" + 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, @@ -466,15 +483,15 @@ def test_process_feature_flag_resource_with_conditions(self): ), ) - result = self.provider._process_feature_flag_resource(feature_flag) + 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_feature_flag_resource_with_variants_and_allocation(self): - """Test processing a feature flag resource with variants and allocation.""" + def test_process_enhanced_feature_flag_with_variants_and_allocation(self): + """Test processing an enhanced feature flag with variants and allocation.""" feature_flag = FeatureFlag( name="MyFeature", enabled=True, @@ -492,7 +509,7 @@ def test_process_feature_flag_resource_with_variants_and_allocation(self): ), ) - result = self.provider._process_feature_flag_resource(feature_flag) + result = self.provider._process_enhanced_feature_flag(feature_flag) self.assertEqual(len(result["variants"]), 2) self.assertEqual(result["variants"][0]["name"], "Control") @@ -507,8 +524,8 @@ def test_process_feature_flag_resource_with_variants_and_allocation(self): self.assertEqual(allocation["group"], [{"variant": "Test", "groups": ["group1"]}]) self.assertEqual(allocation["seed"], "1234") - def test_process_feature_flag_resource_with_telemetry_and_tags(self): - """Test processing a feature flag resource with telemetry settings and tags.""" + 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, @@ -516,49 +533,49 @@ def test_process_feature_flag_resource_with_telemetry_and_tags(self): tags={"team": "infra"}, ) - result = self.provider._process_feature_flag_resource(feature_flag) + result = self.provider._process_enhanced_feature_flag(feature_flag) # Telemetry metadata gets ETag/FeatureFlagReference metadata appended by - # _update_ff_resource_telemetry_metadata as part of processing. + # _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_feature_flag_resource_updates_telemetry_metadata(self): - """Test that processing a feature flag resource adds ETag/FeatureFlagReference telemetry metadata.""" + 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 = "resource_etag" + feature_flag.etag = "enhanced_etag" - result = self.provider._process_feature_flag_resource(feature_flag) + result = self.provider._process_enhanced_feature_flag(feature_flag) metadata = result["telemetry"][METADATA_KEY] - self.assertEqual(metadata[ETAG_KEY], "resource_etag") + self.assertEqual(metadata[ETAG_KEY], "enhanced_etag") self.assertIn(FEATURE_FLAG_REFERENCE_KEY, metadata) - # The resource-based feature flag reference uses the "ff" path segment, not "kv". + # The enhanced feature flag reference uses the "ff" path segment, not "kv". self.assertIn("/ff/MyFeature", metadata[FEATURE_FLAG_REFERENCE_KEY]) self.assertIn("?label=prod", metadata[FEATURE_FLAG_REFERENCE_KEY]) -class TestUpdateFfResourceTelemetryMetadata(unittest.TestCase): - """Test the _update_ff_resource_telemetry_metadata method.""" +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_ff_resource_telemetry_metadata(self): - """Test resource-based feature flag telemetry processing uses the 'ff' reference segment.""" + 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_ff_resource_telemetry_metadata(endpoint, feature_flag, feature_flag_value) + 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") @@ -573,23 +590,23 @@ class TestMergeFeatureFlags(unittest.TestCase): def test_merge_no_overlap(self): """Test merging when there is no identifier overlap between the two sources.""" kv_flags = [{"id": "KvFeature", "enabled": True}] - resource_flags = [{"name": "ResourceFeature", "enabled": False}] + enhanced_flags = [{"id": "EnhancedFeature", "enabled": False}] - merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, resource_flags) + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, enhanced_flags) self.assertEqual(len(merged), 2) self.assertIn({"id": "KvFeature", "enabled": True}, merged) - self.assertIn({"name": "ResourceFeature", "enabled": False}, merged) + self.assertIn({"id": "EnhancedFeature", "enabled": False}, merged) - def test_merge_resource_takes_precedence_on_collision(self): - """Test that a resource-based feature flag overrides a key-value one with the same identifier.""" + 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"}] - resource_flags = [{"name": "SharedFeature", "enabled": True, "source": "resource"}] + enhanced_flags = [{"id": "SharedFeature", "enabled": True, "source": "enhanced"}] - merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, resource_flags) + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, enhanced_flags) self.assertEqual(len(merged), 1) - self.assertEqual(merged[0]["source"], "resource") + self.assertEqual(merged[0]["source"], "enhanced") self.assertTrue(merged[0]["enabled"]) def test_merge_empty_lists(self): @@ -605,11 +622,11 @@ def test_merge_only_kv_flags(self): self.assertEqual(len(merged), 2) - def test_merge_only_resource_flags(self): - """Test merging when only resource-based feature flags are present.""" - resource_flags = [{"name": "Feature1", "enabled": True}, {"name": "Feature2", "enabled": False}] + 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([], resource_flags) + merged = AzureAppConfigurationProviderBase._merge_feature_flags([], enhanced_flags) self.assertEqual(len(merged), 2) 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 85d13144c746..4daa4eade5a8 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 @@ -389,46 +389,45 @@ def test_check_page_etags_keys_first_then_snapshot(): ) -def test_load_feature_flag_resources_no_feature_flag_client(): +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_feature_flag_resources(selects) + feature_flags, page_etags = wrapper.load_enhanced_feature_flags(selects) assert feature_flags == [] assert page_etags == [[], []] -def test_load_feature_flag_resources_skips_snapshot_selectors(): - """Selectors with a snapshot_name are not supported by the feature flag resource endpoint and are skipped.""" +def test_load_enhanced_feature_flags_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 = [ - SettingSelector(snapshot_name="my-snapshot"), - SettingSelector(key_filter="app/*"), - ] + selects = [SettingSelector(key_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_feature_flag_resources(selects) + feature_flags, page_etags = wrapper.load_enhanced_feature_flags(selects) assert feature_flags == [flag1] - assert page_etags == [[], ["etag1"]] - # Only the non-snapshot selector should trigger a service call + 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_feature_flag_resources_multiple_pages(): +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() @@ -458,25 +457,25 @@ def __next__(self): 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_feature_flag_resources(selects) + feature_flags, page_etags = wrapper.load_enhanced_feature_flags(selects) assert feature_flags == [flag1, flag2] assert page_etags == [["etag1", "etag2"]] -def test_check_feature_flag_resource_etags_no_feature_flag_client(): +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_feature_flag_resource_etags(selects, [["etag1"]]) + result = wrapper.check_enhanced_feature_flag_etags(selects, [["etag1"]]) assert result is False -def test_check_feature_flag_resource_etags_no_change(): +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() @@ -489,7 +488,7 @@ def test_check_feature_flag_resource_etags_no_change(): mock_response.by_page.return_value = iter([]) mock_feature_flag_client.list_feature_flags.return_value = mock_response - result = wrapper.check_feature_flag_resource_etags(selects, page_etags) + 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( @@ -498,7 +497,7 @@ def test_check_feature_flag_resource_etags_no_change(): mock_response.by_page.assert_called_once_with(match_conditions=["etag1"]) -def test_check_feature_flag_resource_etags_change_detected(): +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() @@ -511,27 +510,36 @@ def test_check_feature_flag_resource_etags_change_detected(): mock_response.by_page.return_value = iter([[Mock()]]) mock_feature_flag_client.list_feature_flags.return_value = mock_response - result = wrapper.check_feature_flag_resource_etags(selects, page_etags) + result = wrapper.check_enhanced_feature_flag_etags(selects, page_etags) assert result is True -def test_check_feature_flag_resource_etags_skips_snapshot_selectors(): - """Selectors with a snapshot_name are not supported and should be skipped without a service call.""" +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 = [SettingSelector(snapshot_name="my-snapshot")] - page_etags = [[]] + selects = [SettingSelector(key_filter="app/*")] + page_etags = [["etag1"]] - result = wrapper.check_feature_flag_resource_etags(selects, page_etags) + 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_not_called() + mock_feature_flag_client.list_feature_flags.assert_called_once_with( + name_filter="app/*", label_filter="\0", tags_filter=None + ) -def test_check_feature_flag_resource_etags_missing_page_etags_triggers_refresh(): +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() @@ -544,6 +552,6 @@ def test_check_feature_flag_resource_etags_missing_page_etags_triggers_refresh() mock_feature_flag_client.list_feature_flags.return_value = mock_response page_etags = [["etag1"]] - result = wrapper.check_feature_flag_resource_etags(selects, page_etags) + result = wrapper.check_enhanced_feature_flag_etags(selects, page_etags) assert result is True diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_enhanced_feature_flags.py similarity index 76% rename from sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.py rename to sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_enhanced_feature_flags.py index e9b82ec7457c..f2685adfef0b 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_enhanced_feature_flags.py @@ -4,8 +4,8 @@ # license information. # -------------------------------------------------------------------------- """ -Tests for loading feature flags from the dedicated feature flag resource endpoint -(``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value based +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 @@ -23,15 +23,15 @@ ) -class TestAppConfigurationProviderFeatureFlagResources(AppConfigTestCase): - """Tests for the provider loading feature flags from the dedicated feature flag resource endpoint.""" +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_feature_flag_resource(self, appconfiguration_endpoint_string): - """A feature flag created via the feature flag resource endpoint should be loaded by the provider.""" - feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + 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) @@ -51,9 +51,9 @@ def test_load_feature_flag_resource(self, appconfiguration_endpoint_string): # method: load @AppConfigProviderPreparer() @recorded_by_proxy - def test_load_feature_flag_resource_disabled(self, appconfiguration_endpoint_string): - """A disabled feature flag resource should be loaded with enabled set to False.""" - feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + 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) @@ -72,9 +72,9 @@ def test_load_feature_flag_resource_disabled(self, appconfiguration_endpoint_str # method: load @AppConfigProviderPreparer() @recorded_by_proxy - def test_load_feature_flag_resource_with_label(self, appconfiguration_endpoint_string): - """A feature flag resource with a label should be loaded when the label filter matches.""" - feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + 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) @@ -95,9 +95,9 @@ def test_load_feature_flag_resource_with_label(self, appconfiguration_endpoint_s # method: load @AppConfigProviderPreparer() @recorded_by_proxy - def test_feature_flag_resource_selector_filters_by_name(self, appconfiguration_endpoint_string): - """The feature_flag_selectors key_filter should scope which feature flag resources are loaded.""" - feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + 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) @@ -120,16 +120,16 @@ def test_feature_flag_resource_selector_filters_by_name(self, appconfiguration_e # method: load @AppConfigProviderPreparer() @recorded_by_proxy - def test_feature_flag_resource_overrides_key_value(self, appconfiguration_endpoint_string): - """A feature flag resource should take precedence over a key-value based feature flag with the + 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_feature_flag_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) - resource_feature_flag = FeatureFlag(name="OverlapFeature", enabled=True) - feature_flag_client.set_feature_flag(resource_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( @@ -139,12 +139,12 @@ def test_feature_flag_resource_overrides_key_value(self, appconfiguration_endpoi feature_flag_selectors=[SettingSelector(key_filter="OverlapFeature")], ) - # The resource-based feature flag (enabled=True) should win over the key-value based one + # 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 "name" in feature_flag + 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/testcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py index a2cdedb6310e..7314dc389213 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py @@ -46,7 +46,7 @@ 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_feature_flag_client(self, appconfiguration_endpoint_string): + 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") @@ -170,23 +170,23 @@ def create_feature_flag_config_setting(key, label, enabled, tags=None): return FeatureFlagConfigurationSetting(feature_id=key, label=label, enabled=enabled, tags=tags) -def create_feature_flag_resource(name, enabled, label=None, **kwargs): +def create_enhanced_feature_flag(name, enabled, label=None, **kwargs): """ - Create a FeatureFlag resource object for use with the dedicated feature flag resource endpoint - (``FeatureFlagClient``), as opposed to the classic key-value based ``FeatureFlagConfigurationSetting``. + 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 resource object. + :return: A FeatureFlag object. :rtype: ~azure.appconfiguration.FeatureFlag """ return FeatureFlag(name=name, enabled=enabled, label=label, **kwargs) -def cleanup_feature_flag_resources(feature_flag_client, feature_flags): +def cleanup_enhanced_feature_flags(feature_flag_client, feature_flags): """ - Delete feature flag resources created via the dedicated feature flag resource endpoint. + 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. From 177c154eec830579a5a42e3e374d0cd845764e5e Mon Sep 17 00:00:00 2001 From: Yuan Qu Date: Thu, 30 Jul 2026 14:20:25 -0700 Subject: [PATCH 3/8] Wire FeatureFlagSelector into feature flag loading, add samples, and add enhanced feature flag telemetry with tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../appconfiguration/provider/__init__.py | 2 + .../_azureappconfigurationproviderbase.py | 61 +++++++++++-- .../provider/_client_manager.py | 19 ++-- .../azure/appconfiguration/provider/_load.py | 23 +++-- .../appconfiguration/provider/_models.py | 40 +++++++++ .../provider/_request_tracing_context.py | 4 + .../provider/aio/_async_client_manager.py | 23 +++-- .../async_enhanced_feature_flag_sample.py | 23 +++++ .../samples/enhanced_feature_flag_sample.py | 20 +++++ .../test_azureappconfigurationproviderbase.py | 20 ++++- .../test_configuration_client_manager.py | 14 +-- .../tests/test_request_tracing_context.py | 89 +++++++++++++++++++ 12 files changed, 287 insertions(+), 51 deletions(-) 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/_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py index b6bf97f28e2b..803c8ee86527 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -27,7 +27,7 @@ FeatureFlagConfigurationSetting, FeatureFlag, ) -from ._models import SettingSelector +from ._models import FeatureFlagSelector, SettingSelector from ._constants import ( NULL_CHAR, TELEMETRY_KEY, @@ -84,6 +84,53 @@ 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 not selectors: + return [SettingSelector(key_filter="*")], [FeatureFlagSelector(name_filter="*")] + + is_feature_flag_selector = [isinstance(select, FeatureFlagSelector) for select in selectors] + if any(is_feature_flag_selector) and not all(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 all(is_feature_flag_selector): + kv_selectors = [ + SettingSelector(key_filter=select.name_filter, label_filter=select.label_filter, tag_filters=select.tag_filters) + for select in selectors + ] + # FeatureFlagSelector has no snapshot_name, so every selector is used for enhanced feature flags. + enhanced_selectors = list(selectors) + return kv_selectors, enhanced_selectors + + kv_selectors = list(selectors) + enhanced_selectors = [ + FeatureFlagSelector(name_filter=select.key_filter, label_filter=select.label_filter, tag_filters=select.tag_filters) + for select in selectors + if select.snapshot_name is None + ] + return kv_selectors, enhanced_selectors + + class AzureAppConfigurationProviderBase(Mapping[str, Union[str, JSON]]): # pylint: disable=too-many-instance-attributes """ Provides a dictionary-like interface to Azure App Configuration settings. Enables loading of sets of configuration @@ -106,14 +153,9 @@ 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="*")] - # The enhanced feature flag currently does not support snapshots, so selectors with a snapshot_name are - # filtered out. - self._enhanced_feature_flag_selectors = [ - select for select in self._feature_flag_selectors if select.snapshot_name is None - ] + 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) @@ -588,6 +630,7 @@ def _process_enhanced_feature_flag(self, feature_flag: FeatureFlag) -> Dict[str, 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) + self._tracing_context.uses_enhanced_feature_flags = True return feature_flag_value def _update_watched_settings( 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 184ac5a3afc3..f42aecb8e027 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py @@ -27,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 @@ -310,14 +310,13 @@ def check_feature_flag_page_etags( @distributed_trace def load_enhanced_feature_flags( - self, feature_flag_selectors: List[SettingSelector], **kwargs + 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. - The enhanced feature flag endpoint currently does not support snapshots. - :param feature_flag_selectors: List of setting selectors to filter feature flags - :type feature_flag_selectors: List[SettingSelector] + :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]]] @@ -329,7 +328,7 @@ def load_enhanced_feature_flags( for select in feature_flag_selectors: selector_etags: List[str] = [] feature_flags = self._enhanced_feature_flag_client.list_feature_flags( - name_filter=select.key_filter, + name_filter=select.name_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, **kwargs, @@ -343,13 +342,13 @@ def load_enhanced_feature_flags( @distributed_trace def check_enhanced_feature_flag_etags( - self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs + 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 setting selectors for feature flags - :type feature_flag_selectors: List[SettingSelector] + :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]] @@ -364,7 +363,7 @@ def check_enhanced_feature_flag_etags( return True selector_etags = page_etags[i] feature_flags = self._enhanced_feature_flag_client.list_feature_flags( - name_filter=select.key_filter, + name_filter=select.name_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, **kwargs, diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py index 6660ba2a6786..4624ddc71ed5 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_load.py @@ -10,13 +10,14 @@ Mapping, Optional, Tuple, + Union, overload, ) from azure.core.credentials import TokenCredential from ._constants import ( DEFAULT_STARTUP_TIMEOUT, ) -from ._models import AzureAppConfigurationKeyVaultOptions, SettingSelector +from ._models import AzureAppConfigurationKeyVaultOptions, FeatureFlagSelector, SettingSelector from ._utils import ( delay_failure, process_load_parameters, @@ -46,7 +47,7 @@ def load( # pylint: disable=docstring-keyword-should-match-keyword-only on_refresh_success: Optional[Callable] = None, on_refresh_error: Optional[Callable[[Exception], None]] = None, feature_flag_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = None, + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = None, feature_flag_refresh_enabled: bool = False, startup_timeout: int = DEFAULT_STARTUP_TIMEOUT, **kwargs, @@ -84,9 +85,11 @@ def load( # pylint: disable=docstring-keyword-should-match-keyword-only :paramtype on_refresh_error: Optional[Callable[[Exception], None]] :keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False. :paramtype feature_flag_enabled: bool - :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all - feature flags without a label. - :paramtype feature_flag_selectors: List[SettingSelector] + :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. Either a list of + ~azure.appconfiguration.provider.SettingSelector or a list of + ~azure.appconfiguration.provider.FeatureFlagSelector (the two types cannot be mixed in the same list). + By default will load all feature flags without a label. + :paramtype feature_flag_selectors: Union[List[SettingSelector], List[FeatureFlagSelector]] :keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is False. :paramtype feature_flag_refresh_enabled: bool @@ -121,7 +124,7 @@ def load( # pylint: disable=docstring-keyword-should-match-keyword-only on_refresh_success: Optional[Callable] = None, on_refresh_error: Optional[Callable[[Exception], None]] = None, feature_flag_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = None, + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = None, feature_flag_refresh_enabled: bool = False, startup_timeout: int = DEFAULT_STARTUP_TIMEOUT, **kwargs, @@ -161,9 +164,11 @@ def load( # pylint: disable=docstring-keyword-should-match-keyword-only :paramtype on_refresh_error: Optional[Callable[[Exception], None]] :keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False. :paramtype feature_flag_enabled: bool - :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all - feature flags without a label. - :paramtype feature_flag_selectors: List[SettingSelector] + :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. Either a list of + ~azure.appconfiguration.provider.SettingSelector or a list of + ~azure.appconfiguration.provider.FeatureFlagSelector (the two types cannot be mixed in the same list). + By default will load all feature flags without a label. + :paramtype feature_flag_selectors: Union[List[SettingSelector], List[FeatureFlagSelector]] :keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is False. :paramtype feature_flag_refresh_enabled: bool diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_models.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_models.py index c43d3cdc91c0..1e5f3f593141 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_models.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_models.py @@ -103,6 +103,46 @@ def __init__( self.snapshot_name = snapshot_name +class FeatureFlagSelector: + """ + Selects a set of feature flags from the dedicated enhanced feature flag endpoint. + + :keyword name_filter: A filter to select feature flags based on their name. + :type name_filter: str + :keyword label_filter: A filter to select feature flags based on their labels. Default + value is \0 i.e. (No Label) as seen in the portal. + :type label_filter: Optional[str] + :keyword tag_filters: A filter to select feature flags based on their tags. This is a + list of strings that will be used to match tags on the feature flags. Reserved characters (\\*, \\, ,) + must be escaped with backslash if they are part of the value. Tag filters must follow the format + "tagName=tagValue", for empty values use "tagName=" and for null values use "tagName=\\0". + :type tag_filters: Optional[List[str]] + """ + + def __init__( + self, + *, + name_filter: Optional[str] = None, + label_filter: Optional[str] = NULL_CHAR, + tag_filters: Optional[List[str]] = None, + ): + if name_filter is None: + raise ValueError("name_filter must be specified.") + + if tag_filters is not None: + if not isinstance(tag_filters, list): + raise TypeError("tag_filters must be a list of strings.") + for tag in tag_filters: + if not tag: + raise ValueError("Tag filter cannot be an empty string or None.") + if not isinstance(tag, str) or "=" not in tag or tag.startswith("="): + raise ValueError("Tag filter " + tag + ' does not follow the format "tagName=tagValue".') + + self.name_filter = name_filter + self.label_filter = label_filter + self.tag_filters = tag_filters + + class WatchKey(NamedTuple): key: str label: str = NULL_CHAR diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py index 330a719613a6..3043bb867d92 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py @@ -40,6 +40,7 @@ AI_CONFIGURATION_FEATURE = "AI" AI_CHAT_COMPLETION_FEATURE = "AICC" SNAPSHOT_REFERENCE_TAG = "SnapshotRef" +ENHANCED_FEATURE_FLAG_TAG = "EnhancedFF" # Correlation context constants FEATUREMANAGEMENT_PACKAGE = "featuremanagement" @@ -84,6 +85,7 @@ def __init__(self, load_balancing_enabled: bool = False) -> None: self.uses_ai_configuration = False self.uses_aicc_configuration = False # AI Chat Completion self.uses_snapshot_reference = False + self.uses_enhanced_feature_flags = False self.uses_telemetry = False self.uses_seed = False self.max_variants: Optional[int] = None @@ -285,6 +287,8 @@ def _create_features_string(self) -> str: features_list.append(AI_CHAT_COMPLETION_FEATURE) if self.uses_snapshot_reference: features_list.append(SNAPSHOT_REFERENCE_TAG) + if self.uses_enhanced_feature_flags: + features_list.append(ENHANCED_FEATURE_FLAG_TAG) return Delimiter.join(features_list) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py index fd7c20e5ef0c..4f215d5e3619 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 @@ -25,7 +25,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 .._snapshot_reference_parser import SnapshotReferenceParser from .._constants import SNAPSHOT_REF_CONTENT_TYPE @@ -311,16 +311,13 @@ async def check_feature_flag_page_etags( @distributed_trace async def load_enhanced_feature_flags( - self, feature_flag_selectors: List[SettingSelector], **kwargs + 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, collecting - page etags for each selector. The enhanced feature flag endpoint currently does not support snapshots, so - ``feature_flag_selectors`` is expected to already be filtered to exclude selectors with a - ``snapshot_name`` (see ``ConfigurationProviderBase._enhanced_feature_flag_selectors``). + Loads enhanced feature flags from the enhanced feature flag endpoint using page-based iteration. - :param feature_flag_selectors: List of setting selectors to filter feature flags - :type feature_flag_selectors: List[SettingSelector] + :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]]] @@ -332,7 +329,7 @@ async def load_enhanced_feature_flags( for select in feature_flag_selectors: selector_etags: List[str] = [] feature_flags = self._enhanced_feature_flag_client.list_feature_flags( - name_filter=select.key_filter, + name_filter=select.name_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, **kwargs, @@ -347,13 +344,13 @@ async def load_enhanced_feature_flags( @distributed_trace async def check_enhanced_feature_flag_etags( - self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs + 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 setting selectors for feature flags - :type feature_flag_selectors: List[SettingSelector] + :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]] @@ -368,7 +365,7 @@ async def check_enhanced_feature_flag_etags( return True selector_etags = page_etags[i] feature_flags = self._enhanced_feature_flag_client.list_feature_flags( - name_filter=select.key_filter, + name_filter=select.name_filter, label_filter=select.label_filter, tags_filter=select.tag_filters, **kwargs, 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 index 3b61b6275241..0e4c65f17ea3 100644 --- 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 @@ -69,6 +69,29 @@ async def main(): await config.close() # [END enhanced_feature_flag_selector_async] + + # [START enhanced_feature_flag_selector_with_feature_flag_selector_async] + from azure.appconfiguration.provider.aio import load + from azure.appconfiguration.provider import FeatureFlagSelector + + # FeatureFlagSelector is an alternative to SettingSelector for selecting feature flags. It uses + # name_filter instead of key_filter (the equivalent concept for enhanced feature flags), and has no + # snapshot_name since the enhanced feature flag endpoint does not support snapshots. A list of + # FeatureFlagSelector is used to filter both key-value based and enhanced feature flags; it cannot be + # mixed with SettingSelector in the same list. + 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") 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 index 815a3696d0db..d5f1df92f2b2 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py @@ -59,6 +59,26 @@ enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") print(enhanced_flag_beta["enabled"]) # [END enhanced_feature_flag_selector] + + # [START enhanced_feature_flag_selector_with_feature_flag_selector] + from azure.appconfiguration.provider import load, FeatureFlagSelector + + # FeatureFlagSelector is an alternative to SettingSelector for selecting feature flags. It uses + # name_filter instead of key_filter (the equivalent concept for enhanced feature flags), and has no + # snapshot_name since the enhanced feature flag endpoint does not support snapshots. A list of + # FeatureFlagSelector is used to filter both key-value based and enhanced feature flags; it cannot be + # mixed with SettingSelector in the same list. + 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") diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py index 792b02ed2440..33658513884a 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -27,7 +27,7 @@ _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, @@ -216,7 +216,8 @@ def test_initialization_with_custom_values(self): def test_enhanced_feature_flag_selectors_excludes_snapshot_selectors(self): """The enhanced feature flag endpoint doesn't support snapshots, so snapshot-name selectors should be filtered out once at startup, while the original selector list (used for the key-value store, which does - support snapshots) is left untouched.""" + support snapshots) is left untouched. Enhanced selectors are converted to FeatureFlagSelector, since the + enhanced feature flag endpoint filters by name_filter rather than key_filter.""" key_select = SettingSelector(key_filter="app:*") snapshot_select = SettingSelector(snapshot_name="my-snapshot") feature_flag_selectors = [snapshot_select, key_select] @@ -227,7 +228,10 @@ def test_enhanced_feature_flag_selectors_excludes_snapshot_selectors(self): ) self.assertEqual(provider._feature_flag_selectors, feature_flag_selectors) - self.assertEqual(provider._enhanced_feature_flag_selectors, [key_select]) + 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_process_key_name_with_no_prefix(self): """Test key name processing with no matching prefix.""" @@ -461,6 +465,16 @@ def test_process_enhanced_feature_flag_minimal(self): self.assertIn("telemetry", result) self.assertNotIn("enabled", result["telemetry"]) + def test_process_enhanced_feature_flag_sets_uses_enhanced_feature_flags_tracing(self): + """Processing an enhanced feature flag should mark the tracing context as having used the enhanced + feature flag endpoint, for the Correlation-Context telemetry header.""" + self.assertFalse(self.provider._tracing_context.uses_enhanced_feature_flags) + + feature_flag = FeatureFlag(name="MyFeature", enabled=True) + self.provider._process_enhanced_feature_flag(feature_flag) + + 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") 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 4daa4eade5a8..bfd9c6ad6620 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py @@ -7,7 +7,7 @@ from unittest.mock import patch, call, Mock, MagicMock import pytest from azure.appconfiguration.provider._client_manager import ConfigurationClientManager, _ConfigurationClientWrapper -from azure.appconfiguration.provider._models import SettingSelector +from azure.appconfiguration.provider._models import SettingSelector, FeatureFlagSelector def _create_mock_credential(): @@ -411,7 +411,7 @@ def test_load_enhanced_feature_flags_assumes_pre_filtered_selectors(): mock_feature_flag_client = Mock() wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) - selects = [SettingSelector(key_filter="app/*")] + selects = [FeatureFlagSelector(name_filter="app/*")] flag1 = Mock(name="flag1") mock_response = Mock() @@ -433,7 +433,7 @@ def test_load_enhanced_feature_flags_multiple_pages(): mock_feature_flag_client = Mock() wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) - selects = [SettingSelector(key_filter="app/*")] + selects = [FeatureFlagSelector(name_filter="app/*")] flag1 = Mock(name="flag1") flag2 = Mock(name="flag2") @@ -481,7 +481,7 @@ def test_check_enhanced_feature_flag_etags_no_change(): mock_feature_flag_client = Mock() wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) - selects = [SettingSelector(key_filter="app/*")] + selects = [FeatureFlagSelector(name_filter="app/*")] page_etags = [["etag1"]] mock_response = Mock() @@ -503,7 +503,7 @@ def test_check_enhanced_feature_flag_etags_change_detected(): mock_feature_flag_client = Mock() wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) - selects = [SettingSelector(key_filter="app/*")] + selects = [FeatureFlagSelector(name_filter="app/*")] page_etags = [["etag1"]] mock_response = Mock() @@ -524,7 +524,7 @@ def test_check_enhanced_feature_flag_etags_assumes_pre_filtered_selectors(): mock_feature_flag_client = Mock() wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) - selects = [SettingSelector(key_filter="app/*")] + selects = [FeatureFlagSelector(name_filter="app/*")] page_etags = [["etag1"]] mock_response = Mock() @@ -545,7 +545,7 @@ def test_check_enhanced_feature_flag_etags_missing_page_etags_triggers_refresh() mock_feature_flag_client = Mock() wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) - selects = [SettingSelector(key_filter="app/*"), SettingSelector(key_filter="other/*")] + 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([]) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py index ad3cf6285d8b..8db3c2a39356 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,91 @@ 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, "EnhancedFF") + + 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) + From c383fba0744a61b8e9a389ea67ea46a55aa77204 Mon Sep 17 00:00:00 2001 From: Yuan Qu Date: Thu, 30 Jul 2026 14:53:27 -0700 Subject: [PATCH 4/8] Trim redundant comments and rewrite README enhanced feature flag section to recommend FeatureFlagSelector Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-appconfiguration-provider/README.md | 28 +++++++++++++++++-- .../appconfiguration/provider/_constants.py | 7 ++--- .../async_enhanced_feature_flag_sample.py | 6 +--- .../samples/enhanced_feature_flag_sample.py | 6 +--- .../test_azureappconfigurationproviderbase.py | 4 --- 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/README.md index 31ae14598326..345d1e0a82ce 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/README.md @@ -379,7 +379,7 @@ config = load( ### Loading Enhanced Feature Flags -Feature flags can also be created using the dedicated enhanced feature flag endpoint (via `FeatureFlagClient`/`FeatureFlag` in `azure-appconfiguration`), instead of as key-value configuration settings. The provider loads both kinds side by side into the same `feature_management.feature_flags` list, with enhanced feature flags taking precedence over key-value based feature flags when they share the same name. No additional `load()` options are required to enable this — it happens automatically whenever `feature_flag_enabled=True`, using the same `feature_flag_selectors`. +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. @@ -396,7 +396,29 @@ print(enhanced_flag_beta["enabled"]) -The same `SettingSelector` used to filter key-value based feature flags also filters enhanced feature flags, by name, label, or tags. Note that selectors with a `snapshot_name` are not currently supported by the enhanced feature flag endpoint and are skipped when loading enhanced feature flags. +`FeatureFlagSelector` is the dedicated selector type for filtering enhanced feature flags by name, label, or tags, and is the recommended way to select enhanced feature flags. + + + +```python +from azure.appconfiguration.provider import load, FeatureFlagSelector + +# FeatureFlagSelector is the dedicated selector type for filtering enhanced feature flags. +config = load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[FeatureFlagSelector(name_filter="Enhanced*")], + **kwargs, +) +feature_flags = config["feature_management"]["feature_flags"] +enhanced_flag_beta = next(flag for flag in feature_flags if flag.get("id") == "EnhancedFeatureBeta") +print(enhanced_flag_beta["enabled"]) +``` + + + +The same `SettingSelector` used to filter key-value based feature flags also filters enhanced feature flags, by name, label, or tags. A list of `feature_flag_selectors` must contain either `SettingSelector` or `FeatureFlagSelector` instances, but not both. Note that selectors with a `snapshot_name` are not currently supported for enhanced feature flags and are skipped when loading them. @@ -419,6 +441,8 @@ print(enhanced_flag_beta["enabled"]) +Existing customers using key-value based feature flags do not need to make any code changes to benefit from this feature. If enhanced feature flags are created in the same App Configuration store, the provider will automatically load and merge them alongside the existing key-value based feature flags whenever `feature_flag_enabled=True`. + ## JSON Content Type Configuration settings with a JSON content type (e.g., `application/json`) are automatically deserialized into their corresponding Python objects when loaded by the provider. 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 2ed2e07053f3..5c44b8717e2b 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py @@ -15,14 +15,11 @@ ALLOCATION_ID_KEY = "AllocationId" ETAG_KEY = "ETag" -# Identifier field required by the feature management library's schema for every feature flag entry. For -# enhanced feature flags, which do not have their own "id" concept, the enhanced feature flag's name is used -# as the value of this field. +# 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 loaded from the enhanced -# feature flag endpoint. +# Path segment used to build the feature flag reference URL for enhanced feature flags. ENHANCED_FEATURE_FLAG_REFERENCE_SEGMENT = "ff" # ------------------------------------------------------------------------ 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 index 0e4c65f17ea3..d4dd51215445 100644 --- 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 @@ -74,11 +74,7 @@ async def main(): from azure.appconfiguration.provider.aio import load from azure.appconfiguration.provider import FeatureFlagSelector - # FeatureFlagSelector is an alternative to SettingSelector for selecting feature flags. It uses - # name_filter instead of key_filter (the equivalent concept for enhanced feature flags), and has no - # snapshot_name since the enhanced feature flag endpoint does not support snapshots. A list of - # FeatureFlagSelector is used to filter both key-value based and enhanced feature flags; it cannot be - # mixed with SettingSelector in the same list. + # FeatureFlagSelector is the dedicated selector type for filtering enhanced feature flags. config = await load( endpoint=endpoint, credential=credential, 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 index d5f1df92f2b2..58bd72e141df 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/enhanced_feature_flag_sample.py @@ -63,11 +63,7 @@ # [START enhanced_feature_flag_selector_with_feature_flag_selector] from azure.appconfiguration.provider import load, FeatureFlagSelector - # FeatureFlagSelector is an alternative to SettingSelector for selecting feature flags. It uses - # name_filter instead of key_filter (the equivalent concept for enhanced feature flags), and has no - # snapshot_name since the enhanced feature flag endpoint does not support snapshots. A list of - # FeatureFlagSelector is used to filter both key-value based and enhanced feature flags; it cannot be - # mixed with SettingSelector in the same list. + # FeatureFlagSelector is the dedicated selector type for filtering enhanced feature flags. config = load( endpoint=endpoint, credential=credential, diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py index 33658513884a..f715b2fe9dfe 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -214,10 +214,6 @@ def test_initialization_with_custom_values(self): self.assertEqual(provider._refresh_timer._interval, 60) def test_enhanced_feature_flag_selectors_excludes_snapshot_selectors(self): - """The enhanced feature flag endpoint doesn't support snapshots, so snapshot-name selectors should be - filtered out once at startup, while the original selector list (used for the key-value store, which does - support snapshots) is left untouched. Enhanced selectors are converted to FeatureFlagSelector, since the - enhanced feature flag endpoint filters by name_filter rather than key_filter.""" key_select = SettingSelector(key_filter="app:*") snapshot_select = SettingSelector(snapshot_name="my-snapshot") feature_flag_selectors = [snapshot_select, key_select] From 198846a8190dbc383478eab20e1f3457bf0093c8 Mon Sep 17 00:00:00 2001 From: Yuan Qu Date: Thu, 30 Jul 2026 14:58:13 -0700 Subject: [PATCH 5/8] Trim redundant docstring in test_configuration_client_manager.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/test_configuration_client_manager.py | 4 ---- 1 file changed, 4 deletions(-) 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 bfd9c6ad6620..986239f61dfc 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py @@ -403,10 +403,6 @@ def test_load_enhanced_feature_flags_no_feature_flag_client(): def test_load_enhanced_feature_flags_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) From 969c15c27fea8a44012a57f35c850727a64c4b9f Mon Sep 17 00:00:00 2001 From: Yuan Qu Date: Thu, 30 Jul 2026 16:19:23 -0700 Subject: [PATCH 6/8] Update ENHANCED_FEATURE_FLAG_TAG from EnhancedFF to EnhFF Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/appconfiguration/provider/_request_tracing_context.py | 2 +- .../tests/test_request_tracing_context.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 3043bb867d92..a4aa3e974aae 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py @@ -40,7 +40,7 @@ AI_CONFIGURATION_FEATURE = "AI" AI_CHAT_COMPLETION_FEATURE = "AICC" SNAPSHOT_REFERENCE_TAG = "SnapshotRef" -ENHANCED_FEATURE_FLAG_TAG = "EnhancedFF" +ENHANCED_FEATURE_FLAG_TAG = "EnhFF" # Correlation context constants FEATUREMANAGEMENT_PACKAGE = "featuremanagement" 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 8db3c2a39356..0ff004b198b4 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 @@ -521,7 +521,7 @@ class TestEnhancedFeatureFlagTracking(unittest.TestCase): 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, "EnhancedFF") + self.assertEqual(ENHANCED_FEATURE_FLAG_TAG, "EnhFF") def test_initialization(self): """Test that request tracing context initializes enhanced feature flag tracking to False.""" From 3a11cd2198845c7e539f8f5ac53d18aaebedb311 Mon Sep 17 00:00:00 2001 From: Yuan Qu Date: Fri, 31 Jul 2026 11:28:11 -0700 Subject: [PATCH 7/8] Fix feature flag selector type checking to support sets and reset enhanced feature flag tracing when none are loaded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_azureappconfigurationproviderbase.py | 19 +++++++++++-------- .../test_azureappconfigurationproviderbase.py | 11 ++++++++--- 2 files changed, 19 insertions(+), 11 deletions(-) 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 803c8ee86527..16bceab38bfc 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -106,14 +106,17 @@ def _normalize_feature_flag_selectors( if not selectors: return [SettingSelector(key_filter="*")], [FeatureFlagSelector(name_filter="*")] - is_feature_flag_selector = [isinstance(select, FeatureFlagSelector) for select in selectors] - if any(is_feature_flag_selector) and not all(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." - ) + selectors_iter = iter(selectors) + first_selector = next(selectors_iter) + is_feature_flag_selector = isinstance(first_selector, FeatureFlagSelector) + for select in selectors_iter: + if isinstance(select, FeatureFlagSelector) != is_feature_flag_selector: + raise TypeError( + "feature_flag_selectors must be either a list of SettingSelector or a list of FeatureFlagSelector, " + "not a mix of both." + ) - if all(is_feature_flag_selector): + if is_feature_flag_selector: kv_selectors = [ SettingSelector(key_filter=select.name_filter, label_filter=select.label_filter, tag_filters=select.tag_filters) for select in selectors @@ -498,6 +501,7 @@ def _process_and_merge_feature_flags( self._processed_enhanced_feature_flags = [ self._process_enhanced_feature_flag(ff) for ff in enhanced_feature_flags ] + self._tracing_context.uses_enhanced_feature_flags = bool(enhanced_feature_flags) if feature_flags or enhanced_feature_flags: processed_feature_flags = self._merge_feature_flags( @@ -630,7 +634,6 @@ def _process_enhanced_feature_flag(self, feature_flag: FeatureFlag) -> Dict[str, 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) - self._tracing_context.uses_enhanced_feature_flags = True return feature_flag_value def _update_watched_settings( diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py index f715b2fe9dfe..476d29b2e155 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -462,15 +462,20 @@ def test_process_enhanced_feature_flag_minimal(self): self.assertNotIn("enabled", result["telemetry"]) def test_process_enhanced_feature_flag_sets_uses_enhanced_feature_flags_tracing(self): - """Processing an enhanced feature flag should mark the tracing context as having used the enhanced - feature flag endpoint, for the Correlation-Context telemetry header.""" + """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_enhanced_feature_flag(feature_flag) + self.provider._process_and_merge_feature_flags({}, [], [], [feature_flag]) self.assertTrue(self.provider._tracing_context.uses_enhanced_feature_flags) + self.provider._process_and_merge_feature_flags({}, [], [], []) + + self.assertFalse(self.provider._tracing_context.uses_enhanced_feature_flags) + def test_process_enhanced_feature_flag_with_label_and_description(self): """Test processing an enhanced feature flag with label and description.""" feature_flag = FeatureFlag(name="MyFeature", enabled=False, label="prod", description="A test feature") From 33fdc6bbc5a29dc532c732cdaa324a7d120fa2df Mon Sep 17 00:00:00 2001 From: Yuan Qu Date: Fri, 31 Jul 2026 15:53:16 -0700 Subject: [PATCH 8/8] Fix enhanced feature flag schema mapping, selector type narrowing, async API type hints, Python version floor, and rename tests README to avoid the package-readme section check, all addressing code review feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHANGELOG.md | 2 + .../azure-appconfiguration-provider/README.md | 2 +- .../azure-appconfiguration-provider/api.md | 19 ++++-- .../api.metadata.yml | 4 +- .../_azureappconfigurationproviderbase.py | 61 ++++++++++-------- .../appconfiguration/provider/_constants.py | 2 +- .../provider/aio/_async_client_manager.py | 2 +- .../provider/aio/_async_load.py | 23 ++++--- .../_azureappconfigurationproviderasync.py | 3 +- .../azure-appconfiguration-provider/setup.py | 5 +- .../test_azureappconfigurationproviderbase.py | 64 ++++++++++++++----- .../tests/test_request_tracing_context.py | 1 - .../tests/{README.md => tests.md} | 22 +++++++ 13 files changed, 144 insertions(+), 66 deletions(-) rename sdk/appconfiguration/azure-appconfiguration-provider/tests/{README.md => tests.md} (76%) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 5d36e017dc8a..b2e72a63ba26 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md @@ -8,6 +8,8 @@ ### Breaking Changes +- Raised the minimum supported Python version to 3.10, matching the minimum required by `azure-appconfiguration>=1.10.0b1`. Dropped support for Python 3.7, 3.8, and 3.9. + ### Bugs Fixed ### Other Changes diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/README.md index 345d1e0a82ce..767ac1d2dba4 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/README.md @@ -539,7 +539,7 @@ This library uses the standard [logging](https://docs.python.org/3/library/loggi (This content is for `azure-appconfiguration-provider` package developer only) -See [tests/README.md](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/appconfiguration/azure-appconfiguration-provider/tests/README.md) for instructions on running unit and integration tests, working with recordings, and setting up environment variables for local testing. +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 diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/api.md b/sdk/appconfiguration/azure-appconfiguration-provider/api.md index 97959ac187a1..8c4676874192 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/api.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/api.md @@ -8,7 +8,7 @@ namespace azure.appconfiguration.provider *, feature_flag_enabled: bool = False, feature_flag_refresh_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = ..., + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = ..., key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = ..., keyvault_client_configs: Optional[Mapping[str, JSON]] = ..., keyvault_credential: Optional[TokenCredential] = ..., @@ -31,7 +31,7 @@ namespace azure.appconfiguration.provider connection_string: str, feature_flag_enabled: bool = False, feature_flag_refresh_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = ..., + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = ..., key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = ..., keyvault_client_configs: Optional[Mapping[str, JSON]] = ..., keyvault_credential: Optional[TokenCredential] = ..., @@ -68,6 +68,17 @@ namespace azure.appconfiguration.provider def refresh(self, **kwargs) -> None: ... + class azure.appconfiguration.provider.FeatureFlagSelector: + + def __init__( + self, + *, + label_filter: Optional[str] = NULL_CHAR, + name_filter: Optional[str] = ..., + tag_filters: Optional[List[str]] = ... + ): ... + + class azure.appconfiguration.provider.SettingSelector: def __init__( @@ -94,7 +105,7 @@ namespace azure.appconfiguration.provider.aio *, feature_flag_enabled: bool = False, feature_flag_refresh_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = ..., + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = ..., key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = ..., keyvault_client_configs: Optional[Mapping[str, JSON]] = ..., keyvault_credential: Optional[AsyncTokenCredential] = ..., @@ -117,7 +128,7 @@ namespace azure.appconfiguration.provider.aio connection_string: str, feature_flag_enabled: bool = False, feature_flag_refresh_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = ..., + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = ..., key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = ..., keyvault_client_configs: Optional[Mapping[str, JSON]] = ..., keyvault_credential: Optional[AsyncTokenCredential] = ..., diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/api.metadata.yml b/sdk/appconfiguration/azure-appconfiguration-provider/api.metadata.yml index 54ff9f5af349..3ea31f2567ee 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/api.metadata.yml +++ b/sdk/appconfiguration/azure-appconfiguration-provider/api.metadata.yml @@ -1,3 +1,3 @@ -apiMdSha256: 1b252a78094b95bf515ebe5ec67fc2de12ce5b595bad25438a58241ca7153b84 +apiMdSha256: 808ba824eedfb7fc2365479c13867983ee557982cd635995a9617e140c1919dc parserVersion: 0.3.28 -pythonVersion: 3.14.3 +pythonVersion: 3.12.10 diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py index 16bceab38bfc..c42c252b2739 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -21,6 +21,7 @@ ItemsView, ValuesView, TypeVar, + cast, ) from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, @@ -95,7 +96,7 @@ def _normalize_feature_flag_selectors( - 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). + (``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). @@ -103,8 +104,12 @@ def _normalize_feature_flag_selectors( :return: A tuple of (kv_selectors, enhanced_selectors). :rtype: Tuple[List[SettingSelector], List[FeatureFlagSelector]] """ - if not selectors: + 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) @@ -117,18 +122,24 @@ def _normalize_feature_flag_selectors( ) if is_feature_flag_selector: + feature_flag_selectors = cast(List[FeatureFlagSelector], selectors) kv_selectors = [ - SettingSelector(key_filter=select.name_filter, label_filter=select.label_filter, tag_filters=select.tag_filters) - for select in selectors + SettingSelector( + key_filter=select.name_filter, label_filter=select.label_filter, tag_filters=select.tag_filters + ) + for select in feature_flag_selectors ] # FeatureFlagSelector has no snapshot_name, so every selector is used for enhanced feature flags. - enhanced_selectors = list(selectors) + enhanced_selectors = list(feature_flag_selectors) return kv_selectors, enhanced_selectors - kv_selectors = list(selectors) + setting_selectors = cast(List[SettingSelector], selectors) + kv_selectors = list(setting_selectors) enhanced_selectors = [ - FeatureFlagSelector(name_filter=select.key_filter, label_filter=select.label_filter, tag_filters=select.tag_filters) - for select in selectors + FeatureFlagSelector( + name_filter=select.key_filter, label_filter=select.label_filter, tag_filters=select.tag_filters + ) + for select in setting_selectors if select.snapshot_name is None ] return kv_selectors, enhanced_selectors @@ -273,7 +284,7 @@ def _update_ff_telemetry_metadata_common( # pylint: disable=too-many-positional 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, reference_path_segment) + allocation_id = self._generate_allocation_id(feature_flag_value) if allocation_id: feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ALLOCATION_ID_KEY] = allocation_id @@ -287,14 +298,12 @@ def _update_ff_telemetry_metadata_common( # pylint: disable=too-many-positional self._tracing_context.update_max_variants(len(variants)) @staticmethod - def _generate_allocation_id(feature_flag_value: Dict[str, JSON], reference_path_segment: str) -> Optional[str]: + def _generate_allocation_id(feature_flag_value: Dict[str, JSON]) -> Optional[str]: """ Generates an allocation ID for the specified feature. seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Control,20;20,Test,100\nvariants=Control,standard;Test,special # pylint:disable=line-too-long :param Dict[str, JSON] feature_flag_value: The feature to generate an allocation ID for. - :param str reference_path_segment: The path segment identifying which source the feature flag was loaded - from, e.g. "kv" for key-value based feature flags or "ff" for enhanced feature flags. :rtype: str :return: The allocation ID. """ @@ -356,13 +365,9 @@ def _generate_allocation_id(feature_flag_value: Dict[str, JSON], reference_path_ for v in sorted_variants: allocation_id += f"{base64.b64encode(v.get('name', '').encode()).decode()}," - # Key-value based feature flags store the variant value under "configuration_value". Enhanced - # feature flags store it under "value" instead. - if reference_path_segment == FEATURE_FLAG_KV_REFERENCE_SEGMENT: - value_key = "configuration_value" - else: - value_key = "value" - allocation_id += f"{json.dumps(v.get(value_key, ''), 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] @@ -490,20 +495,20 @@ def _process_and_merge_feature_flags( feature_flags: Optional[List[FeatureFlagConfigurationSetting]], enhanced_feature_flags: Optional[List[FeatureFlag]] = None, ) -> Dict[str, Any]: - if feature_flags or enhanced_feature_flags: + if feature_flags or enhanced_feature_flags is not None: # Reset feature flag usage self._tracing_context.reset_feature_filter_usage() if feature_flags: self._processed_kv_feature_flags = [self._process_kv_feature_flag(ff) for ff in feature_flags] - if enhanced_feature_flags: + if enhanced_feature_flags is not None: self._processed_enhanced_feature_flags = [ self._process_enhanced_feature_flag(ff) for ff in enhanced_feature_flags ] self._tracing_context.uses_enhanced_feature_flags = bool(enhanced_feature_flags) - if feature_flags or enhanced_feature_flags: + if feature_flags or enhanced_feature_flags is not None: processed_feature_flags = self._merge_feature_flags( self._processed_kv_feature_flags, self._processed_enhanced_feature_flags ) @@ -534,9 +539,13 @@ def _merge_feature_flags( 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()) @@ -554,7 +563,7 @@ def _process_enhanced_feature_flag(self, feature_flag: FeatureFlag) -> Dict[str, """ 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 + 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 @@ -588,7 +597,7 @@ def _process_enhanced_feature_flag(self, feature_flag: FeatureFlag) -> Dict[str, feature_flag_value["variants"] = [ { "name": variant.name, - "value": variant.value, + "configuration_value": variant.value, "content_type": variant.content_type, "status_override": variant.status_override, } @@ -605,8 +614,8 @@ def _process_enhanced_feature_flag(self, feature_flag: FeatureFlag) -> Dict[str, allocation_value["percentile"] = [ { "variant": percentile.variant, - "percentile_from": percentile.percentile_from, - "percentile_to": percentile.percentile_to, + "from": percentile.percentile_from, + "to": percentile.percentile_to, } for percentile in feature_flag.allocation.percentile ] 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 5c44b8717e2b..f368eff8c525 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py @@ -15,7 +15,7 @@ ALLOCATION_ID_KEY = "AllocationId" ETAG_KEY = "ETag" -# Identifier field required by the feature management library's schema for every feature flag entry. +# 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" 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 4f215d5e3619..2e578dbfa1a6 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py @@ -347,7 +347,7 @@ 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. + 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] diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_load.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_load.py index 81a8854340aa..4a857b9986df 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_load.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_load.py @@ -13,12 +13,13 @@ overload, List, Tuple, + Union, ) from azure.core.credentials_async import AsyncTokenCredential from .._constants import ( DEFAULT_STARTUP_TIMEOUT, ) -from .._models import AzureAppConfigurationKeyVaultOptions, SettingSelector +from .._models import AzureAppConfigurationKeyVaultOptions, FeatureFlagSelector, SettingSelector from .._utils import ( delay_failure, process_load_parameters, @@ -49,7 +50,7 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only on_refresh_success: Optional[Callable] = None, on_refresh_error: Optional[Callable[[Exception], Awaitable[None]]] = None, feature_flag_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = None, + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = None, feature_flag_refresh_enabled: bool = False, startup_timeout: int = DEFAULT_STARTUP_TIMEOUT, **kwargs, @@ -87,9 +88,11 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only :paramtype on_refresh_error: Optional[Callable[[Exception], Awaitable[None]]] :keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False. :paramtype feature_flag_enabled: bool - :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all - feature flags without a label. - :paramtype feature_flag_selectors: List[SettingSelector] + :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. Either a list of + ~azure.appconfiguration.provider.SettingSelector or a list of + ~azure.appconfiguration.provider.FeatureFlagSelector (the two types cannot be mixed in the same list). + By default will load all feature flags without a label. + :paramtype feature_flag_selectors: Union[List[SettingSelector], List[FeatureFlagSelector]] :keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is False. :paramtype feature_flag_refresh_enabled: bool @@ -124,7 +127,7 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only on_refresh_success: Optional[Callable] = None, on_refresh_error: Optional[Callable[[Exception], Awaitable[None]]] = None, feature_flag_enabled: bool = False, - feature_flag_selectors: Optional[List[SettingSelector]] = None, + feature_flag_selectors: Optional[Union[List[SettingSelector], List[FeatureFlagSelector]]] = None, feature_flag_refresh_enabled: bool = False, startup_timeout: int = DEFAULT_STARTUP_TIMEOUT, **kwargs, @@ -164,9 +167,11 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only :paramtype on_refresh_error: Optional[Callable[[Exception], Awaitable[None]]] :keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False. :paramtype feature_flag_enabled: bool - :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all - feature flags without a label. - :paramtype feature_flag_selectors: List[SettingSelector] + :keyword feature_flag_selectors: Optional list of selectors to filter feature flags. Either a list of + ~azure.appconfiguration.provider.SettingSelector or a list of + ~azure.appconfiguration.provider.FeatureFlagSelector (the two types cannot be mixed in the same list). + By default will load all feature flags without a label. + :paramtype feature_flag_selectors: Union[List[SettingSelector], List[FeatureFlagSelector]] :keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is False. :paramtype feature_flag_refresh_enabled: bool diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py index b06e5d536a80..a12289af0086 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py @@ -168,8 +168,7 @@ async def _attempt_refresh( # own page-level etag state, since they are a separate resource type with a separate # change-detection mechanism. if not self._enhanced_feature_flag_etags or await client.check_enhanced_feature_flag_etags( - self._enhanced_feature_flag_selectors, self._enhanced_feature_flag_etags, headers=headers, - **kwargs + self._enhanced_feature_flag_selectors, self._enhanced_feature_flag_etags, headers=headers, **kwargs ): enhanced_feature_flags, enhanced_feature_flag_etags = await client.load_enhanced_feature_flags( self._enhanced_feature_flag_selectors, headers=headers, **kwargs diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/setup.py b/sdk/appconfiguration/azure-appconfiguration-provider/setup.py index 495014bbd097..8d01796d2530 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/setup.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/setup.py @@ -61,16 +61,13 @@ "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.10.0b1", diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py index 476d29b2e155..551257e60ac4 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -34,7 +34,6 @@ METADATA_KEY, ETAG_KEY, FEATURE_FLAG_REFERENCE_KEY, - FEATURE_FLAG_KV_REFERENCE_SEGMENT, ) from azure.appconfiguration.provider._refresh_timer import _RefreshTimer @@ -229,6 +228,23 @@ def test_enhanced_feature_flag_selectors_excludes_snapshot_selectors(self): self.assertEqual(provider._enhanced_feature_flag_selectors[0].name_filter, key_select.key_filter) self.assertEqual(provider._enhanced_feature_flag_selectors[0].label_filter, key_select.label_filter) + def test_feature_flag_selectors_none_defaults_to_all_unlabeled_flags(self): + provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + self.assertEqual(len(provider._feature_flag_selectors), 1) + self.assertEqual(provider._feature_flag_selectors[0].key_filter, "*") + self.assertEqual(len(provider._enhanced_feature_flag_selectors), 1) + self.assertEqual(provider._enhanced_feature_flag_selectors[0].name_filter, "*") + + def test_feature_flag_selectors_explicit_empty_list_loads_none(self): + provider = AzureAppConfigurationProviderBase( + endpoint="https://test.azconfig.io", + feature_flag_selectors=[], + ) + + self.assertEqual(provider._feature_flag_selectors, []) + self.assertEqual(provider._enhanced_feature_flag_selectors, []) + def test_process_key_name_with_no_prefix(self): """Test key name processing with no matching prefix.""" config = Mock() @@ -374,9 +390,7 @@ def test_update_ff_telemetry_metadata_max_variants(self): def test_generate_allocation_id_no_allocation(self): """Test allocation ID generation with no allocation.""" feature_flag_value: Dict[str, Any] = {"no_allocation": "here"} - result = AzureAppConfigurationProviderBase._generate_allocation_id( - feature_flag_value, FEATURE_FLAG_KV_REFERENCE_SEGMENT - ) + result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) self.assertIsNone(result) def test_generate_allocation_id_with_allocation(self): @@ -393,9 +407,7 @@ def test_generate_allocation_id_with_allocation(self): ], } - result = AzureAppConfigurationProviderBase._generate_allocation_id( - feature_flag_value, FEATURE_FLAG_KV_REFERENCE_SEGMENT - ) + result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) self.assertIsNotNone(result) self.assertIsInstance(result, str) # Should be a base64 encoded string @@ -414,9 +426,7 @@ def test_generate_allocation_id_no_variants_no_seed(self): "default_when_enabled": "Control" } } - result = AzureAppConfigurationProviderBase._generate_allocation_id( - feature_flag_value, FEATURE_FLAG_KV_REFERENCE_SEGMENT - ) + result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) # Since default_when_enabled is provided, allocated_variants won't be empty # so this should return a valid allocation ID self.assertIsNotNone(result) @@ -429,9 +439,7 @@ def test_generate_allocation_id_truly_empty(self): # No seed and no default_when_enabled } } - result = AzureAppConfigurationProviderBase._generate_allocation_id( - feature_flag_value, FEATURE_FLAG_KV_REFERENCE_SEGMENT - ) + result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) # This should return None because allocated_variants is empty and no seed self.assertIsNone(result) @@ -528,13 +536,13 @@ def test_process_enhanced_feature_flag_with_variants_and_allocation(self): self.assertEqual(len(result["variants"]), 2) self.assertEqual(result["variants"][0]["name"], "Control") - self.assertEqual(result["variants"][0]["value"], {"key": "control_value"}) + self.assertEqual(result["variants"][0]["configuration_value"], {"key": "control_value"}) self.assertEqual(result["variants"][1]["content_type"], "application/json") allocation = result["allocation"] self.assertEqual(allocation["default_when_disabled"], "Control") self.assertEqual(allocation["default_when_enabled"], "Test") - self.assertEqual(allocation["percentile"], [{"variant": "Control", "percentile_from": 0, "percentile_to": 50}]) + 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") @@ -646,5 +654,31 @@ def test_merge_only_enhanced_flags(self): self.assertEqual(len(merged), 2) +class TestProcessAndMergeFeatureFlags(unittest.TestCase): + """Test _process_and_merge_feature_flags distinguishes None (not loaded this round) from an explicitly + empty list (loaded this round, zero found).""" + + def setUp(self): + self.provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + def test_enhanced_feature_flags_none_preserves_previous_processed_flags(self): + feature_flag = FeatureFlag(name="MyFeature", enabled=True) + self.provider._process_and_merge_feature_flags({}, [], None, [feature_flag]) + self.assertEqual(len(self.provider._processed_enhanced_feature_flags), 1) + + # Passing None again should leave the previously processed enhanced feature flags untouched. + self.provider._process_and_merge_feature_flags({}, [], None, None) + self.assertEqual(len(self.provider._processed_enhanced_feature_flags), 1) + + def test_enhanced_feature_flags_explicit_empty_list_clears_previous_processed_flags(self): + feature_flag = FeatureFlag(name="MyFeature", enabled=True) + self.provider._process_and_merge_feature_flags({}, [], None, [feature_flag]) + self.assertEqual(len(self.provider._processed_enhanced_feature_flags), 1) + + # An explicitly empty list means the endpoint was queried and returned zero feature flags. + self.provider._process_and_merge_feature_flags({}, [], None, []) + self.assertEqual(self.provider._processed_enhanced_feature_flags, []) + + if __name__ == "__main__": unittest.main() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py index 0ff004b198b4..3393a2866463 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_request_tracing_context.py @@ -601,4 +601,3 @@ def test_correlation_context_with_enhanced_feature_flags_and_snapshot_reference( 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/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/tests/tests.md similarity index 76% rename from sdk/appconfiguration/azure-appconfiguration-provider/tests/README.md rename to sdk/appconfiguration/azure-appconfiguration-provider/tests/tests.md index 85f257fc1fce..af064b22e021 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/tests.md @@ -48,3 +48,25 @@ Notes: * Authentication for Entra ID-based tests relies on your local Azure CLI login (`az login`); make sure you're signed in to the subscription that contains your App Configuration store. * Add `AZURE_SKIP_LIVE_RECORDING=true` if you want to run tests live against the real store without generating/overwriting recording files (useful for a quick sanity check). * Omit `AZURE_TEST_RUN_LIVE` (or set it to `false`) to run the same tests in playback mode against existing recordings — this does not require any of the App Configuration environment variables above. + +## Pre-PR validation checks (sdist, mypy, pylint, black, snippets) + +Make sure all unit tests and live tests passed, test recodings updated, all tests against recordings also passed. + +Before opening or updating a PR, run the same static/build checks that CI enforces, using the `azpysdk` entrypoint from `eng/tools/azure-sdk-tools`. See [doc/tool_usage_guide.md](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/tool_usage_guide.md) for the full list of available checks and options (e.g. `--isolate`). + +From this package's directory (`sdk/appconfiguration/azure-appconfiguration-provider`): + +```bash +azpysdk sdist . # builds the sdist and runs the full test suite against it +azpysdk mypy . # static type checking +azpysdk pylint . # lint checks +azpysdk black . # formatting check (auto-reformats files in place) +azpysdk update_snippet . # regenerates README code snippets from sample files +``` + +Notes: + +* Run `black` again after making any other fixes, since it may reformat files you just edited. +* Run `update_snippet` after changing any `samples/*.py` file, then diff to confirm the regenerated snippets in `README.md` match what you expect. +* See [doc/dev/pylint_checking.md](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/pylint_checking.md) and [doc/dev/static_type_checking_cheat_sheet.md](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/static_type_checking_cheat_sheet.md) for guidance on fixing pylint/mypy issues.