Skip to main content

ycon

Load YAML configuration with file and environment includes, shared defaults, and references between values. Validate the result with Pydantic or export the effective configuration as plain YAML.

Requires Python 3.12 or newer.

Getting started

From a checkout of this repository, install the dependencies:

uv sync

Create config.yaml:

app:
  name: example
  retries: 3

Load it in Python with uv run python:

from ycon import load

config = load("config.yaml")
assert config["app"]["retries"] == 3

File arguments accept strings or pathlib.Path objects. Each call reads files and environment variables again, so subsequent loads reflect changes. Without defaults, the result can be any supported YAML value, including a mapping, list, scalar, or None for an empty document.

See the runnable examples for complete configurations.

Scalar type resolution

Unquoted scalars resolve to Python types using YAML 1.2's Core Schema, not PyYAML's YAML 1.1 defaults. This avoids well-known YAML 1.1 surprises:

Value YAML 1.1 (PyYAML default) ycon (YAML 1.2 Core Schema)
yes, no, on, off bool str
017 15 (octal) str ("017")
12:34 754 (sexagesimal) str ("12:34")
true, false bool bool (unchanged)
0o17, 0x1F bool/int variants int (explicit-prefix octal/hex only)
42, 3.14, .inf, .nan resolved as usual unchanged
2024-01-01 datetime.date unchanged (timestamps aren't restricted)

Quote a value ("yes") if you want the literal string under either scheme; explicit-prefix octal/hex (0o17, 0x1F) still resolve as integers. This applies everywhere YAML is parsed — !included files, defaults=, and top-level configs alike.

Include files and environment variables

# db_settings.yaml
host: localhost
port: 5432
# config.yaml
database: !include db_settings.yaml
credentials: !include env://API_KEY

File includes are relative to the directory of the file containing the !include, including when files include other files.

Include form Behavior
!include settings.yaml Load a relative file. Absolute paths also work.
!include file:///etc/app/settings.yaml Load a local file URI; percent-encoded characters are decoded.
!include env://API_KEY Read the environment variable as {"value": "<contents>"}.
!include db://record-id Raises NotImplementedError unless a db handler is registered (see Custom include schemes).

For example, when API_KEY=demo, config["credentials"] is {"value": "demo"}. An empty environment value is valid; an unset variable raises ycon.includes.MissingEnvVarError, a subclass of KeyError.

Plain filenames preserve literal # and % characters. File URIs accept an empty host or localhost; remote hosts, queries, and fragments are rejected. Environment URIs must contain only the variable name, without a path, query, or fragment.

Custom include schemes

Register a handler to support db:// (or any other scheme) without forking the package:

from ycon import register_scheme


def load_from_db(uri: str) -> dict:
    record_id = uri.removeprefix("db://")
    return {"id": record_id, "name": "..."}  # look it up for real


register_scheme("db", load_from_db)

register_scheme(scheme, handler) registers a callable. The handler receives the full URI string and returns the resolved value. Registering a handler under env overrides the built-in environment handler, taking priority over the built-in handling; file/relative-path includes are resolved before scheme handlers run and cannot be overridden this way. Registration is process-global and takes effect for every load() call afterward, until removed with unregister_scheme(scheme).

For a temporary registration — in a test, or to scope a handler to one call — use using_scheme as a context manager. It restores whatever was registered before (or removes the entry entirely) on exit, even if the block raises:

from ycon import using_scheme

with using_scheme("db", load_from_db):
    config = load("config.yaml")
# the "db" handler (if any) reverts here

Reference other values

Use !ref with a dot-separated mapping path:

database:
  host: localhost
  port: 5432
app:
  db_host: !ref database.host
  db_port: !ref database.port

References preserve the target's type: app.db_port above is an integer. They can target other references, entire mappings or lists, and values introduced by includes or defaults. A reference inside an included file uses the final top-level document as its root.

Paths address string mapping keys. List indexing and keys containing a literal dot are not supported. Missing keys raise KeyError; invalid indexing raises TypeError. Error messages identify the reference path and the segment that failed.

Repeated includes and shared YAML aliases are allowed. Circular includes, references, and recursive YAML aliases raise ValueError.

Layer configuration over defaults

# defaults.yaml
database:
  host: localhost
  port: 5432
  options:
    timeout: 30
    retries: 3
# config.yaml
database:
  port: 6543
  options:
    retries: 5
from ycon import load

config = load("config.yaml", defaults="defaults.yaml")
assert config == {
    "database": {
        "host": "localhost",
        "port": 6543,
        "options": {"timeout": 30, "retries": 5},
    }
}

Nested mappings merge key by key; the configuration wins wherever it supplies a replacement value.

Override value Effect on the default
Mapping Merge recursively when the default is also a mapping; otherwise replace it.
List, including [] Replace the entire default value.
Scalar, including null, false, 0, or "" Replace the default value.
Empty mapping {} Preserve an existing default mapping.
Omitted key Keep the default value.

When defaults= is supplied, both documents must be mappings. Empty or top-level null documents contribute an empty mapping; other document types raise TypeError. Use defaults=None to omit defaults. An empty path string is still treated as a path and raises an I/O error.

Pass strict=True to catch typos: any key in the configuration that doesn't exist in defaults at the same nesting level raises ValueError naming the full dotted path to the key (e.g. "database.hots"), instead of silently being added.

load("config.yaml", defaults="defaults.yaml", strict=True)

strict is ignored when defaults is not given, and is also accepted by load_config(), resolve_to_file(), and ycon.merge.deep_merge().

Loading happens in this order:

  1. Load defaults and configuration, expanding their includes.
  2. Merge configuration over defaults.
  3. Resolve references against the merged document.
  4. Validate, when using load_config().

This means a reference defined in defaults sees overridden values. To merge dictionaries directly, use ycon.merge.deep_merge(base, override).

Validate with Pydantic

from pydantic import BaseModel
from ycon import load_config


class Database(BaseModel):
    host: str
    port: int


class Config(BaseModel):
    database: Database


config = load_config("config.yaml", Config, defaults="defaults.yaml")
assert config.database.port == 6543

load_config() returns an instance of the supplied model, using model.model_validate(...). Pydantic controls coercion, extra-field handling, and validation rules. Invalid or missing required values raise pydantic.ValidationError; loading errors propagate unchanged.

Export resolved YAML

from ycon import resolve_to_file

resolve_to_file("config.yaml", "resolved.yaml", defaults="defaults.yaml")

The output preserves the configuration's nested structure and, by default, contains no !include, !ref, or YAML aliases — values shared via aliases are written out in full at each location. Pass ignore_aliases=False to keep them as YAML anchors/aliases instead. The output includes resolved environment values and can be loaded by a standard YAML reader. The output file is created or overwritten; source comments and formatting are not preserved.

Gzip support

Paths ending in .gz are automatically read or written as gzip, including configuration files, defaults, includes, and exported output:

from ycon import load, resolve_to_file

config = load("config.yaml.gz", defaults="defaults.yaml.gz")
resolve_to_file("config.yaml", "resolved.yaml.gz")
data: !include dataset.yaml.gz

To write an already-resolved Python value, use ycon.dump.dump_yaml(data, path, ignore_aliases=True).

Compare two configs

from ycon import compare_files

result = compare_files("a.yaml", "b.yaml", defaults="defaults.yaml")

Loads and fully resolves both files (defaults=, if given, applies to both) and returns how the second differs from the first, as a dict with three keys, each keyed by dotted path:

  • added — present only in the second file: {"new_key": value}
  • removed — present only in the first file: {"old_key": value}
  • changed — present in both with different values: {"path": (old, new)}

Nested mappings are compared key by key recursively; any other differing value, including a whole list, is reported as one entry in changed rather than diffed further. To compare two already-loaded values instead of files, use ycon.diff.diff_configs(a, b) directly. Type changes such as true to 1 are reported, including inside lists and in mapping keys. Matching YAML .nan values are treated as unchanged. Paths use brackets for literal dotted keys, empty keys, and non-string keys: ['a.b'] differs from nested a.b, and [1] differs from the string-key path 1.

Sign a config

Install the optional signing extra (adds a dependency on PyNaCl for Ed25519 signatures):

uv sync --extra signing

Subclass SignableModel instead of BaseModel to sign and verify a validated config with an Ed25519 key:

from nacl.signing import SigningKey
from ycon import load_config
from ycon.signing import SignableModel


class Config(SignableModel):
    name: str
    retries: int


signing_key = SigningKey.generate()  # keep this with the config publisher

config = load_config("config.yaml", Config)
bundle = config.to_signed_bundle(signing_key)
# bundle == {"payload": {...}, "signature": "<hex>", "signer_pubkey": "<hex>"}

# Only the verify key needs to travel with the app that reads the config.
verified = Config.from_signed_bundle(bundle, signing_key.verify_key)

to_signed_bundle() signs model_dump(mode="json") serialized as sorted, separator-minimal JSON (canonical_bytes()) and returns a JSON-serializable dict with the payload, hex-encoded signature, and signer public key. from_signed_bundle() checks the received payload signature and signer against the supplied trusted verify key before validating the model. It raises ValueError if the signature doesn't match — use it to reject a tampered or misattributed config before trusting it. sign()/verify() are also available directly for lower-level use, without the bundle wrapper. ycon.signing is a separate module, not imported by ycon/__init__.py, so the core package has no hard dependency on PyNaCl. See examples/05_signed_config for a runnable version, including the tampering-detection path.

Command line

Installing the package provides a ycon command:

uv run ycon show config.yaml --defaults defaults.yaml --strict
uv run ycon resolve config.yaml -o resolved.yaml --defaults defaults.yaml
uv run ycon resolve config.yaml -o resolved.yaml --keep-aliases
uv run ycon diff a.yaml b.yaml --defaults defaults.yaml
uv run ycon --version

--defaults, --strict, and --keep-aliases are optional.

show loads and resolves a config the same way load() does and prints it as JSON; a value a custom scheme handler returns that JSON can't represent (e.g. a set) is flagged as {"__unrepresentable__": "<type>", "repr": "<repr>"} rather than being silently stringified. resolve is the CLI form of resolve_to_file(), useful for resolving a config once at build/deploy time so the runtime only ever loads the resolved YAML. diff is the CLI form of compare_files(), printing - removed, + added, and ~ changed lines. Run uv run ycon --help or uv run ycon <command> --help for details.

Development

Run these commands from the repository root:

uv sync
uv run pytest
uv run pytest --cov=ycon --cov-report=term-missing
uv run ruff check .
uv run ruff format --check .

Apply formatting with uv run ruff format .. Start exploring with uv run python examples/01_basic_loading/run.py, or choose another example.

The package is fully type-hinted and ships a py.typed marker, so mypy/pyright type-check against it directly. ycon.__version__ (and ycon --version) reports the installed package version.

  • PyYAML — the underlying YAML parser; no includes, references, or defaults-merging on its own.
  • OmegaConf — hierarchical config with variable interpolation and merging; part of the larger Hydra ecosystem.
  • Dynaconf — layered settings across files/env/secrets, oriented around app settings rather than YAML tags.
  • Hydra — configuration composition for applications, built on OmegaConf, with a CLI and multi-run support.
  • python-anyconfig — loads and merges config in many formats (YAML, JSON, TOML, ...) behind one API.
  • confuse — YAML settings with defaults and validation, from the beets project.
  • pyaml-env — minimal YAML loader that expands ${ENV_VAR} references, no includes or refs.

Metadata

Release files for ycon 0.1.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ycon 0.1.3
File Size Uploaded Provenance
ycon-0.1.3.tar.gz 15.3 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for ycon 0.1.3
File Interpreter ABI Platform
ycon-0.1.3-py3-none-any.whl Python 3 none any Details

Total release size: 35.3 kB

Release files / ycon-0.1.3.tar.gz

Download URL ycon-0.1.3.tar.gz
Size 15.3 kB
Tags Source
SHA-256 checksum
How to use checksums
154ad33ab2582c875c759c10f38223e5e55b2e708c93d709bf66406f6c1a5194
BLAKE2b-256 checksum
How to use checksums
f3e4333093c4c63100bdbee84b4de4aeffbceadcc2976f61634572ae15b14722
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.12 {"installer":{"name":"uv","version":"0.12.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / ycon-0.1.3-py3-none-any.whl

Download URL ycon-0.1.3-py3-none-any.whl
Size 20.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
15fb003c9d8aab5fb0016c2204bb2909ce3cbd6d65e22d2f40de7854dc8fcc6b
BLAKE2b-256 checksum
How to use checksums
c5d2b26128a51520824297bf39d4a40d5ee48dd43e3ca7e32137c03ab733aa95
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.12 {"installer":{"name":"uv","version":"0.12.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for ycon 0.1.3

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.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.1.3 This release

2 release files No provenance

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