Metadata-Version: 2.4
Name: justconf
Version: 0.2.0
Summary: Minimal schema-agnostic configuration loader library
Project-URL: Documentation, https://aleksey925.github.io/justconf/
Project-URL: Homepage, https://github.com/aleksey925/justconf
Project-URL: Issues, https://github.com/aleksey925/justconf/issues
Project-URL: Repository, https://github.com/aleksey925/justconf
Author-email: Aleksey Petrunnik <petrunnik.a@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: config,configuration,dotenv,environment,secrets,settings,toml,vault
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: dotenv
Requires-Dist: python-dotenv>=1; extra == 'dotenv'
Description-Content-Type: text/markdown

justconf
========

[![license](https://img.shields.io/pypi/l/justconf?style=for-the-badge)](https://pypi.org/project/justconf/)
[![python version](https://img.shields.io/pypi/pyversions/justconf?style=for-the-badge)](https://pypi.org/project/justconf/)
[![version](https://img.shields.io/pypi/v/justconf?style=for-the-badge)](https://pypi.org/project/justconf/)
[![coverage](https://img.shields.io/codecov/c/github/aleksey925/justconf/master?style=for-the-badge)](https://app.codecov.io/gh/aleksey925/justconf)
[![downloads](https://img.shields.io/pypi/dm/justconf?style=for-the-badge)](https://pypi.org/project/justconf/)

Minimal schema-agnostic configuration library for Python.

Provides simple, composable building blocks for configuration management:

- **Loaders** — fetch config from various sources (environment variables, `.env` files, TOML)
- **Merge** — combine multiple configs with deep merge and priority control
- **Processors** — resolve placeholders from external sources (HashiCorp Vault)

Schema-agnostic: use your preferred validation library (Pydantic, msgspec, dataclasses) or none at all.

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [Loaders](#loaders)
- [Merge](#merge)
- [Processors](#processors)
- [Schema Placeholders](#schema-placeholders)
- [Using with pydantic-settings](#using-with-pydantic-settings)
- [Migration from pydantic-settings](#migration-from-pydantic-settings)
- [Development](#development)
- [License](#license)

## Installation

```bash
pip install justconf
```

For `.env` file support:

```bash
pip install justconf[dotenv]
```

## Quick Start

```python
from typing import Annotated
from pydantic import BaseModel
from justconf import merge, process, toml_loader, env_loader
from justconf.processor import VaultProcessor, TokenAuth
from justconf.schema import Placeholder, extract_placeholders

# Define schema with secret placeholders
class DatabaseConfig(BaseModel):
    host: str = "localhost"
    port: int = 5432
    password: Annotated[str, Placeholder("${vault:secret/data/db#password}")]

class AppConfig(BaseModel):
    debug: bool = False
    database: DatabaseConfig

# Load and merge (later sources override earlier)
config = merge(
    extract_placeholders(AppConfig),  # schema defaults with placeholders
    toml_loader("config.toml"),       # base config file
    env_loader(prefix="APP_"),        # environment overrides
)

# Resolve secrets from Vault
vault = VaultProcessor(
    url="http://vault:8200",
    auth=TokenAuth(token="hvs.xxx"),
)
config = process(config, [vault])

# Validate
app_config = AppConfig(**config)
```

## Loaders

Loaders fetch configuration from various sources and return a dictionary.

- **env_loader(prefix=None, case_sensitive=False, nested_delimiter="\_\_", nested_max_split=None)** — loads from environment variables. If `prefix` is set, filters variables by prefix and strips it from keys. The prefix is matched exactly as given — include the separator if needed (e.g. `"APP_"`).
  ```python
  config = env_loader(prefix="APP_")
  # APP_DEBUG=true, APP_PORT=8080 -> {"debug": "true", "port": "8080"}
  ```

- **dotenv_loader(path=".env", prefix=None, case_sensitive=False, nested_delimiter="\_\_", nested_max_split=None, encoding="utf-8")** — loads from `.env` file. Requires `pip install justconf[dotenv]`. Supports variable interpolation (`${VAR}`).
  ```python
  config = dotenv_loader(".env", prefix="APP_")
  ```

- **toml_loader(path="config.toml", encoding="utf-8")** — loads from TOML file using Python's built-in `tomllib`. Native TOML types are preserved (int, float, bool, list, dict, datetime).
  ```python
  config = toml_loader("config.toml")
  ```

### Nested Configuration

Use double underscores (`__`) to create nested structures from flat environment variables (default delimiter):

```bash
export DATABASE__HOST=localhost
export DATABASE__PORT=5432
```

```python
config = env_loader()
# {"database": {"host": "localhost", "port": "5432"}}
```

The delimiter is configurable via `nested_delimiter`. Set it to `None` to disable nesting:

```python
# Use dot as delimiter
config = env_loader(prefix="APP_", nested_delimiter=".")
# APP_DATABASE.HOST=localhost -> {"database": {"host": "localhost"}}

# Disable nesting entirely
config = env_loader(prefix="APP_", nested_delimiter=None)
# APP_DATABASE__HOST=localhost -> {"database__host": "localhost"}
```

Use `nested_max_split` to limit the number of parts when splitting by delimiter (`None` means unlimited, `0` disables nesting):

```python
config = env_loader(prefix="APP_", nested_max_split=0)
# APP_A__B__C__D=value -> {"a__b__c__d": "value"}  (no splitting)

config = env_loader(prefix="APP_", nested_max_split=2)
# APP_A__B__C__D=value -> {"a": {"b__c__d": "value"}}  (split into 2 parts)

config = env_loader(prefix="APP_", nested_max_split=3)
# APP_A__B__C__D=value -> {"a": {"b": {"c__d": "value"}}}  (split into 3 parts)
```

## Merge

The `merge` function combines multiple dictionaries with deep merge. Later arguments have higher priority.

```python
from justconf import merge

config = merge(
    {"db": {"host": "localhost", "port": 5432}, "tags": ["a", "b"]},
    {"db": {"port": 3306}, "tags": ["c"]},
)
# {"db": {"host": "localhost", "port": 3306}, "tags": ["c"]}
```

**Merge strategy:**
- `dict` + `dict` → recursive deep merge
- Everything else (list, str, int, etc.) → overwrite

## Processors

Processors resolve placeholders in your configuration, fetching values from external sources.

### Placeholder Syntax

```
${processor:path#key|modifier:value}
```

- `processor` — name of the processor (e.g., `vault`)
- `path` — full API path to the secret (for Vault KV v2, include `{mount}/data/{secret_path}`)
- `key` — (optional) specific key within the secret
- `modifiers` — (optional) post-processing modifiers

Placeholders can be embedded within strings:

```python
config = {"dsn": "postgres://user:${vault:secret/data/db#password}@localhost/db"}
```

### VaultProcessor

Allows fetching secrets from HashiCorp Vault (KV v2).

```python
from justconf import process
from justconf.processor import VaultProcessor, TokenAuth

processor = VaultProcessor(
    url="http://vault:8200",
    auth=TokenAuth(token="hvs.xxx"),
    timeout=30,           # request timeout in seconds
    verify=True,          # SSL verification (default: True)
)

config = {"db_pass": "${vault:secret/data/db#password}"}
result = process(config, [processor])
# {"db_pass": "secret_value"}
```

> The path from placeholder matches Vault's HTTP API exactly (`GET /v1/{path}`).
> For KV v2, this means `{mount}/data/{secret_path}`.

In the example, `secret/data/db` is the Vault path. The `#password` is the field
name inside the secret.

**Finding the path in Vault UI (≥ 1.15):** open the secret, go to the
**Overview** tab (or the **Paths** tab), and copy the **API path**. Remove
the `/v1/` prefix — the rest is your placeholder path:

```
API path:      /v1/secret/data/db
Placeholder:       secret/data/db   →  ${vault:secret/data/db#field}
```

**Vault < 1.15** (no Paths tab): extract mount and secret path from the URL:

```
https://vault.example.com/ui/vault/secrets/secret/show/db
                                            ~~~~~~     ~~
                                            mount      secret path

API path:      secret/data/db
```

Regardless of the UI URL format, the placeholder path is always `{mount}/data/{secret_path}`.

Since the full path is specified in the placeholder, you can fetch secrets from
different mount points in a single config (e.g., secret/data/..., team-kv/data/...)
— just ensure your token has access to them.

#### SSL Verification

The `verify` parameter controls SSL certificate verification:

- `verify=True` (default) — use system CA certificates
- `verify=False` — disable SSL verification (not recommended for production)
- `verify="/path/to/ca-bundle.crt"` — use custom CA bundle

```python
# For internal Vault with self-signed certificate
processor = VaultProcessor(
    url="https://vault.internal:8200",
    auth=TokenAuth(token="hvs.xxx"),
    verify="/etc/ssl/certs/internal-ca.crt",
)
```

#### Authentication Methods

VaultProcessor supports multiple [Vault auth methods](https://developer.hashicorp.com/vault/docs/auth):

- **TokenAuth(token)** — direct [token](https://developer.hashicorp.com/vault/docs/auth/token) authentication
- **AppRoleAuth(role_id, secret_id, mount_path="approle")** — for [AppRole](https://developer.hashicorp.com/vault/docs/auth/approle) automated workflows
- **JwtAuth(role, jwt, mount_path="jwt")** — for [JWT/OIDC](https://developer.hashicorp.com/vault/docs/auth/jwt) (GitLab CI/CD, etc.)
- **KubernetesAuth(role, jwt=None, jwt_path="...", mount_path="kubernetes")** — for [Kubernetes](https://developer.hashicorp.com/vault/docs/auth/kubernetes) pods; JWT is read from `/var/run/secrets/kubernetes.io/serviceaccount/token` by default
- **UserpassAuth(username, password, mount_path="userpass")** — [username/password](https://developer.hashicorp.com/vault/docs/auth/userpass) authentication

#### Auth Fallback Chain

Pass a list of auth methods to try them in order until one succeeds:

```python
import os

processor = VaultProcessor(
    url="http://vault:8200",
    auth=[
        TokenAuth(token=os.environ.get("VAULT_TOKEN", "")),
        KubernetesAuth(role="myapp"),
        AppRoleAuth(role_id="xxx", secret_id="yyy"),
    ],
)
```

#### Authentication from Environment Variables

Use `vault_auth_from_env()` to automatically detect credentials from environment variables:

```python
from justconf.processor import VaultProcessor, vault_auth_from_env

# Detect all available auth methods (sorted by priority)
auths = vault_auth_from_env()

# Use first available (like pydantic-settings-vault)
if auths:
    processor = VaultProcessor(
        url="http://vault:8200",
        auth=auths[0],
    )

# Or use fallback chain
processor = VaultProcessor(
    url="http://vault:8200",
    auth=auths,
)

# Explicit method selection
auths = vault_auth_from_env(method='approle')
```

**Supported environment variables (in order of priority):**

| Auth Method    | Required Variables                   | Mount Path Override                                 |
|----------------|--------------------------------------|-----------------------------------------------------|
| AppRoleAuth    | `VAULT_ROLE_ID` + `VAULT_SECRET_ID`  | `VAULT_APPROLE_MOUNT_PATH`    (default: approle)    |
| KubernetesAuth | `VAULT_KUBERNETES_ROLE`              | `VAULT_KUBERNETES_MOUNT_PATH` (default: kubernetes) |
| TokenAuth      | `VAULT_TOKEN`                        | —                                                   |
| JwtAuth        | `VAULT_JWT_ROLE` + `VAULT_JWT_TOKEN` | `VAULT_JWT_MOUNT_PATH`        (default: jwt)        |
| UserpassAuth   | `VAULT_USERNAME` + `VAULT_PASSWORD`  | `VAULT_USERPASS_MOUNT_PATH`   (default: userpass)   |

#### File Modifier

Write secrets to files instead of keeping them in memory. Useful for certificates and keys:

```python
config = {
    "tls_cert": "${vault:secret/data/tls#cert|file:/etc/ssl/cert.pem}",
    "tls_key": "${vault:secret/data/tls#key|file:/etc/ssl/key.pem|encoding:utf-8}",
}

result = process(config, [processor])
# {"tls_cert": "/etc/ssl/cert.pem", "tls_key": "/etc/ssl/key.pem"}
```

If the value is a dict or list, it's serialized as JSON.

## Schema Placeholders

Define default placeholder values directly in your schema using `Placeholder` annotation.
This keeps secret paths co-located with your configuration schema instead of scattered
across config files.

### Basic Usage

```python
from typing import Annotated
from pydantic import BaseModel
from justconf import merge, process, toml_loader
from justconf.processor import VaultProcessor, TokenAuth
from justconf.schema import Placeholder, extract_placeholders

class DatabaseConfig(BaseModel):
    host: str = "localhost"
    port: int = 5432
    password: Annotated[str, Placeholder("${vault:secret/data/db/creds#password}")]

class AppConfig(BaseModel):
    database: DatabaseConfig
    api_key: Annotated[str, Placeholder("${vault:secret/data/api#key}")]

# Extract placeholders from schema
schema_defaults = extract_placeholders(AppConfig)
# {'database': {'password': '${vault:secret/data/db/creds#password}'}, 'api_key': '${vault:secret/data/api#key}'}

# Merge with priority: schema defaults < config file < environment
config = merge(
    schema_defaults,
    toml_loader("config.toml"),
)

# Resolve placeholders
vault_processor = VaultProcessor(url=..., auth=TokenAuth(token=...))
config = process(config, [vault_processor])

# Validate
app_config = AppConfig(**config)
```

### Schema-Agnostic

Works with any class that has type hints:

```python
from dataclasses import dataclass
from typing import Annotated
from justconf.schema import Placeholder, extract_placeholders

@dataclass
class ServiceConfig:
    api_key: Annotated[str, Placeholder("${vault:secret/data/service#key}")]

# Plain classes work too
class PlainConfig:
    token: Annotated[str, Placeholder("${vault:secret/data/auth#token}")]

extract_placeholders(ServiceConfig)  # {'api_key': '${vault:secret/data/service#key}'}
```

### Override Schema Placeholders

Schema placeholders have the lowest priority. Override them in config files or environment:

```toml
[database]
password = "${vault:secret/data/staging/db#password}"
```

### Override Placeholders for Nested Types

Use `WithPlaceholders` to override placeholders for nested types without modifying the original type.
This is useful when you reuse the same type with different secret sources:

```python
from typing import Annotated
from pydantic import BaseModel
from justconf.schema import Placeholder, WithPlaceholders, extract_placeholders

class DatabaseConfig(BaseModel):
    host: str = "localhost"
    password: Annotated[str, Placeholder("${vault:secret/data/default#password}")]
    username: str = "admin"

class AppConfig(BaseModel):
    # Override placeholders for each instance
    main_db: Annotated[DatabaseConfig, WithPlaceholders({
        'password': '${vault:secret/data/main_db#password}',
        'username': '${vault:secret/data/main_db#username}',
    })]
    replica_db: Annotated[DatabaseConfig, WithPlaceholders({
        'password': '${vault:secret/data/replica_db#password}',
    })]

result = extract_placeholders(AppConfig)
# {
#     'main_db': {
#         'password': '${vault:secret/data/main_db#password}',
#         'username': '${vault:secret/data/main_db#username}',
#     },
#     'replica_db': {
#         'password': '${vault:secret/data/replica_db#password}',
#     },
# }
```

**Behavior:**

- Overrides are merged with placeholders from the nested type (overrides take priority)
- Supports nested dicts for deep structures
- Validates that all keys exist in the target type (raises `PlaceholderError` for invalid keys)
- Works with `Optional[NestedType]` / `NestedType | None`

### Auto-Unpack Entire Value

When a placeholder omits the `#key` part, the processor returns the entire value as a dictionary
instead of extracting a single field. This is useful when all fields of a nested type are stored
together under one path:

```python
from typing import Annotated
from pydantic import BaseModel
from justconf import process
from justconf.processor import VaultProcessor, TokenAuth
from justconf.schema import Placeholder, extract_placeholders

class DatabaseConfig(BaseModel):
    host: str
    port: int
    username: str
    password: str

class AppConfig(BaseModel):
    db: Annotated[DatabaseConfig, Placeholder("${vault:secret/data/db}")]

vault_processor = VaultProcessor(url=..., auth=TokenAuth(token=...))
config = extract_placeholders(AppConfig)
config = process(config, [vault_processor])
# {'db': {'host': 'db.example.com', 'port': 5432, 'username': 'admin', 'password': 'secret'}}
app_config = AppConfig(**config)
```

## Using with pydantic-settings

If you already use `pydantic-settings` and only need Vault secret resolution, you don't have
to migrate. Install justconf alongside `pydantic-settings` and use `process()` to resolve
secrets — it works independently from the rest of justconf:

```python
from typing import Annotated
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
from justconf import process
from justconf.processor import VaultProcessor, vault_auth_from_env
from justconf.schema import Placeholder, extract_placeholders

class DatabaseConfig(BaseModel):
    host: str = "localhost"
    port: int = 5432
    password: Annotated[str, Placeholder("${vault:secret/data/db#password}")]

class AppConfig(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_", env_nested_delimiter="__")

    debug: bool = False
    database: DatabaseConfig

# Resolve Vault secrets
vault = VaultProcessor(url="http://vault:8200", auth=vault_auth_from_env())
secrets = process(extract_placeholders(AppConfig), [vault])

# Init values have the highest priority in pydantic-settings
config = AppConfig(**secrets)
```

`pydantic-settings` continues to handle environment variables, `.env` files, and everything else
it normally does. justconf only resolves `${vault:...}` placeholders and passes the result as
init kwargs.

## Migration from pydantic-settings

### Basic Settings

**Before (pydantic-settings):**
```python
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict

class DatabaseConfig(BaseModel):
    host: str = "localhost"
    port: int = 5432

class AppConfig(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_", env_nested_delimiter="__")

    debug: bool = False
    port: int = 8080
    database: DatabaseConfig = DatabaseConfig()

config = AppConfig()
```

**After (justconf):**
```python
from pydantic import BaseModel
from justconf import merge, env_loader

class DatabaseConfig(BaseModel):
    host: str = "localhost"
    port: int = 5432

class AppConfig(BaseModel):
    debug: bool = False
    port: int = 8080
    database: DatabaseConfig = DatabaseConfig()

config = AppConfig(**merge(env_loader(prefix="APP_")))
```

### With Vault Secrets

**Before (pydantic-settings-vault):**
```python
from pydantic import Field
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
from pydantic_vault import VaultSettingsSource

class AppConfig(BaseSettings):
    db_password: str = Field(
        json_schema_extra={
            "vault_secret_path": "secret/data/app",
            "vault_secret_key": "db_password",
        },
    )
    api_key: str = Field(
        json_schema_extra={
            "vault_secret_path": "secret/data/app",
            "vault_secret_key": "api_key",
        },
    )

    model_config = {"vault_url": "http://vault:8200"}

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        return (
            init_settings,
            env_settings,
            VaultSettingsSource(settings_cls),
            file_secret_settings,
        )

config = AppConfig()
```

**After (justconf):**
```python
from typing import Annotated
from pydantic import BaseModel
from justconf import merge, process, env_loader
from justconf.processor import VaultProcessor, vault_auth_from_env
from justconf.schema import Placeholder, extract_placeholders

class AppConfig(BaseModel):
    db_password: Annotated[str, Placeholder("${vault:secret/data/app#db_password}")]
    api_key: Annotated[str, Placeholder("${vault:secret/data/app#api_key}")]

config = merge(extract_placeholders(AppConfig), env_loader())

vault = VaultProcessor(
    url="http://vault:8200",
    auth=vault_auth_from_env(),
)
config = AppConfig(**process(config, [vault]))
```

### Environment Variable Changes

If you used `pydantic-settings-vault`, note these environment variable differences:

| pydantic-settings-vault  | justconf                      | Notes                                             |
|--------------------------|-------------------------------|---------------------------------------------------|
| `VAULT_AUTH_MOUNT_POINT` | `VAULT_APPROLE_MOUNT_PATH`    | Per-method variable for AppRole                   |
| `VAULT_AUTH_MOUNT_POINT` | `VAULT_KUBERNETES_MOUNT_PATH` | Per-method variable for Kubernetes                |
| `VAULT_AUTH_PATH`        | `VAULT_JWT_MOUNT_PATH`        | Per-method variable for JWT                       |
| `VAULT_ADDR`             | —                             | Pass URL explicitly via `VaultProcessor(url=...)` |
| `VAULT_NAMESPACE`        | —                             | Not supported                                     |
| `VAULT_CA_BUNDLE`        | —                             | Pass explicitly via `VaultProcessor(verify=...)`  |

In `pydantic-settings-vault`, a single `VAULT_AUTH_MOUNT_POINT` variable is shared across all
authentication methods. In `justconf`, each method has its own variable
(`VAULT_APPROLE_MOUNT_PATH`, `VAULT_KUBERNETES_MOUNT_PATH`, `VAULT_JWT_MOUNT_PATH`), which allows
setting different mount paths for different methods in a fallback chain.

The `~/.vault-token` file is not read automatically — the token must be passed explicitly via
`TokenAuth(token=...)` or the `VAULT_TOKEN` environment variable.

Authentication method auto-detection priority also differs: `pydantic-settings-vault` uses
Token → Kubernetes → AppRole → JWT, while `justconf` uses AppRole → Kubernetes → Token → JWT → Userpass.
In practice this rarely matters, since typically only one method is configured.

### Key Differences

| pydantic-settings                   | justconf                                                          |
|-------------------------------------|-------------------------------------------------------------------|
| `BaseSettings` class inheritance    | Plain `BaseModel` + loaders                                       |
| Field-level vault config            | Placeholders in schema or any config source                       |
| Implicit env loading                | Explicit `merge()` of sources                                     |
| `VAULT_AUTH_MOUNT_POINT` (shared)   | Per-method mount path env vars (`VAULT_APPROLE_MOUNT_PATH`, etc.) |

## Development

### Debugging with a real Vault server

You can use a real Vault server to debug this project. To make this process
easier, this project includes a `docker-compose.yml` file that can run a
ready-to-use Vault server.

To run the server and set it up, run the following commands:

```shell
docker compose up
make vault
```

After that, you will have a Vault server running at `http://localhost:8200`, where you can authorize in three ways:

- using the root token (which is `token`)
- using the JWT method (role=`jwt_role`, token=[link](./configs/vault/jwt_token.txt))
- using the AppRole method (the values of role_id and secret_id can be found in the logs of the `make vault` command).

## License

MIT
