Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,6 +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
- [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

Expand All @@ -141,10 +143,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

Expand All @@ -157,8 +160,26 @@ 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
- [ ] 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

- [ ] `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
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
89 changes: 87 additions & 2 deletions src/graphworks/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down Expand Up @@ -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
# ------------------------------------------------------------------
Expand All @@ -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)
# ------------------------------------------------------------------
Expand Down
109 changes: 109 additions & 0 deletions tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading