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
ConfigSourceand convert it to a typed value with a purestr -> Tdecoder. - Application config (
Config) — declare a schema of typed fields and resolve them all at once. - Composition — nest configs (always-loaded sections, or
one_ofchoices) 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 (adapter→MYAPP_ADAPTER); each branch declaresKIND = Enum.MEMBER; only the chosen branch loads; the choice is exposed (typed) as<name>_kind. Pick a branch in tests withload(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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
156486021adf37f4b23f26fc4b15df89c8acab0e2e2d2042158780722844ce5f
|
|
| MD5 |
32b9ba9bf059b3579556b8068a9cde03
|
|
| BLAKE2b-256 |
6d84f4908f79f09510d464ea9651b3c5d03102922a8d6d2d0491562808449118
|
Provenance
The following attestation bundles were made for vcti_config-4.0.0.tar.gz:
Publisher:
publish.yml on vcollab/vcti-python-config
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
vcti_config-4.0.0.tar.gz -
Subject digest:
156486021adf37f4b23f26fc4b15df89c8acab0e2e2d2042158780722844ce5f - Sigstore transparency entry: 1802693877
- Sigstore integration time:
-
Permalink:
vcollab/vcti-python-config@69c149e6dcd2677776709c7311659b30729456ec -
Branch / Tag:
refs/heads/main - Owner: https://github.com/vcollab
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@69c149e6dcd2677776709c7311659b30729456ec -
Trigger Event:
workflow_dispatch
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ff3a1043ce5d4efc40d87b6022106d3779899222483b7c9856384e7606295ae0
|
|
| MD5 |
f1247c5dc1d7d91f1845a626a9d88488
|
|
| BLAKE2b-256 |
e91d65d3aa62e8e787d31fc141b9afa20ed9a28e8fbfbb3251043bceaf318da8
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
vcti_config-4.0.0-py3-none-any.whl -
Subject digest:
ff3a1043ce5d4efc40d87b6022106d3779899222483b7c9856384e7606295ae0 - Sigstore transparency entry: 1802694013
- Sigstore integration time:
-
Permalink:
vcollab/vcti-python-config@69c149e6dcd2677776709c7311659b30729456ec -
Branch / Tag:
refs/heads/main - Owner: https://github.com/vcollab
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@69c149e6dcd2677776709c7311659b30729456ec -
Trigger Event:
workflow_dispatch
-
Statement type: