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
Metadata
Release files for vcti-config 4.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| vcti_config-4.0.0.tar.gz | 32.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vcti_config-4.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 56.4 kB
Release files / vcti_config-4.0.0.tar.gz
| Download URL | vcti_config-4.0.0.tar.gz |
|---|---|
| Size | 32.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
156486021adf37f4b23f26fc4b15df89c8acab0e2e2d2042158780722844ce5f
|
|
BLAKE2b-256 checksum How to use checksums |
6d84f4908f79f09510d464ea9651b3c5d03102922a8d6d2d0491562808449118
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 12, 2026.
Transparency logRelease files / vcti_config-4.0.0-py3-none-any.whl
| Download URL | vcti_config-4.0.0-py3-none-any.whl |
|---|---|
| Size | 24.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ff3a1043ce5d4efc40d87b6022106d3779899222483b7c9856384e7606295ae0
|
|
BLAKE2b-256 checksum How to use checksums |
e91d65d3aa62e8e787d31fc141b9afa20ed9a28e8fbfbb3251043bceaf318da8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 12, 2026.
Transparency log