Skip to main content

Deterministic, multi-source configuration management with validation, provenance tracking, and hot-reloading

Project description

Layer

CI PyPI version Python versions Docs License: MIT

Deterministic, multi-source configuration with validation, provenance tracking, and hot-reloading.

Layer is a Python library for applications that pull config from multiple places (files, environment variables, AWS SSM, HashiCorp Vault, etc.) and need to merge them reliably, validate them explicitly, and understand exactly where every value came from.

load() never validates. validate() never loads. The result is a typed, frozen, thread-safe object where every field knows which provider set it and what the value was before.


Installation

pip install layerconf

# Optional providers
pip install layerconf[watch]   # FileProvider(watch=True) hot-reload via watchdog
pip install layerconf[dotenv]  # EnvProvider(env_file=".env") support
pip install layerconf[aws]     # SSMProvider via boto3
pip install layerconf[vault]   # VaultProvider via hvac
pip install layerconf[etcd]    # EtcdProvider via etcd3

Quick Start

from layer import layerclass, field, ConfigPipeline, require, is_port
from layer.providers import FileProvider, EnvProvider

@layerclass
class AppConfig:
    host:        str = field(str, default="localhost")
    port:        int = field(int, default=8080, prod=[require, is_port])
    timeout_ms:  int = field(int, default=5000)
    db_password: str = field(str, default=None, secret=True)

pipeline = (
    ConfigPipeline(AppConfig)
    .add_provider(FileProvider("config.yml"))
    .add_provider(EnvProvider("APP"))           # APP_HOST, APP_PORT, etc. win over file
)

config = pipeline.load()                        # ingest → merge → resolve → freeze
pipeline.validate(["prod"]).raise_if_invalid()  # explicit, never inside load()

print(config.source_of("host"))   # "env:APP_HOST", "config.yml", or "default"

Provenance Tracking

Every field records where its value came from. Useful when something is wrong and you need to know which of your five providers is responsible.

config.source_of("host")
# "env:APP_HOST"

config.source_history_of("host")
# [
#   SourceEntry(source="default",      value="localhost"),
#   SourceEntry(source="config.yml",   value="db.internal"),
#   SourceEntry(source="env:APP_HOST", value="db.prod.example.com"),
# ]

config.explain()
# [
#   {"field": "host",        "value": "db.prod.example.com", "source": "env:APP_HOST", ...},
#   {"field": "port",        "value": 5432,                  "source": "config.yml",   ...},
#   {"field": "db_password", "value": "***",                 "source": "env:APP_...",  ...},
# ]

explain() redacts secret=True fields by default. to_dict() defaults to no redaction since it's typically used for serialization — pass redact=True explicitly when using it for logging or display.

When you set a field programmatically, you can tag the source for full traceability:

config.set("database.host", "db-failover.internal", source="failover-handler")
config.source_of("database.host")   # "failover-handler"

Nested Configs and Interpolation

Nest @layerclass instances as typed fields. ${...} interpolation resolves across the full tree using dot-paths, after all providers have been merged.

@layerclass
class DatabaseConfig:
    host: str = field(str, default="localhost")
    port: int = field(int, default=5432)
    name: str = field(str, default="myapp")

@layerclass
class AppConfig:
    database: DatabaseConfig = field(DatabaseConfig, default=None)
    dsn: str = field(str, default="postgresql://${database.host}:${database.port}/${database.name}")

Dot-notation works everywhere: config.get("database.host"), on_change("database.host", cb), LayerRule overrides in add_provider().


Validation

Categories

Attach validators to named categories. Only the categories you request are checked. Bare (uncategorized) validators always run.

@layerclass
class DBConfig:
    host:     str = field(str, default="localhost")
    port:     int = field(int, is_port, default=5432)       # bare — always runs
    ssl_cert: str = field(str, default=None,
                          prod=[require, path_exists],       # only in prod
                          dev=[optional])
    password: str = field(str, default=None,
                          prod=[require],
                          secret=True)
pipeline.validate(["prod"]).raise_if_invalid()   # prod rules + bare
pipeline.validate([]).raise_if_invalid()         # bare only
pipeline.validate("*").raise_if_invalid()        # every category + bare

# Validate specific fields only
pipeline.validate(["prod"], fields=["ssl_cert", "password"]).raise_if_invalid()

Built-in Validators

