Types¶
This page covers the kinds of type PyGX gives you — classes, functions, and containers — and how each is derived from its ordinary Python counterpart. See Objects for what they all have in common.
Classes¶
Classes are the basic units of modern computer programs. PyGX makes it easy to build such classes from regular Python classes in two ways:
- Developing a dataclass-like
class by subclassing
pg.Object. - Developing a class as usual and decorating
it with
pg.symbolize. This also works with existing classes.
Warning
pg.symbolize on existing classes can fail. The flexibility of Python
allows a user class to do a wide range of things. For example, Python
classes generated from
Protocol Buffers do not
allow themselves to be subclassed, while pg.symbolize requires
inheritance to create symbolic types from existing ones. Another example
is the neural-modeling library Flax,
which tracks objects in the call stack of __init__ to figure out the
containing layer for the current layer. However, the generated symbolic
class changes the __init__ call stack, which breaks the premise. In
such cases, the user class may need adjustments to make peace with
PyGX's implementation of symbolization.
Defining a dataclass-like class¶
This is the simplest way to write one from scratch:
subclass pg.Object and declare the fields as
annotated class attributes, exactly as you would with a dataclass. PyGX
synthesizes a keyword-only __init__, derives a runtime validation rule from
each annotation, and exposes the fields as ordinary attributes.
class Greeting(pg.Object):
name: str | None
time_of_day: Literal['morning', 'afternoon', 'evening'] = 'morning'
def __call__(self):
# Field values are readable as ordinary attributes.
print(f'Good {self.time_of_day}, {self.name}')
# Create an object of Greeting and invoke it.
# This prints 'Good morning, Bob'.
Greeting(name='Bob')()
The annotations are doing real work at runtime, not just documenting intent:
str | None becomes a nullable string check, and the Literal becomes an
enum, so time_of_day='midnight' raises. See
Validation for the full mapping
from annotations to validation rules.
typing.Annotated carries the extras a bare annotation has no room for. A
plain string slot becomes the field's docstring:
class Greeting(pg.Object):
name: Annotated[str | None, 'Name to greet.']
time_of_day: Annotated[
Literal['morning', 'afternoon', 'evening'], 'Time of the day.'
] = 'morning'
and a pg.field descriptor in the same position
carries anything further — a stricter runtime rule, a default factory, or
flags like init=False:
class Greeting(pg.Object):
name: Annotated[str, pg.field(
value_spec=pg.typing.Str(regex='[A-Z][a-z]*'),
doc='Name to greet, capitalized.',
)] = 'World'
See Validation for when to reach for that.
pg.members: the same thing, spelled as data
Fields can also be declared with the
pg.members decorator, which takes a list of
(key, value spec, docstring) tuples instead of annotations:
@pg.members([
('name', pg.typing.Str().noneable(use_none_as_default=False),
'Name to greet.'),
('time_of_day',
pg.typing.Enum(['morning', 'afternoon', 'evening'], 'morning'),
'Time of the day.'),
])
class Greeting(pg.Object):
pass
This produces the same schema as the annotated version above. Prefer
annotations for ordinary classes — they type-check statically and read
like a dataclass. pg.members earns its place when the fields are
computed rather than written out: generated from a config, built in a
loop, or otherwise not known when the source is authored.
Watch the default. A bare .noneable() also sets the default to
None, making the field optional — so plain
pg.typing.Str().noneable() does not match name: str | None,
which has no default and stays required. Hence
use_none_as_default=False above. In the annotation form the two are
spelled apart, as they are for a dataclass: name: str | None is
required and accepts None; name: str | None = None is optional.
Understanding fields¶
A field declares one __init__ argument: its name and what values it
accepts. At runtime you can read a field either as an ordinary attribute
(obj.x, for pg.Object subclasses) or through
sym_init_args, which works for every
symbolic type.
Symbolic fields can be organized hierarchically, which is useful when there
are many of them and they group naturally. Declare the group as a
TypedDict and annotate the field with it — the keys inside are validated
just like top-level fields:
class Dataset(pg.Object):
path: str = ''
class TrainingCfg(TypedDict):
dataset: Dataset
total_steps: int
class EvalCfg(TypedDict):
dataset: Dataset
steps: int
class Trainer(pg.Object):
training: TrainingCfg
evaluation: EvalCfg
trainer = Trainer(
training=dict(
dataset=Dataset(),
total_steps=100,
),
evaluation=dict(
dataset=Dataset(),
steps=20,
),
)
# Validation descends into the group: `total_steps` must be an int.
Trainer(
training=dict(dataset=Dataset(), total_steps='soon'),
evaluation=dict(dataset=Dataset(), steps=20),
) # raises TypeError
See Validation for more details on field declaration,
and the style guide
for the TypedDict specifics (including NotRequired and closed/extra keys).
Field inheritance¶
PyGX allows field inheritance for classes created by subclassing
pg.Object or its subclasses. Fields from the base
class are inherited by the subclass in their order of declaration, and the
subclass can override the inherited fields with stricter validation rules or
different default values. For example:
class Foo(pg.Object):
x: int = pg.field(value_spec=pg.typing.Int(max_value=10))
y: float = pg.field(value_spec=pg.typing.Float(min_value=0))
class Bar(Foo):
x: int = pg.field(value_spec=pg.typing.Int(min_value=1), default=1)
z: str | None = None
# Bar's schema has 3 fields, in base-first declaration order:
# x : Int(default=1, min=1, max=10) <- merged, not replaced
# y : Float(min=0) <- inherited as-is
# z : Str(default=None, noneable=True)
print(Bar.__schema__)
Note what happened to x: Bar only asked for min_value=1, but the
inherited max_value=10 is still there. An override extends the base
spec rather than replacing it, so a subclass cannot loosen what the base
promised.
Symbolizing a regular class¶
There are several scenarios in which you may want to use
pg.symbolize:
- You need to make an existing class symbolic (it keeps its own behavior).
- You want to develop a class as usual and make it symbolic with minimal change.
- You encounter a use case that requires multi-inheriting
pg.Objectand another class. - You need to subclass an already symbolized class.
Here is how pg.symbolize works: it generates a class by multi-inheriting
pg.ClassWrapper (a pg.Object subclass) and
your (regular) class. As a result, functionalities from both worlds can be
combined.
pg.symbolize can be used as a decorator to make symbolic class development
simple:
Or it can be used as a function to symbolize a class without modifying the source code of the original classes:
To avoid name clashes on object attributes, fields are only
accessible via the sym_init_args property for symbolized classes.
Custom behaviors¶
There are a few behaviors you can customize during pg.symbolize via its
arguments:
repr: defaultTrue. Whether to generate__repr__and__str__based on the object's stored arguments.eq: defaultFalse. Whether to generate__eq__,__ne__, and__hash__based on value equality rather than identity.class_name: class name used for the symbolized class. Defaults to the same name as the source class.module_name: module name used for the symbolized class. Defaults to the same module name as the source class.override: an optional dict that contains key-value pairs to override the symbolized class's attributes.
Enable validation¶
Users can enable validation on class arguments by providing value
specifications during pg.symbolize, similar to
how it's done with pg.members. This allows for
automatic validation of the argument values at the time
of its creation and any subsequent manipulation:
SymbolicFoo = pg.symbolize(Foo, [
('x', pg.typing.Int()),
])
# Raises: `x` should be an integer.
SymbolicFoo('abc')
Class inheritance¶
A symbolized class can be subclassed, which automatically makes the subclass
symbolic. For example, Bar is also a symbolized class since it subclasses
Foo:
@pg.symbolize
class Foo:
def __init__(self, x):
self._x = x
class Bar(Foo):
def __init__(self, y):
super().__init__(y ** 2)
Tip
There is a subtle difference between symbolic classes created by
subclassing pg.Object and those created using pg.symbolize. While the
former inherit symbolic fields from their base classes (like
dataclasses.dataclass), the latter do not. Instead, a symbolized class
always has the same number of fields aligned with its __init__
signature. The field definitions passed to pg.symbolize can specify
validation rules or add metadata to the arguments but cannot add new
fields whose keys are absent from the __init__ signature. If default
values are present in the signature, they will be checked against the
fields when they are present and carried over to the fields if they are
not specified.
Functions (Functors)¶
A functor is a symbolized Python function: a call you can hold before
running it.
Symbolic functions are subclasses of
pg.Functor, which is a symbolic class with a
__call__ method. Therefore, their instances are also symbolic objects,
representing functions with bound arguments.
Functors vs. regular functions¶
In Python, there is no language construct for representing a bound function. When a function is bound with values, it is immediately evaluated, leaving no runtime entity that captures the binding itself. For example:
def foo(x, y):
return x + y
# Binding is evaluated immediately,
# and there is no long-lived object for a bound function.
assert foo(1, 2) == 3
Note
functools.partial
is commonly used to create
partially bound functions that can be passed around, but it is not yet
widely used to make bound functions and objects interchangeable and equal
throughout a software system.
A functor lets bound functions to be treated on par with objects. This means bound functions can be created and manipulated with the same API as any other object. Instead of invoking the function immediately at binding time, a functor returns an object representing the binding. The user must then call the object separately to invoke the function's body. This allows for greater flexibility and consistency in the way functions and objects are handled throughout a software system. For example:
@pg.symbolize
def foo(x, y):
return x + y
# `f` is a bound `foo` with (1, 2).
f = foo(1, 2)
# `f` needs to be explicitly called.
f()
Creating functors¶
Creating one is simply a matter of annotating the function with the
pg.symbolize decorator. For example:
If the function is defined in a source file that cannot be modified, you can also do:
Defining validation rules¶
As with classes, users can also provide an optional specification of the validation rules for the function's arguments:
@pg.symbolize([
('x', pg.typing.Int(min_value=1)),
('z', pg.typing.Int(min_value=1)),
])
def foo(x, y, z):
pass
The specification is not required to cover all argument names. For omitted
arguments, PyGX's runtime validation system treats them as
pg.typing.Any().
Handling the return value¶
Symbolic validation can be used not only to check the values of arguments, but also to validate the return value of a function or method. This allows for increased type safety and ensures that the function or method returns the expected output. To validate the return value:
Handling *args¶
We can add a validation rule for variable positional arguments by defining a
field whose key is the name of the variable positional argument and whose
value is a pg.typing.List:
@pg.symbolize([
('args', pg.typing.List(pg.typing.Int(min_value=1))),
])
def bar(x, *args):
pass
# Okay.
bar(1, 2, 3)
assert bar.sym_init_args['args'] == [2, 3]
# Not okay: 'abc' is not an integer.
bar(1, 'abc')
Handling **kwargs¶
Similarly, we can add validation rules for variable keyword arguments. If we want to use a uniform rule for all keyword arguments, we can do the following:
@pg.symbolize([
(pg.typing.StrKey('foo.*'), pg.typing.Int()),
])
def bar(x, y, **kwargs):
pass
# Okay: `foo1` matches the regular expression 'foo.*' and 3 is an integer.
bar(1, 2, foo1=3)
# Not okay: `s` is neither an argument nor acceptable
# by the regular expression 'foo.*'.
bar(1, 2, s=3)
# Not okay: 'abc' is not an integer.
bar(1, 2, foo2='abc')
Furthermore, if we want to specify validation rules separately based on the keyword, we can add multiple fields in the definition. For example:
@pg.symbolize([
('p', pg.typing.Int()),
('q', pg.typing.Str()),
(pg.typing.StrKey(), pg.typing.Bool()),
])
def bar(x, y, **kwargs):
pass
# Okay: `p` and `q` are applied with separate validation rules
# instead of using the general keyword-argument rules.
bar(1, 2, p=3, q='abc', r=True)
Advanced binding¶
Symbolic functions support a set of advanced binding capabilities.
Regular binding¶
Create a symbolic function instance with all arguments bound:
Partial binding¶
Partially bind a symbolic function on some arguments:
Incremental binding¶
Incremental binding can be done via attribute assignment:
Rebinding¶
We can also override an existing bound argument:
Binding at invocation time¶
A functor can be invoked via its __call__ method, with arguments that are
not yet provided, or new values to override existing bound ones:
# Invoke functor with x=2 (incrementally bound), y=1 (early bound),
# and z=2.
f(z=2)
# Invoke functor with x=1 (override existing value 2), y=1 (early bound),
# and z=2.
f(z=2, x=1, override_args=True)
# Raises: x is already bound.
f(z=2, x=1)
Tip
When f is called with arguments that are not yet bound, it only uses
the provided value for calling the function, without binding it. For
example:
Other operations¶
The same as symbolic classes, symbolic operations can be applied to symbolic functions too. See Operations for details.
Containers¶
PyGX provides pg.List and
pg.Dict to address the symbolic needs for list and
dict.
pg.List¶
pg.List implements a list type whose instances are
symbolically programmable. pg.List is:
- a subclass of the standard Python
list. - a subclass of class
pg.Symbolic.
Instantiation¶
pg.List can be used as a regular list:
Validation¶
pg.List supports symbolic validation through the value_spec argument:
l = pg.List([1, 2, 3], value_spec=pg.typing.List(
pg.typing.Int(min_value=1),
max_size=10,
))
# Raises: 0 is not in the acceptable range.
l.append(0)
See Validation for more details.
Subscription to changes¶
Users can subscribe to subtree updates within pg.List:
def on_change(updates):
print(updates)
l = pg.List([{'foo': 1}], onchange_callback=on_change)
# `on_change` is triggered on item insertion.
l.append({'bar': 2})
# `on_change` is triggered on item removal.
l.pop(0)
# `on_change` is also triggered on subtree change.
l.sym_rebind({'[0].bar': 3})
Operations¶
See Operations for details.
Caveats¶
Recursive conversion¶
pg.List converts a regular list into its symbolic representation. Therefore,
if the input list contains nested list or dict, they are converted to
instances of pg.List and pg.Dict respectively. For example:
regular_list = [
[1, 2, 3],
{'a': 1, 'b': 2},
]
symbolic_list = pg.List(regular_list)
# Nested lists and dicts are converted into symbolic ones.
assert isinstance(symbolic_list[0], pg.List)
assert isinstance(symbolic_list[1], pg.Dict)
Hashing¶
A regular list is not hashable. For example:
However, a symbolic list is hashable, with a hash value computed from the symbolic representations of its items. Therefore, two bindings with the same type and parameters end up with the same hash value:
class Foo(pg.Object):
x: int
assert hash(pg.List([Foo(x=1), Foo(x=2)])) == hash(pg.List([Foo(x=1), Foo(x=2)]))
pg.Dict¶
pg.Dict implements a dict type whose instances are
symbolically programmable. pg.Dict is:
- a subclass of the standard Python
dict. - a subclass of class
pg.Symbolic.
Instantiation¶
pg.Dict can be used as a regular dict with string keys:
or:
Warning
pg.Dict does not support non-string keys.
Attribute access¶
Besides regular item access using [], pg.Dict allows attribute access to
its keys:
Creating a hyper dict¶
pg.Dict is often used for constructing hyper values during prototyping,
without introducing symbolic classes or functions:
space = pg.Dict(x=pg.oneof(range(10)), y=pg.floatv(0.1, 1.0))
example = next(pg.random_sample(space))
Validation¶
pg.Dict supports symbolic validation when the value_spec argument is
provided:
d = pg.Dict(x=1, y=2, value_spec=pg.typing.Dict([
('x', pg.typing.Int(min_value=1)),
('y', pg.typing.Int(min_value=1)),
(pg.typing.StrKey('foo.*'), pg.typing.Str()),
]))
# Okay: all keys starting with 'foo' are acceptable and are strings.
d.foo1 = 'abc'
# Raises: 'bar' is not acceptable as a key in the dict.
d.bar = 'abc'
See Validation for more details.
Subscription to changes¶
Users can subscribe to subtree updates within pg.Dict:
def on_change(updates):
print(updates)
d = pg.Dict(x=1, onchange_callback=on_change)
# `on_change` is triggered on item insertion.
d['y'] = {'z': 1}
# `on_change` is triggered on item removal.
del d.x
# `on_change` is also triggered on subtree change.
d.sym_rebind({'y.z': 2})
Operations¶
See Operations for details.
Caveats¶
Recursive conversion (Dict)¶
pg.Dict converts a regular dict into its symbolic representation. Therefore,
if the input dict contains nested list or dict, they are converted to
instances of pg.List and pg.Dict respectively. For example:
regular_dict = {
'a': [1, 2, 3],
'b': {
'x': 1,
'y': 2,
},
}
symbolic_dict = pg.Dict(regular_dict)
# Nested lists and dicts are converted into symbolic ones.
assert isinstance(symbolic_dict.a, pg.List)
assert isinstance(symbolic_dict.b, pg.Dict)
Hashing (Dict)¶
A regular dict is not hashable. For example:
However, a symbolic dict is hashable, with a hash value computed from the symbolic representations of its items. Therefore, two bindings with the same type and parameters end up with the same hash value: