Validators

didactic.api.validates decorates a method as a per-field check that runs at construction time and on with_(...):

import didactic.api as dx


class User(dx.Model):
    id: str
    email: str

    @dx.validates("email")
    def _check_email(self, value: str) -> str:
        if "@" not in value:
            raise ValueError("missing '@'")
        return value.lower()

A validator receives the field value and returns the value to store (possibly modified). To reject the input, raise ValueError or raise TypeError; the failure is collected as a ValidationError entry with type="validator_error" and loc=(field_name,).

before and after

@validates accepts a mode argument:

class Email(dx.Model):
    address: str

    @dx.validates("address", mode="before")
    def _lower(self, value: str) -> str:
        return value.lower()

Method shapes

The decorator works on instance methods, @classmethod, and @staticmethod. The Model is frozen and not yet constructed when validators run, so there is no instance self. Instance methods receive the class as their first argument:

class M(dx.Model):
    name: str

    @dx.validates("name")
    def _strip(cls, value: str) -> str:   # name `cls` is conventional
        return value.strip()

    @dx.validates("name")
    @classmethod
    def _check_nonempty(cls, value: str) -> str:
        if not value:
            raise ValueError("empty")
        return value

    @dx.validates("name")
    @staticmethod
    def _trim(value: str) -> str:
        return value.strip()

Multiple fields share a validator

class Pair(dx.Model):
    first: str
    last: str

    @dx.validates("first", "last")
    def _strip(cls, value: str) -> str:
        return value.strip()

Multiple validators per field

Multiple @validates methods on the same field run in declaration order, threading the value through. The first one to raise short-circuits the chain for that field.

class Word(dx.Model):
    text: str

    @dx.validates("text")
    def _strip(cls, v: str) -> str:
        return v.strip()

    @dx.validates("text")
    def _upper(cls, v: str) -> str:
        return v.upper()

Inheritance

Subclasses inherit their parent's validators. To override an inherited validator, redeclare the method with @validates:

class Base(dx.Model):
    name: str

    @dx.validates("name")
    def _strip(cls, v: str) -> str:
        return v.strip()


class Loud(Base):
    @dx.validates("name")
    def _strip(cls, v: str) -> str:
        return v.strip().upper()

A subclass that shadows the method without @validates is treated as a deliberate disable: the inherited marker is dropped and the field skips validation.

Cross-field invariants

@validates runs against one field at a time. For a check that spans multiple fields, prefer an axiom when the constraint is expressible in the surface syntax:

class Range(dx.Model):
    low: int
    high: int

    __axioms__ = [dx.axiom("low <= high")]

When the check needs Python (cross-collection consistency, length matching across two optional fields, anything that can't be cleanly written as an axiom expression), use @dx.model_validator(). The decorated method receives the constructed instance and may raise ValueError / raise TypeError to reject it; failures surface as ValidationError entries with type="validator_error" and an empty loc:

import didactic.api as dx


class Rules(dx.Model):
    binary_rules: tuple[str, ...] = ()
    binary_weights: tuple[float, ...] | None = None

    @dx.model_validator()
    def _check_lengths(self) -> "Rules":
        if self.binary_weights is not None and len(self.binary_weights) != len(
            self.binary_rules
        ):
            raise ValueError(
                "binary_weights length must match binary_rules length"
            )
        return self

Class-level validators run after every per-field @validates and every __axioms__ check have passed. Multiple @model_validator methods on the same class run in declaration order; failures from all of them are collected into a single ValidationError. Subclass inheritance and silent-shadow disable work the same as for @validates.

mode="before" is reserved for a future pre-construction hook; only mode="after" (the default) is currently accepted.

Validators do not travel with the Theory

@validates-decorated methods live on the Python side only; they are not lifted into the panproto Theory. Constraints expressed as Annotated[T, ...] metadata or as __axioms__ are lifted. Choose the axiom path when you want the constraint to travel cross-language with the Theory.

Validation error shape

try:
    User(id="u1", email="not-an-email")
except dx.ValidationError as exc:
    exc.entries        # tuple[ValidationErrorEntry, ...]
    exc.model          # type[Model]
    str(exc)           # rendered message

Each entry has:

The set of type values is open; new versions may add more.