holden
| Version | |
| Status | |
| Documentation | |
| Tools | |
| Compatibility | |
| Stats | |
What is holden?
This package is named after the Roci's captain in The Expanse, James Holden, who was adept at furling his brow and recognizing connections. In a similar vein, holden offers users easy-to-use composite data structures without the overhead or complexity of larger graph packages. The included graphs are built for basic workflow design or analysis of conditional relationships. They are not designed for big data network analysis or similar large-scale projects (although nothing prevents you from using them in that manner). Rather, the goal of holden is to provide lightweight, turnkey, extensible composite data structures without all of the stuff you don't need in packages like networkx. holden serves as the base for my chrisjen workflow package (similarly named for a character from The Expanse), but I have made holden available separately for easier integration into other uses.
Why use holden?
Simple
The basic building blocks provided are:
Composite: the abstract base class for all types of composite data structuresGraph: subclass ofCompositeand the base class for all graph data structuresEdge: an optional edge class which can be treated as a drop-intuplereplacement or extended for greater functionalityNode: an optional vertex class which provides universal hashability and some other convenient functionsForms: a registry that automatically stores all directCompositesubclasses to allow flexible subtype checking of and transformation between composite subtypes using itsclassifyandtransformmethods
Out of the box, Graph has several subtypes with varying internal storage formats:
Adjacency: an adjacency list using adict[Node, set[Node]]structureMatrix: an adjacency matrix that uses alist[list[float | int]]for mapping edges and a separatelist[Node]attribute (labels) that corresponds to the rows and columns of the matrixEdges: an edge list structure that uses alist[tuple[Node, Node]]format
Paths through a graph are stored in two other composite data structures:
Serial: a single path, stored as alist[Node]Parallel: a collection of paths, stored as alist[list[Node]]
System is a ready-to-use directed graph that is stored as an adjacency list. It combines the Adjacency form with the traits described below.
You can use holden without any regard to what is going on inside the graph. The methods and properties are the same regardless of which internal format is used. But the different forms are provided in case you want to utilize the advantages or avoid certain drawbacks of a particular form. Unless you want to design a different graph form, you should design subclasses to inherit from one of the included forms and add mixins to expand functionality.
Flexible
Various traits can be added to graphs, nodes, and edges as mixins including:
- Weighted edges (
Weighted) - Ability to create a graph from or convert any graph to any recognized form using properties and class methods with consistent syntax (
Fungible) - Directed graphs with roots, endpoints, paths, and
+for joining graphs (Directed) - Automatically names objects if a name is not passed (
Labeled) - Has methods to convert and export to other graph formats (
Exportable) - Ability to store node data internally for easy reuse separate from the graph structure (
Storage)
holden provides transformation methods between all of the internal storage forms as well as functions to convert graphs into a set of paths (Parallel) or a single path (Serial). The transformation methods can be used as class properties or with functions using an easy-to-understand naming convention (e.g., adjacency_to_edges or edges_to_parallel).
holden's framework supports a wide range of coding styles. You can create complex multiple inheritance structures with mixins galore or simpler, compositional objects. Even though the data structures are necessarily object-oriented, all of the tools to modify them are also available as functions, for those who prefer a more functional approach to programming.
The package also uses structural subtyping that allows raw forms of the supported composite subtypes to be used and recognized as the same forms for which holden includes classes. So, for example, the is_adjacency function will recognize any object with a dict[Node, set[Node]] structure and isinstance(item, holden.Adjacency) will similarly return True for a raw adjacency list.
Getting started
Requirements
holden requires Python 3.11 or later. It runs on Linux, macOS, and Windows. Its only dependencies are bunches (the base classes for its collections) and wonka (the base class for its registry). Both are installed automatically by pip.
Installation
To install holden, use pip:
pip install holden
Usage
Build a graph
A System can be created from any raw form of a graph: an edge list, an adjacency list, an adjacency matrix, a list of paths, or a single path. Nodes can be any hashable object, but the examples here use strings.
import holden
edges = [("a", "b"), ("c", "d"), ("a", "d"), ("d", "e")]
dag = holden.System.from_edges(edges)
dag["a"] == {"b", "d"} # True
dag.nodes == {"a", "b", "c", "d", "e"} # True
"c" in dag # True
"z" in dag # False
A graph can also be built by hand. Nodes must be added before they are connected and an edge cannot connect a node to itself.
workflow = holden.System()
for step in ("load", "clean", "model", "report"):
workflow.add(step)
workflow.connect(("load", "clean"))
workflow.connect(("clean", "model"))
workflow.connect(("model", "report"))
workflow.walk() # [['load', 'clean', 'model', 'report']]
workflow.disconnect(("model", "report"))
workflow.delete("report")
workflow.endpoint # ['model']
add, delete, connect, disconnect, merge, and subset work the same way for every form of graph. Each one raises an informative error (TypeError, ValueError, or KeyError) if it is passed something that does not make sense.
dag.merge({"e": {"f"}, "f": set()})
dag["e"] == {"f"} # True
smaller = dag.subset(exclude=["f", "e"])
smaller.nodes == {"a", "b", "c", "d"} # True
# Raises ValueError: The starting point of an edge cannot be the same as the ending point
dag.connect(("a", "a"))
Find roots, endpoints, and paths
A directed graph has roots (nodes with nothing pointing at them), endpoints (nodes that point at nothing), and paths from roots to endpoints. walk returns every path. A start and/or a stop can be passed to limit the paths.
dag.root # ['a', 'c']
dag.endpoint # ['b', 'f']
dag.walk("c") # [['c', 'd', 'e', 'f']]
dag.walk("a", "f") # [['a', 'd', 'e', 'f']]
Graphs can be joined with append and prepend (or + and +=). Appending connects every endpoint of the graph to every root of the joined graph. A single node can also be appended or prepended.
pipeline = holden.System.from_edges([("load", "clean")])
pipeline.append("model")
pipeline.prepend("download")
pipeline.walk() # [['download', 'load', 'clean', 'model']]
pipeline += holden.System.from_edges([("report", "email")])
pipeline.endpoint # ['email']
Change the form
Every graph can be viewed in every other form using the properties of the Fungible trait. Each property returns an instance of the class for that form. The from_{form} class methods create a new graph from any raw form or instance.
dag = holden.System.from_edges(edges)
dag.edges.contents # [('a', 'b'), ('a', 'd'), ('c', 'd'), ('d', 'e')]
dag.matrix.labels # ['a', 'b', 'c', 'd', 'e']
dag.matrix.contents[0] # [0, 1, 0, 1, 0]
dag.parallel.contents # [['a', 'b'], ['a', 'd', 'e'], ['c', 'd', 'e']]
dag.serial.contents # ['a', 'b', 'a', 'd', 'e', 'c', 'd', 'e']
same_dag = holden.System.from_matrix(dag.matrix)
same_dag == dag # True
A Serial is a single path. When a graph has one path, the Serial is that path. When a graph has more than one path, its paths are joined end to end (as shown above). A Parallel keeps the paths separate.
If you prefer functions, every conversion is also available as a function that takes and returns raw structures. The names are {form}_to_{form}.
holden.adjacency_to_edges({"a": {"b"}, "b": set()}) # [('a', 'b')]
holden.edges_to_matrix([("a", "b")]) # ([[0, 1], [0, 0]], ['a', 'b'])
holden.matrix_to_parallel(([[0, 1], [0, 0]], ["a", "b"])) # [['a', 'b']]
holden.transform([("a", "b"), ("b", "c")], "serial") # ['a', 'b', 'c']
Use raw structures
Because holden uses structural subtyping, you can ask about the form of any object. The is_{form} functions check the structure and classify returns the name of the form.
raw = {"a": {"b"}, "b": set()}
holden.is_adjacency(raw) # True
holden.is_edges(raw) # False
holden.classify(raw) # 'adjacency'
isinstance(raw, holden.Adjacency) # True
isinstance([("a", "b")], holden.Edges) # True
isinstance(raw, holden.Edges) # False
An edge is a tuple of two nodes (or an Edge). Lists are used for paths.
holden.is_edge(("a", "b")) # True
holden.is_edge(["a", "b"]) # False
holden.Edge("a", "b") == ("a", "b") # True
Export a graph
The Exportable trait (part of System) exports to Graphviz dot and mermaid formats. Both return a str and, if a path is passed, save a file.
print(dag.to_dot(name="dag"), end="")
# Output:
# digraph dag {
# a -> b
# a -> d
# c -> d
# d -> e
# }
print(dag.to_mermaid(name="dag"), end="")
# Output:
# ---
# title: dag
# ---
# flowchart LR
# a(a) --> b(b)
# a(a) --> d(d)
# c(c) --> d(d)
# d(d) --> e(e)
Rendered, the two exports look like this:
The functions holden.to_dot and holden.to_mermaid do the same for any form of graph, including raw structures.
Name nodes and store data
The Labeled trait gives nodes a name. Labeled nodes are hashed and compared by name, so the name can be used anywhere the node could be.
import dataclasses
@dataclasses.dataclass
class Step(holden.Labeled, holden.Node):
pass
flow = holden.System()
flow.add(Step(name="load"))
flow.add(Step(name="clean"))
flow.connect(("load", "clean"))
"clean" in flow["load"] # True
flow.root[0].name # 'load'
The Storage trait stores data for nodes in a library, so the graph itself can be made of lightweight labels.
@dataclasses.dataclass
class Workflow(holden.Storage, holden.System):
pass
workflow = Workflow.from_edges([("load", "clean")])
workflow.store("load", "load_data.py")
workflow.retrieve("load") # 'load_data.py'
Build your own
Any direct subclass of Composite is added to Forms. Any subclass of a form (such as Adjacency) inherits the behavior of that form and can add traits as mixins. See the advanced user guide for details.
@dataclasses.dataclass
class Pipeline(holden.Directed, holden.Edges, holden.Fungible):
pass
pipeline = Pipeline.from_adjacency({"load": {"clean"}, "clean": set()})
pipeline.contents # [('load', 'clean')]
pipeline.walk() # [['load', 'clean']]
An Edges graph only stores nodes that are part of an edge, so connect (rather than add) is used to build one up. All of the other forms store nodes on their own.
Contributing
Contributors are always welcome. Feel free to grab an issue to work on or make a suggested improvement. If you wish to contribute, please read the Contribution Guide and Code of Conduct.
Similar Projects
- networkx: the market leader for python graphs. Offers greater flexibility and extensibility at the cost of substantial overhead.
Acknowledgments
holden is named for James Holden and his crew from The Expanse by James S. A. Corey (the pen name of Daniel Abraham and Ty Franck). The graph algorithms for finding paths are adapted from Python Patterns - Implementing Graphs by Guido van Rossum, and the sliding window function is adapted from more-itertools.
License
Use of this repository is authorized under the Apache Software License 2.0.
Metadata
Release files for holden 0.2.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| holden-0.2.2.tar.gz | 177.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| holden-0.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 218.0 kB
Release files / holden-0.2.2.tar.gz
| Download URL | holden-0.2.2.tar.gz |
|---|---|
| Size | 177.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4ebd75a18a2a2b5b052cc2831c199348f13625f7b8cd32640d13f8ee58536ba3
|
|
BLAKE2b-256 checksum How to use checksums |
0db35a8b87ec2328fc736380aaea90ecfbde46787444c7414ea718b799b1e571
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.20 {"installer":{"name":"uv","version":"0.12.20","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / holden-0.2.2-py3-none-any.whl
| Download URL | holden-0.2.2-py3-none-any.whl |
|---|---|
| Size | 40.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3365f7f0166815640cbccc990ed971b5a0c7a3629c254a84a2c4ecacd58ce8d9
|
|
BLAKE2b-256 checksum How to use checksums |
5d2c2c1b3fb38b145b8d41b41a15d7d92fe92b08b1527fe09b150a3acf75ec92
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.20 {"installer":{"name":"uv","version":"0.12.20","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|