Skip to content

Configuration Management

ZodiacCore provides a robust utility for managing application settings using .ini files. It is designed to follow the "Base + Override" pattern, making it ideal for multi-environment deployments (Development, Testing, Production).

1. Core Concepts

Environment-Based Loading

The configuration system automatically detects your current environment (via environment variables) and loads files in a specific order:

  1. Base Config: Files like app.ini. These are loaded first.
  2. Environment Config: Files like app.production.ini. These are loaded second, overriding any matching keys from the base config.

Dot-Notation Access

Instead of using dictionary keys (e.g., config['db']['host']), ZodiacCore can convert your settings into a SimpleNamespace, allowing for cleaner dot-notation access (e.g., config.db.host).


2. Setting Up Your Config Folder

A typical production-ready configuration folder structure:

config/
├── app.ini             # Default settings (all environments)
├── app.develop.ini     # Local development overrides
├── app.testing.ini     # Test overrides
└── app.production.ini  # Production secrets/tuning

Loading the Config

You can use ConfigManagement to find the correct files and then load them using your preferred library (like configparser or dependency-injector).

from pathlib import Path
from zodiac_core.config import ConfigManagement

# 1. Get the list of files in correct loading order
config_dir = Path(__file__).parent / "config"
config_files = ConfigManagement.get_config_files(
    search_paths=[config_dir],
    env_var="APPLICATION_ENVIRONMENT",  # Default: APPLICATION_ENVIRONMENT
    default_env="production"            # Default fallback if env_var is missing
)

# For local app templates, you can override the fallback explicitly:
# ConfigManagement.get_config_files(search_paths=[config_dir], default_env="develop")

Integrating with dependency-injector

This is the default integration pattern used by the generated project template.

from pathlib import Path

from dependency_injector import containers, providers
from zodiac_core.config import ConfigManagement
from zodiac_core.utils import strtobool


class Container(containers.DeclarativeContainer):
    config = providers.Configuration(strict=True)

    @staticmethod
    def initialize():
        config_dir = Path(__file__).resolve().parent.parent / "config"
        config_files = ConfigManagement.get_config_files(
            search_paths=[config_dir],
            default_env="develop",
        )

        container = Container()
        for path in config_files:
            container.config.from_ini(path, required=True)

        return container


container = Container.initialize()
db_url = container.config.db.url()
db_echo = container.config.db.get("echo", as_=strtobool)

Testing Environment

testing is a first-class environment. A common pattern is:

  • Keep test overrides in config/app.testing.ini
  • Set APPLICATION_ENVIRONMENT=testing in your test bootstrap
  • Let ConfigManagement.get_config_files() load app.ini first, then app.testing.ini
import os

os.environ.setdefault("APPLICATION_ENVIRONMENT", "testing")

3. Configuration Objects

ZodiacCore provides two ways to access your configuration data using ConfigManagement.provide_config:

If you are using the generated template or dependency-injector, prefer providers.Configuration() plus from_ini(...) as your main path. provide_config() is mainly for projects that want ZodiacCore's config helpers without using DI.

Mode A: SimpleNamespace (Quick Access)

This mode is useful for rapid prototyping. It converts the dictionary into a SimpleNamespace, allowing for dot-notation access but without type hints or validation.

raw_data = {"db": {"host": "localhost", "port": 5432}}
config = ConfigManagement.provide_config(raw_data)

print(config.db.host)  # 'localhost'

For production applications, it is highly recommended to use a Pydantic model. This provides:

  1. Type Safety: Full IDE autocompletion and type checking.
  2. Validation: Runtime checks to ensure your configuration is valid.
  3. Defaults: Automatically fill in missing values defined in your schema.
from pydantic import BaseModel
from zodiac_core.config import ConfigManagement

class DbConfig(BaseModel):
    host: str
    port: int = 5432

class AppConfig(BaseModel):
    db: DbConfig

raw_data = {"db": {"host": "localhost"}}
# Pass the model class as the second argument
config = ConfigManagement.provide_config(raw_data, AppConfig)

print(config.db.host)  # 'localhost' (with IDE autocomplete!)
print(config.db.port)  # 5432 (default value applied)

4. Best Practices with dependency-injector

For the full providers.Configuration API, see the official documentation.

When using providers.Configuration from dependency-injector, follow these practices to catch configuration errors early and keep your code type-safe.

Strict Mode

Always enable strict=True on the Configuration provider. Without it, accessing an undefined config key silently returns None instead of raising an error — bugs surface at runtime instead of startup.

# Good — typo or missing key raises immediately
config = providers.Configuration(strict=True)

# Bad — config.db.hoost() silently returns None
config = providers.Configuration()

Required Config Files

Pass required=True to from_ini() for files that must exist. By default, missing files are silently ignored.

for path in config_files:
    container.config.from_ini(path, required=True)

Type Conversion

All values from .ini files are strings. Use the built-in helpers or Pydantic models for conversion:

Need Approach
Integer config.api.timeout.as_int()
Float config.api.ratio.as_float()
Bool config.db.echo.as_(strtobool)not as_(bool), since bool("false") is True
Custom config.pi.as_(Decimal)
Whole section ConfigManagement.provide_config(container.config.db(), DbConfig) — Pydantic handles all conversions

The Pydantic model approach is recommended for sections with multiple fields — it handles type coercion, validation, and defaults in one place, and you don't need to worry about strtobool or as_int().

Use StrictConfig as the base class instead of BaseModel. It adds extra='forbid' (rejects typo keys like ech0) and frozen=True (immutable after creation):

from zodiac_core.config import ConfigManagement, StrictConfig

class DbConfig(StrictConfig):
    url: str
    echo: bool = False      # Pydantic correctly parses "false" → False

db_cfg = ConfigManagement.provide_config(container.config.db(), DbConfig)
db.setup(database_url=db_cfg.url, echo=db_cfg.echo)

Environment Variable Interpolation

.ini files support ${ENV_VAR} and ${ENV_VAR:default} syntax for injecting secrets without hardcoding:

