PyYAML config validated with jsonschema Draft 2020-12: working versions (tested 2026-10-16)
Load a YAML config with PyYAML safe loading and validate it offline against a JSON Schema Draft 2020-12 with jsonschema, including format checks and duplicate-key detection.
passed6 passed · 0 failed2026-10-16 00:56 UTC- Status
- passed
- Last passed
- 2026-10-16 00:56 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38832319459
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pyyaml-jsonschema-config") (install), or see them on GitHub.
pyyaml==6.0.3 jsonschema==4.26.0 pytest==9.1.1
from __future__ import annotations
from typing import Any
import yaml
from jsonschema import Draft202012Validator, FormatChecker
SCHEMA: dict[str, Any] = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["name", "port", "released"],
"additionalProperties": False,
"properties": {
"name": {"type": "string", "minLength": 1},
"port": {"type": "integer", "minimum": 1, "maximum": 65535},
"released": {"type": "string", "format": "date"},
"debug": {"type": "boolean"},
"hosts": {"type": "array", "items": {"type": "string", "format": "ipv4"}},
},
}
class ConfigError(ValueError):
pass
class ConfigLoader(yaml.SafeLoader):
# YAML 1.1 parses unquoted 2024-01-01 into a datetime.date, which would
# then fail the schema's "type: string". Drop the timestamp resolver so
# dates stay plain strings for format: date to check.
yaml_implicit_resolvers = {
ch: [(tag, rx) for tag, rx in resolvers if tag != "tag:yaml.org,2002:timestamp"]
for ch, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
}
def _unique_mapping(loader: ConfigLoader, node: yaml.MappingNode) -> dict[Any, Any]:
# PyYAML silently keeps the last value for duplicate keys; make it loud.
loader.flatten_mapping(node)
pairs = loader.construct_pairs(node)
keys = [key for key, _ in pairs]
dupes = sorted({key for key in keys if keys.count(key) > 1})
if dupes:
raise ConfigError(f"duplicate config keys: {dupes}")
return dict(pairs)
ConfigLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _unique_mapping
)
VALIDATOR = Draft202012Validator(SCHEMA, format_checker=FormatChecker())
def load_config(text: str) -> dict[str, Any]:
try:
data = yaml.load(text, Loader=ConfigLoader)
except yaml.YAMLError as exc:
raise ConfigError(f"invalid YAML: {exc}") from exc
if data is None:
raise ConfigError("config is empty")
if not isinstance(data, dict):
raise ConfigError("config root must be a mapping")
errors = sorted(VALIDATOR.iter_errors(data), key=lambda e: list(e.path))
if errors:
path = "/".join(str(p) for p in errors[0].path) or "<root>"
raise ConfigError(f"{path}: {errors[0].message}")
return data
import pytest
from config import ConfigError, load_config
VALID = """
name: api
port: 8080
released: 2024-01-01
hosts: [10.0.0.1, 10.0.0.2]
"""
def test_valid_config_loads():
cfg = load_config(VALID)
assert cfg["port"] == 8080
assert cfg["hosts"] == ["10.0.0.1", "10.0.0.2"]
def test_unquoted_date_stays_a_string():
assert load_config(VALID)["released"] == "2024-01-01"
def test_bad_date_fails_format_check():
with pytest.raises(ConfigError, match="is not a 'date'"):
load_config("name: api\nport: 80\nreleased: 2024-13-40\n")
def test_yaml11_boolean_breaks_string_field():
with pytest.raises(ConfigError, match="not of type 'string'"):
load_config("name: no\nport: 80\nreleased: 2024-01-01\n")
def test_unknown_key_is_rejected():
with pytest.raises(ConfigError, match="Additional properties are not allowed"):
load_config("name: api\nport: 80\nreleased: 2024-01-01\nlevel: 3\n")
def test_duplicate_keys_are_rejected():
with pytest.raises(ConfigError, match="duplicate config keys"):
load_config("name: api\nname: web\nport: 80\nreleased: 2024-01-01\n")
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
jsonschema Draft 2020-12 validation with a referencing registry
Validate JSON fixtures against Draft 2020-12 schemas that cross-reference each other via a referencing Registry, including dynamic anchors, unevaluatedProperties and format checking.
pydantic-settings v2 config from env, YAML and defaults with SecretStr
Nested pydantic-settings models loaded with precedence init > env > YAML > defaults, with env_nested_delimiter, SecretStr masking, and offline tests using tmp_path and monkeypatch.