Skip to main content

Type-safe environment variable decoding and configuration resolution for Python

Project description

vcti-config

Type-safe environment variable decoding and configuration resolution for Python.

Overview

VCollab applications read configuration from environment variables, CLI arguments, and hardcoded defaults. Without a shared layer, every package reinvents env var parsing, the resolution priority (explicit > env > default), and error reporting — each with subtle differences.

vcti-config provides one declarative config class. You subclass Config, declare typed fields, and call .load(); it reads each value from a source, decodes it to the right type, applies your defaults and validators, and reports every problem at once instead of one per restart. A real app config is rarely flat, so a config can also compose other configs: always-loaded nested sections and discriminated one_of choices (an LLM adapter, a deployment target, …), all resolved in a single pass with merged errors. Loaded configs are immutable.

The package is built around four concepts:

  • Priority resolution (resolve_value) — pick the first available value from explicit > env > default.
  • Env decoding — extract a raw string from a ConfigSource and convert it to a typed value with a pure str -> T decoder.
  • Application config (Config) — declare a schema of typed fields and resolve them all at once.
  • Composition — nest configs (always-loaded sections, or one_of choices) and load the whole tree together.

It depends on one lightweight sibling, vcti-enum (used to decode one_of discriminators); it does not depend on pydantic.


Installation

pip install vcti-config

In requirements.txt

vcti-config>=4.0.0

In pyproject.toml dependencies

dependencies = [
    "vcti-config>=4.0.0",
]

Quick Start

Declare a configuration

from vcti.config import Config, Field

class AppConfig(Config):
    ENV_PREFIX = "MYAPP"                 # a bare token; the framework adds separators
    debug = Field.bool(default=False)
    port  = Field.int(default=8080, validator=lambda p: 1 <= p <= 65535)
    name  = Field.str(default="myapp")

config = AppConfig.load()                # reads os.environ by default
config.debug   # bool, from MYAPP_DEBUG
config.port    # int,  from MYAPP_PORT
config.name    # str,  from MYAPP_NAME

ENV_PREFIX is a bare token: a field reads ENV_PREFIX + _ + the upper-cased attribute (MYAPP_PORT). Migrating from the deprecated ConfigSchema? Drop the trailing underscore ("MYAPP_""MYAPP").

Test with overrides and a custom source

config = AppConfig.load(
    overrides={"port": 9999},            # already-typed; skips decoding
    source={"MYAPP_DEBUG": "true"},      # any dict works -- no monkeypatching
)

source is any ConfigSource — anything with get(name) -> str | None, so os.environ and a plain dict both work.

Handle all errors at once

from vcti.config import ConfigErrors

try:
    config = AppConfig.load(source={})
except ConfigErrors as exc:
    for err in exc.errors:               # every failure across the whole tree
        print(f"{err.field_name}: {err}")

Decode, read, and resolve standalone

from vcti.config import decode_int, decode_bool, read_env, resolve_value, MISSING

decode_int("8080")        # 8080
decode_bool("maybe")      # raises DecodeError

raw = read_env("PORT")    # str | None (stripped; None if unset/blank)
port = resolve_value(cli_port, raw and decode_int(raw), default=8080)
# MISSING -- not None -- is the skip sentinel, so None is a real value:
resolve_value(MISSING, "env", default="x")   # "env"
resolve_value(None, "env", default="x")       # None

Composition

A config can hold other configs. load() resolves the whole tree in one pass and merges every error.

import os
from enum import StrEnum
from vcti.config import Config, Field, one_of

class Adapter(StrEnum):
    CLAUDE_CODE = "claude-code"
    OPENAI = "openai"

class ClaudeCodeConfig(Config):
    KIND = Adapter.CLAUDE_CODE                # → MYAPP_CLAUDE_CODE__*
    model = Field.str(default="sonnet")

class OpenAiConfig(Config):
    KIND = Adapter.OPENAI                     # → MYAPP_OPENAI__*
    api_key = Field.secret()                  # a SecretStr (masks itself)
    model = Field.str(default="gpt-4o-mini")

class StorageConfig(Config):
    cache_dir = Field.path()

class AppConfig(Config):
    ENV_PREFIX = "MYAPP"
    storage = StorageConfig                   # always-loaded section → MYAPP_STORAGE__*
    adapter = one_of(Adapter, ClaudeCodeConfig, OpenAiConfig)  # discriminated choice

config = AppConfig.load(source=os.environ)
config.storage.cache_dir     # Path, from MYAPP_STORAGE__CACHE_DIR
config.adapter.api_key       # SecretStr, from MYAPP_OPENAI__API_KEY
config.adapter_kind          # Adapter.OPENAI -- the chosen member, typed
  • A nested section loads as an always-present sub-config, reached by the attribute name. Rename the env namespace without renaming the attribute via section(StorageConfig, env_name="STORAGE").
  • A one_of(Enum, *branches) picks exactly one branch at runtime. The discriminator env var is named for the attribute (adapterMYAPP_ADAPTER); each branch declares KIND = Enum.MEMBER; only the chosen branch loads; the choice is exposed (typed) as <name>_kind. Pick a branch in tests with load(context={"adapter": Adapter.OPENAI}).

Names are unique across the tree by construction (single _ at the top level, double __ at each nesting boundary), and collisions are caught when the classes are defined, not at runtime.


Secrets

Declare secrets with Field.secret(); the value is a SecretStr that masks itself everywhere except an explicit read:

config.adapter.api_key                      # SecretStr('***') in repr/str/logs
config.adapter.api_key.get_secret_value()   # the plaintext -- the only unwrap

A SecretStr is not JSON-serializable (it fails loudly rather than leaking), and the masking is intrinsic, so it holds through repr, str, f-strings, and logging. Masking is leak-prevention, not access control — load secrets from the environment or a secrets manager, never as hardcoded defaults.


Immutability

A loaded config is immutable; mutating it (at any depth) raises:

config.port = 1234          # AttributeError: ... immutable after load
config.adapter.model = "x"  # also raises -- the whole tree is frozen

Load a fresh config rather than mutating one. (This freezes the attribute bindings; a mutable value such as a list from a default_factory can still be changed in place, so prefer immutable defaults.)


Boolean Flag Values

True values False values
1, true, yes, on, enabled 0, false, no, off, disabled

All comparisons are case-insensitive. Custom sets can be passed via make_bool_decoder().


Field Types

Constructor Python type Decoder
Field.bool() bool decode_bool
Field.str() str decode_str
Field.int() int decode_int
Field.float() float decode_float
Field.path() Path decode_path
Field.json() Any decode_json
Field.secret() SecretStr decode_secret
Field(custom_fn) T Any Callable[[str], T]

All constructors accept: default, default_factory (for mutable defaults), validator, env_name (a full literal name that bypasses the prefix), and description. A field with neither default nor default_factory is required. None is a real default, distinct from "not provided."


Public API

Name Purpose
Config The everyday declarative config class with .load()
ConfigBase Boundary-agnostic core; subclass it for a custom naming scheme
ConfigSchema Deprecated flat class (legacy literal naming)
Field Typed field descriptor; Field.bool/int/float/str/path/json/secret
one_of, section Composition members (discriminated choice, nested section)
resolve_value Standalone priority-chain resolution
read_env Read + strip a name from a ConfigSource; None if unset/blank
ConfigSource Protocol: any source with get(name) returning a string or None
SecretStr Self-masking secret value (.get_secret_value() to read)
decode_bool/int/float/str/path/json/secret Pure str -> T decoders
make_bool_decoder Factory for custom boolean decoders
MISSING Sentinel for "no value provided" (distinct from None)
ConfigError, DecodeError, MissingFieldError, ValidationError, ConfigErrors Structured error hierarchy

Documentation

  • Design — the four concepts, architecture, and design decisions
  • Source Guide — file-by-file walkthrough and execution traces
  • API Reference — autodoc for all modules

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

vcti_config-4.0.0.tar.gz (32.3 kB view details)

Uploaded Source

Built Distribution

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

vcti_config-4.0.0-py3-none-any.whl (24.1 kB view details)

Uploaded Python 3

File details

Details for the file vcti_config-4.0.0.tar.gz.

File metadata

  • Download URL: vcti_config-4.0.0.tar.gz
  • Upload date:
  • Size: 32.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for vcti_config-4.0.0.tar.gz
Algorithm Hash digest
SHA256 156486021adf37f4b23f26fc4b15df89c8acab0e2e2d2042158780722844ce5f
MD5 32b9ba9bf059b3579556b8068a9cde03
BLAKE2b-256 6d84f4908f79f09510d464ea9651b3c5d03102922a8d6d2d0491562808449118

See more details on using hashes here.

Provenance

The following attestation bundles were made for vcti_config-4.0.0.tar.gz:

Publisher: publish.yml on vcollab/vcti-python-config

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

File details

Details for the file vcti_config-4.0.0-py3-none-any.whl.

File metadata

  • Download URL: vcti_config-4.0.0-py3-none-any.whl
  • Upload date:
  • Size: 24.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for vcti_config-4.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ff3a1043ce5d4efc40d87b6022106d3779899222483b7c9856384e7606295ae0
MD5 f1247c5dc1d7d91f1845a626a9d88488
BLAKE2b-256 e91d65d3aa62e8e787d31fc141b9afa20ed9a28e8fbfbb3251043bceaf318da8

See more details on using hashes here.

Provenance

The following attestation bundles were made for vcti_config-4.0.0-py3-none-any.whl:

Publisher: publish.yml on vcollab/vcti-python-config

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