from layer import (
    require, optional, not_empty, one_of, in_range, is_port, is_url,
    is_positive, regex, min_length, max_length, path_exists, instance_of,
    each_item, requires_if, requires_any, requires_all,
    mutually_exclusive, depends_on,
)
Validator What it checks
require value is not None
not_empty not None, "", [], or {}
optional always passes (documents that None is intentional)
one_of("a", "b") value is in the given set
in_range(lo, hi) numeric value within [lo, hi]
is_port integer in 1–65535
is_url starts with http:// or https://
is_positive numeric value > 0
regex(pattern) string matches the regex
min_length(n) / max_length(n) string/list length
path_exists path exists on the filesystem
each_item(validator) applies any validator to every list item
requires_if("other", value) this field is None when other == value
requires_any("a", "b", ...) all listed fields are None
mutually_exclusive("a", "b", ...) more than one listed field is set
depends_on("a", "b", ...) this field is set but a dependency is None

Custom Validators

Any callable (value, field_name, config) -> True | raise ValidationError. The config argument gives access to the full object, so cross-field checks are straightforward:

from layer import ValidationError

def no_localhost(value, field_name, config):
    if value and "localhost" in value:
        raise ValidationError(field_name, "localhost not allowed in production", "no_localhost", "prod")
    return True

@layerclass
class ServerConfig:
    endpoint: str = field(str, default=None, prod=[require, is_url, no_localhost])

For checks that need self or span multiple fields, use @validator and @root_validator:

from layer import validator, root_validator, ValidationError, ConfigError

@layerclass
class TLSConfig:
    cert_path: str = field(str, default=None)
    key_path:  str = field(str, default=None)

    @validator("cert_path", "key_path", categories=["prod"])
    def _files_exist(self, field_name, value):
        if value and not os.path.exists(value):
            raise ValidationError(field_name, f"File not found: {value}", "file_check", "prod")

    @root_validator(categories=["prod"])
    def _cert_and_key_together(self):
        if bool(self.cert_path) != bool(self.key_path):
            raise ConfigError("cert_path and key_path must be set together")

Parsers

Parsers transform a field's value during loading, separate from validation. By default they run after type coercion; pass before_coerce=True when you need to clean a raw string before int() or similar is called.

from layer import parser

@layerclass
class PaymentConfig:
    amount_cents: int = field(int, default=0)
    endpoint:     str = field(str, default=None)

    @parser("amount_cents", before_coerce=True)
    def _clean_amount(self, value):
        """Strip currency symbols before int() is called."""
        if isinstance(value, str):
            return value.strip().lstrip("$€£").replace(",", "")
        return value

    @parser("endpoint")
    def _normalize_endpoint(self, value):
        """Trim and remove trailing slashes after coercion."""
        if isinstance(value, str):
            return value.strip().rstrip("/")
        return value

Providers and Layering

Built-in Providers

Provider Source
FileProvider(path, watch=False, required=True) YAML, JSON, or TOML file
EnvProvider(prefix, env_file=None) Environment variables and .env files
SSMProvider(path_prefix) AWS SSM Parameter Store
VaultProvider(secret_path, url, token) HashiCorp Vault KV v2
EtcdProvider(prefix, host, port) Etcd cluster

Providers are applied in order. Later providers override earlier ones by default.

Layering Rules

Control how specific fields are merged per provider:

from layer import LayerRule

pipeline.add_provider(
    FileProvider(str(home_dir / ".mycli/config.toml"), required=False),
    rules={
        "plugins":       LayerRule.APPEND,    # append to existing list
        "feature_flags": LayerRule.MERGE,     # union with existing dict
        "log_level":     LayerRule.PRESERVE,  # keep the first value set
    }
)

Available rules: OVERRIDE (default), PRESERVE, MERGE (dicts), APPEND (lists). Dot-notation works for nested fields: {"database.port": LayerRule.PRESERVE}.

Custom Providers

from layer.providers import BaseProvider

class RedisProvider(BaseProvider):
    def __init__(self, redis_client, key: str):
        self._client = redis_client
        self._key = key

    def read(self) -> dict:
        raw = self._client.get(self._key)
        return json.loads(raw) if raw else {}

    @property
    def source_name(self) -> str:
        return f"redis:{self._key}"

Any BaseProvider can be used for polling remote sources (KV stores, S3, feature flag APIs). Call pipeline._reload() on a timer to pull changes without file watching.


Hot Reloading

pipeline = (
    ConfigPipeline(AppConfig)
    .add_provider(FileProvider("config.yml", watch=True))
    .on_change("log_level",     lambda field, old, new, shadow: reconfigure_logging(new))
    .on_change("database.host", lambda field, old, new, shadow: reconnect_db(new))
)
config = pipeline.load()
pipeline.start()   # starts background watchdog thread

