Symbolic Validation¶
PyGX uses a runtime type system (module pygx.typing) to
prevent errors in symbolic object manipulation. Without it, bugs can arise
easily — such as a mistakenly modified int attribute. PyGX's type system
automatically validates symbolic objects on creation and modification,
reducing the need for manual input validation and allowing the developer to
focus on the main logic.
Runtime typing¶
The runtime type system of PyGX is based on schemas (class
pg.typing.Schema), which define the symbolic attributes
of a type (e.g., dict, list, class, function).
A schema consists of symbolic fields (class
pg.typing.Field) that specify the keys and acceptable
values for the attributes. Schemas are created and associated with a symbolic
type through decorators like pg.members and
pg.symbolize during the declaration. For example:
@pg.members([
('x', pg.typing.Int(default=1)),
('y', pg.typing.Float().noneable()),
])
class A(pg.Object):
pass
print(A.__schema__)
@pg.symbolize([
('a', pg.typing.Int()),
('b', pg.typing.Float()),
])
def foo(a, b):
return a + b
print(foo.__schema__)
Key and value specifications¶
The first argument of pg.members and pg.symbolize takes a list of Field
definitions for the symbolic attributes. It's usually described by a tuple of
four items:
The key specification (or KeySpec, described by class
pg.typing.KeySpec) and value specification (or
ValueSpec, described by class pg.typing.ValueSpec)
are required, while the doc string and the field metadata are optional.
KeySpec defines acceptable identifiers for this field, and ValueSpec
defines the attribute's type, default value, and validation rules. The doc
string provides additional description for the field, and the field metadata
can be used for code generation.
The following code snippet illustrates common KeySpec and ValueSpec
subclasses and their usage with a manually created schema:
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', ['foo', 'bar', 0, 1])),
# List type.
('m', pg.typing.List(pg.typing.Int(), size=2, default=[])),
('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, default=[])),
# 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.
('p', pg.typing.Tuple([
('p1', pg.typing.Int()),
('p2', 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()],
kwargs=[('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¶
In PyGX, symbolic attributes and their defining schemas can be inherited during
subclassing. The base class's schema is carried over to the subclass and can be
overridden by redefining a field with the same key. The subclass cannot
arbitrarily change the base class's field — it must use a more restrictive
validation rule of the same type or change the default value. See
ValueSpec.extend for details.
The code snippet below illustrates schema inheritance during subclassing:
@pg.members([
('x', pg.typing.Int(min_value=1)),
('y', pg.typing.Float()),
])
class A(pg.Object):
pass
@pg.members([
# Further restrict inherited 'x' by specifying the max value, as well
# as providing a default value.
('x', pg.typing.Int(max_value=5, default=2)),
('z', pg.typing.Str('foo').freeze()),
])
class B(A):
pass
assert list(B.__schema__.fields.keys()) == ['x', 'y', 'z']
@pg.members([
# Raises: 'z' is frozen in class B and cannot be extended further.
('z', pg.typing.Str()),
])
class C(B):
pass
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 A:
def __init__(self, s):
self._s = s
def __eq__(self, other):
return isinstance(other, self.__class__) and self._s == other._s
@classmethod
def __pg_accept__(cls, value):
return cls(value) if isinstance(value, str) else NotImplemented
assert pg.typing.Object(A).apply('abc') == A('abc')
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.