Skip to content

Validation

A symbolic object retains the arguments it was constructed from, and those arguments can be rewritten at any point afterwards. That makes validation matter more than it does for an ordinary object: a value has to be checked not only when it arrives at __init__, but every time something later rewrites it.

PyGX validates both, from a single declaration — the field annotation:

class Trainer(pg.Object):
    lr: float = 0.01
    steps: int = 100

Trainer(lr='fast')        # raises TypeError at construction
t = Trainer()
t.lr = 'fast'             # TypeError at assignment, too
t.sym_rebind(lr='fast')   # and at rebind

You write the type once, in the place you would annotate it anyway, and it holds for the object's whole lifetime.

From annotation to value spec

Behind each field is a value spec (class pg.typing.ValueSpec) — the runtime object that performs the check. You rarely construct one by hand: PyGX derives it from the annotation. The mapping covers most of the typing vocabulary:

Annotation Derived value spec Accepts
int / float / str / bool Int() / Float() / Str() / Bool() the primitive (int widens to float)
list[int] List(Int()) a list whose every element is an int
dict[str, int] Dict() with a StrKey() field a dict with str keys and int values
tuple[int, str] Tuple([Int(), Str()]) a 2-tuple, checked per position
str \| None Str(noneable=True) a str or None
int \| str Union([Int(), Str()]) either member
Literal['a', 'b'] Enum(values=['a', 'b']) one of the listed values
SomeObject Object(SomeObject) an instance of that class
datetime / UUID / Decimal / Path Datetime() / Uuid() / Decimal() / Path() the stdlib scalar, with a wire codec
Callable[[int], str] Callable(args=[Int()], returns=Str()) a callable of that shape
Any Any() anything

Nesting composes as you would expect — list[SomeObject] becomes List(Object(SomeObject)), and the check descends into every element.

You can see what a class inferred by printing its schema:

class Trainer(pg.Object):
    lr: float = 0.01

print(Trainer.__schema__)

Constraints the annotation cannot express

An annotation gives you the type. When you also need a range, a pattern, or another runtime rule, pass an explicit spec through pg.field — the annotation still drives static type checking, while the spec drives the runtime check:

class Trainer(pg.Object):
    lr: float = pg.field(
        value_spec=pg.typing.Float(min_value=0.0, max_value=1.0),
        default=0.01,
        doc='Learning rate.',
    )
    name: str = pg.field(value_spec=pg.typing.Str(regex='[a-z]+'), default='run')

Trainer(lr=5.0)           # ValueError: out of range
Trainer(name='ABC')       # ValueError: does not match the regex

pg.field can also go inside typing.Annotated, which keeps the type visually leading and frees the = slot for the plain default:

class Trainer(pg.Object):
    lr: Annotated[float, pg.field(
        value_spec=pg.typing.Float(min_value=0.0, max_value=1.0),
        doc='Learning rate.',
    )] = 0.01
    name: Annotated[str, pg.field(
        value_spec=pg.typing.Str(regex='[a-z]+'))] = 'run'

The two spellings build the same field — pick whichever reads better. A docstring can be passed as a plain string slot alongside the descriptor (Annotated[int, 'Steps to run.', pg.field(...)]), and default= may live inside pg.field instead of after the = if you prefer everything in one place.

This is the main reason to reach for pg.typing specs directly. For the full set of declaration forms — default_factory, init=False, and the rest — see the pg.Object style guide.

The spec catalogue

The table above lists what annotations infer; this section is the fuller catalogue of pg.typing specs, for when you pass one explicitly via pg.field(value_spec=...) — or need to read a schema PyGX built for you.

Every field pairs a key spec with a value spec. A ValueSpec (class pg.typing.ValueSpec) defines a field's type, default, and validation rules — this is what an annotation infers. A KeySpec (class pg.typing.KeySpec) defines which names the field accepts; for an annotated field the key is simply the attribute name, so you only meet KeySpec explicitly when matching a family of keys at once (see pg.typing.StrKey below).

The snippet below builds a schema by hand to show the common specs side by side. Note the shorthand: a bare value in the spec slot means "this type, with this default", so ('b', True) is ('b', pg.typing.Bool(default=True)).

class A(pg.Object):
    pass


schema = pg.typing.create_schema([
    # Primitive types.
    ('a', pg.typing.Bool(default=True).noneable()),
    ('b', True),       # Equivalent to ('b', pg.typing.Bool(default=True)).
    ('c', pg.typing.Int()),
    ('d', 0),          # Equivalent to ('d', pg.typing.Int(default=0)).
    ('e', pg.typing.Int(
        min_value=0,
        max_value=10).noneable()),
    ('f', pg.typing.Float()),
    ('g', 1.0),        # Equivalent to ('g', pg.typing.Float(default=1.0)).
    ('h', pg.typing.Str()),
    ('i', 'foo'),      # Equivalent to ('i', pg.typing.Str(default='foo')).
    ('j', pg.typing.Str(regex='foo.*')),

    # Enum type.
    ('l', pg.typing.Enum(['foo', 'bar', 0, 1], 'foo')),

    # List type.
    ('m', pg.typing.List(pg.typing.Int(), size=2)),
    ('n', pg.typing.List(pg.typing.Dict([
        ('n1', pg.typing.List(pg.typing.Int())),
        ('n2', pg.typing.Str().noneable()),
    ]), min_size=1, max_size=10)),

    # Dict type.
    ('o', pg.typing.Dict([
        ('o1', pg.typing.Int()),
        ('o2', pg.typing.List(pg.typing.Dict([
            ('o21', 1),
            ('o22', 1.0),
        ]))),
        ('o3', pg.typing.Dict([
            # Use of regex key.
            (pg.typing.StrKey('n3.*'), pg.typing.Int()),
        ])),
    ])),

    # Tuple type. Elements are positional, so they are declared as a list
    # of value specs (no per-element names).
    ('p', pg.typing.Tuple([
        pg.typing.Int(),
        pg.typing.Str(),
    ])),

    # Object type.
    ('q', pg.typing.Object(A, default=A())),

    # Type type.
    ('r', pg.typing.Type(int)),

    # Callable type.
    ('s', pg.typing.Callable(
        [pg.typing.Int(), pg.typing.Int()],
        kw=[('a', pg.typing.Str())])),

    # Functor type (same as Callable, but only for symbolic.Functor).
    ('t', pg.typing.Functor(
        [pg.typing.Str()],
        kw=[('a', pg.typing.Str())])),

    # Union type.
    ('u', pg.typing.Union([
        pg.typing.Int(),
        pg.typing.Str(),
    ], default=1)),

    # Any type.
    ('v', pg.typing.Any(default=1)),
])

Schema inheritance

A subclass inherits its base class's fields and may add to them. It may also narrow an inherited field — tighten its validation rule or change its default — but never widen it: whatever the base promised about a field still holds for every subclass.

class A(pg.Object):
    x: int = pg.field(value_spec=pg.typing.Int(min_value=1), default=1)
    y: float = 0.0


class B(A):
    # Narrow inherited `x` with an upper bound and a new default...
    x: int = pg.field(
        value_spec=pg.typing.Int(min_value=1, max_value=5), default=2)
    # ...and add a field of its own.
    z: str = 'foo'


assert list(B.__schema__.fields.keys()) == ['x', 'y', 'z']

B(x=9)    # ValueError: out of range — B's own max_value=5 applies
B(x=0)    # ValueError: out of range — A's min_value=1 is still enforced

Widening is not so much rejected as impossible: an overriding spec is extended from the inherited one rather than replacing it. If B redeclared x as a plain pg.typing.Int(), the resulting spec would still carry A's min_value=1 — the base's guarantee cannot lapse by omission. See ValueSpec.extend for the rules, and the style guide for overriding just a default (including pg.typing.Inherit for keeping a complex parent spec).

A field can also be frozen with .freeze(), pinning it to its default. That one is enforced at class creation: a subclass trying to override a frozen field raises TypeError ("cannot extend a frozen value spec").

Automatic type conversions

When a value assigned to an attribute does not match the type defined by the ValueSpec, PyGX coerces it automatically if the target type knows how to accept it. Each coercion is owned by the type (or field) that needs it — there is no global converter registry.

Target-owned coercion (__pg_accept__)

A type opts into accepting foreign source values by defining a __pg_accept__(value) classmethod that returns a converted instance, or NotImplemented to decline. It is consulted only on a type mismatch (the cold path), so it never slows down matching assignments:

class Duration:

    def __init__(self, s):
        self._s = s

    @classmethod
    def __pg_accept__(cls, value):
        return cls(value) if isinstance(value, str) else NotImplemented


class Job(pg.Object):
    timeout: Duration


j = Job(timeout='30s')    # accepted: coerced to Duration('30s')
j.timeout = '60s'         # and on assignment too
Job(timeout=5)            # raises TypeError: __pg_accept__ declined an int

The field is annotated with the plain target type; __pg_accept__ is what widens what that annotation will take.

Other coercion homes

  • Primitive wideningintfloat is intrinsic to pg.typing.Float, so a float field accepts an int.
  • Field-specific — for builtin targets that cannot host a classmethod, use pg.field(transform=...) to coerce on a single field.
  • Built-in __pg_accept__pg.KeyPath accepts a str (parsed as a JSONPath), and the HTML view types (Html/Label/ Tooltip) accept a str.