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:
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 widening —
int→floatis intrinsic topg.typing.Float, so afloatfield accepts anint. - 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.KeyPathaccepts astr(parsed as a JSONPath), and the HTML view types (Html/Label/Tooltip) accept astr.