Skip to main content

axa-fr-app-settings

PyPI version Python versions License Typing Downloads Total downloads

A Python 3.10+ package designed for uv that provides a typed, chainable, .NET-like configuration builder.

The idea is to be able to write:

import os

from axa_fr_app_settings import ConfigurationBuilder, SettingsModel

class ApiSettings(SettingsModel):
    debug: bool = False
    http_timeout: int = 45

environment = os.getenv("PYTHON_ENVIRONMENT", "development")

settings = (
    ConfigurationBuilder(ApiSettings)
    .add_yaml_file("settings.yaml", optional=True)
    .add_yaml_file(f"settings.{environment}.yaml", optional=True)
    .add_json_file("settings.json", optional=True)
    .add_json_file(f"settings.{environment}.json", optional=True)
    .add_environment_variables(prefix="", nested_delimiter="__")
    .build()
)

What the package provides

  • Fluent API à la .NET ConfigurationBuilder
  • YAML as the default source
  • Override by order of source addition
  • Environment variable support with __ for nested keys
  • .env file support
  • Typed validation with Pydantic v2
  • Optional strict validation for unknown keys
  • Compatible with dict[str, SubModel], lists, booleans, integers, etc.
  • Legacy, literal, or model-aware key normalization for flat sources
  • Direct path access with config["section:key"]
  • Typed subsections with .get_section("...").get(MyModel)

Installation

With uv:

uv add axa-fr-app-settings

Locally for contributing:

uv sync --dev

Usage

1. Define typed models

from pydantic import Field

from axa_fr_app_settings import SettingsModel


class EndpointSettings(SettingsModel):
    name: str
    url: str


class RegionSettings(SettingsModel):
    name: str
    endpoints: list[EndpointSettings] = Field(default_factory=list)


class OIDCSettings(SettingsModel):
    endpoint_url: str
    issuer: str
    client_id: str
    client_secret: str | None = None
    private_key: str | None = None
    scopes: str


class DatabaseSettings(SettingsModel):
    endpoint_url: str


class CacheRedisSettings(SettingsModel):
    master: str = "mymaster"
    sentinels: str = "redis-ha:26379"
    expiry_time: int = 60


class CacheSettings(SettingsModel):
    type: str = "redis"
    redis: CacheRedisSettings = Field(default_factory=CacheRedisSettings)


class AppSettings(SettingsModel):
    database: dict[str, DatabaseSettings] = Field(default_factory=dict)
    llm_oidc: dict[str, OIDCSettings] = Field(default_factory=dict)
    debug: bool = False
    http_timeout: int = 45
    http_verify: bool = False
    cache: CacheSettings = Field(default_factory=CacheSettings)
    allowed_hosts: list[str] = Field(default_factory=list)
    regions: list[RegionSettings] = Field(default_factory=list)

By default, SettingsModel ignores keys that are not declared in the model. Use StrictSettingsModel when unknown keys must raise a Pydantic validation error:

from axa_fr_app_settings import StrictSettingsModel


class StrictDatabaseSettings(StrictSettingsModel):
    endpoint_url: str


class StrictAppSettings(StrictSettingsModel):
    database: StrictDatabaseSettings

Strictness belongs to each model. Every nested model that must reject unknown keys must therefore inherit from StrictSettingsModel as well.

2. Build the configuration

import os

from axa_fr_app_settings import ConfigurationBuilder

environment = os.getenv("PYTHON_ENVIRONMENT", "development")

settings = (
    ConfigurationBuilder(AppSettings)
    .add_yaml_file("settings.yaml", optional=True)
    .add_yaml_file(f"settings.{environment}.yaml", optional=True)
    .add_json_file("settings.json", optional=True)
    .add_json_file(f"settings.{environment}.json", optional=True)
    .add_env_file(".env", optional=True)
    .add_environment_variables(prefix="", nested_delimiter="__")
    .build()
)

3. Environment variable examples

