Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
# 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

### 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)

Expand Down
93 changes: 93 additions & 0 deletions sdk/appconfiguration/azure-appconfiguration-provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,48 @@ config = load(

<!-- END SNIPPET -->

### 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`.

<!-- SNIPPET:feature_flag_resource_sample.feature_flag_resource_loading -->

```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"])
```

<!-- END SNIPPET -->

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.

<!-- SNIPPET:feature_flag_resource_sample.feature_flag_resource_selector -->

```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"])
```

<!-- END SNIPPET -->

## 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.
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should move this to it's own file, or a readme inside the testing folder.


(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=<connection string of your App Configuration store, if you have one>
APPCONFIGURATION_ENDPOINT_STRING=<endpoint of your App Configuration store, e.g. https://<your-store>.azconfig.io>
APPCONFIGURATION_KEY_VAULT_REFERENCE=<a Key Vault secret URI used by Key Vault reference tests>
APPCONFIGURATION_KEY_VAULT_REFERENCE2=<a second Key Vault secret URI used by Key Vault reference tests>
APPCONFIGURATION_KEYVAULT_SECRET_URL=<a Key Vault secret URI used by Key Vault reference tests>
APPCONFIGURATION_KEYVAULT_SECRET_URL2=<a second Key Vault secret URI used by Key Vault reference tests>
```

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
)
from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
ConfigurationSetting,
FeatureFlag,
FeatureFlagConfigurationSetting,
SecretReferenceConfigurationSetting,
)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Comment on lines +157 to +160
self._feature_flag_selectors, headers=headers, **kwargs
)

# Default to existing settings if no refresh occurred
processed_settings = self._dict

Expand All @@ -157,20 +170,24 @@ 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
# Update the watch keys that have changed
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()
Comment on lines +190 to 191
except AzureError as e:
logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint)
Expand Down Expand Up @@ -278,14 +295,22 @@ 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(
self._feature_flag_selectors,
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:
Expand All @@ -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)
Expand Down
Loading
Loading