Constructing Graphtage Trees

Graphtage operates on trees represented by the graphtage.TreeNode base class. There are various predefined specializations of tree nodes, such as graphtage.IntegerNode for integers, graphtage.ListNode for lists, and graphtage.DictNode for dictionaries. graphtage.TreeNode has an optional parent and a potentially empty set of children.

Graphtage provides a graphtage.builder.Builder class for conveniently converting arbitrary objects into a tree of TreeNode objects. It uses Python magic to define the conversions.

from graphtage import IntegerNode, ListNode, StringNode, TreeNode
from graphtage.builder import BasicBuilder, Builder

class CustomBuilder(Builder):
    @Builder.builder(int)
    def build_int(self, node: int, children: list[TreeNode]):
        return IntegerNode(node)
>>> CustomBuilder().build_tree(10)
IntegerNode(10)

The @Builder.builder(int) decorator specifies that the function is able to build a Graphtage TreeNode object from inputs that are isinstance() of the type int. If there are multiple builder functions that match a given object, the function associated with the most specialized type is chosen. For example:

class Foo:
    pass


class Bar(Foo):
    pass


class CustomBuilder(Builder):
    @Builder.builder(Foo)
    def build_foo(self, node: Foo, children: list[TreeNode]):
        return StringNode("foo")

    @Builder.builder(Bar)
    def build_bar(self, node: Bar, children: list[TreeNode]):
        return StringNode("bar")
>>> CustomBuilder().build_tree(Foo())
StringNode('foo')
>>> CustomBuilder().build_tree(Bar())
StringNode('bar')

Expanding Children

So far we have only given examples of the production of leaf nodes, like integers and strings. What if a node has children, like a list? We can handle this using the @Builder.expander decorator. Here is an example of how a list can be built:

class CustomBuilder(Builder):
    ...

    @Builder.expander(list)
    def expand_list(self, node: list):
        """Returns an iterable over the node's children"""
        yield from node

    @Builder.builder(list)
    def build_list(self, node: list, children: list[TreeNode]):
        return ListNode(children)
>>> CustomBuilder().build_tree([1, 2, 3, 4])
ListNode((IntegerNode(1), IntegerNode(2), IntegerNode(3), IntegerNode(4)))

If an expander is not defined for a type, it is assumed that the type is a leaf with no children.

If the root node or one of its descendants is of a type that has no associated builder function, a NotImplementedError is raised.

Graphtage has a subclassed builder graphtage.builder.BasicBuilder that has builders and expanders for the Python basic types like int, float, str, bytes, list, dict, set, and tuple. You can extend graphtage.builder.BasicBuilder to implement support for additional types.

Custom Nodes

Graphtage provides abstract classes like graphtage.ContainerNode and graphtage.sequences.SequenceNode to aid in the implementation of custom node types. But the easiest way to define a custom node type is to extend off of graphtage.dataclasses.DataClassNode.

from graphtage import IntegerNode, ListNode, StringNode
from graphtage.dataclasses import DataClassNode

class CustomNode(DataClassNode):
    name: StringNode
    value: IntegerNode
    attributes: ListNode

This will automatically build a node type that has three children: a string, an integer, and a list.

>>> CustomNode(name=StringNode("the name"), value=IntegerNode(1337), attributes=ListNode((IntegerNode(1), IntegerNode(2), IntegerNode(3))))
CustomNode(name=StringNode('the name'), value=IntegerNode(1337), attributes=ListNode((IntegerNode(1), IntegerNode(2), IntegerNode(3))))

Let’s say you have another, non-graphtage class that corresponds to CustomNode:

class NonGraphtageClass:
    name: str
    value: int
    attributes: list[int]

You can add support for building Graphtage nodes from this custom class as follows:

class CustomBuilder(BasicBuilder):
    @Builder.expander(NonGraphtageClass)
    def expand_non_graphtage_class(self, node: NonGraphtageClass):
        yield node.name
        yield node.value
        yield node.attributes

    @Builder.builder(NonGraphtageClass)
    def build_non_graphtage_class(self, node: NonGraphtageClass, children: list[TreeNode]) -> CustomNode:
        return CustomNode(*children)

Data Class Slots

The annotations on a graphtage.dataclasses.DataClassNode subclass become its slots: the children of the node, in the order they are declared. Only annotations that name a graphtage.TreeNode subclass directly are turned into slots. Every other annotation is ignored, including a subscripted generic like list[IntegerNode], which is skipped rather than rejected:

class SkipsTheSecondAnnotation(DataClassNode):
    name: StringNode
    items: list[IntegerNode]
>>> SkipsTheSecondAnnotation._SLOTS
('name',)

Slot types are enforced when the node is constructed. Passing a node of the wrong type raises a ValueError:

>>> CustomNode(StringNode("the name"), StringNode("1337"), ListNode(()))
Traceback (most recent call last):
  ...
ValueError: Expected a node of type IntegerNode for argument CustomNode.value but instead got StringNode('1337')

A subclass cannot redefine a slot that one of its ancestors already declares. Doing so raises a TypeError when the subclass is defined, not when it is instantiated:

>>> class Redefined(CustomNode):
...     name: StringNode
Traceback (most recent call last):
  ...
TypeError: Dataclass Redefined cannot redefine slot 'name' because it is already defined in its superclass CustomNode

Initializing a Data Class Node

DataClassNode.__init__ assigns the slots from its positional and keyword arguments, so overriding it means reimplementing that assignment. Override graphtage.dataclasses.DataClassNode.post_init() instead. It is called once the slots have been assigned, and it should not call super().post_init(): every implementation in the class hierarchy is called automatically, starting with the least derived data class and ending with the class being instantiated.

class UnquotedName(DataClassNode):
    name: StringNode

    def post_init(self):
        self.name.quoted = False

Note

An implementation that a subclass inherits without overriding runs once, not once per class that inherits it.