export DEBUG=true
export HTTP_TIMEOUT=30
export CACHE__REDIS__EXPIRY_TIME=120
export DATABASE__main__ENDPOINT_URL="postgresql://localhost:5432/app"
export ALLOWED_HOSTS__0="api.local"
export ALLOWED_HOSTS__1="admin.local"
export REGIONS__0__NAME="eu-west"
export REGIONS__0__ENDPOINTS__0__NAME="catalog"
export REGIONS__0__ENDPOINTS__0__URL="https://eu-west/catalog"
export REGIONS__0__ENDPOINTS__1__NAME="orders"
export REGIONS__0__ENDPOINTS__1__URL="https://eu-west/orders"
export REGIONS__1__NAME="us-east"
export REGIONS__1__ENDPOINTS__0__NAME="catalog"
export REGIONS__1__ENDPOINTS__0__URL="https://us-east/catalog"

Flat-key normalization

Flat sources normalize key segments by default: they lowercase them and replace hyphens with underscores. This keeps the existing behavior, so youhou__uuu-Toto__youhou becomes youhou.uuu_toto.youhou.

Use key_normalization="preserve" to keep every segment exactly as written:

data = (
    ConfigurationBuilder(AppSettings)
    .add_environment_variables(
        environ={"youhou__uuu-Toto__youhou": "secret"},
        key_normalization="preserve",
    )
    .build_data()
)

The resulting mapping preserves uuu-Toto:

{"youhou": {"uuu-Toto": {"youhou": "secret"}}}

Use key_normalization="model" when static Pydantic field names must be matched case-insensitively while dynamic dictionary keys must be preserved. For example, LLM_OIDC__gpt-4o__CLIENT_ID is converted to:

{"llm_oidc": {"gpt-4o": {"client_id": "secret"}}}

The builder uses its settings model to distinguish fields from dictionary keys:

data = (
    ConfigurationBuilder(AppSettings)
    .add_environment_variables(
        environ={
            "LLM_OIDC__gpt-4o__CLIENT_ID": "secret",
            "PATH": "/usr/bin",
        },
        key_normalization="model",
        ignore_unknown_environment_variables=True,
    )
    .build_data()
)

ignore_unknown_environment_variables=True filters environment variables whose root segment does not match a settings field. Unknown segments below a known root are kept so that strict models can still report configuration mistakes.

By default, model normalization preserves dynamic dictionary keys. Set dynamic_key_case="lower" when environment keys must merge case-insensitively with lowercase keys coming from files:

settings = (
    ConfigurationBuilder(AppSettings)
    .add_json_file("settings.json", optional=True)
    .add_environment_variables(
        key_normalization="model",
        dynamic_key_case="lower",
        ignore_unknown_environment_variables=True,
    )
    .build()
)

LLM_OIDC__SMARTGUIDE__CLIENT_ID then targets llm_oidc["smartguide"], while LLM_OIDC__gpt-4o__CLIENT_ID still targets llm_oidc["gpt-4o"].

case_sensitive only applies to the legacy strategy. The __ delimiter remains structural. Model and preserve normalization are also supported by .add_env_file().

Custom providers can reuse the public conversion function:

from axa_fr_app_settings import mapping_from_flat_items

nested = mapping_from_flat_items(
    {"LLM_OIDC__gpt-4o__CLIENT_ID": "secret"},
    key_normalization="model",
    settings_type=AppSettings,
)

0.4.2 migration: preserve_keys=True has been replaced by key_normalization="preserve" in 0.4.3. Calls supported by 0.4.1 keep their historical behavior.

4. YAML example

debug: false
http_timeout: 45
allowed_hosts:
  - api.local
  - admin.local

database:
  main:
    endpoint_url: "postgresql://localhost:5432/app"

regions:
  - name: eu-west
    endpoints:
      - name: catalog
        url: "https://eu-west/catalog"
      - name: orders
        url: "https://eu-west/orders"
  - name: us-east
    endpoints:
      - name: catalog
        url: "https://us-east/catalog"

cache:
  type: redis
  redis:
    master: mymaster
    sentinels: redis-ha:26379
    expiry_time: 60

5. JSON example

