Metadata-Version: 2.4
Name: vcti-config
Version: 4.0.0
Summary: Type-safe environment variable decoding and configuration resolution for Python
Author: Visual Collaboration Technologies Inc.
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, https://github.com/vcollab/vcti-python-config
Project-URL: Repository, https://github.com/vcollab/vcti-python-config
Project-URL: Changelog, https://github.com/vcollab/vcti-python-config/blob/main/CHANGELOG.md
Keywords: configuration,environment-variables,config,env,settings,typed
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Python: <3.15,>=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: vcti-enum>=1.1.0
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Provides-Extra: lint
Requires-Dist: ruff; extra == "lint"
Provides-Extra: typecheck
Requires-Dist: mypy; extra == "typecheck"
Dynamic: license-file

# 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

```bash
pip install vcti-config
```

### In `requirements.txt`

```
vcti-config>=4.0.0
```

### In `pyproject.toml` dependencies

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

---

## Quick Start

### Declare a configuration

```python
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

```python
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

```python
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

```python
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.

```python
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:

```python
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:

```python
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](docs/design.md) — the four concepts, architecture, and design decisions
- [Source Guide](docs/source-guide.md) — file-by-file walkthrough and execution traces
- [API Reference](docs/api.md) — autodoc for all modules