[db]
url = ${DATABASE_URL:sqlite+aiosqlite:///:memory:}
echo = false

To require that all referenced environment variables are defined (no silent empty substitution), pass envs_required=True:

container.config.from_ini(path, required=True, envs_required=True)

5. API Reference

StrictConfig

zodiac_core.config.StrictConfig

Bases: BaseModel

Base model for configuration sections.

Enforces two constraints: - extra='forbid': unknown keys (e.g. typos in .ini) raise ValidationError instead of being silently ignored. - frozen=True: config objects are immutable after creation.

Source code in zodiac_core/config.py
class StrictConfig(BaseModel):
    """Base model for configuration sections.

    Enforces two constraints:
    - ``extra='forbid'``: unknown keys (e.g. typos in .ini) raise ``ValidationError``
      instead of being silently ignored.
    - ``frozen=True``: config objects are immutable after creation.
    """

    model_config = ConfigDict(extra="forbid", frozen=True)

Environment Enum

zodiac_core.config.Environment

Bases: str, Enum

Supported application environments.

Source code in zodiac_core/config.py
class Environment(str, Enum):
    """
    Supported application environments.
    """

    DEVELOP = "develop"  # Local development
    TESTING = "testing"  # Testing environment
    STAGING = "staging"  # Staging environment (reserved)
    PRODUCTION = "production"  # Production environment

Configuration Management

zodiac_core.config.ConfigManagement

Configuration management utility for scanning, loading, and converting configuration files.

This utility is designed to work well with configuration loading patterns where subsequent files override previous ones (e.g., standard configparser or dependency-injector).

See: https://python-dependency-injector.ets-labs.org/providers/configuration.html

Source code in zodiac_core/config.py
class ConfigManagement:
    """
    Configuration management utility for scanning, loading, and converting configuration files.

    This utility is designed to work well with configuration loading patterns where
    subsequent files override previous ones (e.g., standard `configparser` or
    `dependency-injector`).

    See: https://python-dependency-injector.ets-labs.org/providers/configuration.html
    """

    @staticmethod
    def get_config_files(
        search_paths: List[Union[str, Path]],
        env_var: str = "APPLICATION_ENVIRONMENT",
        default_env: str = "production",
    ) -> List[str]:
        """
        Scans specified directories for configuration files and returns them in loading order.

        The scanning strategy follows these rules:
        1. Base Config: Files with at most one dot (e.g., 'app.ini'). Loaded first.
        2. Env Config: Files matching '{name}.{env}.ini' (e.g., 'app.develop.ini'). Loaded second.

        The returned order (Base -> Env) ensures that environment-specific settings
        override base settings when loaded by configuration providers.

        Files not matching the current environment but belonging to a known Environment enum
        are silently skipped. Files with unknown environment suffixes are logged as debug.

        Args:
            search_paths: List of directory paths to search. **REQUIRED**.
            env_var: Environment variable name to determine current environment.
            default_env: Fallback environment if env_var is not set.

        Returns:
            A list of absolute paths to configuration files, sorted by priority (Base -> Env).

        Example:
            ```python
            from pathlib import Path
            from zodiac_core import ConfigManagement

            # Resolve config path relative to your main application file
            config_dir = Path(__file__).parent / "config"
            files = ConfigManagement.get_config_files(search_paths=[config_dir])
            ```
        """

        target_env = os.environ.get(env_var, default_env).lower()
        base_files = []
        env_files = []

        # Normalize paths
        abs_paths = [Path(p).resolve() for p in search_paths if p]

        # Valid environments set for quick lookup
        valid_envs = {e.value for e in Environment}

        for config_dir in abs_paths:
            if not config_dir.exists():
                continue

            # Get all .ini files and sort them to ensure deterministic order
            ini_files = sorted(glob.glob(str(config_dir / "*.ini")))

            for file_path in ini_files:
                filename = os.path.basename(file_path)

                if ConfigManagement.__is_base_config_file(filename):
                    base_files.append(str(file_path))
                    continue

                # Rule 2: Env Config ({name}.{env}.ini)
                candidate_env = ConfigManagement.__get_configuration_env(filename)

                if candidate_env == target_env:
                    env_files.append(str(file_path))
                elif candidate_env in valid_envs:
                    # Valid environment file, but not for current environment. Skip silently.
                    pass
                else:
                    # Unknown environment or weird format. Log it.
                    logger.debug(f"Ignored config file (unknown env/format): {file_path}")

        return base_files + env_files

    @overload
    @staticmethod
    def provide_config(config: dict) -> SimpleNamespace: ...

    @overload
    @staticmethod
    def provide_config(config: dict, model: Type[T]) -> T: ...

    @staticmethod
    def provide_config(config: dict = None, model: Type[T] = None) -> Union[SimpleNamespace, T]:
        """
        Converts a configuration dictionary into a structured object.

        Supports two modes:
        1. **SimpleNamespace mode** (default): Returns a SimpleNamespace for dot notation access.
        2. **Pydantic model mode**: Pass a Pydantic model class to get type-safe, validated config.

        Args:
            config: The configuration dictionary to convert.
            model: Optional Pydantic model class. If provided, returns an instance of this model.

        Returns:
            SimpleNamespace if no model provided, otherwise an instance of the model.

        Example:
            ```python
            # Mode 1: SimpleNamespace (no type hints, but convenient)
            config = ConfigManagement.provide_config({"db": {"host": "localhost"}})
            print(config.db.host)  # "localhost"

            # Mode 2: Pydantic model (full type hints and validation)
            from pydantic import BaseModel

            class DbConfig(BaseModel):
                host: str
                port: int = 5432

            class AppConfig(BaseModel):
                db: DbConfig

            config = ConfigManagement.provide_config({"db": {"host": "localhost"}}, AppConfig)
            print(config.db.host)  # IDE autocomplete works!
            print(config.db.port)  # 5432 (default value)
            ```
        """
        if config is None:
            config = {}

        # Pydantic model mode: delegate to Pydantic for validation and type coercion
        if model is not None:
            return model(**config)

        # SimpleNamespace mode: recursive conversion
        def _convert(value):
            if isinstance(value, dict):
                return SimpleNamespace(**{k: _convert(v) for k, v in value.items()})
            elif isinstance(value, list):
                return [_convert(item) for item in value]
            return value

        return _convert(config)

    @staticmethod
    def __is_base_config_file(filename: str) -> bool:
        """
        Checks if a filename represents a base configuration file.

        Base files are identified by having one dot in their name.
        """
        return filename.count(".") == 1

    @staticmethod
    def __get_configuration_env(filename: str) -> str:
        """
        Extracts the environment name segment from a configuration filename.

        Expected format: {name}.{env}.ini
        """
        parts = filename.split(".")
        # Caller ensure we have at least 2 dots (split into >= 3 parts)
        # by checking __is_base_config_file first.
        return parts[-2].lower()
get_config_files(search_paths, env_var='APPLICATION_ENVIRONMENT', default_env='production') staticmethod

Scans specified directories for configuration files and returns them in loading order.

The scanning strategy follows these rules: 1. Base Config: Files with at most one dot (e.g., 'app.ini'). Loaded first. 2. Env Config: Files matching '{name}.{env}.ini' (e.g., 'app.develop.ini'). Loaded second.

The returned order (Base -> Env) ensures that environment-specific settings override base settings when loaded by configuration providers.

Files not matching the current environment but belonging to a known Environment enum are silently skipped. Files with unknown environment suffixes are logged as debug.

Parameters:

Name Type Description Default
search_paths List[Union[str, Path]]

List of directory paths to search. REQUIRED.

required
env_var str

Environment variable name to determine current environment.

'APPLICATION_ENVIRONMENT'
default_env str

Fallback environment if env_var is not set.

'production'

Returns:

Type Description
List[str]

A list of absolute paths to configuration files, sorted by priority (Base -> Env).

Example
from pathlib import Path
from zodiac_core import ConfigManagement

# Resolve config path relative to your main application file
config_dir = Path(__file__).parent / "config"
files = ConfigManagement.get_config_files(search_paths=[config_dir])
Source code in zodiac_core/config.py
@staticmethod
def get_config_files(
    search_paths: List[Union[str, Path]],
    env_var: str = "APPLICATION_ENVIRONMENT",
    default_env: str = "production",
) -> List[str]:
    """
    Scans specified directories for configuration files and returns them in loading order.

    The scanning strategy follows these rules:
    1. Base Config: Files with at most one dot (e.g., 'app.ini'). Loaded first.
    2. Env Config: Files matching '{name}.{env}.ini' (e.g., 'app.develop.ini'). Loaded second.

    The returned order (Base -> Env) ensures that environment-specific settings
    override base settings when loaded by configuration providers.

    Files not matching the current environment but belonging to a known Environment enum
    are silently skipped. Files with unknown environment suffixes are logged as debug.

    Args:
        search_paths: List of directory paths to search. **REQUIRED**.
        env_var: Environment variable name to determine current environment.
        default_env: Fallback environment if env_var is not set.

    Returns:
        A list of absolute paths to configuration files, sorted by priority (Base -> Env).

    Example:
        ```python
        from pathlib import Path
        from zodiac_core import ConfigManagement

        # Resolve config path relative to your main application file
        config_dir = Path(__file__).parent / "config"
        files = ConfigManagement.get_config_files(search_paths=[config_dir])
        ```
    """

    target_env = os.environ.get(env_var, default_env).lower()
    base_files = []
    env_files = []

    # Normalize paths
    abs_paths = [Path(p).resolve() for p in search_paths if p]

    # Valid environments set for quick lookup
    valid_envs = {e.value for e in Environment}

    for config_dir in abs_paths:
        if not config_dir.exists():
            continue

        # Get all .ini files and sort them to ensure deterministic order
        ini_files = sorted(glob.glob(str(config_dir / "*.ini")))

        for file_path in ini_files:
            filename = os.path.basename(file_path)

            if ConfigManagement.__is_base_config_file(filename):
                base_files.append(str(file_path))
                continue

            # Rule 2: Env Config ({name}.{env}.ini)
            candidate_env = ConfigManagement.__get_configuration_env(filename)

            if candidate_env == target_env:
                env_files.append(str(file_path))
            elif candidate_env in valid_envs:
                # Valid environment file, but not for current environment. Skip silently.
                pass
            else:
                # Unknown environment or weird format. Log it.
                logger.debug(f"Ignored config file (unknown env/format): {file_path}")

    return base_files + env_files
provide_config(config=None, model=None) staticmethod
provide_config(config: dict) -> SimpleNamespace
provide_config(config: dict, model: Type[T]) -> T

Converts a configuration dictionary into a structured object.

Supports two modes: 1. SimpleNamespace mode (default): Returns a SimpleNamespace for dot notation access. 2. Pydantic model mode: Pass a Pydantic model class to get type-safe, validated config.

Parameters:

Name Type Description Default
config dict

The configuration dictionary to convert.

None
model Type[T]

Optional Pydantic model class. If provided, returns an instance of this model.

None

Returns:

Type Description
Union[SimpleNamespace, T]

SimpleNamespace if no model provided, otherwise an instance of the model.

Example
# Mode 1: SimpleNamespace (no type hints, but convenient)
config = ConfigManagement.provide_config({"db": {"host": "localhost"}})
print(config.db.host)  # "localhost"

# Mode 2: Pydantic model (full type hints and validation)
from pydantic import BaseModel

class DbConfig(BaseModel):
    host: str
    port: int = 5432

class AppConfig(BaseModel):
    db: DbConfig

config = ConfigManagement.provide_config({"db": {"host": "localhost"}}, AppConfig)
print(config.db.host)  # IDE autocomplete works!
print(config.db.port)  # 5432 (default value)
Source code in zodiac_core/config.py
@staticmethod
def provide_config(config: dict = None, model: Type[T] = None) -> Union[SimpleNamespace, T]:
    """
    Converts a configuration dictionary into a structured object.

    Supports two modes:
    1. **SimpleNamespace mode** (default): Returns a SimpleNamespace for dot notation access.
    2. **Pydantic model mode**: Pass a Pydantic model class to get type-safe, validated config.

    Args:
        config: The configuration dictionary to convert.
        model: Optional Pydantic model class. If provided, returns an instance of this model.

    Returns:
        SimpleNamespace if no model provided, otherwise an instance of the model.

    Example:
        ```python
        # Mode 1: SimpleNamespace (no type hints, but convenient)
        config = ConfigManagement.provide_config({"db": {"host": "localhost"}})
        print(config.db.host)  # "localhost"

        # Mode 2: Pydantic model (full type hints and validation)
        from pydantic import BaseModel

        class DbConfig(BaseModel):
            host: str
            port: int = 5432

        class AppConfig(BaseModel):
            db: DbConfig

        config = ConfigManagement.provide_config({"db": {"host": "localhost"}}, AppConfig)
        print(config.db.host)  # IDE autocomplete works!
        print(config.db.port)  # 5432 (default value)
        ```
    """
    if config is None:
        config = {}

    # Pydantic model mode: delegate to Pydantic for validation and type coercion
    if model is not None:
        return model(**config)

    # SimpleNamespace mode: recursive conversion
    def _convert(value):
        if isinstance(value, dict):
            return SimpleNamespace(**{k: _convert(v) for k, v in value.items()})
        elif isinstance(value, list):
            return [_convert(item) for item in value]
        return value

    return _convert(config)