From b28b515dbe00d1687a175334c4237a8b924be212 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Mon, 12 Oct 2026 16:55:31 -0600 Subject: [PATCH 1/3] Update docs --- CLAUDE.md | 4 ++-- README.md | 28 +++++++++++++++++++++++----- pyproject.toml | 1 - 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fde5b7c..d43ecc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,9 +92,9 @@ in `tests/data/`. Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@py - **Formatting:** black and ruff (line-length 100), isort (black profile) - **Type checking:** ty in strict mode - All algorithm functions are pure functions — no classes wrapping algorithms -- Version is dynamic via `hatchling-vcs` (git tags like `vX.Y.Z`) +- Version is dynamic via `hatchling-vcs` (git tags like `X.Y.Z`, no `v` prefix) ## Publishing -Tag a commit with `git tag -a vX.Y.Z -m 'message'` and push tags to trigger PyPI publish via GitHub +Tag a commit with `git tag -a X.Y.Z -m 'message'` and push tags to trigger PyPI publish via GitHub Actions. diff --git a/README.md b/README.md index 2018def..5004430 100755 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ ## A Python module for efficient graph theoretic programming -[Documentation](https://graphworks.readthedocs.io) | [Wiki](https://github.com/nathan-gilbert/graphworks/wiki) ### Quick Start @@ -130,6 +129,10 @@ Version is managed automatically via git tags using `hatchling-vcs`. export - [x] `add_edge` supports `weight` and `label` keyword arguments - [x] `edge()` lookup method for direct O(1) edge retrieval +- [ ] `remove_vertex()` / `remove_edge()` (there is currently no way to delete anything) +- [ ] `copy()` (today the only copy is `Graph() | g`) +- [ ] `predecessors()` / in-neighbors for directed graphs (`is_connected`, `find_circuit`, and + `find_isolated_vertices` each recompute in-edges by hand) ### Tier 3: Lossless conversions @@ -141,10 +144,11 @@ Version is managed automatically via git tags using `hatchling-vcs`. ### Tier 4: Algorithms +- [ ] Unweighted shortest path via BFS (`find_path` returns *a* path, not the shortest; + `diameter` already computes BFS distances internally) - [ ] Dijkstra's shortest path (weighted) - [ ] Prim's / Kruskal's minimum spanning tree -- [ ] Strongly connected components (Tarjan or Kosaraju) -- [ ] Improved shortest-path implementations leveraging weighted edges +- [ ] Strongly connected components (Tarjan or Kosaraju) and `is_strongly_connected` ### Tier 5: Export and CLI @@ -157,8 +161,22 @@ Version is managed automatically via git tags using `hatchling-vcs`. ### Tier 6: Cross-cutting quality -- [ ] Thread safety (immutable graph views or locking around mutations) -- [ ] Input validation hardening +- [ ] Iterative traversals: `find_path`, `find_all_paths`, and `topological_sort` are recursive and + raise `RecursionError` on a ~3,000-vertex chain +- [ ] Input validation hardening: + - [ ] Reject non-list neighbor values in JSON (`{"A": "B"}` is silently read as the characters + of `"B"`) + - [ ] Raise when more than one of `input_file` / `input_graph` / `input_matrix` is given (extras + are silently ignored) +- [ ] Read-only graph view (instead of locking for thread safety) - [ ] Performance benchmarks +- [ ] Run docstring examples as tests (`--doctest-modules`) so examples cannot go stale + +### Tier 7: Docs and releases + +- [ ] `CHANGELOG.md` (linked from the PyPI metadata but missing), starting with the 0.10.0 + breaking changes +- [ ] Hosted API documentation (Sphinx deps exist in the `[docs]` extra, but there is no `docs/` + source and the Read the Docs site does not exist) [1]: https://github.com/nathan-gilbert/graphworks/actions/workflows/python-package-ci.yml diff --git a/pyproject.toml b/pyproject.toml index c9298a4..c15f4e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ Homepage = "https://github.com/nathan-gilbert/graphworks" Repository = "https://github.com/nathan-gilbert/graphworks" "Bug Tracker" = "https://github.com/nathan-gilbert/graphworks/issues" Changelog = "https://github.com/nathan-gilbert/graphworks/blob/main/CHANGELOG.md" -Documentation = "https://graphworks.readthedocs.io" [project.scripts] demo = "examples.demo:main" From 8a86e076dcb423f7cfd018e5d6e21add847e3eb8 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Mon, 12 Oct 2026 17:01:19 -0600 Subject: [PATCH 2/3] Remove stuff from graphs --- CLAUDE.md | 4 +- README.md | 7 ++- src/graphworks/graph.py | 89 +++++++++++++++++++++++++++++++- tests/test_graph.py | 109 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d43ecc1..44d2ab9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,8 +51,8 @@ prek run --all-files same `Edge` object), so `neighbors()` is symmetric and `edges()` de-duplicates. Accepts JSON files/strings (neighbors may be bare names or `{"target", "weight", "label", "attrs"}` objects; optional `"vertices"` key for vertex labels/attrs) or stdlib adjacency matrices. Supports - directed, weighted, and labeled graphs, fluent `add_*` chaining, `|`/`|=` union, and structural - `==`. + directed, weighted, and labeled graphs, fluent `add_*`/`remove_*` chaining, `copy()`, + `predecessors()` (O(V) scan; there is no reverse index), `|`/`|=` union, and structural `==`. - **`vertex.py`** / **`edge.py`** — Frozen `Vertex` and `Edge` dataclasses; identity is the name (vertex) or `(source, target, directed)` (edge). `Edge.create` / `Vertex.create` accept a plain `attrs` dict. diff --git a/README.md b/README.md index 5004430..76a921e 100755 --- a/README.md +++ b/README.md @@ -129,10 +129,9 @@ Version is managed automatically via git tags using `hatchling-vcs`. export - [x] `add_edge` supports `weight` and `label` keyword arguments - [x] `edge()` lookup method for direct O(1) edge retrieval -- [ ] `remove_vertex()` / `remove_edge()` (there is currently no way to delete anything) -- [ ] `copy()` (today the only copy is `Graph() | g`) -- [ ] `predecessors()` / in-neighbors for directed graphs (`is_connected`, `find_circuit`, and - `find_isolated_vertices` each recompute in-edges by hand) +- [x] `remove_vertex()` / `remove_edge()` (chainable; removing a vertex drops its incident edges) +- [x] `copy()` (independent structure, shared immutable `Vertex`/`Edge` objects) +- [x] `predecessors()` for directed graphs (O(V); same as `neighbors()` when undirected) ### Tier 3: Lossless conversions diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py index ea982e7..49c58b1 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -27,8 +27,11 @@ * a stdlib adjacency matrix (``input_matrix``), or * programmatically via :meth:`add_vertex` / :meth:`add_edge`. -Fluent chaining is supported — :meth:`add_vertex`, :meth:`add_edge`, and :meth:`add_edges` all -return ``self`` so calls can be chained:: +Vertices and edges can be deleted with :meth:`remove_vertex` / :meth:`remove_edge`, and +:meth:`copy` returns an independent copy. + +Fluent chaining is supported — :meth:`add_vertex`, :meth:`add_edge`, :meth:`add_edges`, +:meth:`remove_vertex`, and :meth:`remove_edge` all return ``self`` so calls can be chained:: >>> g = Graph("demo") >>> g.add_edge("A", "B").add_edge("B", "C").add_edge("C", "A") @@ -401,6 +404,21 @@ def size(self) -> int: """ return len(self.edges()) + def copy(self) -> Graph: + """Return an independent copy of this graph. + + The copy has the same label, flags, vertices, and edges. Mutating either graph (adding or + removing vertices or edges) does not affect the other; the immutable + :class:`~graphworks.vertex.Vertex` and :class:`~graphworks.edge.Edge` objects are shared. + + :return: A new graph equal to this one. + :rtype: Graph + """ + result = Graph(self._label, directed=self._directed, weighted=self._weighted) + result._vertices = dict(self._vertices) + result._adj = {v: dict(targets) for v, targets in self._adj.items()} + return result + # ------------------------------------------------------------------ # Vertex access # ------------------------------------------------------------------ @@ -470,6 +488,27 @@ def add_vertex( return self + def remove_vertex(self, name: str) -> Self: + """Remove vertex *name* and every edge incident to it. + + Returns ``self`` to support fluent call chaining:: + + >>> g = Graph().add_edge("A", "B").add_edge("B", "C") + >>> g.remove_vertex("B") + Graph('', order=2, size=0) + + :param name: Vertex name. + :type name: str + :return: This graph instance (for chaining). + :rtype: Self + :raises KeyError: If *name* is not in the graph. + """ + for source in self.predecessors(name): + del self._adj[source][name] + del self._adj[name] + del self._vertices[name] + return self + # ------------------------------------------------------------------ # Edge access # ------------------------------------------------------------------ @@ -615,6 +654,33 @@ def add_edges( return self + def remove_edge(self, source: str, target: str) -> Self: + """Remove the edge from *source* to *target*. + + For undirected graphs the argument order does not matter. Both vertices are kept. + + Returns ``self`` to support fluent call chaining:: + + >>> g = Graph().add_edge("A", "B").add_edge("B", "C") + >>> g.remove_edge("B", "A") + Graph('', order=3, size=1) + + :param source: Source vertex name. + :type source: str + :param target: Target vertex name. + :type target: str + :return: This graph instance (for chaining). + :rtype: Self + :raises KeyError: If there is no such edge. + """ + if self.edge(source, target) is None: + msg = f"No edge from {source!r} to {target!r} in the graph." + raise KeyError(msg) + del self._adj[source][target] + if not self._directed: + self._adj[target].pop(source, None) # absent for a self-loop + return self + # ------------------------------------------------------------------ # Neighbour access # ------------------------------------------------------------------ @@ -636,6 +702,25 @@ def neighbors(self, v: str) -> list[str]: raise KeyError(msg) return list(self._adj[v]) + def predecessors(self, v: str) -> list[str]: + """Return the names of all vertices with an edge *into* *v*. + + For directed graphs these are the in-neighbors of *v*, in vertex insertion order. For + undirected graphs this is the same as :meth:`neighbors`. Runs in O(V). + + :param v: Vertex name. + :type v: str + :return: List of vertex names with an edge to *v*. + :rtype: list[str] + :raises KeyError: If *v* is not in the graph. + """ + if not self._directed: + return self.neighbors(v) + if v not in self._adj: + msg = f"Vertex {v!r} is not in the graph." + raise KeyError(msg) + return [u for u, targets in self._adj.items() if v in targets] + # ------------------------------------------------------------------ # Matrix representation (stdlib only — no numpy) # ------------------------------------------------------------------ diff --git a/tests/test_graph.py b/tests/test_graph.py index dc8bfd9..c896276 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -394,3 +394,112 @@ def test_not_equal_to_other_types(self) -> None: def test_unhashable(self) -> None: with pytest.raises(TypeError): hash(Graph()) + + +# --------------------------------------------------------------------------- +# Removal, copy, and predecessors +# --------------------------------------------------------------------------- + + +class TestRemoveVertex: + def test_removes_incident_undirected_edges(self) -> None: + g = Graph().add_edges([("A", "B"), ("B", "C"), ("C", "A")]).remove_vertex("B") + assert g.vertices() == ["A", "C"] + assert g.neighbors("A") == ["C"] + assert g.neighbors("C") == ["A"] + assert g.size == 1 + + def test_removes_incoming_directed_edges(self) -> None: + g = Graph(directed=True).add_edges([("A", "B"), ("B", "C"), ("C", "B")]) + g.remove_vertex("B") + assert g.neighbors("A") == [] + assert g.neighbors("C") == [] + assert g.size == 0 + + def test_self_loop(self) -> None: + g = Graph().add_edge("A", "A").add_edge("A", "B").remove_vertex("A") + assert g.vertices() == ["B"] + assert g.neighbors("B") == [] + + def test_missing_raises(self) -> None: + with pytest.raises(KeyError): + Graph().remove_vertex("Z") + + def test_returns_self(self) -> None: + g = Graph().add_vertex("A") + assert g.remove_vertex("A") is g + + +class TestRemoveEdge: + def test_undirected_either_order(self) -> None: + g = Graph().add_edge("A", "B").remove_edge("B", "A") + assert g.size == 0 + assert g.neighbors("A") == [] + assert g.neighbors("B") == [] + assert g.vertices() == ["A", "B"] + + def test_directed_only_one_way(self) -> None: + g = Graph(directed=True).add_edges([("A", "B"), ("B", "A")]).remove_edge("A", "B") + assert g.edge("A", "B") is None + assert g.edge("B", "A") is not None + + def test_directed_wrong_direction_raises(self) -> None: + with pytest.raises(KeyError, match="No edge"): + Graph(directed=True).add_edge("A", "B").remove_edge("B", "A") + + def test_self_loop(self) -> None: + g = Graph().add_edge("A", "A").remove_edge("A", "A") + assert g.size == 0 + + def test_missing_vertex_raises(self) -> None: + with pytest.raises(KeyError): + Graph().remove_edge("A", "B") + + def test_returns_self(self) -> None: + g = Graph().add_edge("A", "B") + assert g.remove_edge("A", "B") is g + + +class TestCopy: + def test_equal_but_independent(self) -> None: + g = Graph("orig", weighted=True).add_edge("A", "B", weight=2) + c = g.copy() + assert c == g + assert (c.label, c.directed, c.weighted) == ("orig", False, True) + c.add_edge("B", "C").remove_edge("A", "B") + assert g.size == 1 + assert g.order == 2 + assert g.edge("A", "B").weight == 2 + + def test_mutating_original_leaves_copy(self) -> None: + g = Graph(directed=True).add_edge("A", "B") + c = g.copy() + g.remove_vertex("A") + assert c.vertices() == ["A", "B"] + assert c.neighbors("A") == ["B"] + + def test_preserves_vertex_and_edge_data(self) -> None: + g = Graph().add_vertex("A", label="Alpha").add_edge("A", "B", label="x") + c = g.copy() + assert c.vertex("A") == g.vertex("A") + assert c.vertex("A").label == "Alpha" + assert c.edge("B", "A").label == "x" + + +class TestPredecessors: + def test_directed(self) -> None: + g = Graph(directed=True).add_edges([("A", "C"), ("B", "C"), ("C", "D")]) + assert g.predecessors("C") == ["A", "B"] + assert g.predecessors("A") == [] + + def test_directed_self_loop(self) -> None: + assert Graph(directed=True).add_edge("A", "A").predecessors("A") == ["A"] + + def test_undirected_same_as_neighbors(self) -> None: + g = Graph().add_edge("A", "B").add_edge("B", "C") + assert g.predecessors("B") == g.neighbors("B") + + @pytest.mark.parametrize("directed", [True, False]) + def test_missing_raises(self, directed: bool) -> None: + with pytest.raises(KeyError): + Graph(directed=directed).predecessors("Z") From bf95785d95e8ce9bc7c3aac8ac2b9d8cac7f298a Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Mon, 12 Oct 2026 17:03:09 -0600 Subject: [PATCH 3/3] Update todos --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 76a921e..d541129 100755 --- a/README.md +++ b/README.md @@ -169,6 +169,10 @@ Version is managed automatically via git tags using `hatchling-vcs`. are silently ignored) - [ ] Read-only graph view (instead of locking for thread safety) - [ ] Performance benchmarks +- [ ] Reverse adjacency index so `predecessors()` is O(in-degree) instead of O(V); then switch + `is_connected`, `find_circuit`, and `find_isolated_vertices` to it (they currently rebuild + in-edges by hand, which is faster than calling the O(V) `predecessors()` per vertex). Every + add/remove path and `copy()` must maintain the index. - [ ] Run docstring examples as tests (`--doctest-modules`) so examples cannot go stale ### Tier 7: Docs and releases