Runtime Typing¶
Typing is at the core of symbolic programming: it constrains symbolic attributes, and it is what lets PyGX validate, generate and mutate objects safely.
The primary way to declare types is the standard Python annotation. Annotate a field and PyGX derives a runtime pg.typing.ValueSpec from it — validation at construction and at every rebind, with no extra vocabulary to learn:
class Foo(pg.Object):
x: int # validated as an integer
y: str = 'abc' # validated as a string, defaults to 'abc'
pg.typing is the layer underneath. You reach for it directly only when an annotation cannot express the constraint you need — a numeric range, a regex, a size bound. This notebook covers the annotation path first, then the value-spec vocabulary it desugars into.
Before we get started, we install and import the pygx library:
!pip install pygx
from typing import Annotated, Callable, Literal, TypedDict
import pygx as pg
import contextlib
@contextlib.contextmanager
def expect_error(error_type):
has_error = False
try:
yield
except error_type as e:
print(f'ERROR: {e!r}')
has_error = True
finally:
assert has_error
1. Annotations are the primary interface¶
Declare a field with a standard type annotation. PyGX converts it to a value spec and enforces it — on construction, and on every subsequent mutation.
class Foo(pg.Object):
x: int
y: str = 'abc'
z: list[int] = pg.field(default_factory=list)
f = Foo(x=1)
print(f)
Foo( x = 1, y = 'abc', z = [] )
# Validated at construction.
with expect_error(TypeError):
_ = Foo(x='not an int')
ERROR: TypeError("Expect <class 'int'> but encountered <class 'str'>: not an int. (path=x)")
# ...and on every mutation, not just at construction.
with expect_error(TypeError):
f.x = 'not an int'
with expect_error(TypeError):
f.sym_rebind(x='not an int')
ERROR: TypeError("Expect <class 'int'> but encountered <class 'str'>: not an int. (path=x)")
ERROR: TypeError("Expect <class 'int'> but encountered <class 'str'>: not an int. (path=x)")
Optional fields use | None, unions use |, and choices use Literal — all standard Python, all enforced at runtime.
class Bar(pg.Object):
x: int | None = None
y: int | str = 1
mode: Literal['fast', 'slow'] = 'fast'
print(Bar(x=None, y='abc', mode='slow'))
# `mode` only accepts the literal values.
with expect_error(ValueError):
_ = Bar(mode='medium')
Bar(
x = None,
y = 'abc',
mode = 'slow'
)
ERROR: ValueError("Value 'medium' is not in candidate list ['fast', 'slow']. (path=mode)")
2. Annotation → pg.typing mapping¶
Every annotation desugars to a pg.typing.ValueSpec. You rarely need to know
which one — but this is the full mapping, and it tells you exactly what will be
enforced.
| Annotation | Value spec | Example |
|---|---|---|
int |
pg.typing.Int() |
x: int |
float |
pg.typing.Float() |
x: float |
str |
pg.typing.Str() |
x: str |
bool |
pg.typing.Bool() |
x: bool |
Any |
pg.typing.Any() |
x: Any |
None |
pg.typing.Object(NoneType).noneable() |
x: None |
T | None |
<T>.noneable() |
x: int | None |
A | B |
pg.typing.Union([<A>, <B>]) |
x: int | str |
Literal[...] |
pg.typing.Enum([...]) |
x: Literal['a', 'b'] |
list[T] |
pg.typing.List(<T>, symbolic=False) |
x: list[int] |
dict[str, T] |
pg.typing.Dict([(StrKey(), <T>)], symbolic=False) |
x: dict[str, int] |
tuple[A, B] |
pg.typing.Tuple([<A>, <B>]) |
x: tuple[int, str] |
tuple[T, ...] |
pg.typing.Tuple(<T>) |
x: tuple[int, ...] |
Sequence[T] |
pg.typing.Union([List(<T>), Tuple(<T>)]) |
x: Sequence[int] |
pg.List[T] |
pg.typing.List(<T>, symbolic=True) |
x: pg.List[int] |
pg.Dict[str, T] |
pg.typing.Dict([...], symbolic=True) |
x: pg.Dict[str, int] |
A TypedDict |
pg.typing.Dict([...]) mirroring its schema |
x: Movie |
| A class | pg.typing.Object(<cls>) |
x: MyClass |
type[T] |
pg.typing.Type(<T>) |
x: type[Foo] |
Callable[[A], R] |
pg.typing.Callable([<A>], returns=<R>) |
x: Callable[[int], bool] |
Final[T] |
<T>.freeze(...) |
x: Final[int] |
datetime.datetime |
pg.typing.Datetime() |
x: datetime |
datetime.date |
pg.typing.Date() |
x: date |
datetime.time |
pg.typing.Time() |
x: time |
datetime.timedelta |
pg.typing.Timedelta() |
x: timedelta |
uuid.UUID |
pg.typing.Uuid() |
x: UUID |
decimal.Decimal |
pg.typing.Decimal() |
x: Decimal |
pathlib.Path |
pg.typing.Path() |
x: Path |
A note on list[T] vs pg.List[T]: both validate elements identically. The
builtin spelling stores a plain Python list; the pg.List spelling stores a
symbolic container, so nested members join the object tree and become
individually addressable and mutable.
# The mapping in action: each annotation becomes the spec above.
class Shapes(pg.Object):
a: int
b: Literal['x', 'y']
c: list[int]
d: dict[str, float]
e: tuple[int, str]
for name, field in Shapes.__schema__.fields.items():
print(f'{str(name):4s} {field.value}')
a Int()
b Enum(
values=['x', 'y']
)
c List(
Int(),
symbolic=False
)
d Dict(
fields=[Field(
key=StrKey(),
value=Float()
)],
symbolic=False
)
e Tuple(
[Int(), Str()]
)
3. Constraints beyond the annotation¶
An annotation names a type. When you also need a range, a pattern, or a size
bound, attach a value spec with pg.field(...) — the annotation stays as the
readable type, and the spec carries the constraint.
class Model(pg.Object):
# An integer, further constrained to [1, 10].
units: int = pg.field(value_spec=pg.typing.Int(min_value=1, max_value=10), default=1)
# A string, further constrained by a regex.
path: str = pg.field(value_spec=pg.typing.Str(regex='.*\\.json'), default='a.json')
print(Model())
with expect_error(ValueError):
_ = Model(units=11)
with expect_error(ValueError):
_ = Model(path='a.txt')
Model(
units = 1,
path = 'a.json'
)
ERROR: ValueError('Value 11 is out of range (min=1, max=10). (path=)')
ERROR: ValueError("String 'a.txt' does not match regular expression '.*\\\\.json'. (path=)")
typing.Annotated does the same thing while keeping the type visually leading —
useful when you want the annotation to read as the type it is. It also carries a
plain string as the field's docstring.
class Model2(pg.Object):
units: Annotated[int, pg.field(value_spec=pg.typing.Int(min_value=1, max_value=10))] = 1
name: Annotated[str, 'The name shown in the tree view.'] = 'model'
print(Model2.__schema__['units'].value)
print(Model2.__schema__['name'].description)
Int( default=1, min=1, max=10 ) The name shown in the tree view.
Which form to use
- Bare annotation — the type is the whole constraint. Reach for this first.
Annotated[T, 'doc']— you need a one-line field docstring.pg.field(...)— anything else: ranges, regexes, sizes,default_factoryfor mutable defaults, or flags likeinit=False.
4. Where typing applies¶
Annotations drive typing for pg.Object subclasses. The same value specs are
also available on the symbolic containers and on functors — the places where
there is no class body to annotate.
4.1 pg.Object subclasses¶
class Y(TypedDict):
z: float
class Foo(pg.Object):
x: int
y: Y = {'z': 1.0}
f = Foo(x=1, y=pg.Dict(z=2.0))
print(f)
Foo(
x = 1,
y = {
z = 2.0
}
)
4.2 pg.List and pg.Dict¶
Standalone containers take a value_spec directly, since there is no annotation
site.
l = pg.List(value_spec=pg.typing.List(pg.typing.Int()))
# Okay
l.append(1)
# Not okay. 1.0 is not an integer.
with expect_error(TypeError):
l.append(1.0)
ERROR: TypeError("Expect <class 'int'> but encountered <class 'float'>: 1.0. (path=[1])")
d = pg.Dict(x='foo', value_spec=pg.typing.Dict([
('x', pg.typing.Any()),
('y', pg.typing.Int().noneable())
]))
print(d)
# Okay. 1 is an integer.
d.y = 1
# Not okay, 'z' is not allowed as a key in the Dict.
with expect_error(KeyError):
d.z = True
{
x = 'foo',
y = None
}
ERROR: KeyError("Key 'z' is not allowed for <class 'pygx.symbolic._dict.Dict'>. (path=)")
# Define a dict of str to integers.
d = pg.Dict(value_spec=pg.typing.Dict([
(pg.typing.StrKey(), pg.typing.Int()),
]))
# Okay. string keys with integer values.
d.x = 1
d.y = 2
# Not okay. The value is not an integer.
with expect_error(TypeError):
d.z = 'foo'
ERROR: TypeError("Expect <class 'int'> but encountered <class 'str'>: foo. (path=z)")
# Constraint on key names
d = pg.Dict(value_spec=pg.typing.Dict([
(pg.typing.StrKey('.*_file'), pg.typing.Str()),
]))
# Okay. String keys ended with '_file'.
d.data1_file = 'abc'
d.data2_file = 'cde'
# Not okay. Key does not end with '_file'.
with expect_error(KeyError):
d.data1 = 'efg'
ERROR: KeyError("Key 'data1' is not allowed for <class 'pygx.symbolic._dict.Dict'>. (path=)")
4.3 Functors via pg.functor¶
Functors take their schema from the function signature. Unlike pg.Object,
annotation-driven typing is opt-in here: pass auto_typing=True to have
the annotations enforced, otherwise arguments are accepted as Any.
# Without `auto_typing`, annotations are recorded but not enforced.
@pg.functor()
def foo(x: float = 1.0, y: float = 2.0):
pass
print(foo.__schema__['x'].value)
# `auto_typing=True` turns the annotations into real value specs.
@pg.functor(auto_typing=True)
def bar(x: float = 1.0, y: float = 2.0):
pass
print(bar.__schema__['x'].value)
print(bar())
with expect_error(TypeError):
_ = bar(x='not a float')
Any(
default=1.0,
annotation=<class 'float'>
)
Float(
default=1.0
)
bar(
x = 1.0,
y = 2.0
)
ERROR: TypeError("Expect <class 'float'> but encountered <class 'str'>: not a float. (path=)")
You can also pass the specs explicitly, which works regardless of annotations:
@pg.functor([
('x', pg.typing.Float()),
('y', pg.typing.Float())
])
def baz(x=1.0, y=2.0):
pass
print(baz())
baz( x = 1.0, y = 2.0 )
4.4 Class wrappers via pg.symbolize¶
class _Foo:
def __init__(self, x, y):
pass
# Type constraint can be passed as the second argument
# of `pg.symbolize`.
Foo = pg.symbolize(_Foo, [
('x', pg.typing.Int()),
('y', pg.typing.Int()),
])
# Not okay. 1.0 is not an integer.
with expect_error(TypeError):
_ = Foo(1.0, 2)
ERROR: TypeError("Expect <class 'int'> but encountered <class 'float'>: 1.0. (path=)")
5. The pg.typing vocabulary¶
These are the specs the annotations above desugar into. Use them directly when you need a constraint an annotation cannot carry.
All ValueSpec subclasses share two traits:
defaultargument: sets the default value of the field..noneable(): marks the field as accepting None, and uses None as the default whendefaultis not given.
5.1 Any type¶
print(pg.typing.Any())
Any()
5.2 Numbers and string¶
# Boolean
print(pg.typing.Bool(default=True))
# Integer: with optional min/max constraint.
print(pg.typing.Int(min_value=0, max_value=10, default=1))
# Float: with optional min/max constraint.
print(pg.typing.Float(min_value=0.0, max_value=1.0).noneable())
# Str: with optinal constraint with regular expression.
print(pg.typing.Str(regex='.*file'))
Bool( default=True ) Int( default=1, min=0, max=10 ) Float( default=None, min=0.0, max=1.0, noneable=True ) Str( regex='.*file' )
5.3 Lists¶
# Lists of non-negative integers.
print(pg.typing.List(pg.typing.Int(min_value=0)))
# Lists of fixed size (2).
print(pg.typing.List(pg.typing.Int(), size=2))
# Lists of min/max size.
print(pg.typing.List(pg.typing.Any(), min_size=1, max_size=10))
List(
Int(
min=0
)
)
List(
Int(),
min_size=2,
max_size=2
)
List(
Any(),
min_size=1,
max_size=10
)
5.4 Dicts¶
# Free-form dict.
print(pg.typing.Dict())
# Dict with fixed schema.
print(pg.typing.Dict([
('x', pg.typing.Int(), 'Optional docstr for x'),
('y', pg.typing.Float().noneable())
]))
# Dict with any string keys.
print(pg.typing.Dict([
(pg.typing.StrKey(), pg.typing.Int())
]))
# Dict with string keys with a regex pattern.
print(pg.typing.Dict([
(pg.typing.StrKey('.*file'), pg.typing.Int())
]))
Dict()
Dict(
fields=[
Field(
key=x,
value=Int(),
description='Optional docstr for x'
),
Field(
key=y,
value=Float(
default=None,
noneable=True
)
)
]
)
Dict(
fields=[Field(
key=StrKey(),
value=Int()
)]
)
Dict(
fields=[Field(
key=StrKey(regex='.*file'),
value=Int()
)]
)
5.5 Object of a class¶
class Foo:
pass
# Object of Foo.
print(pg.typing.Object(Foo))
Object( Foo )
5.6 Type¶
# Subclasses of Foo.
print(pg.typing.Type(Foo))
Type( <class '__main__.Foo'> )
5.7 Callable¶
# Any callable.
print(pg.typing.Callable())
# Callable with 2 positional integer arguments.
print(pg.typing.Callable([
pg.typing.Int(), pg.typing.Int()
]))
# Callable with 1 positional argument, 1 keyword argument
# and requires the return value to be a boolean.
print(pg.typing.Callable(
[pg.typing.Int()],
kw=[('x', pg.typing.Float())],
returns=pg.typing.Bool()))
Callable()
Callable(
args=[Int(), Int()]
)
Callable(
args=[Int()],
kw=[('x', Float())],
returns=Bool()
)
5.8 Union¶
class Foo:
pass
class Bar:
pass
# An union of int, float, Foo or Bar.
print(pg.typing.Union([
pg.typing.Int(),
pg.typing.Float(),
pg.typing.Object(Foo),
pg.typing.Object(Bar)
]))
# An union of a nested union and a callable.
print(pg.typing.Union([
pg.typing.Union([pg.typing.Int(), pg.typing.Float()]) ,
pg.typing.Callable(returns=pg.typing.Int())
]))
Union(
[Int(), Float(), Object(
Foo
), Object(
Bar
)]
)
Union(
[Union(
[Int(), Float()]
), Callable(
returns=Int()
)]
)
6. Automatic type conversions¶
Python has no built-in implicit type conversion, but the need still arises — e.g. a pg.typing.Int() field might reasonably accept a numpy.integer.
PyGX coerces a value into a field's declared type when the target type knows how to accept it, via a __pg_accept__(value) classmethod on that type. For builtin targets like int (which cannot host a classmethod), coerce at the field level with pg.field(transform=...).
import numpy as np
# `pg.typing.Int()` accepts a Python int, but not a numpy integer.
with expect_error(TypeError):
pg.typing.Int().apply(np.int32(0))
# Coerce at the field level with a transform.
class Foo(pg.Object):
x: int = pg.field(transform=int)
# Okay. The transform converts np.int32 -> int on assignment.
Foo(x=np.int32(5)).x
ERROR: TypeError("Expect <class 'int'> but encountered <class 'numpy.int32'>: 0. (path=)")
5