Skip to content

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:

(Key specification, Value specification, Doc string, Field metadata)

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 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.