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
56 changes: 37 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ repository.
## Project Overview

Graphworks is a zero-dependency Python library for graph theory computation. It provides a `Graph`
class using adjacency-list storage and pure-function algorithm modules for traversal, path-finding,
properties, and directed graph operations. Optional extras add numpy matrix interop (`[matrix]`) and
Graphviz export (`[viz]`).
class (with immutable `Vertex` and `Edge` value objects) and pure-function algorithm and export
modules for traversal, path-finding, properties, directed graph operations, and rendering. Optional
extras add numpy matrix interop (`[matrix]`) and Graphviz export (`[viz]`).

## Development Commands

Expand Down Expand Up @@ -38,40 +38,58 @@ uv run ty check
# Code complexity
uv run xenon --max-average=A --max-modules=B --max-absolute=B src/

# Run all pre-commit hooks
pre-commit run --all-files
# Run all pre-commit hooks (CI also lints with ruff --preview rules such as PLR0917)
prek run --all-files
```

## Architecture

### Source layout: `src/graphworks/`

- **`graph.py`** — Core `Graph` class. Stores graphs as `defaultdict[str, list[str]]`. Accepts JSON
files/strings, stdlib adjacency matrices, or numpy arrays as input. Supports directed, weighted,
and labeled graphs.
- **`edge.py`** — `Edge` dataclass (`vertex1`, `vertex2`, `directed`, `weight`).
- **`graph.py`** — Core `Graph` class. Stores `_vertices: dict[str, Vertex]` and
`_adj: dict[str, dict[str, Edge]]`. Undirected edges are stored under **both** endpoints (the
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
`==`.
- **`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.
- **`types.py`** — Type alias `AdjacencyMatrix = list[list[int]]` (pure Python, no numpy).
- **`numpy_compat.py`** — Optional numpy interop, gated behind `[matrix]` extra.
- **`numpy_compat.py`** — Optional `ndarray_to_matrix` / `matrix_to_ndarray`, gated behind
`[matrix]`. `Graph` does not take ndarrays directly; convert first.
- **`utilities.py`** — Private helpers shared across modules (`_freeze_attrs`, `_file_stem`).
- **`algorithms/`** — Pure functions that take a `Graph` as input:
- `properties.py` — Degree, connectivity, density, complement, etc.
- `properties.py` — Degrees, connectivity, density, diameter, `complement`, etc.
- `paths.py` — `find_path()`, `find_all_paths()`, `find_isolated_vertices()`
- `search.py` — BFS, DFS, arrival/departure DFS
- `directed.py` — `is_dag()`, `find_circuit()` (Hierholzer's), `build_neighbor_matrix()`
- `sort.py` — Topological sort
- **`export/`** — `save_to_json()` and `save_to_dot()` standalone functions.
- **`data/`** — JSON test graph fixtures (g1–g4).
- `search.py` — `breadth_first_search()`, `depth_first_search()`
- `directed.py` — `is_dag()`, `find_circuit()` (Hierholzer's)
- `sort.py` — `topological_sort()`
- **`export/`** — `save_to_json()`, `save_to_dot()` (needs `[viz]`), `to_text()`, `to_ascii()`.
`_labels.py` holds the label/weight formatting shared by the renderers and DOT export.

### API conventions

- Unknown vertex names raise `KeyError`; `graph.vertex()` / `graph.edge()` return `None`.
- Predicates are vacuously true on an empty graph; undefined metrics raise `ValueError`.
- Failed preconditions raise `ValueError` (cyclic `topological_sort`, non-Eulerian
`find_circuit`, disconnected `diameter`, directed/undirected `Edge` mismatch in `add_edge`).
- Degree = number of incident edges (in + out for directed; self-loop counts 2).
- Recursion/accumulator state stays in private helpers, never in public signatures.

### Tests: `tests/`

Tests mirror the source modules (e.g., `test_properties.py` tests `algorithms/properties.py`).
Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`.
Tests mirror the source modules (e.g., `tests/algorithms/test_properties.py` tests
`algorithms/properties.py`). Shared JSON fixtures live in `conftest.py`; file fixtures (g1–g4) live
in `tests/data/`. Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`.

## Code Style and Conventions

- **Python 3.13+** required; `from __future__ import annotations` used throughout
- **PEP 257** docstrings on all public APIs; ruff enforces `ANN` (annotations) and `D` (docstrings)
rules on `src/` but exempts `tests/`
- **Formatting:** black (line-length 88), isort (black profile)
- **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`)
Expand Down
52 changes: 42 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,44 @@ pip install graphworks
```

```python
import json
from graphworks.graph import Graph
from graphworks import Graph, to_text
from graphworks.algorithms import find_path, is_connected

graph = Graph("roads").add_edge("A", "B", weight=2).add_edge("B", "C", label="toll")
print(graph.neighbors("B")) # ['A', 'C'] — undirected edges work both ways
print(find_path(graph, "C", "A")) # ['C', 'B', 'A']
print(is_connected(graph)) # True
print(to_text(graph))
```

json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}}
graph = Graph(input_graph=json.dumps(json_graph))
print(graph)
Graphs can also be loaded from JSON (`Graph(input_file=...)` / `Graph(input_graph=...)`); a
neighbor may be a bare name or an object carrying edge data:

```json
{
"label": "roads",
"directed": false,
"weighted": true,
"vertices": { "A": { "label": "Alpha" } },
"graph": { "A": ["B", { "target": "C", "weight": 2.5, "label": "toll" }], "B": [], "C": [] }
}
```

`save_to_json` writes this same format, so weights, labels, and attributes round-trip.

### API conventions

- `Graph` is the only class; algorithms (`graphworks.algorithms`) and exporters
(`graphworks.export`) are free functions that take a `Graph`.
- A vertex name that is not in the graph raises `KeyError` (`graph[v]`, `neighbors`, and every
algorithm). The `dict.get`-style `graph.vertex(name)` and `graph.edge(u, v)` return `None`.
- Predicates are vacuously true on an empty graph; metrics that are undefined there
(`min_degree`, `max_degree`, `diameter`) raise `ValueError`.
- Algorithms whose precondition fails raise `ValueError` rather than returning a wrong answer
(`topological_sort` on a cyclic graph, `find_circuit` on a non-Eulerian graph, `diameter` on a
disconnected graph).
- Degree counts every incident edge (in + out for directed graphs; a self-loop counts twice).

Optional extras:

```sh
Expand Down Expand Up @@ -96,17 +126,18 @@ Version is managed automatically via git tags using `hatchling-vcs`.

- [x] Internal storage uses `dict[str, Vertex]` for vertex lookup
- [x] Adjacency structure uses `dict[str, dict[str, Edge]]` for O(1) edge access
- [x] Edge weights, labels, and attributes survive all operations
- [x] Edge weights, labels, and attributes survive union, complement (vertices), and JSON/DOT
export
- [x] `add_edge` supports `weight` and `label` keyword arguments
- [x] `edge()` lookup method for direct O(1) edge retrieval

### Tier 3: Lossless conversions

- [ ] `to_adjacency_matrix()` / `from_adjacency_matrix()` round-trips that preserve vertex names
via an index mapping
- [ ] Fix `get_complement` to preserve original vertex names instead of generating UUIDs
- [x] `complement` preserves original vertex names and the `directed` flag
- [ ] `to_edge_list()` / `from_edge_list()` conversions
- [ ] Parse weighted JSON format (e.g. `g4.json` dict-as-neighbor style)
- [x] Weighted JSON format (`{"target": ..., "weight": ...}` neighbor objects, see `g4.json`)

### Tier 4: Algorithms

Expand All @@ -117,8 +148,9 @@ Version is managed automatically via git tags using `hatchling-vcs`.

### Tier 5: Export and CLI

- [x] JSON export (`save_to_json`)
- [x] Graphviz DOT export (`save_to_dot`)
- [x] JSON export (`save_to_json`, lossless round-trip)
- [x] Graphviz DOT export (`save_to_dot`, with vertex/edge labels and weights)
- [x] Text tree and 2-D ASCII rendering (`to_text`, `to_ascii`)
- [x] Rich-based demo script (`uv run demo`)
- [ ] CLI application for common graph operations
- [ ] Rich rendering integration for interactive graph display
Expand Down
26 changes: 6 additions & 20 deletions examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from graphworks.algorithms import (
breadth_first_search,
complement,
degree_sequence,
density,
depth_first_search,
Expand All @@ -41,7 +42,7 @@
is_dag,
is_regular,
is_simple,
topological,
topological_sort,
vertex_degree,
)
from graphworks.graph import Graph
Expand Down Expand Up @@ -286,32 +287,17 @@ def demo_paths(graph: Graph) -> None:
def demo_complement(graph: Graph) -> None:
"""Show the complement graph.

The library's :func:`get_complement` returns a matrix-based graph with
UUID vertex names (the original names are lost in the matrix round-trip).
For display purposes we compute the missing edges directly so we can
show them with the original, human-readable vertex names.

:param graph: An undirected graph.
:type graph: Graph
:return: Nothing.
:rtype: None
"""
_section("6 · Complement graph")

verts = sorted(graph.vertices())
original_edges: set[tuple[str, str]] = set()
for v in verts:
for n in graph.neighbors(v):
edge = (min(v, n), max(v, n))
original_edges.add(edge)

complement_edges: list[tuple[str, str]] = []
for i, v1 in enumerate(verts):
for v2 in verts[i + 1 :]:
if (min(v1, v2), max(v1, v2)) not in original_edges:
complement_edges.append((v1, v2))
comp = complement(graph)
complement_edges = sorted(tuple(sorted((e.source, e.target))) for e in comp.edges())

_kv("original edges", len(original_edges))
_kv("original edges", graph.size)
_kv("complement edges", len(complement_edges))
console.print()

Expand Down Expand Up @@ -350,7 +336,7 @@ def demo_directed() -> None:
dag = Graph(input_graph=json.dumps(dag_def))

_kv("DAG?", is_dag(dag))
_kv("topo sort", " → ".join(topological(dag)))
_kv("topo sort", " → ".join(topological_sort(dag)))

console.print()
_graph_panel(dag, "build pipeline (DAG)", border_style="green")
Expand Down
29 changes: 8 additions & 21 deletions src/graphworks/algorithms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,20 @@
Submodules
----------

- :mod:`~graphworks.algorithms.properties` — structural predicates and metrics
- :mod:`~graphworks.algorithms.paths` — path finding and edge utilities
- :mod:`~graphworks.algorithms.properties` — structural predicates, metrics, and the complement
- :mod:`~graphworks.algorithms.paths` — path finding and isolated vertices
- :mod:`~graphworks.algorithms.search` — graph traversal
- :mod:`~graphworks.algorithms.directed` — directed-graph algorithms
- :mod:`~graphworks.algorithms.sort` — sorting algorithms
"""

from .directed import find_circuit, is_dag
from .paths import (
find_all_paths,
find_isolated_vertices,
find_path,
generate_edges,
)
from .paths import find_all_paths, find_isolated_vertices, find_path
from .properties import (
complement,
degree_sequence,
density,
diameter,
get_complement,
invert,
is_complete,
is_connected,
is_degree_sequence,
Expand All @@ -35,16 +29,12 @@
min_degree,
vertex_degree,
)
from .search import (
arrival_departure_dfs,
breadth_first_search,
depth_first_search,
)
from .sort import topological
from .search import breadth_first_search, depth_first_search
from .sort import topological_sort

__all__ = [
"arrival_departure_dfs",
"breadth_first_search",
"complement",
"degree_sequence",
"density",
"depth_first_search",
Expand All @@ -53,9 +43,6 @@
"find_circuit",
"find_isolated_vertices",
"find_path",
"generate_edges",
"get_complement",
"invert",
"is_complete",
"is_connected",
"is_dag",
Expand All @@ -67,6 +54,6 @@
"is_sparse",
"max_degree",
"min_degree",
"topological",
"topological_sort",
"vertex_degree",
]
Loading
Loading