Symbolic Validation#

PyGlove uses a runtime type system (module pg.typing) to prevent errors in symbolic object manipulation. Without it, bugs can arise easily, such as a mistakenly modified int attribute. PyGlove’s type system automatically validates symbolic objects on creation and modification, reducing the need for manual input validation, allowing the developer to focus on the main logic.

Runtime Typing#

The runtime type system of PyGlove 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 as the 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 PyGlove, 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 but 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 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#

PyGlove’s typing system can be extended through type conversion, which allows for registering type conversions. If a value being assigned to an attribute does not match its type defined by the ValueSpec, a conversion will occur automatically when a converter from the input type to the target type exists.

Type converter#

Type converter is a callable object that converts a source value into a target value. For example:

class A:
  def __init__(self, str):
    self._str = str

  def __str__(self):
    return self._str

  def __eq__(self, other):
    return isinstance(other, self.__class__) and self._str == other._str

pg.typing.register_converter(A, str, str)
pg.typing.register_converter(str, A, A)

assert pg.typing.Str().accept(A('abc')) == 'abc'
assert pg.typing.Object(A).accept('abc') == A('abc')

See pg.register_converter for more details.

Built-in converters#

By default, PyGlove registered converters between the following pairs: