Skip to main content

PyPI Downloads codecov Tests Python versions Platforms Implementations Development Status

strictyamlx

An extension of StrictYAML that adds more expressive schema tools for validation.

Installation

pip install strictyamlx

Features

DMap (Dynamic Map)

DMap allows you to validate YAML where the schema of a map depends on the value of one or more of its keys. It evaluates a "control" schema first, and uses its values to determine which Case schema applies. The final parsed object safely merges the validated control and case values.

Case matching rules:

  • At most one Case can evaluate to True.
  • Zero matching Case blocks is allowed; in that scenario, DMap validates with control + active overlays only.
from strictyamlx import Map, Str, Int, load, Control, Case, DMap

# Control schema evaluates the "action" key to route the document
ctrl = Control(Map({"action": Str()}))

# Blocks conditionally apply a schema based on the evaluated control value
blocks = [
    Case(
        when=lambda raw, ctrl: ctrl["action"] == "message", 
        schema=Map({"text": Str()})
    ),
    Case(
        when=lambda raw, ctrl: ctrl["action"] == "transfer", 
        schema=Map({"amount": Int(), "to": Str()})
    ),
]

# Create the schema 
schema = DMap(ctrl, blocks)

# Validation merges the control ("action") and case schema values ("text")
yaml_str = """
action: message
text: Hello!
"""
doc = load(yaml_str, schema)
assert doc.data == {"action": "message", "text": "Hello!"}

Using source in Control

Sometimes the values needed to determine the schema aren't placed at the root of the document, but nested inside another key (like "metadata"). You can use source to define where the Control values should be drawn from:

from strictyamlx import Map, Str, Int, load, Control, Case, DMap

# Evaluates the control values from the `meta` key
ctrl = Control(Map({"type": Str()}), source="meta")

blocks = [
    Case(
        when=lambda raw, ctrl: ctrl["type"] == "number", 
        schema=Map({"value": Int()})
    )
]

schema = DMap(ctrl, blocks)

yaml_str = """
meta:
  type: number
value: 42
"""
doc = load(yaml_str, schema)
assert doc.data == {"meta": {"type": "number"}, "value": 42}

Constraints

You can append constraints to Case blocks, Overlay blocks, or globally on the DMap. Constraints are callables that validate the incoming data. raw and the validated value refer to the current (local) DMap node only, not the whole YAML document.

Constraints on a Case — validate the merged control + case data for that node:

Case(
    when=lambda raw, ctrl: ctrl["action"] == "transfer",
    schema=Map({"amount": Int(), "from": Str(), "to": Str()}),
    constraints=[
        lambda raw, ctrl, validated: validated["amount"] > 0,
        lambda raw, ctrl, validated: validated["from"] != validated["to"]
    ]
)

Constraints on an Overlay — validate the node when that overlay is active (e.g. require cert_file when TLS is enabled):

Overlay(
    when=lambda raw, ctrl: "tls" in raw,
    schema=Map({
        "tls": Map({
            "enabled": Bool(),
            Optional("cert_file"): Str(),
            Optional("key_file"): Str(),
        })
    }),
    constraints=[
        lambda raw, ctrl, val: not val.get("tls", {}).get("enabled") or val.get("tls", {}).get("cert_file"),
    ]
)

Constraint callbacks receive local data: raw is the current node’s raw contents, and validated is the validated data for that node only. Signatures:

  • constraint(raw, ctrl, validated) — no parent context
  • constraint(raw, ctrl, validated, parents=None) — parent-aware

When parents is provided to constraints, each item is a dictionary with:

  • raw: ancestor raw node (local to that ancestor DMap)
  • ctrl: ancestor control projection
  • val: ancestor validated value

Constraints are evaluated after schema validation, so parent val is available for nested DMap constraints.

Nesting DMaps

DMaps can nested to create complex state graphs. A Case block can even have another DMap as its schema!

from strictyamlx import Map, Str, Int, Control, Case, DMap

inner_schema = DMap(
    Control(Map({"subkind": Str()})),
    [
        Case(when=lambda raw, ctrl: ctrl["subkind"] == "V1", schema=Map({"v1": Int()})),
        Case(when=lambda raw, ctrl: ctrl["subkind"] == "V2", schema=Map({"v2": Str()})),
    ]
)

schema = DMap(
    Control(Map({"kind": Str()})),
    [
        Case(when=lambda raw, ctrl: ctrl["kind"] == "complex", schema=inner_schema),
        Case(when=lambda raw, ctrl: ctrl["kind"] == "simple", schema=Map({"value": Str()})),
    ]
)

Parent context in nested when

Nested when clauses can inspect ancestor context with an optional parents argument:

schema = DMap(
    control=Control(Map({"type": Str()})),
    blocks=[
        Case(
            when=lambda raw, ctrl: ctrl["type"] == "parent",
            schema=Map({
                "child": DMap(
                    control=Control(Map({"subtype": Str()})),
                    blocks=[
                        Case(
                            # Access parent control from the immediate parent.
                            when=lambda raw, ctrl, parents=None: (
                                parents
                                and parents[-1]["ctrl"]["type"] == "parent"
                                and ctrl["subtype"] == "V1"
                            ),
                            schema=Map({"v1": Int()}),
                        )
                    ],
                )
            }),
        )
    ],
)

For when, each parents item contains:

  • raw
  • ctrl

raw passed to when/constraints is the current DMap node, not the whole YAML document.

Parent context in nested constraints

Nested constraints can also read ancestor validated values:

def child_constraint(raw, ctrl, val, parents=None):
    if not parents:
        return False
    parent_val = parents[-1]["val"]
    return parent_val["type"] == "parent" and val["value"] > 0

schema = DMap(
    control=Control(Map({"type": Str()})),
    blocks=[
        Case(
            when=lambda raw, ctrl: ctrl["type"] == "parent",
            schema=Map({
                "child": DMap(
                    control=Control(Map({"subtype": Str()})),
                    blocks=[
                        Case(
                            when=lambda raw, ctrl: ctrl["subtype"] == "child_type",
                            schema=Map({"value": Int()}),
                            constraints=[child_constraint],
                        )
                    ],
                )
            }),
        )
    ],
)

KeyedChoiceMap

KeyedChoiceMap validates a mapping where a bounded number of keys from a predefined set may be present.

from strictyamlx import KeyedChoiceMap, Str, Bool, Int, Float, Seq, Map

predicate_value = KeyedChoiceMap(
    choices=[
        ("eq", Str() | Bool()),
        ("in", Seq(Str())),
        ("not_in", Seq(Str())),
        ("gt", Int() | Float()),
        ("gte", Int() | Float()),
        ("lt", Int() | Float()),
        ("lte", Int() | Float()),
        ("range", Map({"min": Int() | Float(), "max": Int() | Float()})),
    ],
    minimum_keys=1,  # default
    maximum_keys=1,  # default
)

minimum_keys/maximum_keys count only the declared choice keys.

Overlay Blocks

Overlay blocks allow you to conditionally apply schema fragments on top of your base Case schema. Unlike Case blocks (where only one can match), multiple Overlay blocks can be active at the same time.

Overlays are useful for features like debug flags, optional configurations (like TLS or metrics), or environment-specific settings that should only appear when certain conditions are met.

Precedence: Case > Overlay (in order) > Control. This means fields in the Case schema override overlays, and overlays override the control schema.

from strictyamlx import Map, Str, Int, Bool, Enum, Optional, Seq, load, Control, Case, DMap, Overlay

schema = DMap(
    control=Control(
        validator=Enum(["simple", "advanced"]),
        source=("meta", "mode"),
    ),
    blocks=[
        # Base Case 1: Simple Mode
        Case(
            when=lambda raw, ctrl: ctrl == "simple",
            schema=Map({
                "meta": Map({"mode": Enum(["simple"])}),
                "service": Map({"name": Str(), "port": Int()}),
            }),
        ),
        # Base Case 2: Advanced Mode
        Case(
            when=lambda raw, ctrl: ctrl == "advanced",
            schema=Map({
                "meta": Map({"mode": Enum(["advanced"])}),
                "service": Map({"name": Str(), "ports": Seq(Int())}),
                Optional("workers"): Int(),
            }),
        ),
        # Overlay: Debug (applied if 'debug' is true)
        Overlay(
            when=lambda raw, ctrl: raw.get("debug") is True,
            schema=Map({
                Optional("debug"): Bool(),
                Optional("log_level"): Enum(["debug", "info", "warn", "error"]),
            }),
        ),
        # Overlay: TLS (applied if 'tls' key exists)
        Overlay(
            when=lambda raw, ctrl: "tls" in raw,
            schema=Map({
                "tls": Map({
                    "enabled": Bool(),
                    Optional("cert_file"): Str(),
                    Optional("key_file"): Str(),
                })
            }),
        ),
    ],
)

# Example 1: Simple mode with Debug overlay
yaml_simple_debug = """
meta:
  mode: simple
service:
  name: api
  port: 8080
debug: true
log_level: debug
"""
doc = load(yaml_simple_debug, schema)
assert doc.data["log_level"] == "debug"

# Example 2: Advanced mode with TLS overlay
yaml_advanced_tls = """
meta:
  mode: advanced
service:
  name: api
  ports: [8080, 8081]
workers: 4
tls:
  enabled: true
  cert_file: /etc/certs/server.crt
"""
doc = load(yaml_advanced_tls, schema)
assert doc.data["tls"]["enabled"] is True

Overlay.choice

strictyaml.compound.validators.OrValidator answers the question, does this value runtime validate against Validator A or B.

Overlay.choice answers the question, is either key A or B (or ... n) provided. Provide no or multiple items with key(s) from KeyedChoiceMap choices, validation fails. Non-choice(s) are ignored!

