Advanced User Guide¶
This guide explains how holden is put together and how to extend it. Every example on this page is run by the test suite.
Structural subtyping¶
The is_{form} functions in holden.check look at the structure of an object rather than its class. So, a plain dict of set values is an adjacency list, even if it was not created by holden.
| Form | Raw structure | Checker |
|---|---|---|
adjacency |
dict[Node, set[Node]] |
is_adjacency |
edges |
list[tuple[Node, Node]] |
is_edges |
matrix |
tuple[list[list[float]], list[Node]] of a matrix and labels |
is_matrix |
parallel |
list[list[Node]] |
is_parallel |
serial |
list[Node] |
is_serial |
import dataclasses
import holden
raw = {"a": {"b"}, "b": set()}
assert holden.is_adjacency(raw)
assert isinstance(raw, holden.Adjacency) # a raw structure is an instance
assert not isinstance(raw, holden.Edges)
assert isinstance(holden.System(raw), holden.Adjacency)
Some raw structures can match more than one form. An empty list is a valid edge list, path list, and path. holden.classify checks the forms in this order: adjacency, edges, matrix, parallel, serial, and then any forms that you have registered.
Only the classes for registered forms (see below) accept raw structures in isinstance. A subclass, such as System, only has instances that were created from it.
Add your own checker¶
add_checker adds a function to holden.check, so that classify can use it. The name must be is_{form}, where form is the name of a registered form.
@dataclasses.dataclass
class Bag(holden.Composite):
"""Unordered collection of nodes."""
contents: set = dataclasses.field(default_factory=set)
@property
def nodes(self):
return set(self.contents)
def _add(self, item, **kwargs):
self.contents.add(item)
def _delete(self, item, **kwargs):
self.contents.discard(item)
def _merge(self, item, **kwargs):
self.contents.update(item)
def _subset(self, include=None, exclude=None):
return self.__class__(set(self._selected(self.contents, include, exclude)))
holden.add_checker("is_bag", lambda item: isinstance(item, set))
assert holden.classify({"a", "b"}) == "bag"
The registry of forms¶
holden.Forms is a registry. Every class that inherits directly from Composite (or, for graphs, from Graph) is added to it when the class is created. The key is the snake case name of the class. Subclasses of those classes, like System, are not registered.
assert holden.Forms.registry["bag"] is Bag
assert holden.Forms.registry["adjacency"] is holden.Adjacency
assert "system" not in holden.Forms.registry
A registered form is used by classify (for classes) and by Forms.transform (which returns an instance of the registered class).
To use your own class for a form, subclass the form and call holden.set_base.
@dataclasses.dataclass
class LoggedEdges(holden.Edges):
"""Edge list that could do something useful when it is used."""
original = holden.get_base("edges")
holden.set_base("edges", LoggedEdges)
try:
system = holden.System.from_edges([("a", "b")])
assert type(system.edges) is LoggedEdges
finally:
holden.set_base("edges", original)
assert type(system.edges) is holden.Edges
Writing a form¶
A composite data structure needs the nodes property and the _add, _delete, _merge, and _subset methods. Graphs also need _connect and _disconnect. The public methods (add, delete, merge, subset, connect, and disconnect) do all of the error checking, so the private methods only have to do the work for the way that the data is stored. The Bag above is a complete example.
bag = Bag()
bag.add("a")
bag.add("b")
assert bag.nodes == {"a", "b"}
assert "a" in bag
bag.delete("a")
assert bag.subset(include="b").contents == {"b"}
bag.merge({"c", "d"})
assert bag.nodes == {"b", "c", "d"}
_selected(nodes, include, exclude) is a helper for _subset. It returns the nodes that are in include (unless include is None) and not in exclude.
Because Bag does not have edges, it is not a graph. To make a graph, inherit directly from Graph and provide the connection methods. A graph that can only store nodes that are part of an edge (such as an edge list) should set _implicit_nodes = True. Then connect creates the nodes that are missing instead of raising an error.
Conversions to and from a new form need transformers. add_transformer adds a function to holden.workshop, where transform looks for {source}_to_{output} functions. Only a conversion to and from one form (usually adjacency) is required if you add every conversion that you need.
def bag_to_adjacency(item):
return {node: set() for node in item}
def adjacency_to_bag(item):
return set(item)
holden.add_transformer("bag_to_adjacency", bag_to_adjacency)
holden.add_transformer("adjacency_to_bag", adjacency_to_bag)
assert holden.transform({"a", "b"}, "adjacency") == {"a": set(), "b": set()}
assert holden.transform({"a": {"b"}, "b": set()}, "bag") == {"a", "b"}
assert isinstance(holden.Forms.transform({"a"}, "adjacency"), holden.Adjacency)
Traits¶
Traits are mixins. Put them in front of the form in the list of base classes. Directed must be before the form so that the + operators of Directed are used.
| Trait | Adds |
|---|---|
Directed |
root, endpoint, walk, append, prepend, +, +=, and reflected + |
Exportable |
to_dot and to_mermaid |
Fungible |
properties for every form and from_{form} class methods |
Labeled |
a name that is used for hashing and equality |
Storage |
a library with store, retrieve, and discard |
Weighted |
a weight and float() |
@dataclasses.dataclass
class Network(
holden.Directed, holden.Fungible, holden.Exportable, holden.Matrix):
"""Directed graph that is stored as an adjacency matrix."""
network = Network.from_edges([("a", "b"), ("b", "c")])
assert isinstance(network.contents, list)
assert network.labels == ["a", "b", "c"]
assert network.walk() == [["a", "b", "c"]]
assert network.to_dot(name="network").startswith("digraph network")
System is a class with Directed, Fungible, and Exportable on top of Adjacency. Serial and Parallel also include them.
Weighted edges and nodes¶
Edge subclasses can add fields. They are still edges: only the start and stop are used for indexing, unpacking, and len.
@dataclasses.dataclass(frozen=True)
class WeightedEdge(holden.Edge):
weight: float = 1.0
edge = WeightedEdge("a", "b", 2.5)
assert tuple(edge) == ("a", "b")
edges = holden.Edges()
edges.connect(edge)
assert edges.nodes == {"a", "b"}
Weighted adds the weight to something that is not a frozen dataclass, such as a Node.
@dataclasses.dataclass
class WeightedNode(holden.Weighted, holden.Node):
pass
assert float(WeightedNode("a", weight=2)) == 2.0
A Matrix stores the weight of an edge as the value in the matrix. Any value that is not zero is an edge, and connect does not overwrite a value that is already there.
weighted = holden.Matrix([[0, 4.5], [0, 0]], ["a", "b"])
weighted.connect(("a", "b"))
assert weighted.contents[0][1] == 4.5
assert holden.matrix_to_adjacency(weighted) == {"a": {"b"}, "b": set()}
Paths¶
Serial and Parallel store paths instead of graphs. A Serial is a single path. A Parallel is several paths.
serial = holden.Serial(["load", "clean", "model"])
assert serial.root == ["load"]
assert serial.endpoint == ["model"]
assert serial.walk("clean") == [["clean", "model"]]
assert serial[0] == "load"
assert serial[1:] == ["clean", "model"]
assert serial["clean"] == "clean" # access by name is inherited from `bunches`
parallel = holden.Parallel([["a", "b"], ["a", "c"]])
assert parallel.root == ["a"]
assert parallel.endpoint == ["b", "c"]
assert parallel.nodes == {"a", "b", "c"}
Appending to a Parallel follows the meaning of appending to a graph: every path is followed by every path of the appended item.
parallel.append([["x"], ["y"]])
assert parallel.contents == [
["a", "b", "x"], ["a", "b", "y"], ["a", "c", "x"], ["a", "c", "y"]]
Converting a graph to a Serial follows the paths from its roots to its endpoints. If there is only one path, that path is returned. If there is more than one, the paths are joined end to end. Converting to a Parallel returns the paths themselves.
dag = holden.System.from_edges([("a", "b"), ("a", "c")])
assert dag.parallel.contents == [["a", "b"], ["a", "c"]]
assert dag.serial.contents == ["a", "b", "a", "c"]
assert holden.System.from_edges([("a", "b"), ("b", "c")]).serial.contents == [
"a", "b", "c"]
Graphs that have cycles have no roots on the cycle, so paths that need to start on a cycle are not found. A path never visits the same node twice.
Functional style¶
Everything that the classes do can also be done with functions that take and return raw structures.
| Module | Functions |
|---|---|
holden.check |
is_adjacency, is_composite, is_edge, is_edges, is_graph, is_matrix, is_node, is_nodes, is_parallel, is_serial |
holden.workshop |
{form}_to_{form} transformers |
holden.report |
get_roots, get_endpoints, and get_roots_{form} and get_endpoints_{form} for each form |
holden.traverse |
walk and walk_{form} for each form |
holden.export |
to_dot and to_mermaid |
adjacency = {"a": {"b", "c"}, "b": set(), "c": set()}
assert holden.get_roots(adjacency) == ["a"]
assert holden.get_endpoints_adjacency(adjacency) == ["b", "c"]
assert holden.walk(adjacency) == [["a", "b"], ["a", "c"]]
assert holden.walk_adjacency(adjacency, "a", "c") == [["a", "c"]]
Determinism¶
The connections of a node in an adjacency list are stored in a set, which does not have a stable order between python sessions. Every function in holden that iterates over a set (the conversions, walk, and the exporters) sorts by the str of each node so that its output is reproducible. Nodes that are the keys of a dict keep their order of insertion.
Performance¶
holden is designed for clarity and small graphs, not for speed. walk finds every path, so the number of paths that it returns can grow exponentially with the size of the graph. walk_adjacency uses a loop rather than recursion, so the depth of a path is not limited by the recursion limit of python.