Skip to content

ADR-0010: Unified Configuration via @configured and ContextConfig

Status: Accepted


Context

Before v2.0.0, Pico-IoC offered two parallel mechanisms for configuration binding: @configuration for flat sources and @configured for nested (tree) sources. Each required a different initialization argument (config vs tree_config), duplicating both user cognitive load and internal logic. Inconsistent ordering, normalization, and documentation made the developer experience error-prone.

Since adoption of v2.0.0 was still limited, we took the opportunity to introduce a breaking but cleaner unification, simplifying the configuration model for the long term.

Rationale for Value: During testing and real-world deployments, we observed recurring cases where a few configuration fields needed to be statically fixed regardless of the environment (e.g., embedded secrets in CI, non-configurable defaults, or deterministic test fixtures). The previous system required extra patching or post-processing. Introducing Annotated[..., Value(...)] provides an explicit, type-safe, and framework-agnostic mechanism to declare immutable constants inside @configured classes, ensuring reproducibility and clarity while still coexisting with dynamic configuration sources.


Decision

Configuration is unified around a single decorator (@configured) and a single runtime entry point (configuration(...) → ContextConfig → init(config=...)).

  1. Remove @configuration. Only @configured remains.

  2. Enhance @configured:

  3. Signature:

    @configured(prefix: str = "", mapping: Literal["auto","flat","tree"] = "auto")
    
    * Auto-detection rules:

    • If any field is a dataclass, list, dict, or Union → treat as tree.
    • If all fields are primitives (str, int, float, bool) → treat as flat.
    • Explicit mapping always overrides auto-detection.
  4. Introduce configuration(...)ContextConfig:

  5. Accepts an ordered list of configuration sources (environment, YAML, JSON, dicts, CLI adapters, etc.).

  6. Supports overrides and explicit values for final patching.
  7. Defines deterministic precedence: Value(...) > overrides > sources (left to right).

  8. Normalize casing and key mapping:

  9. Internal attributes use snake_case.

  10. Environment variables use UPPER_CASE.
  11. Tree mapping uses __ as a path separator (e.g., APP_DB__HOST).
  12. Flat mapping uses PREFIX_FIELD.

  13. Unify coercion and discriminator logic across flat and tree modes via config_runtime.

  14. Add Inline Field Overrides via Annotated[..., Value(...)]:

  15. Value can be applied as metadata to any field to force a constant value.

  16. When present, it short-circuits lookup, bypassing all configuration sources.
  17. Used for secrets templating in tests, static defaults, or environment-independent fallbacks.

  18. Single entry point:

init(modules=[...], config=ContextConfig(...))

Consequences

Positive

  • Cleaner Mental Model: One decorator (@configured) and one initialization path (configuration(...) → ContextConfig) for all configuration types.

  • Deterministic Configuration: Explicit, documented precedence across heterogeneous sources, with Value(...) providing the top-level override.

  • Inline Overrides: Annotated[..., Value(...)] offers a standard Pythonic way to hard-code constants or test values without relying on environment setup.

  • First-Class ENV Integration: Environment sources remain intuitive, with predictable normalization rules (APP_DB_URLapp.db.url).

  • Unified Mapping Strategy: Flat vs. tree behavior becomes a runtime mode, not a separate decorator, reducing conceptual duplication.

  • Improved Testability: The immutable, preprocessed ContextConfig makes configuration deterministic and easy to mock or inspect.

Negative

  • Breaking Change: Removes @configuration and the old config argument from init(). Users must migrate to the unified model.

  • Migration Effort: Existing classes must adopt @configured, update field names to match conventions, and use the configuration(...) builder.

  • Learning Curve: Developers must understand the mapping modes, ContextConfig, and the new Annotated[..., Value(...)] mechanism.

  • Convention Reliance: "auto" mode relies on consistent naming and type hints for accurate mapping inference.


Alternatives Considered

  • Keep both decorators: Rejected — duplicates logic and confuses users.
  • Force tree-mode only: Rejected — flat ENV/CLI setups would become cumbersome.
  • Detect mode by field name casing: Rejected — type-shape inference is more robust.

Implementation Sketch

  • Add ContextConfig and configuration(...) in config_builder.py.
  • Extend @configured to record prefix and mapping in PICO_META.
  • For flat mode: query normalized keys (PREFIX_FIELD).
  • For tree mode: deep-merge sources into a composite tree and instantiate via config_runtime.
  • Implement and document Value(...) and Discriminator(...) metadata in config_runtime.

Migration

  • Remove @configuration and the tree_config argument from init().
  • Replace existing usages with @configured.
  • Update documentation and provide examples demonstrating configuration(...) and Value(...).
  • Optional: provide a script to rewrite decorators automatically.

Examples

from dataclasses import dataclass
from typing import Annotated
from pico_ioc import configured, configuration, EnvSource, Value, init

@configured(prefix="APP_", mapping="auto")
@dataclass
class HttpCfg:
    host: str
    port: int
    debug: bool = False
    timeout: Annotated[int, Value(60)]  # Inline override

ctx = configuration(EnvSource(prefix=""))
c = init(modules=[__name__], config=ctx)
from dataclasses import dataclass
from typing import Annotated, Union
from pico_ioc import configured, configuration, EnvSource, Discriminator, init

@dataclass
class Postgres:
    kind: str
    host: str
    port: int

@dataclass
class Sqlite:
    kind: str
    path: str

@configured(prefix="DB_", mapping="tree")
@dataclass
class DbCfg:
    model: Annotated[Union[Postgres, Sqlite], Discriminator("kind")]

ctx = configuration(EnvSource(prefix=""))
c = init(modules=[__name__], config=ctx)