{
  "debug": false,
  "http_timeout": 45,
  "allowed_hosts": ["api.local", "admin.local"],
  "database": {
    "main": {
      "endpoint_url": "postgresql://localhost:5432/app"
    }
  },
  "regions": [
    {
      "name": "eu-west",
      "endpoints": [
        {
          "name": "catalog",
          "url": "https://eu-west/catalog"
        },
        {
          "name": "orders",
          "url": "https://eu-west/orders"
        }
      ]
    },
    {
      "name": "us-east",
      "endpoints": [
        {
          "name": "catalog",
          "url": "https://us-east/catalog"
        }
      ]
    }
  ],
  "cache": {
    "type": "redis",
    "redis": {
      "master": "mymaster",
      "sentinels": "redis-ha:26379",
      "expiry_time": 60
    }
  }
}

YAML and JSON sources can be mixed freely. The last source added always wins.

Arrays and nested arrays

Arrays are supported in file sources (yaml, json) and also in flat sources like environment variables and .env files.

Simple array

allowed_hosts:
  - api.local
  - admin.local

Nested array with regions

regions:
  - name: eu-west
    endpoints:
      - name: catalog
        url: https://eu-west/catalog
      - name: orders
        url: https://eu-west/orders
  - name: us-east
    endpoints:
      - name: catalog
        url: https://us-east/catalog

Equivalent environment variables:

export REGIONS__0__NAME="eu-west"
export REGIONS__0__ENDPOINTS__0__NAME="catalog"
export REGIONS__0__ENDPOINTS__0__URL="https://eu-west/catalog"
export REGIONS__0__ENDPOINTS__1__NAME="orders"
export REGIONS__0__ENDPOINTS__1__URL="https://eu-west/orders"
export REGIONS__1__NAME="us-east"
export REGIONS__1__ENDPOINTS__0__NAME="catalog"
export REGIONS__1__ENDPOINTS__0__URL="https://us-east/catalog"

Priority order

As in .NET, the last source added wins.

Example:

settings = (
    ConfigurationBuilder(AppSettings)
    .add_yaml_file("settings.yaml", optional=True)
    .add_yaml_file("settings.production.yaml", optional=True)
    .add_environment_variables()
    .build()
)

Here:

  1. settings.yaml loads the base values
  2. settings.production.yaml overrides them
  3. environment variables override everything

Available API

Method Description
add_yaml_file(path, *, optional=False, encoding="utf-8", reload_on_change=False) Add a YAML file source
add_json_file(path, *, optional=False, encoding="utf-8", reload_on_change=False) Add a JSON file source
add_env_file(path=".env", *, optional=False, prefix="", nested_delimiter="__", case_sensitive=False, key_normalization="legacy", dynamic_key_case="preserve", parse_values=True, reload_on_change=False) Add a .env file source
add_environment_variables(*, prefix="", nested_delimiter="__", case_sensitive=False, key_normalization="legacy", dynamic_key_case="preserve", ignore_unknown_environment_variables=False, parse_values=True) Add environment variables
add_in_memory_collection(data) Add an in-memory dict
add_source(source) Add a custom source (any object with a load() method)
mapping_from_flat_items(items, *, prefix="", nested_delimiter="__", case_sensitive=False, key_normalization="legacy", dynamic_key_case="preserve", settings_type=None, ignore_unknown=False, parse_values=True) Convert flat items into a nested mapping
build() Build and return the validated settings model
build_data() Build and return the raw merged dict
build_configuration() Build and return a navigable configuration root
build_watched(*, debounce_seconds=0.3, polling_interval_seconds=None) Build and return a SettingsWatcher with auto-reload

Key parameters:

Parameter Default Description
optional False When True, the source is silently skipped if the file does not exist. When False (default), a FileNotFoundError is raised.
case_sensitive False In legacy mode, preserve case while still replacing hyphens with underscores.
key_normalization "legacy" Use "legacy" for 0.4.1 behavior, "preserve" for literal segments, or "model" for case-insensitive Pydantic fields and exact dictionary keys.
dynamic_key_case "preserve" In model mode, use "lower" to lowercase dynamic dictionary keys before merging sources.
ignore_unknown_environment_variables False In model mode, ignore variables whose root segment is not a declared settings field.
reload_on_change False When True, the file is watched for changes and the configuration is automatically rebuilt when modified (requires watchdog, see below). When False (default), the file is read once at build time.
polling_interval_seconds None When set to a number of seconds, build_watched() will periodically rebuild the whole configuration at that interval. Useful for non-file sources (Key Vault, databases…) that cannot be watched with watchdog. None (default) disables polling.