import textwrap
from typing import Any, cast

from strictyamlx import (
    Case,
    Control,
    DMap,
    KeyedChoiceMap,
    Map,
    Optional,
    Overlay,
    Str,
    load,
)

ctrl_default = Control(Map({}))
case_default = Case(
    when=True,
    schema=Map({}),
)

ChoiceSchema = KeyedChoiceMap(
    choices=[
        ("id_", Str()),
        ("xpath", Str()),
    ],
)

# No Control. In same Map, other items
str_yaml = textwrap.dedent("""\
    xpath: '//input[@id=''username'']'
    type_: div
    value: mylogin
""")

PageElementValidator = DMap(
    # no-op: no keys required, no routing
    control=ctrl_default,
    blocks=[
        # Catch-all base (required by DMap)
        case_default,
        # Overlay 0: at least one of id_ / xpath
        Overlay.choice(ChoiceSchema),
        # Overlay 1: optional extras
        Overlay(
            when=True,
            schema=Map({
                Optional("type_"): Str(),
                Optional("value"): Str(),
            }),
        ),
    ],
)
yaml_obj = load(str_yaml, schema=PageElementValidator)
d_actual = cast("dict[str, Any]", yaml_obj.data)
d_actual

{'type_': 'div', 'value': 'mylogin', 'xpath': "//input[@id='username']"}

What happened?

Purposefully declined to use DMap features: Control and Case. Case appears only because it's a DMap requirement. The example is focused on the two Overlay.

  • Overlay 0 -- Overlay expects a Map instance. Overlay.choice expects a KeyedChoiceMap instance. Intuitively, KeyedChoiceMap deals with key choices. This KeyedChoiceMap had to choose between the choices: "id_" or "xpath"; others keys were ignored.

  • Overlay 1 -- Dealt with "type_" and "value" whichever are present added to the output.

The DMap rules apply to params: when and constraints. constraints most likely are unnecessary. In "Overlay 0" the defaults for when and constraints were silently applied.

Look through the example again, it's mostly boilerplate. Could go away with sane defaults with preferrably a DMap subclass and if not a factory.

Hopefully will see the UX become DMap(blocks=blocks) with blocks being the two Overlay, without Case or Control (unless needed by other Overlay).

ForwardRef

ForwardRef defines recursive or mutually dependent schemas, letting you use a schema component before it is fully defined.

from strictyamlx import Map, Str, Optional, Seq, load, ForwardRef

# 1. Create the reference
tree = ForwardRef()

# 2. Define the schema recursively and assign it using .set()
tree.set(Map({"name": Str(), Optional("children"): Seq(tree)}))

# 3. Validation handles recursive resolution automatically
yaml_str = """
name: root
children:
  - name: child
    children:
      - name: grandchild
"""
doc = load(yaml_str, tree)

DMaps with ForwardRef

You can use a ForwardRef inside DMap case schemas to build recursive dynamic behavior:

from strictyamlx import Map, Str, Int, Optional, load, Control, Case, DMap, ForwardRef

ref = ForwardRef()

schema = DMap(
    Control(Map({"type": Str()})),
    [
        Case(
            when=lambda raw, ctrl: ctrl["type"] == "node", 
            schema=Map({"value": Int(), Optional("child"): ref})
        ),
        Case(
            when=lambda raw, ctrl: ctrl["type"] == "leaf", 
            schema=Map({"value": Int()})
        ),
    ]
)

# Reference points to the DMap itself
ref.set(schema)

yaml_str = """
type: node
value: 1
child:
  type: leaf
  value: 2
"""
doc = load(yaml_str, schema)

Metadata

Release files for strictyamlx 0.4.0

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

Source distribution (sdist)

Source distribution for strictyamlx 0.4.0
File Size Uploaded
strictyamlx-0.4.0.tar.gz 19.6 kB Details

Built distribution (wheel)

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

Total release size: 38.0 kB

Release files / strictyamlx-0.4.0.tar.gz

Download URL strictyamlx-0.4.0.tar.gz
Size 19.6 kB
Tags Source
SHA-256 checksum
How to use checksums
a76f0de00a0e315ae98b42f776a28a7760368c12c224b35899c740d2ca82c219
BLAKE2b-256 checksum
How to use checksums
4758823c59eec8b719ba7722246d87e972af9cb0b745fee22c460582ada74cca
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.10

Release files / strictyamlx-0.4.0-py3-none-any.whl

Download URL strictyamlx-0.4.0-py3-none-any.whl
Size 18.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9f0785909cd5d2a58c4e17d244b51c73a3fb44a8bb18d76e0a2b4192924e2229
BLAKE2b-256 checksum
How to use checksums
eae97875d25b14b4fc39667011e236ed297c7883d60b3736adcba0ca06077f1e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.10

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.5

2 release files

0.3.4

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

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