Skip to main content

Minimal schema-agnostic configuration loader library

Project description

justconf

license python version version coverage downloads

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

pip install justconf

For .env file support:

pip install justconf[dotenv]

Quick Start

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_").

    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}).

    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).

    config = toml_loader("config.toml")
    

Nested Configuration

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

export DATABASE__HOST=localhost
export DATABASE__PORT=5432
config = env_loader()
# {"database": {"host": "localhost", "port": "5432"}}

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

# 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):

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.

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:

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

VaultProcessor

Allows fetching secrets from HashiCorp Vault (KV v2).

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
# 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:

  • TokenAuth(token) — direct token authentication
  • AppRoleAuth(role_id, secret_id, mount_path="approle") — for AppRole automated workflows
  • JwtAuth(role, jwt, mount_path="jwt") — for JWT/OIDC (GitLab CI/CD, etc.)
  • KubernetesAuth(role, jwt=None, jwt_path="...", mount_path="kubernetes") — for Kubernetes pods; JWT is read from /var/run/secrets/kubernetes.io/serviceaccount/token by default
  • UserpassAuth(username, password, mount_path="userpass")username/password authentication

Auth Fallback Chain

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

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:

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:

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

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:

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:

[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:

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:

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)

Migration from pydantic-settings

Basic Settings

Before (pydantic-settings):

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):

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):

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):

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:

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)
  • 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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

justconf-0.2.0a6.tar.gz (113.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

justconf-0.2.0a6-py3-none-any.whl (21.2 kB view details)

Uploaded Python 3

File details

Details for the file justconf-0.2.0a6.tar.gz.

File metadata

  • Download URL: justconf-0.2.0a6.tar.gz
  • Upload date:
  • Size: 113.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for justconf-0.2.0a6.tar.gz
Algorithm Hash digest
SHA256 c810524ed24d2bbb44f24b12f1ef35fc712cf6a398d0a402991c05d47840d44b
MD5 859e1bed9c41cedcc04340a5faeb9d6f
BLAKE2b-256 bd8f313c194fc95e1fdbac53e9bfca6d7c0801df4ef70c023f2e08202f831607

See more details on using hashes here.

File details

Details for the file justconf-0.2.0a6-py3-none-any.whl.

File metadata

  • Download URL: justconf-0.2.0a6-py3-none-any.whl
  • Upload date:
  • Size: 21.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for justconf-0.2.0a6-py3-none-any.whl
Algorithm Hash digest
SHA256 95d5da258e4e9d77367f74b133e4c82b44fbfd2f34990e337928ecba21232628
MD5 bdd57fe5af2f13bc7f6abdb960fc81cd
BLAKE2b-256 f205923a43f3db7548bfdabbf5a15a3b9ac8bc26f04d199f28f89824a3118e9f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page