reloadOnChange – Auto-reload on file change

Like .NET's reloadOnChange: true, you can watch configuration files for changes and automatically rebuild the settings.

Installation

The file-watching feature requires the watchdog package (optional dependency):

uv add axa-fr-app-settings[watch]

Usage

import os

from axa_fr_app_settings import ConfigurationBuilder

environment = os.getenv("PYTHON_ENVIRONMENT", "development")

watcher = (
    ConfigurationBuilder(AppSettings)
    .add_yaml_file("settings.yaml", optional=True, reload_on_change=True)
    .add_yaml_file(f"settings.{environment}.yaml", optional=True, reload_on_change=True)
    .add_json_file("settings.json", optional=True, reload_on_change=True)
    .add_environment_variables(prefix="", nested_delimiter="__")
    .build_watched()              # ← returns a SettingsWatcher instead of a model
)

# Register a callback (like IOptionsMonitor.OnChange in .NET)
watcher.on_change(lambda s: print(f"Settings reloaded! debug={s.debug}"))

# Access the latest settings at any time (thread-safe)
print(watcher.settings.debug)

# Use as a context manager (starts/stops the file watcher)
with watcher:
    ...  # watcher.settings is always up-to-date

# Or start/stop manually
watcher.start()
# ...
watcher.stop()

A complete example is available in examples/reload_on_change_example.py.

Custom Source / Provider (e.g. Azure Key Vault)

Like .NET's IConfigurationSource / IConfigurationProvider, you can create your own configuration source and plug it into the builder via add_source().

Any object that implements a load() -> Mapping[str, Any] method satisfies the SettingsSource protocol:

from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any

from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient


@dataclass
class KeyVaultSource:
    """Fetches secrets from Azure Key Vault and maps them to config keys."""

    vault_url: str
    secret_mapping: dict[str, str] = field(default_factory=dict)

    def load(self) -> Mapping[str, Any]:
        credential = DefaultAzureCredential()
        client = SecretClient(vault_url=self.vault_url, credential=credential)

        result: dict[str, Any] = {}
        for secret_name, config_key in self.secret_mapping.items():
            secret = client.get_secret(secret_name)
            if secret.value is None:
                continue
            # Convert "database__main__password" → nested dict
            parts = config_key.split("__")
            current = result
            for part in parts[:-1]:
                current = current.setdefault(part, {})
            current[parts[-1]] = secret.value

        return result

Static build (one-shot)

settings = (
    ConfigurationBuilder(AppSettings)
    .add_yaml_file("settings.yaml", optional=True)
    .add_source(
        KeyVaultSource(
            vault_url="https://my-vault.vault.azure.net/",
            secret_mapping={
                "db-password": "database__main__password",
                "api-key": "secret_api_key",
            },
        )
    )
    .add_environment_variables(prefix="", nested_delimiter="__")
    .build()
)

With watcher – periodic secret refresh (polling)

Since a Key Vault is not a file, reload_on_change cannot watch it. Instead, use polling_interval_seconds to rebuild the configuration at a regular interval. File sources with reload_on_change=True are still watched instantly; the two mechanisms work together:

keyvault = KeyVaultSource(
    vault_url="https://my-vault.vault.azure.net/",
    secret_mapping={
        "db-password": "database__main__password",
        "api-key": "secret_api_key",
    },
)

watcher = (
    ConfigurationBuilder(AppSettings)
    .add_yaml_file("settings.yaml", optional=True, reload_on_change=True)
    .add_source(keyvault)
    .add_environment_variables(prefix="", nested_delimiter="__")
    .build_watched(
        polling_interval_seconds=300,  # re-fetch secrets every 5 min
    )
)

watcher.on_change(lambda s: print("Secrets refreshed!", s.secret_api_key))

with watcher:
    ...  # watcher.settings always holds the latest values

The Key Vault source (or any custom source) follows the same priority rule: the last source added wins.

A complete example is available in examples/custom_keyvault_source.py.

Direct key access and typed sections

Like in .NET, you can also work with the raw merged configuration tree. This is useful when you want direct path access or when you only want to bind one subsection.

from pydantic import Field

from axa_fr_app_settings import ConfigurationBuilder, SettingsModel


class EndpointSettings(SettingsModel):
    name: str
    url: str


class RegionSettings(SettingsModel):
    name: str
    endpoints: list[EndpointSettings] = Field(default_factory=list)


class AppSettings(SettingsModel):
    application_name: str
    max_users: int
    feature_toggle: bool = False
    allowed_hosts: list[str] = Field(default_factory=list)
    regions: list[RegionSettings] = Field(default_factory=list)


class RootSettings(SettingsModel):
    appsettings: AppSettings


config = (
    ConfigurationBuilder(RootSettings)
    .add_json_file("appsettings.json")
    .build_configuration()
)

app_name = config["appsettings:application_name"]
max_users = config["appsettings:max_users"]
first_region = config["appsettings:regions:0:name"]
first_endpoint_url = config["appsettings:regions:0:endpoints:0:url"]

app_settings = config.get_section("appsettings").get(AppSettings)
print(f"FeatureToggle: {app_settings.feature_toggle}")
print(f"Application Name: {app_settings.application_name}")
print(f"Max Users: {app_settings.max_users}")
print(f"First Region: {first_region}")
print(f"First Endpoint URL: {first_endpoint_url}")

You can also bind the full root model directly:

root_settings = config.bind()

For environment variables or .env files, use numeric indexes with __:

export APPSETTINGS__ALLOWED_HOSTS__0=api.local
export APPSETTINGS__ALLOWED_HOSTS__1=admin.local
export APPSETTINGS__REGIONS__0__NAME="eu-west"
export APPSETTINGS__REGIONS__0__ENDPOINTS__0__NAME="catalog"
export APPSETTINGS__REGIONS__0__ENDPOINTS__0__URL="https://eu/catalog"

A complete runnable example is available in examples/configuration_sections.py.

Full example

Complete examples are provided in:

Publishing to PyPI with GitHub Actions

The repository includes two workflows:

Workflow File Trigger
CI .github/workflows/ci.yml push / PR on main
Publish .github/workflows/publish.yml every merge (push) on main

The publish workflow automatically:

  1. Runs lint + tests
  2. Builds the wheel and sdist with uv build
  3. Publishes to PyPI with uv publish

Setup

Add a PYPI_API_TOKEN secret in your GitHub repository settings:

Settings → Secrets and variables → Actions → New repository secret

Secret name Value
PYPI_API_TOKEN Your PyPI API token (starts with pypi-)

That's it — every merge to main will publish a new version automatically.

Development

uv sync --dev
uv run ruff check .
uv run pytest
uv build

License

MIT

Metadata

Release files for axa-fr-app-settings 0.4.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for axa-fr-app-settings 0.4.4
File Size Uploaded
axa_fr_app_settings-0.4.4.tar.gz 22.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for axa-fr-app-settings 0.4.4
File Interpreter ABI Platform
axa_fr_app_settings-0.4.4-py3-none-any.whl Python 3 none any Details

Total release size: 41.7 kB

Release files / axa_fr_app_settings-0.4.4.tar.gz

Download URL axa_fr_app_settings-0.4.4.tar.gz
Size 22.7 kB
Tags Source
SHA-256 checksum
How to use checksums
9653d8e4021f818396130552e59a1f7a0d084139d945420f7f0289226f2c0b89
BLAKE2b-256 checksum
How to use checksums
534265f4aafde0bec5163bb8c6ce7b4b2ba5597b9a607b54e513ccd05f0d7f8f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.33 {"installer":{"name":"uv","version":"0.11.33","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / axa_fr_app_settings-0.4.4-py3-none-any.whl

Download URL axa_fr_app_settings-0.4.4-py3-none-any.whl
Size 19.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f678f50de5f686a3030667c6ee77a37af5571964e797c236b2d30336117b66be
BLAKE2b-256 checksum
How to use checksums
8a172b9d695b8be97bdaeff98e3b6cf77fe9606b1f0e84756d8ba226e42ccf6a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.33 {"installer":{"name":"uv","version":"0.11.33","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.4.4 This release

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page