Fields marked reloadable=False are locked to their startup value and skipped on reload:

@layerclass
class DBConfig:
    dsn:       str = field(str, default=None, reloadable=False)  # locked at startup
    pool_size: int = field(int, default=5)                       # reloads freely

Computed Fields

@computed_field exposes a method as a read-only property. It's evaluated on every access and appears in to_dict() and explain().

@layerclass
class WorkerConfig:
    worker_ids: list = field(list, default=None)

    @computed_field
    def worker_count(self) -> int:
        """Number of active workers."""
        return len(self.worker_ids) if self.worker_ids else 0

Aliases and Field Options

Fields accept alias and aliases to map external key names (camelCase, kebab-case, etc.) to your Python field names:

@layerclass
class APIConfig:
    api_key:  str = field(str, default=None, secret=True,  alias="apiKey")
    base_url: str = field(str, default=None, aliases=["baseUrl", "base-url"])
    port:     int = field(int, default=8080, env="SERVICE_PORT")  # explicit env var name

to_dict(by_alias=True) exports using alias names — useful when serializing back to a format that expects camelCase.


Observability

Pipeline Observer

import logging
pipeline = ConfigPipeline(AppConfig, logger=logging.getLogger("myapp"))

Or subclass BasePipelineObserver for custom metrics/alerting:

from layer import BasePipelineObserver

class DatadogObserver(BasePipelineObserver):
    def on_hot_reload_triggered(self, diffs):
        statsd.increment("config.reload", tags=[f"fields:{len(diffs)}"])

    def on_hot_reload_locked(self, field):
        # A reloadable=False field changed — may need a restart
        alert.warning(f"Locked config field '{field}' changed; restart required")

pipeline = ConfigPipeline(AppConfig, observer=DatadogObserver())

Available hooks: on_provider_read, on_coercion_error, on_layer_merged, on_hot_reload_triggered, on_hot_reload_locked.

Exporters

Generate deployment artifacts directly from your schema:

from layer import exporters

# .env template — field descriptions become comments, secrets get a placeholder
exporters.to_dotenv_template(AppConfig, prefix="APP")
# # Service hostname
# APP_HOST=localhost
# APP_PORT=8080
# APP_DB_PASSWORD=<secret>

# Kubernetes ConfigMap — secrets omitted with a comment
exporters.to_configmap(AppConfig, name="myapp-config")

# JSON Schema for documentation or external validation
schema = exporters.to_json_schema(AppConfig)

SolidifyMode

Controls how provider data is coerced into your schema:

Mode Unknown keys Type coercion errors
LAX silently ignored swallowed, raw value kept
STANDARD (default) silently ignored raises CoercionError
STRICT raises StructureError no coercion attempted
pipeline = ConfigPipeline(AppConfig, mode=SolidifyMode.STRICT)

Without a Pipeline

For scripts, tests, or one-off validation:

from layer import solidify_file, solidify_env

config = solidify_file("config.yml", AppConfig)
env_overlay = solidify_env("APP", AppConfig)
config.layer(env_overlay)
config.resolve()
config.validate(["prod"]).raise_if_invalid()
config.freeze()

Links


Made with ❤️

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

layerconf-0.1.0.tar.gz (132.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

layerconf-0.1.0-py3-none-any.whl (42.0 kB view details)

Uploaded Python 3

File details

Details for the file layerconf-0.1.0.tar.gz.

File metadata

  • Download URL: layerconf-0.1.0.tar.gz
  • Upload date:
  • Size: 132.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for layerconf-0.1.0.tar.gz
Algorithm Hash digest
SHA256 de6d27f343b1d210c40ac7b245b79bd0b2d01913ed5ea4d30e4a5c451f8984a5
MD5 98c93535649f7a62e74a059b320a521e
BLAKE2b-256 8c16cf45505a953c427cc65b600c42b0464ee467d520076a36b8523bbc46a2f9

See more details on using hashes here.

Provenance

The following attestation bundles were made for layerconf-0.1.0.tar.gz:

Publisher: release.yml on AncientPatata/layer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file layerconf-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: layerconf-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 42.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for layerconf-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8c8d888a9122d5732244d2a4c26d3dc595183e2c969c60fa4f9cb7bf4a33b58e
MD5 24885777875d4cec986dfad4e3b13b6b
BLAKE2b-256 e4a951d9ab38bade6981d70738c5892ed30f6b2a57b98708d45ad347ef85551a

See more details on using hashes here.

Provenance

The following attestation bundles were made for layerconf-0.1.0-py3-none-any.whl:

Publisher: release.yml on AncientPatata/layer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page