1.0.0
First 1.0 release: six years of production use since the first commit in 2020 (including as a real dependency of both the Robotics Toolbox for Python and the Machine Vision Toolbox for Python), and this cycle's type hints, documentation, testing and bug-fixing pass, felt like the right point to commit to API stability. This is the first release with a considered API, so it contains several deliberate breaking changes, listed first with how to migrate.
Breaking changes (from 0.6.5)
- The
UGraph/DGraphconstructor arguments are keyword-only. 0.6.5 accepted and silently ignored a first positional argument, soUGraph(6)looked as though it set a dimension but did nothing. Now it is aTypeError: writeUGraph(dim=6). degreeof aDGraphis the graph-theory degree, in plus out, so a self-loop counts 2 and the degrees sum to twice the number of edges. It used to count only the edges leaving the vertex. This changesvertex.degree,graph.degree()andgraph.connectivity(), andgraph.average_degree()is now 2E/N for both kinds of graph (it was E/N for aDGraph). The previous values arevertex.out_degree,[v.out_degree for v in g]andg.ne / g.n.laplacian()is unchanged (out-degree minus adjacency). Undirected graphs are unchanged.graph.edges()is alistin the order the edges were added. It was the graph's ownset, with no defined order, although documented as a list. Bothgraph.edges()andvertex.edges()return a new list, so changing the result cannot corrupt the graph. Useset(g.edges())for set operations.Edge.v1andEdge.v2are read-only, and so isVertex.label(the graph assigns it, and reading it now finds the components, where 0.6.5 gaveNoneuntilnchad been asked for). Assigning an endpoint of an edge that was in a graph left the graph inconsistent and the edge impossible to remove. Give the vertices toEdge(v1, v2)or connect the edge withv1.connect(v2, edge=e).- Assigning
Vertex.namerenames it in the graph (sog[new]works) and refuses a name already in use orNone. add_vertexraisesValueErrorfor a name already used in the graph or a vertex that already belongs to a graph, instead of silently shadowing it. The default name#Nis now the first free one, which could previously repeat after a removal.- There is one vertex class,
Vertex: a vertex has no direction of its own, the graph it belongs to decides how an edge is recorded at its ends.UVertexandDVertexare deprecated names for it (below). A vertex given toadd_vertexis no longer checked against the kind of graph, because there is no kind to mismatch. An override of the private_attach/_detachhooks moves from a vertex subclass to aUGraph/DGraphsubclass. - The abstract base class
PGraphis nowGraph, andGraphandVertexare exported from the package.Graphis concrete:Graph(directed=True)is aDGraphandGraph()aUGraph. Adjacency()treats a non-zero diagonal element as a self-loop.- The
strof an edge between vertices that are not in a graph shows--, because there is no kind of graph to say whether it is directed.
Deprecated (still work, with a DeprecationWarning)
Laplacian()islaplacian(): a capital initial denotes a class method that builds a graph, likeAdjacency()andDict().UVertexandDVertexareVertex.remove()isremove_vertex()orremove_edge(), which say which they do.- The options of
plot()are named for what they are:voptisvertex_opt,eoptisedge_opt,textislabelsandtext_opt,colorcomponentsiscolor_componentsandforce2disforce_2d. The positional order is unchanged; giving both an old name and its new one is aTypeError.
Added
adjacent()andisadjacent(), the graph-theory names, which have one spelling.neighbours(),neighbors()andisneighbour()from 0.6.5 are kept as aliases, withisneighbor()added, and none of them warn.plot()draws each edge once. A directed graph has arrowheads, whicharrow_optstyles; edges that join the same pair of vertices, in either direction, are arcs that bow apart; and a loop is a circle at its vertex. In 0.6.5 a directed graph looked like an undirected one, parallel edges lay exactly on top of each other and a loop was a line of no length.plot()does the same in 3D, andhighlight_edge()follows an arc.Graph(directed=...), thedirectedproperty, andvertex_classandedge_classconstructor arguments, so that user subclasses ofVertexandEdgeare built by the graph.add_edge()accepts anEdge, andconnect()anedge=argument.edges_to(dest)lists every edge from a vertex to another, so parallel edges can be seen;edgeto(dest)is still the cheapest.- Directed vertices record their incoming edges:
predecessors(),successors(),in_edges(),out_edges(),in_degreeandout_degree. export()writes a graph as GraphViz dot, Mermaid, GraphML or ELK JSON, or as an image rendered by the GraphViz executables, with the layout preserved. Thepgraph.exportersmodule can be extended withregister_exporter.validate()reports every way in which a graph's records disagree, for tests and debugging.- A graph can be pickled, and
copy()works for graphs of any size.copy()raisedRecursionErrorfor a path of about 100 vertices. iscyclic(),remove_edge(),remove_vertex()and thedimargument, which declares that every embedded vertex has a coordinate of exactly that length.- Self-loops and parallel edges are supported throughout: adjacency, incidence, degree and Laplacian matrices, and planning, which takes the cheapest of parallel edges.
SE2Metric(weight)is the"SE2"metric with a weight on the angle, the length that a radian is worth, so that position and orientation need not count equally."SE2"isSE2Metric(), a weight of 1, so existing results are unchanged. It can be the metric or the heuristic of a graph and is pickled with its weight.- Spatial queries on an embedded graph:
coordinates()is the matrix of vertex coordinates, a column per vertex;within(box)the vertices inside a box;near(point, radius)those within a distance, nearest first; andsubgraph(vertices)the graph that a set of vertices induces, given as vertices, names or a boolean mask, as a new graph that shares nothing with the original. build_kdtree()makes an optional k-d tree thatclosest()andnear()use for the built-in metrics,SE2Metricincluded. It needs scipy, which is imported only then and is not a dependency; and it is dropped whenever a vertex is added or removed or a coordinate is assigned, so a stale one is never used.validate()also checks that coordinates are finite vectors of one length, and withembedded=Truethat every vertex has one.
Faster
path_BFS,path_UCSandpath_Astarscaled badly: they searched lists within, and took the minimum of the whole frontier at every step. Results and the verbose output are identical. On a random graph of 10,000 vertices and 30,000 edges, the mean of six random queries is 3 ms for breadth-first search where 0.6.5 took 172 ms, 18 ms for uniform cost where it took 1,500 ms, and 13 ms for A* where it took 410 ms.- The components are found by a union-find over the edges, 4 ms for that graph where 0.6.5 took 15 ms after a change.
- Creating a vertex is faster:
Vertex(coord)takes 0.35 µs where 0.6.5 took 1.5 µs, andg.add_vertex(coord)1.7 µs where it took 2.2 µs. Making the coordinate withnp.r_was most of the cost. import pgraphtakes 0.04 s where it took 0.6 s, and loads neither matplotlib, spatialmath nor scipy: they are imported by the methods that plot or build a spatial index. A script that only builds graphs and plans paths no longer pays for them.str(g)separates thousands:DGraph: 4,881 vertices, 8,392 edges, 14 components.
Fixed
Dict()always created undirected vertices, even for aDGraph;connect()joined vertices of two different graphs, and now also validates the endpoints of a supplied edge;path_BFS/path_UCS/path_Astarchecked the type of the start vertex twice and never the goal; anddistance()and the planners packednanorNoneinto their results for edges with no cost instead of raising.ncnever returned for many directed graphs, the smallest a 4-cycle, and neither didcomponent(),samecomponent()orplot(), which ask for it. Components of a directed graph are weakly connected, as they were meant to be, and are numbered from 0 by the first vertex of each, for both kinds of graph. They are also no longer found again by every call once the graph has changed.Edge.vertices()raisedDeprecationWarningas an exception; it now warns and returns the endpoints, asEdge.endpointsdoes.incidence()returned the wrong matrix for every graph with an edge: a loop variable overwrote the vertex index, so edges were marked in the wrong rows, and for a directed graph the vertex at the end of an edge was not marked at all. The columns followedges(), which is now in the order the edges were added, where 0.6.5 used the order of a set.remove()crashed for every directed edge;dotfile()closedsys.stdoutwhen called with its default arguments; and several methods raised a bareAttributeErrororTypeErrorinstead of a clear message for a vertex with no coordinate or no graph.highlight_edge(), and sohighlight_path(), raisedAttributeErrorfor a graph of three dimensions, because it drew a flat arrow in the 3D axes (0.6.5 drew lines), andhighlight_vertex()put its marker at z = 0. They draw in the dimension of the axes, so a 3D graph plotted withforce_2dis highlighted in 2D.- Removing a vertex left its
_graphpointing at the graph; renaming a vertex left the name table stale;g[x]accepted any type.
Known limitations
- A graph whose metric is a
lambdaor a locally defined function can be copied but not pickled; the built-in"L1","L2"and"SE2"and module-level functions can be.
Build, tests and documentation
- Modern type hints and reST docstrings throughout, most with a runnable
example, and
mypyis a CI check. - The documentation is organized by task, with the data structures explained and
a policy page on object lifecycle, and builds with
sphinx-pyrunblock(which replaces the unmaintainedsphinx-autorun) and the matplotlib plot directive. - The source distribution no longer contains a stale copy of the source
(
build/lib, left by a setuptools build before the move to thesrclayout), nor theRELEASEfile, which held0.6.1and which nothing read. - Python 3.9 to 3.14 are tested, the unused
scipydependency is dropped, and PyPI publishing is automated through GitHub Releases and trusted publishing. The workflow checks that the tag is the version inpyproject.tomland that the tagged commit is onmain, and installs the built wheel and runs a path query before it uploads. - The documentation site is deployed with the GitHub Pages actions. It had not been updated since January 2025, because the CI pushed to a branch that Pages no longer served.
llms.txtandllms-full.txtare published with the documentation, for AI assistants: a short introduction with a quick start and common mistakes, and the whole documentation as one text file.AGENTS.mddescribes the source for assistants that work on it.- Two notebooks in
examples/:roads.ipynb, the Queensland example, andlondon.ipynb, the road network of central London from OpenStreetMap as a directed graph with one-way streets, planned through with A*. There is also a benchmark,examples/benchmark.py, and the README has its results and an installation section. - The Queensland figure in the README shows the shortest route. It had been drawn with coordinates in metres and distances in kilometres, which made the A* heuristic far too large, so the route it found, 1,759 km, was not the shortest, 1,659 km.
0.6.3
- runs with Numpy2.x
- changed build system to .toml file
- misc doco and changes
- migrate package to src layout (pgraph under src/pgraph)
- switch build backend to hatchling
- update CI workflow for main branch and docs publish to gh-pages
- update repository links and badges from master to main