Skip to main content

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 (adapter → MYAPP_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

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)

Source distribution for vcti-config 4.0.0
File Size Uploaded
vcti_config-4.0.0.tar.gz 32.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vcti-config 4.0.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

4.0.0 This release

2 release files

3.1.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page