Operations¶
Because an object keeps the arguments it was built from, it can be treated as a value your code operates on. These are the operations available on any object — and, since objects nest, on any node within one:
- Topological: find where a node sits, walk the tree, query it.
- Semantical: compare, order, hash, and diff by value; ask whether a program is finished.
- Replication: copy it, or write it to disk and read it back.
- Mutation: rewrite parts of it, by location, pattern, or rule.
- Tracking: record where an object came from, for debugging.
What needs topo=True
pg.Object subclasses are flat by default. What a flat object lacks is
a position: the accessors topo_path, topo_parent, topo_root, and
topo_ancestor raise SymbolicModeError, as do upward change
notification and contextual values. Declare such classes with topo=True
(inherited by subclasses), e.g. class Zoo(pg.Object, topo=True): ... —
as the zoo classes below are, since this page is largely about tree
position. Types created via pg.symbolize / pg.functor, and the
pg.Dict / pg.List containers, are tree nodes already.
Everything else on this page works in either mode — including traversal, query, and path-based mutation, which descend into a flat object just as they do a symbolic one, alongside equality, hashing, diff, clone, serialization, and formatting.
The examples below all operate on one zoo object.
The zoo classes (click to expand)
class Animal(pg.Object, topo=True):
name: str
class Python(Animal):
color: str = 'green'
class Shark(Animal):
pass
class Exhibit(pg.Object, topo=True):
animal: Animal
class Cage(Exhibit):
pass
class Pool(Exhibit):
pass
class Zoo(pg.Object, topo=True):
name: str
city: str
exhibits: pg.List[Exhibit] = []
zoo = Zoo(
name='San Diego Zoo',
city='San Diego',
exhibits=[
Cage(animal=Python(name='Bob', color='black')),
Pool(animal=Shark(name='Jack')),
],
)
When we talk about "manipulating the tree" in the context of a
zoo-based program, it means changing one or more sub-nodes within zoo.
Topological¶
Starting from an arbitrary node in a tree (e.g., Cage, Python,
Shark from the code above), PyGX allows the user to access any other nodes
in the tree. To do so, PyGX maintains bi-directional links between
containing/contained objects, allowing update notifications to
propagate through them.
Root¶
Users can access the root of the current tree via the
topo_root property. For example:
assert zoo.topo_root is zoo
assert zoo.exhibits[0].topo_root is zoo
assert zoo.exhibits[0].animal.topo_root is zoo
Parent node¶
Similarly, users can access the immediate parent (the containing node) of a
symbolic value via the topo_parent
property. For example, the Cage object in zoo has exhibits (a
list) as its parent:
Tip
PyGX automatically sets the parent of an object when assignment happens. For example:
Ancestor¶
topo_ancestor can be useful when users
require an ancestor in the containing chain that meets specific criteria
instead of the root or immediate parent. For instance, the following code
illustrates how to retrieve the nearest Zoo object from an Animal object
located in a zoo:
Child nodes¶
What counts as a child node depends on the type:
-
For classes, the child nodes are the arguments of the
__init__method, which can be accessed throughsym_init_args. For example: -
For functors, the child nodes are the bound arguments of the function, which can be directly accessed through its properties as well as
sym_init_args: -
For
pg.List, the child nodes are the items in the list, which can be directly accessed via the[]operator with their indices: -
For
pg.Dict, the child nodes are the key/value pairs stored in the dict, which can be accessed via either the[]operator or as attributes:
The same APIs test and access child nodes across every type:
| Method | Description |
|---|---|
sym_hasattr |
Test if a child key exists. |
sym_getattr |
Get the value of a child by key. |
sym_keys |
Iterate the child keys. |
sym_values |
Iterate the child values. |
sym_items |
Iterate child key/value pairs. |
For example:
assert list(zoo.sym_keys()) == ['name', 'city', 'exhibits']
assert list(zoo.sym_values())[0] == 'San Diego Zoo'
assert list(zoo.sym_items())[0] == ('name', 'San Diego Zoo')
assert zoo.sym_hasattr('name') is True
assert zoo.sym_getattr('name') == 'San Diego Zoo'
Descendants¶
In addition to accessing immediate child nodes,
sym_descendants is a handy tool to
retrieve all nodes in the sub-tree. Users can also specify a filter function
(using the argument where) and choose whether to include intermediate
nodes, leaves, or both in the returned nodes (using the argument option).
For instance, the following code demonstrates how to select all animals from
a zoo:
# `pg.eq` compares by value, and reaches inside plain containers.
assert pg.eq(
zoo.sym_descendants(lambda x: isinstance(x, Animal)),
[Python(name='Bob', color='black'), Shark(name='Jack')],
)
Location¶
Each object has a unique location in its tree, represented
as a key path (pg.KeyPath) — a path consisting of the
keys from the root node to the current node.
For example, a.b[0].c is a path with height 4:
- Level 0: an object or dict as the root node, bearing an empty key.
- Level 1: an object or dict assigned to attribute
aof the root node. - Level 2: a
pg.Listassigned to attributebof the level-1 node. - Level 3: an object or dict assigned to the first item of the level-2 list.
- Level 4: a value assigned to argument
cof the level-3 node.
The property topo_path is the API to access
the location, which is set when an object is added into a
tree, and is updated when the hierarchy of the tree changes.
Relational¶
IS-A and HAS-A are two common relationships among symbolic
representations. Symbolic objects are instances of their symbolic classes,
therefore the IS-A relation can be easily tested through Python's
isinstance operator. For the HAS-A relation,
pg.contains does the job. For example:
@pg.symbolize
def foo(x, y):
pass
@pg.symbolize
def bar(a, b):
pass
f = foo(1, 2)
b = bar(f, 3)
# `f` has an IS-A relation with class `foo`.
assert isinstance(f, foo)
assert isinstance(b, bar)
# `f` has a HAS-A relation with integer 2.
assert pg.contains(f, 2)
# HAS-A is transitive.
assert pg.contains(b, 2)
# HAS-A can be tested on types as well.
# The following code asks whether `b` contains any sub-node of type `foo`.
assert pg.contains(b, type=foo)
Traversal¶
pg.traverse is the API for facilitating symbolic
tree traversal:
- Users provide either a pre-order visitor function, a post-order visitor function, or both to perform the traversal.
- Each visitor function takes a tuple of (
key_path,value,parent) as input and returns an action (seepg.TraverseAction) to indicate whether to continue the traversal, stop, or skip the current branch.
For example:
def print_names(key_path, value, parent):
if isinstance(value, str) and isinstance(parent, Animal):
print(key_path, value)
return pg.symbolic.TraverseAction.ENTER
# Print every animal's name with the path it sits at.
pg.traverse(zoo, print_names)
Query¶
pg.query is the helper when the user needs to query a
tree, which selects nodes from the tree based on user-defined
predicates:
- A regular expression can be provided to perform path-based filtering.
- A value selector can be provided to perform value-based filtering.
- A custom selector can be provided to perform more complex filtering based on a node's path, value, and parent node.
For example:
class A(pg.Object, topo=True):
x: int
y: int
value = {
'a1': A(x=0, y=1),
'a2': [A(x=1, y=1), A(x=1, y=2)],
'a3': {
'p': A(x=2, y=1),
'q': A(x=2, y=2),
},
}
# Query by path regex.
print(pg.query(value, r'.*p'))
# {'a3.p': A(x=2, y=1)}
# Query by value.
print(pg.query(value, where=lambda v: v == 2))
# {
# 'a2[1].y': 2,
# 'a3.p.x': 2,
# 'a3.q.x': 2,
# 'a3.q.y': 2,
# }
# Query by path, value, and parent.
print(pg.query(
value, r'.*y',
where=lambda v, p: v > 1 and isinstance(p, A) and p.x == 1))
# {
# 'a2[1].y': 2,
# }
On top of pg.query, every symbolic object has an
inspect method that combines pg.query
with a print, useful in REPLs:
zoo.inspect(where=lambda v: isinstance(v, Animal))
# Prints each Animal node under zoo with its key path.
Formatting¶
A tree can be presented nicely for human consumption. By default,
all symbolic types override __repr__ and __str__ so that a human-readable
format can be shown during debugging:
__repr__formats a tree into a single-line string representation, which is usually used in error messages.__str__formats a tree into a multi-line string representation, which is usually used for debugging.
Both methods are based on pg.format, which provides a
rich set of features for formatting trees. For example, to exclude
keys with default values from the string representation:
class Foo(pg.Object):
x: int
y: int = 2
foo = Foo(x=1, y=2)
print(foo.format(compact=False))
# Foo(
# x=1,
# y=2
# )
print(foo.format(compact=False, exclude_defaults=True))
# Foo(
# x=1
# )
Semantical¶
In software development, developers often need to work with object representations rather than their states. This poses requirements such as comparing the equality of two representations, hashing objects using their representations, and cloning objects through their representations instead of duplicating their entire state. The APIs necessary for achieving these objectives are discussed in this section.
Equality¶
Symbolic equality is determined by matching types and equal symbolic attributes, regardless of whether the internal states are identical. For example:
class File(pg.Object):
file_path: str
def open(self):
# Internal state, NOT part of the binding.
self._file_handle = object()
path = 'a.json'
f1 = File(file_path=path)
# `f1.open()` gives `f1` internal state that `f2` does not have.
f1.open()
f2 = File(file_path=path)
assert pg.eq(f1, f2)
f1 and f2 are considered equal as they have the same file_path, even
though their _file_handle are different.
Symbolic equality can be tested via pg.eq and
pg.ne:
- For symbolic objects, the member method
sym_eqis called to determine whether they are symbolically equal. - For non-symbolic objects, the comparison is delegated to
object.__eq__andobject.__ne__.
Tip
For symbolic classes that subclass pg.Object,
whether to use symbolic equality as the default
__eq__/__ne__/__hash__ behavior can be customized via the class
variable
use_symbolic_comparison,
which is set to True by default. For classes symbolized via
pg.symbolize, this can be achieved by
specifying the eq argument to pg.symbolize, which is set to
False by default.
Less-than / greater-than¶
Two symbolic objects can be compared not only for equality, but also for
ordering. A symbolic object x is considered less than another symbolic
object y when:
- If
xandyare comparable by their values, the__lt__operator is used for comparison (e.g.,bool,int,float,str). - If
xandyare of the same type and are symbolic containers (e.g.,list,dict,pg.Symbolic), the order is determined by the order of their first differing sub-nodes. For example,['b']is greater than['a', 'b']. - If
xandyare not directly comparable and have different types, they are compared based on their types. The order of different types is:pg.MISSING_VALUE,NoneType,bool,int,float,str,list,tuple,set,dict, functions/classes. If different functions or classes are compared, their order is determined by their qualified name. - Non-symbolic classes can define the method
sym_ltto enable symbolic comparison.
Here are some examples:
class A(pg.Object, topo=True):
x: int = 0
assert pg.lt(False, True) == (False < True)
assert pg.lt(0.1, 1) == (0.1 < 1)
assert pg.lt('a', 'ab') == ('a' < 'ab')
assert pg.lt(['a'], ['a', 'b'])
assert pg.lt(['a', 'b', 'c'], ['b'])
assert pg.lt({'x': 1}, {'x': 2})
assert pg.lt({'x': 1}, {'y': 1})
assert pg.lt(A(x=1), A(x=2))
assert pg.lt(pg.MISSING_VALUE, None)
assert pg.lt(None, 1)
assert pg.lt(1, 'abc')
assert pg.lt('abc', [])
assert pg.lt([], {})
assert pg.lt([], A(x=1))
Similarly, pg.gt determines if a symbolic object is
greater than another symbolic object by its representation.
Hashing¶
The semantics of symbolic hashing are aligned with equality: two symbolically equal objects should produce the same symbolic hash value.
In PyGX, the symbolic hash can be computed via pg.hash:
- For symbolic objects, the member method
sym_hashis called to compute the symbolic hash value. - For non-symbolic objects, PyGX falls back to the original hash semantics.
Warning
Always override sym_hash when sym_eq is overridden.
Difference¶
The symbolic differences between two objects can be obtained via
pg.diff. pg.diff is a handy tool for figuring out
which parts of the objects differ.
Abstract forms¶
PyGX supports abstract objects through symbolic placeholding (see Placeholding), which allows creating and manipulating symbolic objects that are merely representations. Here is a summary of operations that detect the forms of symbolic objects.
| API | Method | Description |
|---|---|---|
pg.is_abstract |
sym_abstract |
Test whether an object is abstract. |
pg.is_partial |
sym_partial |
Test whether an object is partial. |
pg.is_pure_symbolic |
sym_puresymbolic |
Test whether an object is pure symbolic. |
pg.is_deterministic |
N/A | Test whether an object contains objects of pg.symbolic.NonDeterministic. |
The following APIs offer capabilities to query parts of special interest:
| Method | Description |
|---|---|
sym_missing |
Query the missing values from the object. |
sym_nondefault |
Query the non-default values from the object. |
Replication¶
Symbolic objects can be replicated in process or across processes. In-process replication is achieved by cloning, and inter-process replication is achieved by serialization/deserialization.
Warning
By default, symbolic replication does not deal with replication of
internal states, which means a replicated symbolic object is equivalent
to a freshly constructed object with the same binding parameters. The
user can optionally handle internal state replication by overriding the
_sym_clone and sym_jsonify methods.
Clone¶
Users can clone a symbolic object via the
pg.clone function or call the sym_clone member method
of the symbolic object. The semantics of symbolic clone are as follows:
- For symbolic types,
sym_cloneis called when cloning the object. - For non-symbolic types,
__copy__/__deepcopy__is called when cloning the object. Thedeepargument ofpg.clonedetermines which function to use.
It is common to clone a symbolic object with overrides. This can be done with
the overrides argument, which accepts a dictionary of paths to values to
override in the cloned object.
Serialization¶
The automatic serialization/deserialization capability for symbolic objects
is provided by the member method sym_jsonify and the class method
from_json. sym_jsonify converts the current symbolic object into a Python
dict mapped from strings to basic Python values, while from_json converts
them back.
Based on the two methods, PyGX provides a few helper methods for serialization and deserialization.
| Method | Description |
|---|---|
pg.to_json |
Converts a symbolic object into a plain Python dict. |
pg.from_json |
Converts a plain Python dict into a symbolic object. |
pg.to_json_str |
Converts a symbolic object into a JSON string. |
pg.from_json_str |
Creates a symbolic object from a JSON string. |
pg.save |
Saves a symbolic object to a file. |
pg.load |
Loads a symbolic object from a file. |
Tip
For deserialization to work, the user class definition needs to be imported first.
The save and load hook¶
pg.set_save_handler and
pg.set_load_handler are introduced to
allow users to plug in custom IO operations when calling
pg.save and pg.load. Through
this, users can load/save symbolic objects in cloud-based storage without
changing the client code.
Mutation¶
Symbolic mutation is the core of symbolic programming. PyGX provides a rich set of APIs for mutating symbolic objects.
Location-based mutations¶
Location-based mutation is the most basic form of symbolic mutation, performed
through Symbolic.sym_rebind with a dict of
key paths to new values:
zoo.sym_rebind({
'name': 'San Francisco Zoo',
'exhibits[0].animal.name': 'Alice',
'exhibits[1].animal.name': 'Bruce',
})
The keys are key paths (pg.KeyPath-compatible strings) and the values are
the new values for those nodes. Nodes that are not listed are left untouched.
Each rebind triggers the lifecycle events
(on_sym_change, on_sym_bound / on_sym_ready, parent notifications)
exactly once per affected object, regardless of how many keys in the same
call landed inside that object.
Pattern-based mutations¶
Often, the user mutates a symbolic object by rules. Many of these rules can
be described as patterns. For example: change the name property of all
objects, or change the filters property if the object type is Conv2D.
Built on top of Symbolic.sym_rebind, pygx.patching is a sub-module of PyGX
for pattern-based object patching. Common patterns are supported:
| Method | Description |
|---|---|
pg.patching.patch_on_key |
Replaces objects assigned to certain keys (described by a regular expression) in the tree. |
pg.patching.patch_on_path |
Replaces objects with certain paths (described by a regular expression) in the tree. |
pg.patching.patch_on_value |
Replaces objects whose values match a condition. |
pg.patching.patch_on_type |
Replaces objects of specific types in the tree. |
pg.patching.patch_on_member |
Replaces objects that are members of a given type. |
Rule-based mutations¶
More complex symbolic mutations are achievable by passing a transform function
to Symbolic.sym_rebind as a rebinding rule. The function takes three inputs —
the key_path, value, and parent of each node — and returns the new
value (or the original value to leave it untouched):
def shout_names(k, v, p):
# Fires on every string held by a `Zoo` or an `Animal`, at any depth.
if isinstance(v, str) and isinstance(p, (Zoo, Animal)):
return v.upper()
return v
zoo.sym_rebind(shout_names)
The traversal is bottom-up: leaf nodes are visited first, then their parents, so a transform can rely on its children already being in their post-transform form.
Command-based mutations¶
For transformations that are reusable across object trees, PyGX exposes a
declarative pg.patcher decorator that registers a
named patcher with arguments. Patchers can then be applied by name, which
makes them composable and serialisable:
@pg.patcher([('factor', pg.typing.Int(min_value=1))])
def scale_ints(unused_obj, factor):
def transform(k, v, p):
if isinstance(v, int):
return v * factor
return v
return transform
# Apply a patcher by URL-like spec.
pg.patch(zoo, 'scale_ints?factor=3')
See pygx.patching for the full registry / apply / compose
surface.
Sealing an object¶
Symbolic objects can be sealed against further mutation via
Symbolic.sym_seal(), and unsealed by passing
False:
zoo.sym_seal() # equivalent to zoo.sym_seal(True)
zoo.sym_rebind(name='X') # raises WritePermissionError
zoo.sym_seal(False)
zoo.sym_rebind(name='X') # OK
Sealing is useful for objects that should be treated as immutable after a
setup or configuration phase — for instance, a Trainer config that should
not be modified once training has started.
Tracking¶
Since a symbolic object can be created and modified at runtime, sometimes we
want to track the origin of symbolic objects for debugging purposes. PyGX
introduces an Origin class, an instance of which can be associated with a
symbolic object during its creation. The Origin object contains stack
information and the source form of the symbolic object — whether it's a
file path string or an object from which the current object was cloned. The
user can also add origin information to objects using
Symbolic.sym_setorigin and access it using the Symbolic.sym_origin
property.