From e4dc60c4eeec69f0ed94cb978d16fd725f7ce54d Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Mon, 12 Oct 2026 16:46:18 -0600 Subject: [PATCH] Fix some API inconsistency issues --- CLAUDE.md | 56 ++++-- README.md | 52 ++++- examples/demo.py | 26 +-- src/graphworks/algorithms/__init__.py | 29 +-- src/graphworks/algorithms/directed.py | 100 +++++----- src/graphworks/algorithms/paths.py | 125 +++++------- src/graphworks/algorithms/properties.py | 255 ++++++++++++------------ src/graphworks/algorithms/search.py | 53 +---- src/graphworks/algorithms/sort.py | 61 +++--- src/graphworks/export/__init__.py | 14 +- src/graphworks/export/ascii_utils.py | 6 +- src/graphworks/export/graphviz_utils.py | 31 ++- src/graphworks/export/json_utils.py | 55 ++++- src/graphworks/export/text_utils.py | 23 ++- src/graphworks/graph.py | 248 ++++++++++++----------- src/graphworks/utilities.py | 16 +- src/graphworks/vertex.py | 14 +- tests/algorithms/test_directed.py | 48 ++++- tests/algorithms/test_paths.py | 43 ++-- tests/algorithms/test_properties.py | 138 ++++++++++--- tests/algorithms/test_search.py | 54 ++--- tests/algorithms/test_sort.py | 28 ++- tests/data/g4.json | 6 +- tests/export/test_graphviz_utils.py | 19 ++ tests/export/test_json_utils.py | 49 ++++- tests/export/test_text_utils.py | 24 +-- tests/test_graph.py | 121 ++++++++++- tests/test_graph_fluent_chaining.py | 20 +- 28 files changed, 1025 insertions(+), 689 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 854ea31..fde5b7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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`) diff --git a/README.md b/README.md index f57ac18..2018def 100755 --- a/README.md +++ b/README.md @@ -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 @@ -96,7 +126,8 @@ 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 @@ -104,9 +135,9 @@ Version is managed automatically via git tags using `hatchling-vcs`. - [ ] `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 @@ -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 diff --git a/examples/demo.py b/examples/demo.py index f9e0557..ce680c8 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -29,6 +29,7 @@ from graphworks.algorithms import ( breadth_first_search, + complement, degree_sequence, density, depth_first_search, @@ -41,7 +42,7 @@ is_dag, is_regular, is_simple, - topological, + topological_sort, vertex_degree, ) from graphworks.graph import Graph @@ -286,11 +287,6 @@ 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. @@ -298,20 +294,10 @@ def demo_complement(graph: Graph) -> 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() @@ -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") diff --git a/src/graphworks/algorithms/__init__.py b/src/graphworks/algorithms/__init__.py index c4c72db..54fad9c 100644 --- a/src/graphworks/algorithms/__init__.py +++ b/src/graphworks/algorithms/__init__.py @@ -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, @@ -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", @@ -53,9 +43,6 @@ "find_circuit", "find_isolated_vertices", "find_path", - "generate_edges", - "get_complement", - "invert", "is_complete", "is_connected", "is_dag", @@ -67,6 +54,6 @@ "is_sparse", "max_degree", "min_degree", - "topological", + "topological_sort", "vertex_degree", ] diff --git a/src/graphworks/algorithms/directed.py b/src/graphworks/algorithms/directed.py index 852744a..7115530 100644 --- a/src/graphworks/algorithms/directed.py +++ b/src/graphworks/algorithms/directed.py @@ -9,7 +9,8 @@ from typing import TYPE_CHECKING -from .search import arrival_departure_dfs +from .properties import _degrees +from .sort import _topological_order if TYPE_CHECKING: from ..graph import Graph @@ -18,74 +19,67 @@ def is_dag(graph: Graph) -> bool: """Return ``True`` if *graph* is a directed acyclic graph. - Uses arrival/departure DFS to detect back-edges. - :param graph: The graph to test. :type graph: Graph :return: ``True`` if the graph is both directed and acyclic. :rtype: bool """ - if not graph.directed: - return False - - departure = dict.fromkeys(graph.vertices(), 0) - discovered = dict.fromkeys(graph.vertices(), False) - arrival = dict.fromkeys(graph.vertices(), 0) - time = -1 - - for n in graph.vertices(): - if not discovered[n]: - time = arrival_departure_dfs( - graph, - n, - discovered, - arrival, - departure, - time, - ) - - for n in graph.vertices(): - for v in graph.neighbors(n): - if departure[n] <= departure[v]: - return False - - return True - - -def build_neighbor_matrix(graph: Graph) -> dict[str, list[str]]: - """Build a mutable adjacency dict for *graph*. - - Returns a fresh ``dict[str, list[str]]`` that can be mutated without affecting the original - graph — used internally by :func:`find_circuit`. - - :param graph: The graph. - :type graph: Graph - :return: Mutable adjacency mapping. - :rtype: dict[str, list[str]] - """ - return {v: list(graph.neighbors(v)) for v in graph.vertices()} + return graph.directed and _topological_order(graph) is not None def find_circuit(graph: Graph) -> list[str]: """Find an Eulerian circuit using Hierholzer's algorithm. + An Eulerian circuit traverses every edge exactly once and returns to its starting vertex. + The circuit follows edge direction in directed graphs. + :param graph: The graph to search for an Eulerian circuit. :type graph: Graph - :return: List of vertex names forming the circuit, or ``[]`` if the graph has no vertices. + :return: Vertex names along the circuit (first and last are equal), or ``[]`` if the graph + has no edges. :rtype: list[str] + :raises ValueError: If the graph has no Eulerian circuit. """ - if len(graph.vertices()) == 0: + edges = graph.edges() + if not edges: return [] + if not _is_balanced(graph): + msg = "Graph has no Eulerian circuit: vertex degrees are unbalanced." + raise ValueError(msg) + + remaining = {v: graph.neighbors(v) for v in graph} + path = [edges[0].source] circuit: list[str] = [] - adjacency_matrix = build_neighbor_matrix(graph) - current_path: list[str] = [graph.vertices()[0]] - while len(current_path) > 0: - current_vertex = current_path[-1] - if len(adjacency_matrix[current_vertex]) > 0: - next_vertex = adjacency_matrix[current_vertex].pop() - current_path.append(next_vertex) + while path: + current = path[-1] + if remaining[current]: + nxt = remaining[current].pop() + if not graph.directed and nxt != current: + remaining[nxt].remove(current) + path.append(nxt) else: - circuit.append(current_path.pop()) + circuit.append(path.pop()) + + if len(circuit) != len(edges) + 1: + msg = "Graph has no Eulerian circuit: its edges are not connected." + raise ValueError(msg) + return circuit[::-1] + + +def _is_balanced(graph: Graph) -> bool: + """Return whether every vertex satisfies the Eulerian degree condition. + + Directed graphs need in-degree equal to out-degree; undirected graphs need even degree. - return circuit + :param graph: The graph to test. + :type graph: Graph + :return: ``True`` if the degree condition holds. + :rtype: bool + """ + if not graph.directed: + return all(d % 2 == 0 for d in _degrees(graph).values()) + in_degree = dict.fromkeys(graph.vertices(), 0) + for edge in graph.edges(): + in_degree[edge.target] += 1 + return all(len(graph[v]) == in_degree[v] for v in graph) diff --git a/src/graphworks/algorithms/paths.py b/src/graphworks/algorithms/paths.py index dce0073..76f3fd0 100644 --- a/src/graphworks/algorithms/paths.py +++ b/src/graphworks/algorithms/paths.py @@ -1,7 +1,7 @@ -"""Path-finding and edge-generation utilities. +"""Path-finding utilities. -Provides functions for discovering paths between vertices, generating edge lists, and finding -structurally isolated vertices. All functions operate on the adjacency-list representation and +Provides functions for discovering paths between vertices and finding structurally isolated +vertices. All functions operate on the adjacency-list representation and require no external dependencies. """ @@ -10,44 +10,29 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from ..edge import Edge - from ..graph import Graph - - -def generate_edges(graph: Graph) -> list[Edge]: - """Return all edges in *graph*. + from collections.abc import Iterator - Convenience wrapper around :meth:`~graphworks.graph.Graph.edges`. - - :param graph: The graph to enumerate edges from. - :type graph: Graph - :return: List of edges. - :rtype: list[Edge] - """ - return graph.edges() + from ..graph import Graph def find_isolated_vertices(graph: Graph) -> list[str]: - """Return vertices that have no neighbours. + """Return vertices that have no incident edges (degree zero). + + For directed graphs a vertex with only incoming edges is *not* isolated. :param graph: The graph to inspect. :type graph: Graph :return: List of vertex names with degree zero. :rtype: list[str] """ - return [v for v in graph.vertices() if not graph[v]] + touched = {v for edge in graph.edges() for v in (edge.source, edge.target)} + return [v for v in graph.vertices() if v not in touched] -def find_path( - graph: Graph, - start: str, - end: str, - path: list[str] | None = None, -) -> list[str]: +def find_path(graph: Graph, start: str, end: str) -> list[str]: """Find a single path between *start* and *end* using depth-first search. - Returns the first path found, not necessarily the shortest. Returns an empty list if no path - exists or if *start* is not in the graph. + Returns the first path found, not necessarily the shortest. :param graph: The graph to search. :type graph: Graph @@ -55,40 +40,19 @@ def find_path( :type start: str :param end: Destination vertex name. :type end: str - :param path: Accumulated path used by recursive calls. Callers should leave this as ``None``. - :type path: list[str] | None :return: Ordered list of vertex names from *start* to *end*, or ``[]`` if no path exists. :rtype: list[str] + :raises KeyError: If *start* or *end* is not in the graph. """ - if path is None: - path = [] - - if start not in graph.vertices(): - return [] + _require_endpoints(graph, start, end) + paths = _simple_paths(graph, [start], end) + return next(paths, []) - path = [*path, start] - if start == end: - return path - - for node in graph[start]: - if node not in path: - new_path = find_path(graph, node, end, path) - if new_path: - return new_path - return [] - - -def find_all_paths( - graph: Graph, - start: str, - end: str, - path: list[str] | None = None, -) -> list[list[str]]: +def find_all_paths(graph: Graph, start: str, end: str) -> list[list[str]]: """Return all simple paths between *start* and *end*. - A simple path visits each vertex at most once. Returns an empty list if no path exists or if - *start* is not in the graph. + A simple path visits each vertex at most once. :param graph: The graph to search. :type graph: Graph @@ -96,25 +60,46 @@ def find_all_paths( :type start: str :param end: Destination vertex name. :type end: str - :param path: Accumulated path used by recursive calls. Callers should leave this as ``None``. - :type path: list[str] | None - :return: List of all simple paths, each an ordered list of vertex names. + :return: List of all simple paths, each an ordered list of vertex names, or ``[]`` if none. :rtype: list[list[str]] + :raises KeyError: If *start* or *end* is not in the graph. """ - if path is None: - path = [] + _require_endpoints(graph, start, end) + return list(_simple_paths(graph, [start], end)) - if start not in graph.vertices(): - return [] - path = [*path, start] +def _simple_paths(graph: Graph, path: list[str], end: str) -> Iterator[list[str]]: + """Yield every simple path that extends *path* to *end*, depth-first. - if start == end: - return [path] - - paths: list[list[str]] = [] - for node in graph[start]: + :param graph: The graph to search. + :type graph: Graph + :param path: The path walked so far (non-empty). + :type path: list[str] + :param end: Destination vertex name. + :type end: str + :return: Iterator of complete paths. + :rtype: Iterator[list[str]] + """ + if path[-1] == end: + yield path + return + for node in graph[path[-1]]: if node not in path: - new_paths = find_all_paths(graph, node, end, path) - paths.extend(new_paths) - return paths + yield from _simple_paths(graph, [*path, node], end) + + +def _require_endpoints(graph: Graph, *names: str) -> None: + """Raise :class:`KeyError` for the first of *names* not in *graph*. + + :param graph: The graph to check. + :type graph: Graph + :param names: Vertex names that must exist. + :type names: str + :return: Nothing. + :rtype: None + :raises KeyError: If a vertex is missing. + """ + for name in names: + if name not in graph: + msg = f"Vertex {name!r} is not in the graph." + raise KeyError(msg) diff --git a/src/graphworks/algorithms/properties.py b/src/graphworks/algorithms/properties.py index db0a58a..231bbb5 100644 --- a/src/graphworks/algorithms/properties.py +++ b/src/graphworks/algorithms/properties.py @@ -1,21 +1,27 @@ """Graph property queries and structural metrics. Provides predicate functions (``is_*``) and quantitative metrics (``density``, ``diameter``, -``degree_sequence``, etc.) that inspect a :class:`~graphworks.graph.Graph` without modifying it. +``degree_sequence``, etc.) that inspect a :class:`~graphworks.graph.Graph` without modifying it, +plus :func:`complement`, which builds a new graph. All functions are pure: they take a graph (and optional parameters) and return a value. None of the functions here require numpy. + +Conventions +----------- +* A vertex's degree counts every edge incident to it; for directed graphs that is in-degree plus + out-degree. A self-loop contributes 2. +* Predicates are vacuously ``True`` on the empty graph. Metrics that are undefined on the empty + graph (:func:`min_degree`, :func:`max_degree`, :func:`diameter`) raise :class:`ValueError`. +* Passing a vertex that is not in the graph raises :class:`KeyError`. """ from __future__ import annotations -from typing import TYPE_CHECKING, Final +from typing import Final from ..graph import Graph -from .paths import find_all_paths - -if TYPE_CHECKING: - from ..types import AdjacencyMatrix +from .search import breadth_first_search DENSITY_CUTOFF: Final[float] = 0.5 @@ -24,10 +30,25 @@ # --------------------------------------------------------------------------- +def _degrees(graph: Graph) -> dict[str, int]: + """Return the degree of every vertex in one pass over the edges. + + :param graph: The graph to inspect. + :type graph: Graph + :return: Mapping of vertex name to degree. + :rtype: dict[str, int] + """ + degrees = dict.fromkeys(graph.vertices(), 0) + for edge in graph.edges(): + degrees[edge.source] += 1 + degrees[edge.target] += 1 + return degrees + + def vertex_degree(graph: Graph, vertex: str) -> int: """Return the degree of *vertex*. - Self-loops are counted twice (each loop contributes 2 to the degree). + For directed graphs this is in-degree plus out-degree. Self-loops are counted twice. :param graph: The graph to inspect. :type graph: Graph @@ -35,27 +56,23 @@ def vertex_degree(graph: Graph, vertex: str) -> int: :type vertex: str :return: Degree of *vertex*. :rtype: int + :raises KeyError: If *vertex* is not in the graph. """ - adj = graph[vertex] - degree = len(adj) - degree += sum(1 for v in adj if v == vertex) - return degree + if vertex not in graph: + msg = f"Vertex {vertex!r} is not in the graph." + raise KeyError(msg) + return _degrees(graph)[vertex] -def degree_sequence(graph: Graph) -> tuple[int, ...]: +def degree_sequence(graph: Graph) -> list[int]: """Return the degree sequence of the graph in non-increasing order. :param graph: The graph to inspect. :type graph: Graph - :return: Sorted tuple of vertex degrees (highest first). - :rtype: tuple[int, ...] + :return: Sorted list of vertex degrees (highest first). + :rtype: list[int] """ - return tuple( - sorted( - (vertex_degree(graph, v) for v in graph.vertices()), - reverse=True, - ) - ) + return sorted(_degrees(graph).values(), reverse=True) def min_degree(graph: Graph) -> int: @@ -65,8 +82,10 @@ def min_degree(graph: Graph) -> int: :type graph: Graph :return: Minimum degree across all vertices. :rtype: int + :raises ValueError: If the graph has no vertices. """ - return min(vertex_degree(graph, v) for v in graph.vertices()) + _require_vertices(graph, "min_degree") + return min(_degrees(graph).values()) def max_degree(graph: Graph) -> int: @@ -76,8 +95,10 @@ def max_degree(graph: Graph) -> int: :type graph: Graph :return: Maximum degree across all vertices. :rtype: int + :raises ValueError: If the graph has no vertices. """ - return max(vertex_degree(graph, v) for v in graph.vertices()) + _require_vertices(graph, "max_degree") + return max(_degrees(graph).values()) # --------------------------------------------------------------------------- @@ -88,18 +109,17 @@ def max_degree(graph: Graph) -> int: def is_degree_sequence(sequence: list[int]) -> bool: """Return whether *sequence* is a valid degree sequence. - A valid degree sequence has a non-negative, even sum and is non-increasing. + A valid degree sequence is non-negative, has an even sum, and is non-increasing. :param sequence: Candidate degree sequence. :type sequence: list[int] :return: ``True`` if *sequence* is a valid degree sequence. :rtype: bool """ - if not sequence: - return True - return sum(sequence) % 2 == 0 and sequence == sorted( - sequence, - reverse=True, + return ( + all(d >= 0 for d in sequence) + and sum(sequence) % 2 == 0 + and sequence == sorted(sequence, reverse=True) ) @@ -144,98 +164,72 @@ def is_regular(graph: Graph) -> bool: :return: ``True`` if the graph is regular (all degrees equal). :rtype: bool """ - degrees = [vertex_degree(graph, v) for v in graph.vertices()] - return len(set(degrees)) <= 1 + return len(set(_degrees(graph).values())) <= 1 def is_simple(graph: Graph) -> bool: """Return whether the graph contains no self-loops. + :class:`~graphworks.graph.Graph` cannot hold parallel edges, so a loop-free graph is simple. + :param graph: The graph to inspect. :type graph: Graph :return: ``True`` if no vertex has an edge to itself. :rtype: bool """ - return all(v not in graph[v] for v in graph.vertices()) + return not any(edge.is_self_loop() for edge in graph.edges()) -def is_connected( - graph: Graph, - start_vertex: str | None = None, - vertices_encountered: set[str] | None = None, -) -> bool: +def is_connected(graph: Graph) -> bool: """Return whether the graph is connected. - Uses a recursive depth-first traversal from *start_vertex*. + Directed graphs are tested for *weak* connectivity (edge directions are ignored). :param graph: The graph to inspect. :type graph: Graph - :param start_vertex: Vertex to begin the traversal from. Defaults to the first vertex in - :meth:`~graphworks.graph.Graph.vertices`. - :type start_vertex: str | None - :param vertices_encountered: Set of already-visited vertices used by the recursive calls. - Callers should leave this as ``None``. - :type vertices_encountered: set[str] | None - :return: ``True`` if all vertices are reachable from *start_vertex*. + :return: ``True`` if every vertex is reachable from every other, ignoring direction. :rtype: bool """ - if vertices_encountered is None: - vertices_encountered = set() - verts = graph.vertices() - if not start_vertex: - start_vertex = verts[0] - - vertices_encountered.add(start_vertex) - - if len(vertices_encountered) != len(verts): - for vertex in graph[start_vertex]: - if vertex not in vertices_encountered and is_connected( - graph, - vertex, - vertices_encountered, - ): - return True - else: + if not verts: return True - return False + underlying = Graph() + for v in verts: + underlying.add_vertex(v) + for edge in graph.edges(): + underlying.add_edge(edge.source, edge.target) + return len(breadth_first_search(underlying, verts[0])) == len(verts) def is_complete(graph: Graph) -> bool: """Return whether the graph is complete. - A complete graph has every possible edge. Checks that the edge count equals ``V*(V-1)`` for - directed graphs or ``V*(V-1)/2`` for undirected. + A complete graph has an edge from every vertex to every other vertex and no self-loops. :param graph: The graph to inspect. :type graph: Graph :return: ``True`` if the graph is complete. :rtype: bool """ - v_count = len(graph.vertices()) - max_edges = v_count**2 - v_count - if not graph.directed: - max_edges //= 2 - - if len(graph.edges()) != max_edges: - return False - - return all(len(graph[v]) == v_count - 1 for v in graph) + verts = set(graph.vertices()) + return all(set(graph[v]) == verts - {v} for v in verts) def is_sparse(graph: Graph) -> bool: - """Return whether the graph is sparse (``|E| ≤ |V|² / 2``). + """Return whether the graph is sparse (density below ``DENSITY_CUTOFF``). + + Always the opposite of :func:`is_dense`. :param graph: The graph to inspect. :type graph: Graph :return: ``True`` if the graph is sparse. :rtype: bool """ - return graph.size <= (graph.order**2 / 2) + return not is_dense(graph) def is_dense(graph: Graph) -> bool: - """Return whether the graph is dense (density ≥ 0.5). + """Return whether the graph is dense (density ≥ ``DENSITY_CUTOFF``). :param graph: The graph to inspect. :type graph: Graph @@ -253,83 +247,98 @@ def is_dense(graph: Graph) -> bool: def density(graph: Graph) -> float: """Return the density of the graph. - Density is ``2|E| / (|V|² - |V|)``. Returns ``0.0`` for graphs with fewer than two vertices. + Density is the number of edges divided by the maximum possible: ``|V|(|V|-1)`` for directed + graphs and ``|V|(|V|-1)/2`` for undirected graphs. Self-loops are not counted. Returns + ``0.0`` for graphs with fewer than two vertices. :param graph: The graph to inspect. :type graph: Graph :return: Density in the range ``[0.0, 1.0]``. :rtype: float """ - v_count = len(graph.vertices()) + v_count = graph.order if v_count < 2: # noqa: PLR2004 return 0.0 - e_count = len(graph.edges()) - return 2.0 * (e_count / (v_count**2 - v_count)) + e_count = sum(1 for edge in graph.edges() if not edge.is_self_loop()) + max_edges = v_count * (v_count - 1) + if not graph.directed: + max_edges //= 2 + return e_count / max_edges def diameter(graph: Graph) -> int: """Return the diameter of the graph. - The diameter is the length of the longest shortest path between any pair of vertices. - Returns ``0`` for disconnected graphs. + The diameter is the greatest shortest-path distance (in edges) between any ordered pair of + vertices, computed with one breadth-first search per vertex. :param graph: The graph to inspect. :type graph: Graph :return: Diameter (number of edges on the longest shortest path). :rtype: int + :raises ValueError: If the graph is empty, or if some vertex cannot reach another (the + diameter is infinite). """ - verts = graph.vertices() - pairs = [(verts[i], verts[j]) for i in range(len(verts) - 1) for j in range(i + 1, len(verts))] - - shortest_paths: list[list[str]] = [] - for start, end in pairs: - all_paths: list[list[str]] = find_all_paths(graph, start, end) - if all_paths: - shortest_paths.append(min(all_paths, key=lambda path: len(path))) # noqa - - if not shortest_paths: - return 0 - - return len(max(shortest_paths, key=len)) - 1 + _require_vertices(graph, "diameter") + longest = 0 + for start in graph.vertices(): + dist = {start: 0} + frontier = [start] + while frontier: + nxt: list[str] = [] + for u in frontier: + for w in graph[u]: + if w not in dist: + dist[w] = dist[u] + 1 + nxt.append(w) + frontier = nxt + if len(dist) != graph.order: + msg = "diameter is infinite: the graph is not (strongly) connected." + raise ValueError(msg) + longest = max(longest, *dist.values()) + return longest # --------------------------------------------------------------------------- -# Matrix-based operations (stdlib only) +# Derived graphs # --------------------------------------------------------------------------- -def invert(matrix: AdjacencyMatrix) -> AdjacencyMatrix: - """Return the complement of an adjacency matrix. - - Flips ``0`` ↔ ``1`` for every cell, including the diagonal. - - :param matrix: Square adjacency matrix. - :type matrix: AdjacencyMatrix - :return: Inverted (complement) adjacency matrix. - :rtype: AdjacencyMatrix - """ - return [[1 - cell for cell in row] for row in matrix] - - -def get_complement(graph: Graph) -> Graph: +def complement(graph: Graph) -> Graph: """Return the complement graph of *graph*. - The complement is the graph on the same vertex set whose edges are exactly the edges *not* - present in *graph*. - - .. note:: - Because :func:`invert` flips the diagonal, the complement of an isolated graph contains - self-loops. The complement is built via matrix round-trip, so original vertex names are - replaced with UUID strings. + The complement has the same vertices (names, labels, and attributes preserved) and the same + :attr:`~graphworks.graph.Graph.directed` flag, and contains exactly the edges between + distinct vertices that are *not* in *graph*. No self-loops are added. :param graph: The source graph. :type graph: Graph :return: Complement graph. :rtype: Graph """ - adj = graph.adjacency_matrix() - complement_matrix = invert(adj) - return Graph( - f"{graph.label} complement", - input_matrix=complement_matrix, - ) + result = Graph(f"{graph.label} complement".strip(), directed=graph.directed) + for name in graph: + vertex = graph.vertex(name) + if vertex is not None: + result.add_vertex(vertex) + for u in graph: + for v in graph: + if u != v and graph.edge(u, v) is None and result.edge(u, v) is None: + result.add_edge(u, v) + return result + + +def _require_vertices(graph: Graph, name: str) -> None: + """Raise :class:`ValueError` if *graph* is empty. + + :param graph: The graph to inspect. + :type graph: Graph + :param name: Name of the calling function, for the error message. + :type name: str + :return: Nothing. + :rtype: None + :raises ValueError: If the graph has no vertices. + """ + if graph.order == 0: + msg = f"{name}() is undefined for a graph with no vertices." + raise ValueError(msg) diff --git a/src/graphworks/algorithms/search.py b/src/graphworks/algorithms/search.py index c9448a7..6d7b0be 100644 --- a/src/graphworks/algorithms/search.py +++ b/src/graphworks/algorithms/search.py @@ -1,7 +1,6 @@ """Graph traversal algorithms. -Provides breadth-first search, depth-first search, and a DFS variant that records arrival and -departure timestamps for each vertex. All functions are pure — they take a +Provides breadth-first search and depth-first search. All functions are pure — they take a :class:`~graphworks.graph.Graph` and return results without modifying the input. """ @@ -22,6 +21,7 @@ def breadth_first_search(graph: Graph, start: str) -> list[str]: :type start: str :return: List of vertex names in BFS visit order. :rtype: list[str] + :raises KeyError: If *start* is not in the graph. """ visited = dict.fromkeys(graph.vertices(), False) visited[start] = True @@ -48,6 +48,7 @@ def depth_first_search(graph: Graph, start: str) -> list[str]: :type start: str :return: List of vertex names in DFS visit order. :rtype: list[str] + :raises KeyError: If *start* is not in the graph. """ visited: list[str] = [] stack = [start] @@ -59,51 +60,3 @@ def depth_first_search(graph: Graph, start: str) -> list[str]: filter(lambda x: x not in visited, graph[vertex]), ) return visited - - -def arrival_departure_dfs( # noqa: PLR0913 - graph: Graph, - v: str, - discovered: dict[str, bool], - arrival: dict[str, int], - departure: dict[str, int], - time: int, -) -> int: - """Perform DFS recording arrival and departure times for each vertex. - - This variant is used internally by :func:`~graphworks.algorithms.directed.is_dag` to detect - back-edges. Complexity: O(V + E). - - :param graph: The graph to traverse. - :type graph: Graph - :param v: The vertex to traverse from. - :type v: str - :param discovered: Mutable map tracking which vertices have been visited. - :type discovered: dict[str, bool] - :param arrival: Mutable map storing each vertex's arrival timestamp. - :type arrival: dict[str, int] - :param departure: Mutable map storing each vertex's departure timestamp. - :type departure: dict[str, int] - :param time: Current timestamp counter (typically initialized to ``-1``). - :type time: int - :return: The updated timestamp counter after this subtree is fully explored. - :rtype: int - """ - time += 1 - arrival[v] = time - discovered[v] = True - - for n in graph.neighbors(v): - if not discovered.get(n, False): - time = arrival_departure_dfs( - graph, - n, - discovered, - arrival, - departure, - time, - ) - - time += 1 - departure[v] = time - return time diff --git a/src/graphworks/algorithms/sort.py b/src/graphworks/algorithms/sort.py index c44834d..83d5226 100644 --- a/src/graphworks/algorithms/sort.py +++ b/src/graphworks/algorithms/sort.py @@ -8,7 +8,7 @@ from ..graph import Graph -def topological(graph: Graph) -> list[str]: +def topological_sort(graph: Graph) -> list[str]: """Return a topological ordering of the vertices in *graph*. Uses a recursive DFS-based approach. Complexity: O(V + E). @@ -17,38 +17,41 @@ def topological(graph: Graph) -> list[str]: :type graph: Graph :return: List of vertices sorted topologically. :rtype: list[str] + :raises ValueError: If *graph* is undirected or contains a cycle. """ + if not graph.directed: + msg = "topological_sort() requires a directed graph." + raise ValueError(msg) + order = _topological_order(graph) + if order is None: + msg = "topological_sort() requires an acyclic graph; this graph contains a cycle." + raise ValueError(msg) + return order - def _mark_visited( - g: Graph, - v: str, - v_map: dict[str, bool], - result: list[str], - ) -> None: - """Recursively mark *v* and its descendants as visited. - - :param g: The graph. - :type g: Graph - :param v: Current vertex. - :type v: str - :param v_map: Mutable visited-flag map. - :type v_map: dict[str, bool] - :param result: Accumulator for the reverse topological order. - :type result: list[str] - :return: Nothing. - :rtype: None - """ - v_map[v] = True - for n in g.neighbors(v): - if not v_map[n]: - _mark_visited(g, n, v_map, result) - result.append(v) - visited = dict.fromkeys(graph.vertices(), False) +def _topological_order(graph: Graph) -> list[str] | None: + """Return a topological ordering of *graph*'s out-edges, or ``None`` if there is a cycle. + + :param graph: The graph to order. + :type graph: Graph + :return: Vertices in topological order, or ``None`` when a back-edge is found. + :rtype: list[str] | None + """ + on_stack: set[str] = set() + done: set[str] = set() result: list[str] = [] - for v in graph.vertices(): - if not visited[v]: - _mark_visited(graph, v, visited, result) + def _visit(v: str) -> bool: + on_stack.add(v) + for n in graph.neighbors(v): + if n in on_stack or (n not in done and not _visit(n)): + return False + on_stack.discard(v) + done.add(v) + result.append(v) + return True + for v in graph.vertices(): + if v not in done and not _visit(v): + return None return result[::-1] diff --git a/src/graphworks/export/__init__.py b/src/graphworks/export/__init__.py index d81d153..c27869c 100644 --- a/src/graphworks/export/__init__.py +++ b/src/graphworks/export/__init__.py @@ -1,11 +1,17 @@ -"""Export helpers for serializing graphs to external formats. +"""Export helpers for serializing and rendering graphs. Submodules ---------- - :mod:`~graphworks.export.json_utils` — JSON file export -- :mod:`~graphworks.export.graphviz_utils` — Graphviz DOT export +- :mod:`~graphworks.export.graphviz_utils` — Graphviz DOT export (needs the ``[viz]`` extra, so + it is not re-exported here; import :func:`save_to_dot` from the submodule) - :mod:`~graphworks.export.text_utils` — zero-dependency ASCII/Unicode tree rendering -- :mod:`~graphworks.export.ascii_utils` — zero-dependency 2-D ASCII art rendering (Phase 2, - experimental) +- :mod:`~graphworks.export.ascii_utils` — zero-dependency 2-D ASCII art rendering (experimental) """ + +from .ascii_utils import to_ascii +from .json_utils import save_to_json +from .text_utils import to_text + +__all__ = ["save_to_json", "to_ascii", "to_text"] diff --git a/src/graphworks/export/ascii_utils.py b/src/graphworks/export/ascii_utils.py index f5de66d..03bda07 100644 --- a/src/graphworks/export/ascii_utils.py +++ b/src/graphworks/export/ascii_utils.py @@ -8,7 +8,7 @@ * **DAGs** use a layered (Sugiyama-style) layout: 1. *Layer assignment* — each vertex's row is its longest path from a source, reusing - :func:`~graphworks.algorithms.sort.topological`. + :func:`~graphworks.algorithms.sort.topological_sort`. 2. *Dummy nodes* — an edge spanning multiple rows is split into unit-length segments through virtual nodes in the intervening rows, so it routes as a chain of short lines instead of one long diagonal slashing across the layout. @@ -48,7 +48,7 @@ from typing import TYPE_CHECKING from ..algorithms.directed import is_dag -from ..algorithms.sort import topological +from ..algorithms.sort import topological_sort from ._labels import RenderStyle, edge_annotation, vertex_text if TYPE_CHECKING: @@ -236,7 +236,7 @@ def _assign_layers(graph: Graph) -> dict[str, int]: :rtype: dict[str, int] """ layer = dict.fromkeys(graph.vertices(), 0) - for u in topological(graph): + for u in topological_sort(graph): base = layer[u] + 1 for w in graph.neighbors(u): layer[w] = max(layer[w], base) diff --git a/src/graphworks/export/graphviz_utils.py b/src/graphworks/export/graphviz_utils.py index 9c59a17..512ad89 100755 --- a/src/graphworks/export/graphviz_utils.py +++ b/src/graphworks/export/graphviz_utils.py @@ -14,32 +14,41 @@ from graphviz import Digraph from graphviz import Graph as UndirectedGraphViz +from ..utilities import _file_stem +from ._labels import RenderStyle, edge_annotation, vertex_text + if TYPE_CHECKING: from ..graph import Graph -def save_to_dot(graph: Graph, out_dir: str) -> None: +def save_to_dot(graph: Graph, out_dir: str) -> Path: """Render *graph* to a Graphviz DOT file in *out_dir*. - Produces both a ``.gv`` source file and its rendered output. Directed - graphs use :class:`graphviz.Digraph`; undirected graphs use - :class:`graphviz.Graph`. + Produces both a ``.gv`` source file and its rendered output, named after + :attr:`~graphworks.graph.Graph.label` (path separators replaced with ``_``; ``graph.gv`` if + unlabeled). Directed graphs use :class:`graphviz.Digraph`; undirected graphs use + :class:`graphviz.Graph`. Vertex labels are used as node labels, and edge labels and weights + are shown as edge labels (``label, w=…``). :param graph: The graph to render. :type graph: Graph :param out_dir: Directory path where the DOT file will be written. :type out_dir: str - :return: Nothing. - :rtype: None + :return: Path of the written ``.gv`` source file. + :rtype: Path """ if graph.directed: dot = Digraph(comment=graph.label) else: dot = UndirectedGraphViz(comment=graph.label) + style = RenderStyle(weights=True) for node in graph: - dot.node(node, node) - for neighbour in graph[node]: - dot.edge(node, neighbour) - - dot.render(str(Path(out_dir) / f"{graph.label}.gv"), view=False) + dot.node(node, vertex_text(graph, node)) + for edge in graph.edges(): + text = edge_annotation(graph, edge.source, edge.target, style=style) + dot.edge(edge.source, edge.target, label=text or None) + + out_path = Path(out_dir) / f"{_file_stem(graph.label)}.gv" + dot.render(str(out_path), view=False) + return out_path diff --git a/src/graphworks/export/json_utils.py b/src/graphworks/export/json_utils.py index 02acdf0..8e211f6 100644 --- a/src/graphworks/export/json_utils.py +++ b/src/graphworks/export/json_utils.py @@ -8,39 +8,72 @@ import json from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any + +from ..utilities import _file_stem if TYPE_CHECKING: from ..graph import Graph -def save_to_json(graph: Graph, out_dir: str) -> None: +def save_to_json(graph: Graph, out_dir: str) -> Path: """Serialize *graph* to a JSON file in *out_dir*. - The output file is named ``{graph.label}.json`` and uses the same schema accepted by - :class:`~graphworks.graph.Graph`'s ``input_file`` constructor parameter:: + The output file is named after :attr:`~graphworks.graph.Graph.label` (path separators + replaced with ``_``; ``graph.json`` if unlabeled) and uses the schema accepted by + :class:`~graphworks.graph.Graph`'s ``input_file`` parameter. Vertex labels and attributes and + edge weights, labels, and attributes are preserved, so the file round-trips losslessly:: { "label": "...", "directed": false, - "graph": { "A": ["B"], "B": ["A"] } + "weighted": true, + "vertices": {"A": {"label": "Alpha"}}, + "graph": {"A": [{"target": "B", "weight": 2}], "B": []} } + Undirected edges are written once, under their source vertex. ``"vertices"`` is omitted + when no vertex has a label or attributes. + :param graph: The graph to serialize. :type graph: Graph :param out_dir: Directory path where the JSON file will be written. :type out_dir: str - :return: Nothing. - :rtype: None + :return: Path of the written file. + :rtype: Path """ - adjacency: dict[str, list[str]] = {v: graph.neighbors(v) for v in graph.vertices()} + adjacency: dict[str, list[str | dict[str, Any]]] = {v: [] for v in graph} + for edge in graph.edges(): + data = _present(weight=edge.weight, label=edge.label, attrs=dict(edge.attrs)) + adjacency[edge.source].append({"target": edge.target, **data} if data else edge.target) - payload = { + vertices: dict[str, dict[str, Any]] = {} + for name in graph: + vertex = graph.vertex(name) + meta = _present(label=vertex.label, attrs=dict(vertex.attrs)) if vertex else {} + if meta: + vertices[name] = meta + + payload: dict[str, Any] = { "label": graph.label, "directed": graph.directed, "weighted": graph.weighted, - "graph": adjacency, } + if vertices: + payload["vertices"] = vertices + payload["graph"] = adjacency - out_path = Path(out_dir) / f"{graph.label}.json" + out_path = Path(out_dir) / f"{_file_stem(graph.label)}.json" out_path.write_text(json.dumps(payload), encoding="utf-8") + return out_path + + +def _present(**fields: Any) -> dict[str, Any]: + """Return *fields* without the ones that are ``None`` or empty dicts. + + :param fields: Candidate JSON fields. + :type fields: Any + :return: Only the fields worth writing. + :rtype: dict[str, Any] + """ + return {k: v for k, v in fields.items() if v is not None and v != {}} diff --git a/src/graphworks/export/text_utils.py b/src/graphworks/export/text_utils.py index c40c1d5..20e028f 100644 --- a/src/graphworks/export/text_utils.py +++ b/src/graphworks/export/text_utils.py @@ -96,7 +96,16 @@ def to_text( visited: set[str] = set() last_root = len(roots) - 1 for idx, root in enumerate(roots): - _render(graph, root, None, "", idx == last_root, visited, lines, style) + _render( + graph, + root, + parent=None, + prefix="", + is_last=idx == last_root, + visited=visited, + lines=lines, + style=style, + ) return "\n".join(lines) @@ -169,6 +178,7 @@ def _select_roots(graph: Graph, candidates: list[str]) -> list[str]: def _render( # noqa: PLR0913 graph: Graph, node: str, + *, parent: str | None, prefix: str, is_last: bool, @@ -214,7 +224,16 @@ def _render( # noqa: PLR0913 child_prefix = prefix + (_SPACE if is_last else _PIPE) last_child = len(children) - 1 for idx, child in enumerate(children): - _render(graph, child, node, child_prefix, idx == last_child, visited, lines, style) + _render( + graph, + child, + parent=node, + prefix=child_prefix, + is_last=idx == last_child, + visited=visited, + lines=lines, + style=style, + ) def _children(graph: Graph, node: str, parent: str | None) -> list[str]: diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py index 095a908..ea982e7 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -8,6 +8,16 @@ * ``_adj``: ``dict[str, dict[str, Edge]]`` — ``_adj[u][v]`` gives the :class:`~graphworks.edge.Edge` from *u* to *v* in O(1). +In an undirected graph each edge is stored under **both** endpoints (``_adj[u][v]`` and +``_adj[v][u]`` hold the same :class:`~graphworks.edge.Edge` object), so :meth:`Graph.neighbors` +is symmetric and every algorithm sees ``u`` and ``v`` as adjacent to each other. + +Missing vertices +---------------- +Lookups that *require* a vertex (``graph[v]``, :meth:`Graph.neighbors`, +:meth:`Graph.vertex_to_index`, and the algorithm functions) raise :class:`KeyError`. The +``dict.get``-style accessors :meth:`Graph.vertex` and :meth:`Graph.edge` return ``None``. + Construction ------------ A :class:`Graph` can be built from: @@ -35,9 +45,31 @@ >>> g = Graph(input_graph=json.dumps(data)) >>> g.vertices() # ['A', 'B'] >>> g.edges() # [Edge('A' -- 'B')] + >>> g.neighbors("B") # ['A'] >>> g.label # 'demo' >>> g.order # 2 >>> g.size # 1 + +JSON schema +----------- +``"graph"`` maps each vertex name to its neighbors. A neighbor is either a bare name or an +object carrying edge data; an optional ``"vertices"`` object carries vertex labels and +attributes:: + + { + "label": "roads", + "directed": false, + "weighted": true, + "vertices": {"A": {"label": "Alpha", "attrs": {"x": 1}}}, + "graph": { + "A": ["B", {"target": "C", "weight": 2.5, "label": "toll", "attrs": {}}], + "B": [], + "C": [] + } + } + +In an undirected graph an edge only needs to be listed once (under either endpoint); if it is +listed under both, the first occurrence wins. """ from __future__ import annotations @@ -48,7 +80,6 @@ from typing import TYPE_CHECKING from .edge import Edge -from .export.text_utils import to_text as _to_text from .vertex import Vertex if TYPE_CHECKING: @@ -140,13 +171,6 @@ def __init__( # noqa: PLR0913 raise ValueError(msg) self._matrix_to_graph(input_matrix) - if not self._validate(): - msg = ( - "Graph is invalid: edge endpoints reference vertices that do not exist in the " - "vertex set." - ) - raise ValueError(msg) - def __repr__(self) -> str: """Return a developer-friendly representation. @@ -158,8 +182,8 @@ def __repr__(self) -> str: def __str__(self) -> str: """Return a human-readable adjacency-list view of the graph. - Each line shows ``vertex -> neighbor_names`` (or ``-> 0`` for isolated vertices). Lines - are sorted alphabetically by vertex name and preceded by the graph label. + Each line shows ``vertex -> neighbor, neighbor`` (or ``-> 0`` for isolated vertices). + Lines are sorted alphabetically by vertex name and preceded by the graph label. :return: Multi-line adjacency list string. :rtype: str @@ -167,7 +191,7 @@ def __str__(self) -> str: lines: list[str] = [] for name in sorted(self._vertices): nbrs = self.neighbors(name) - rhs = "".join(nbrs) if nbrs else "0" + rhs = ", ".join(nbrs) if nbrs else "0" lines.append(f"{name} -> {rhs}") return f"{self._label}\n" + "\n".join(lines) @@ -180,7 +204,7 @@ def __iter__(self) -> Iterator[str]: return iter(self._vertices) def __getitem__(self, node: str) -> list[str]: - """Return neighbor names for *node*, or ``[]`` if absent. + """Return neighbor names for *node* (same as :meth:`neighbors`). This enables the common ``graph[v]`` idiom used throughout the algorithm modules. @@ -188,11 +212,32 @@ def __getitem__(self, node: str) -> list[str]: :type node: str :return: List of neighbor vertex names. :rtype: list[str] + :raises KeyError: If *node* is not in the graph. """ - if node not in self._vertices: - return [] return self.neighbors(node) + def __eq__(self, other: object) -> bool: + """Return ``True`` if *other* has the same structure. + + Mirrors :class:`Vertex` and :class:`Edge` identity semantics: graphs are equal when they + agree on :attr:`directed`, the vertex names, and the edge endpoints. Labels, weights, and + attributes are descriptive and are not compared. + + :param other: Object to compare against. + :type other: object + :return: ``True`` if structurally equal. + :rtype: bool + """ + if not isinstance(other, Graph): + return NotImplemented + return ( + self._directed == other._directed + and self._vertices.keys() == other._vertices.keys() + and all(self._adj[v].keys() == other._adj[v].keys() for v in self._adj) + ) + + __hash__ = None # type: ignore[assignment] # mutable, so unhashable + def __contains__(self, item: str) -> bool: """Return ``True`` if *item* is a vertex name in this graph. @@ -443,8 +488,7 @@ def edges(self) -> list[Edge]: def edge(self, source: str, target: str) -> Edge | None: """Return the :class:`~graphworks.edge.Edge` from *source* to *target*. - For undirected graphs, checks both the ``source → target`` and ``target → source`` slots - in the adjacency structure. + For undirected graphs the argument order does not matter. :param source: Source vertex name. :type source: str @@ -453,12 +497,7 @@ def edge(self, source: str, target: str) -> Edge | None: :return: The edge object, or ``None`` if no such edge exists. :rtype: Edge | None """ - found = self._adj.get(source, {}).get(target) - if found is not None: - return found - if not self._directed: - return self._adj.get(target, {}).get(source) - return None + return self._adj.get(source, {}).get(target) def add_edge( self, @@ -496,18 +535,18 @@ def add_edge( :return: This graph instance (for chaining). :rtype: Self :raises TypeError: If *source_or_edge* is a ``str`` and *target* is ``None``. + :raises ValueError: If an :class:`Edge`'s ``directed`` flag differs from the graph's. """ if isinstance(source_or_edge, Edge): edge = source_or_edge - self.add_vertex(edge.source) - self.add_vertex(edge.target) - self._adj[edge.source][edge.target] = edge + if edge.directed != self._directed: + kind = "a directed" if self._directed else "an undirected" + msg = f"Cannot add {edge!r} to {kind} graph: directed flags differ." + raise ValueError(msg) else: if target is None: msg = "target is required when source_or_edge is a vertex name string." raise TypeError(msg) - self.add_vertex(source_or_edge) - self.add_vertex(target) edge = Edge( source=source_or_edge, target=target, @@ -515,8 +554,12 @@ def add_edge( weight=weight, label=label, ) - self._adj[source_or_edge][target] = edge + self.add_vertex(edge.source) + self.add_vertex(edge.target) + self._adj[edge.source][edge.target] = edge + if not self._directed: + self._adj[edge.target][edge.source] = edge return self def add_edges( @@ -579,17 +622,18 @@ def add_edges( def neighbors(self, v: str) -> list[str]: """Return the names of all vertices adjacent to *v*. - Returns the out-neighbors of *v* as recorded in the adjacency structure. For undirected - graphs, the JSON input (or programmatic :meth:`add_edge` calls) is expected to declare - both directions. + For directed graphs these are the out-neighbors of *v*. For undirected graphs adjacency + is symmetric: if ``B`` is a neighbor of ``A`` then ``A`` is a neighbor of ``B``. :param v: Vertex name. :type v: str :return: List of adjacent vertex names. :rtype: list[str] + :raises KeyError: If *v* is not in the graph. """ if v not in self._adj: - return [] + msg = f"Vertex {v!r} is not in the graph." + raise KeyError(msg) return list(self._adj[v]) # ------------------------------------------------------------------ @@ -600,7 +644,8 @@ def adjacency_matrix(self) -> AdjacencyMatrix: """Compute and return a stdlib adjacency matrix. Row and column indices correspond to :meth:`vertices` order. ``matrix[i][j] == 1`` means - an edge exists from ``vertices()[i]`` to ``vertices()[j]``; ``0`` means no edge. + an edge exists from ``vertices()[i]`` to ``vertices()[j]``; ``0`` means no edge. The + matrix of an undirected graph is symmetric. The matrix is freshly computed on each call. @@ -624,8 +669,11 @@ def vertex_to_index(self, v: str) -> int: :type v: str :return: Zero-based index into :meth:`vertices`. :rtype: int - :raises ValueError: If *v* is not in the graph. + :raises KeyError: If *v* is not in the graph. """ + if v not in self._vertices: + msg = f"Vertex {v!r} is not in the graph." + raise KeyError(msg) return self.vertices().index(v) def index_to_vertex(self, index: int) -> str: @@ -639,49 +687,6 @@ def index_to_vertex(self, index: int) -> str: """ return self.vertices()[index] - # ------------------------------------------------------------------ - # Rendering - # ------------------------------------------------------------------ - - def to_text( - self, - *, - vertex_labels: bool = True, - edge_labels: bool = True, - weights: bool | None = None, - ) -> str: - """Render this graph as an indented ASCII/Unicode tree. - - Convenience wrapper around :func:`graphworks.export.text_utils.to_text`. Draws the graph - as a depth-first spanning forest using box-drawing connectors (``├──``, ``└──``), marking - edges that close a cycle or revisit a vertex with a trailing ``...``:: - - >>> g = Graph("demo") - >>> g.add_edge("A", "B").add_edge("A", "C") - Graph('demo', order=3, size=2) - >>> print(g.to_text()) - demo - └── A - ├── B - └── C - - :param vertex_labels: Show each vertex's label when set, falling back to its name. - :type vertex_labels: bool - :param edge_labels: Show each edge's label when set. - :type edge_labels: bool - :param weights: Show edge weights. ``None`` follows this graph's :attr:`weighted` flag; - ``True``/``False`` force the choice. - :type weights: bool | None - :return: A multi-line string suitable for printing to a terminal. - :rtype: str - """ - return _to_text( - self, - vertex_labels=vertex_labels, - edge_labels=edge_labels, - weights=weights, - ) - # ------------------------------------------------------------------ # Protected helpers # ------------------------------------------------------------------ @@ -689,70 +694,69 @@ def to_text( def _collect_edges(self) -> list[Edge]: """Build and return the full edge list from ``_adj``. - For undirected graphs each pair ``(u, v)`` is returned once, preferring the - insertion-order direction. + Undirected edges are stored under both endpoints but returned once. :return: List of :class:`~graphworks.edge.Edge` instances. :rtype: list[Edge] """ edges: list[Edge] = [] seen: set[tuple[str, str]] = set() - for src, targets in self._adj.items(): - for tgt, edge in targets.items(): - if self._directed: + for targets in self._adj.values(): + for edge in targets.values(): + key = (edge.source, edge.target) + if key not in seen: + seen.add(key) edges.append(edge) - else: - pair = (min(src, tgt), max(src, tgt)) - if pair not in seen: - seen.add(pair) - edges.append(edge) return edges def _extract_fields_from_json(self, json_data: dict) -> None: """Populate the graph from a parsed JSON dictionary. - Reads ``label``, ``directed``, ``weighted``, and ``graph`` keys from *json_data* and - builds the internal vertex/edge structures. - - Only vertices that appear as **keys** in the ``"graph"`` dict are created. If an - adjacency list references a vertex that is not a key, :meth:`_validate` will catch the - inconsistency. + Reads ``label``, ``directed``, ``weighted``, ``vertices``, and ``graph`` keys from + *json_data* (see the module docstring for the schema). :param json_data: Parsed JSON representation of the graph. :type json_data: dict :return: Nothing. :rtype: None + :raises ValueError: If an adjacency list references a vertex that is not a key of + ``"graph"``, or a neighbor entry is neither a string nor an object with ``"target"``. """ self._label = json_data.get("label", "") self._directed = json_data.get("directed", False) self._weighted = json_data.get("weighted", False) - raw_graph: dict[str, list[str]] = json_data.get("graph", {}) + raw_graph: dict[str, list[str | dict[str, Any]]] = json_data.get("graph", {}) + raw_vertices: dict[str, dict[str, Any]] = json_data.get("vertices", {}) for name in raw_graph: - self.add_vertex(name) + meta = raw_vertices.get(name, {}) + self.add_vertex(name, label=meta.get("label"), attrs=meta.get("attrs")) for src, targets in raw_graph.items(): - for tgt in targets: - edge = Edge( - source=src, - target=tgt, - directed=self._directed, - ) - self._adj[src][tgt] = edge - - def _validate(self) -> bool: - """Verify that all edge endpoints reference existing vertices. - - :return: ``True`` if the graph is internally consistent. - :rtype: bool - """ - for src, targets in self._adj.items(): - if src not in self._vertices: - return False - for tgt in targets: + for spec in targets: + match spec: + case str(tgt): + data: dict[str, Any] = {} + case {"target": str(tgt), **data}: + pass + case _: + msg = f"Invalid neighbor entry for {src!r}: {spec!r}." + raise ValueError(msg) if tgt not in self._vertices: - return False - return True + msg = f"Edge {src!r} -> {tgt!r} references a vertex not in the graph." + raise ValueError(msg) + if not self._directed and tgt in self._adj and src in self._adj[tgt]: + continue # undirected edge already declared from the other side + self.add_edge( + Edge.create( + src, + tgt, + directed=self._directed, + weight=data.get("weight"), + label=data.get("label"), + attrs=data.get("attrs"), + ) + ) @staticmethod def _validate_matrix(matrix: AdjacencyMatrix) -> bool: @@ -778,21 +782,15 @@ def _matrix_to_graph(self, matrix: AdjacencyMatrix) -> None: :return: Nothing. :rtype: None """ - n = len(matrix) - names = [str(uuid.uuid4()) for _ in range(n)] + names = [str(uuid.uuid4()) for _ in matrix] for name in names: self.add_vertex(name) - for r_idx in range(n): - for c_idx, val in enumerate(matrix[r_idx]): + for r_idx, row in enumerate(matrix): + for c_idx, val in enumerate(row): if val > 0: - edge = Edge( - source=names[r_idx], - target=names[c_idx], - directed=self._directed, - ) - self._adj[names[r_idx]][names[c_idx]] = edge + self.add_edge(names[r_idx], names[c_idx]) def _check_union_compat(self, other: object) -> Graph: """Validate that *other* is a compatible :class:`Graph` for union. diff --git a/src/graphworks/utilities.py b/src/graphworks/utilities.py index 386a0a8..6f1b286 100644 --- a/src/graphworks/utilities.py +++ b/src/graphworks/utilities.py @@ -10,7 +10,7 @@ def _freeze_attrs(raw: dict[str, Any] | None) -> MappingProxyType[str, Any]: """Return a read-only *copy* of *raw*, defaulting to an empty mapping. The input dict is copied so that later mutations to the caller's original dict do not - propagate into the frozen vertex. + propagate into the frozen vertex or edge. :param raw: Mutable attribute dictionary (or ``None``). :type raw: dict[str, Any] | None @@ -18,3 +18,17 @@ def _freeze_attrs(raw: dict[str, Any] | None) -> MappingProxyType[str, Any]: :rtype: MappingProxyType[str, Any] """ return MappingProxyType(dict(raw) if raw is not None else {}) + + +def _file_stem(label: str) -> str: + """Return a filesystem-safe file stem for a graph *label*. + + Path separators are replaced with ``_`` and an empty label becomes ``"graph"`` (matching the + renderers' fallback), so exporters never write outside their target directory. + + :param label: Graph label. + :type label: str + :return: A non-empty file stem. + :rtype: str + """ + return label.replace("/", "_").replace("\\", "_") or "graph" diff --git a/src/graphworks/vertex.py b/src/graphworks/vertex.py index 74617bf..b92eca2 100644 --- a/src/graphworks/vertex.py +++ b/src/graphworks/vertex.py @@ -25,19 +25,7 @@ from types import MappingProxyType from typing import Any - -def _freeze_attrs(raw: dict[str, Any] | None) -> MappingProxyType[str, Any]: - """Return a read-only *copy* of *raw*, defaulting to an empty mapping. - - The input dict is copied so that later mutations to the caller's original dict do not - propagate into the frozen vertex. - - :param raw: Mutable attribute dictionary (or ``None``). - :type raw: dict[str, Any] | None - :return: Immutable mapping proxy. - :rtype: MappingProxyType[str, Any] - """ - return MappingProxyType(dict(raw) if raw is not None else {}) +from .utilities import _freeze_attrs @dataclass(frozen=True, slots=True) diff --git a/tests/algorithms/test_directed.py b/tests/algorithms/test_directed.py index c51fee5..d542258 100644 --- a/tests/algorithms/test_directed.py +++ b/tests/algorithms/test_directed.py @@ -4,6 +4,8 @@ import json +import pytest + from graphworks.algorithms.directed import find_circuit, is_dag from graphworks.graph import Graph @@ -22,6 +24,12 @@ def test_removing_cycle_makes_dag(self, directed_cycle_json) -> None: directed_cycle_json["graph"]["D"] = ["E"] assert is_dag(Graph(input_graph=json.dumps(directed_cycle_json))) + def test_self_loop_not_dag(self) -> None: + assert not is_dag(Graph(directed=True).add_edge("A", "A")) + + def test_empty_directed_is_dag(self) -> None: + assert is_dag(Graph(directed=True)) + def test_linear_dag(self) -> None: data = {"directed": True, "graph": {"A": ["B"], "B": ["C"], "C": []}} assert is_dag(Graph(input_graph=json.dumps(data))) @@ -41,4 +49,42 @@ def test_empty_graph(self) -> None: def test_specific_order(self, circuit_json) -> None: circuit = find_circuit(Graph(input_graph=json.dumps(circuit_json))) - assert circuit == ["A", "C", "B", "A"] + assert circuit == ["A", "B", "C", "A"] + + def test_follows_edge_direction(self, circuit_graph) -> None: + circuit = find_circuit(circuit_graph) + for u, v in zip(circuit, circuit[1:], strict=False): + assert circuit_graph.edge(u, v) is not None + + def test_no_edges(self, isolated_graph) -> None: + assert find_circuit(isolated_graph) == [] + + def test_undirected_triangle(self, triangle_graph) -> None: + circuit = find_circuit(triangle_graph) + assert len(circuit) == 4 + assert circuit[0] == circuit[-1] + assert {frozenset(p) for p in zip(circuit, circuit[1:], strict=False)} == { + frozenset(e) for e in (("a", "b"), ("b", "c"), ("a", "c")) + } + + def test_undirected_with_self_loop(self) -> None: + g = Graph().add_edges([("A", "B"), ("B", "C"), ("C", "A"), ("A", "A")]) + circuit = find_circuit(g) + assert len(circuit) == 5 + assert circuit[0] == circuit[-1] + + def test_path_has_no_circuit(self) -> None: + g = Graph(directed=True).add_edge("A", "B").add_edge("B", "C") + with pytest.raises(ValueError, match="unbalanced"): + find_circuit(g) + + def test_odd_degree_has_no_circuit(self, straight_line_json) -> None: + with pytest.raises(ValueError, match="unbalanced"): + find_circuit(Graph(input_graph=json.dumps(straight_line_json))) + + def test_disconnected_cycles_have_no_circuit(self) -> None: + g = Graph(directed=True).add_edges( + [("A", "B"), ("B", "A"), ("C", "D"), ("D", "C")], + ) + with pytest.raises(ValueError, match="not connected"): + find_circuit(g) diff --git a/tests/algorithms/test_paths.py b/tests/algorithms/test_paths.py index ea365ed..c305a35 100644 --- a/tests/algorithms/test_paths.py +++ b/tests/algorithms/test_paths.py @@ -10,23 +10,10 @@ find_all_paths, find_isolated_vertices, find_path, - generate_edges, ) from graphworks.graph import Graph -class TestGenerateEdges: - def test_single_edge(self) -> None: - data = {"graph": {"A": ["B"], "B": []}} - assert len(generate_edges(Graph(input_graph=json.dumps(data)))) == 1 - - def test_no_edges(self, isolated_graph) -> None: - assert generate_edges(isolated_graph) == [] - - def test_matches_graph_edges(self, big_graph) -> None: - assert generate_edges(big_graph) == big_graph.edges() - - class TestFindIsolatedVertices: def test_all_isolated(self, isolated_graph) -> None: assert sorted(find_isolated_vertices(isolated_graph)) == ["a", "b", "c"] @@ -38,6 +25,14 @@ def test_partial(self) -> None: def test_none_isolated(self, big_graph) -> None: assert find_isolated_vertices(big_graph) == [] + def test_undirected_edge_declared_once(self) -> None: + g = Graph().add_edge("A", "B").add_vertex("C") + assert find_isolated_vertices(g) == ["C"] + + def test_directed_sink_not_isolated(self) -> None: + g = Graph(directed=True).add_edge("A", "B").add_vertex("C") + assert find_isolated_vertices(g) == ["C"] + class TestFindPath: @pytest.fixture @@ -65,7 +60,20 @@ def test_same_start_end(self, path_graph) -> None: assert find_path(path_graph, "c", "c") == ["c"] def test_missing_start(self, path_graph) -> None: - assert find_path(path_graph, "z", "a") == [] + with pytest.raises(KeyError): + find_path(path_graph, "z", "a") + + def test_missing_end(self, path_graph) -> None: + with pytest.raises(KeyError): + find_path(path_graph, "a", "z") + + def test_undirected_path_walks_back(self) -> None: + g = Graph().add_edge("A", "B").add_edge("B", "C") + assert find_path(g, "C", "A") == ["C", "B", "A"] + + def test_directed_path_respects_direction(self) -> None: + g = Graph(directed=True).add_edge("A", "B").add_edge("B", "C") + assert find_path(g, "C", "A") == [] class TestFindAllPaths: @@ -89,7 +97,12 @@ def test_multiple_paths(self, multi_path_graph) -> None: assert paths == [["a", "d", "c", "b"], ["a", "f", "d", "c", "b"]] def test_missing_start(self, multi_path_graph) -> None: - assert find_all_paths(multi_path_graph, "z", "b") == [] + with pytest.raises(KeyError): + find_all_paths(multi_path_graph, "z", "b") + + def test_no_path(self) -> None: + g = Graph().add_vertex("A").add_vertex("B") + assert find_all_paths(g, "A", "B") == [] def test_same_start_end(self, multi_path_graph) -> None: assert find_all_paths(multi_path_graph, "a", "a") == [["a"]] diff --git a/tests/algorithms/test_properties.py b/tests/algorithms/test_properties.py index e2f3876..09867e7 100644 --- a/tests/algorithms/test_properties.py +++ b/tests/algorithms/test_properties.py @@ -7,11 +7,10 @@ import pytest from graphworks.algorithms.properties import ( + complement, degree_sequence, density, diameter, - get_complement, - invert, is_complete, is_connected, is_degree_sequence, @@ -46,6 +45,18 @@ def test_isolated_zero(self, isolated_graph) -> None: def test_multiple_neighbours(self, big_graph) -> None: assert vertex_degree(big_graph, "c") == 4 + def test_undirected_counts_both_endpoints(self) -> None: + g = Graph().add_edge("A", "B").add_edge("B", "C") + assert [vertex_degree(g, v) for v in "ABC"] == [1, 2, 1] + + def test_directed_is_in_plus_out(self) -> None: + g = Graph(directed=True).add_edge("A", "B").add_edge("C", "B").add_edge("B", "D") + assert vertex_degree(g, "B") == 3 + + def test_missing_vertex(self, big_graph) -> None: + with pytest.raises(KeyError): + vertex_degree(big_graph, "zz") + class TestMinMaxDegree: def test_min(self) -> None: @@ -59,11 +70,16 @@ def test_max(self) -> None: def test_equal_for_regular(self, isolated_graph) -> None: assert min_degree(isolated_graph) == max_degree(isolated_graph) + @pytest.mark.parametrize("func", [min_degree, max_degree]) + def test_empty_graph_raises(self, func) -> None: + with pytest.raises(ValueError, match="no vertices"): + func(Graph()) + class TestDegreeSequence: def test_sorted_descending(self) -> None: data = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} - assert degree_sequence(Graph(input_graph=json.dumps(data))) == (4, 1, 1) + assert degree_sequence(Graph(input_graph=json.dumps(data))) == [4, 1, 1] def test_all_zeros(self, isolated_graph) -> None: assert all(d == 0 for d in degree_sequence(isolated_graph)) @@ -85,6 +101,7 @@ class TestIsDegreeSequence: ([3, 1, 1], False), ([1], False), ([0, 0, 0], True), + ([2, 0, -2], False), ], ) def test_various(self, seq, expected) -> None: @@ -123,6 +140,14 @@ def test_triangle_is_regular(self, triangle_graph) -> None: def test_irregular(self, big_graph) -> None: assert not is_regular(big_graph) + def test_built_triangle_is_regular(self) -> None: + g = Graph().add_edges([("A", "B"), ("B", "C"), ("C", "A")]) + assert is_regular(g) + assert degree_sequence(g) == [2, 2, 2] + + def test_path_is_not_regular(self) -> None: + assert not is_regular(Graph().add_edge("A", "B").add_edge("B", "C")) + class TestIsSimple: def test_straight_line(self, straight_line_json) -> None: @@ -148,6 +173,15 @@ def test_isolated_not_connected(self, isolated_graph) -> None: def test_big_graph(self, big_graph) -> None: assert is_connected(big_graph) + def test_empty_is_connected(self) -> None: + assert is_connected(Graph()) + + def test_directed_weakly_connected(self) -> None: + assert is_connected(Graph(directed=True).add_edge("B", "A").add_edge("C", "A")) + + def test_directed_disconnected(self, disjoint_directed_graph) -> None: + assert not is_connected(disjoint_directed_graph) + class TestIsComplete: def test_triangle(self, triangle_graph) -> None: @@ -156,8 +190,11 @@ def test_triangle(self, triangle_graph) -> None: def test_isolated_not_complete(self, isolated_graph) -> None: assert not is_complete(isolated_graph) - def test_partial(self, simple_edge_graph) -> None: - assert not is_complete(simple_edge_graph) + def test_single_edge_is_k2(self, simple_edge_graph) -> None: + assert is_complete(simple_edge_graph) + + def test_partial(self, straight_line_json) -> None: + assert not is_complete(Graph(input_graph=json.dumps(straight_line_json))) def test_complete_directed(self) -> None: data = {"directed": True, "graph": {"a": ["b"], "b": ["a"]}} @@ -167,6 +204,12 @@ def test_incomplete_directed(self) -> None: data = {"directed": True, "graph": {"A": ["B"], "B": []}} assert not is_complete(Graph(input_graph=json.dumps(data))) + def test_built_triangle_complete(self) -> None: + assert is_complete(Graph().add_edges([("A", "B"), ("B", "C"), ("C", "A")])) + + def test_self_loop_not_complete(self) -> None: + assert not is_complete(Graph().add_edge("A", "B").add_edge("A", "A")) + class TestSparseAndDense: def test_isolated_sparse(self, isolated_graph) -> None: @@ -178,6 +221,15 @@ def test_triangle_dense(self, triangle_graph) -> None: def test_sparse_not_dense(self, isolated_graph) -> None: assert not is_dense(isolated_graph) + def test_complete_graph_not_sparse(self) -> None: + k4 = Graph().add_edges([(a, b) for a in "ABCD" for b in "ABCD" if a < b]) + assert not is_sparse(k4) + + @pytest.mark.parametrize("fixture", ["isolated_graph", "triangle_graph", "big_graph"]) + def test_sparse_is_not_dense(self, fixture, request) -> None: + g = request.getfixturevalue(fixture) + assert is_sparse(g) is not is_dense(g) + # --------------------------------------------------------------------------- # Metrics @@ -186,7 +238,7 @@ def test_sparse_not_dense(self, isolated_graph) -> None: class TestDensity: def test_connected(self, connected_graph) -> None: - assert density(connected_graph) == pytest.approx(0.4666666666666667) + assert density(connected_graph) == pytest.approx(5 / 15) # self-loops excluded def test_complete(self, triangle_graph) -> None: assert density(triangle_graph) == pytest.approx(1.0) @@ -199,6 +251,13 @@ def test_single_vertex_zero(self) -> None: g.add_vertex("A") assert density(g) == pytest.approx(0.0) + def test_complete_directed_is_one(self) -> None: + g = Graph(directed=True).add_edges([("A", "B"), ("B", "A")]) + assert density(g) == pytest.approx(1.0) + + def test_half_directed(self) -> None: + assert density(Graph(directed=True).add_edge("A", "B")) == pytest.approx(0.5) + class TestDiameter: def test_big_graph(self, big_graph) -> None: @@ -207,40 +266,59 @@ def test_big_graph(self, big_graph) -> None: def test_single_edge(self, simple_edge_graph) -> None: assert diameter(simple_edge_graph) == 1 - def test_disconnected_zero(self, isolated_graph) -> None: - assert diameter(isolated_graph) == 0 + def test_disconnected_raises(self, isolated_graph) -> None: + with pytest.raises(ValueError, match="infinite"): + diameter(isolated_graph) + + def test_directed_not_strongly_connected_raises(self) -> None: + with pytest.raises(ValueError, match="infinite"): + diameter(Graph(directed=True).add_edge("A", "B")) + + def test_empty_raises(self) -> None: + with pytest.raises(ValueError, match="no vertices"): + diameter(Graph()) + + def test_single_vertex(self) -> None: + assert diameter(Graph().add_vertex("A")) == 0 + + def test_directed_cycle(self, circuit_graph) -> None: + assert diameter(circuit_graph) == 2 # --------------------------------------------------------------------------- -# Matrix operations +# Derived graphs # --------------------------------------------------------------------------- -class TestInvert: - def test_zeros_to_ones(self) -> None: - assert invert([[0, 0], [0, 0]]) == [[1, 1], [1, 1]] +class TestComplement: + def test_label(self, isolated_graph) -> None: + assert complement(isolated_graph).label == "complement" + assert complement(Graph("g")).label == "g complement" - def test_ones_to_zeros(self) -> None: - assert invert([[1, 1], [1, 1]]) == [[0, 0], [0, 0]] + def test_preserves_vertex_names(self, big_graph) -> None: + assert complement(big_graph).vertices() == big_graph.vertices() - def test_own_inverse(self) -> None: - original = [[0, 1], [0, 0]] - assert invert(invert(original)) == original + def test_preserves_vertex_objects(self) -> None: + g = Graph().add_vertex("A", label="Alpha") + vertex = complement(g).vertex("A") + assert vertex is not None + assert vertex.label == "Alpha" + def test_isolated_becomes_complete(self, isolated_graph) -> None: + c = complement(isolated_graph) + assert c.size == 3 + assert is_complete(c) -class TestGetComplement: - def test_label(self, isolated_graph) -> None: - assert "complement" in get_complement(isolated_graph).label + def test_complete_becomes_empty(self, triangle_graph) -> None: + assert complement(triangle_graph).size == 0 - def test_isolated_has_cross_edges(self, isolated_graph) -> None: - c = get_complement(isolated_graph) - cross = [e for e in c.edges() if e.source != e.target] - assert len(cross) > 0 + def test_no_self_loops(self, isolated_graph) -> None: + assert is_simple(complement(isolated_graph)) - def test_complete_only_self_loops(self, triangle_graph) -> None: - c = get_complement(triangle_graph) - cross = [e for e in c.edges() if e.source != e.target] - assert len(cross) == 0 + def test_directed_preserved(self) -> None: + c = complement(Graph(directed=True).add_edge("A", "B")) + assert c.directed + assert [(e.source, e.target) for e in c.edges()] == [("B", "A")] - def test_vertex_count_preserved(self, big_graph) -> None: - assert get_complement(big_graph).order == big_graph.order + def test_double_complement_is_identity(self, big_graph) -> None: + assert complement(complement(big_graph)) == big_graph diff --git a/tests/algorithms/test_search.py b/tests/algorithms/test_search.py index a79fc98..0060d17 100644 --- a/tests/algorithms/test_search.py +++ b/tests/algorithms/test_search.py @@ -4,17 +4,15 @@ import json -from graphworks.algorithms.search import ( - arrival_departure_dfs, - breadth_first_search, - depth_first_search, -) +import pytest + +from graphworks.algorithms.search import breadth_first_search, depth_first_search from graphworks.graph import Graph class TestBreadthFirstSearch: def test_bfs_from_c(self, search_graph) -> None: - assert breadth_first_search(search_graph, "c") == ["c", "a", "d", "b"] + assert breadth_first_search(search_graph, "c") == ["c", "a", "b", "d"] def test_visits_all(self, search_graph) -> None: assert sorted(breadth_first_search(search_graph, "a")) == ["a", "b", "c", "d"] @@ -30,10 +28,18 @@ def test_no_duplicates(self, search_graph) -> None: walk = breadth_first_search(search_graph, "a") assert len(walk) == len(set(walk)) + def test_missing_start(self, search_graph) -> None: + with pytest.raises(KeyError): + breadth_first_search(search_graph, "z") + + def test_undirected_reaches_back(self) -> None: + g = Graph().add_edge("A", "B").add_edge("B", "C") + assert breadth_first_search(g, "C") == ["C", "B", "A"] + class TestDepthFirstSearch: def test_dfs_from_c(self, search_graph) -> None: - assert depth_first_search(search_graph, "c") == ["c", "d", "a", "b"] + assert depth_first_search(search_graph, "c") == ["c", "d", "b", "a"] def test_visits_all(self, search_graph) -> None: assert sorted(depth_first_search(search_graph, "a")) == ["a", "b", "c", "d"] @@ -50,34 +56,6 @@ def test_shared_neighbour_visited_once(self) -> None: walk = depth_first_search(Graph(input_graph=json.dumps(data)), "a") assert walk.count("b") == 1 - -class TestArrivalDepartureDFS: - def _run(self, graph): - arrival = dict.fromkeys(graph.vertices(), 0) - departure = dict.fromkeys(graph.vertices(), 0) - discovered = dict.fromkeys(graph.vertices(), False) - time = -1 - for v in graph.vertices(): - if not discovered[v]: - time = arrival_departure_dfs(graph, v, discovered, arrival, departure, time) - return arrival, departure, discovered - - def test_times(self, disjoint_directed_graph) -> None: - arrival, departure, _ = self._run(disjoint_directed_graph) - result = list(zip(arrival.values(), departure.values(), strict=False)) - expected = [(0, 11), (1, 2), (3, 10), (4, 7), (8, 9), (5, 6), (12, 15), (13, 14)] - assert result == expected - - def test_all_discovered(self, disjoint_directed_graph) -> None: - _, _, discovered = self._run(disjoint_directed_graph) - assert all(discovered.values()) - - def test_departure_after_arrival(self, disjoint_directed_graph) -> None: - arrival, departure, _ = self._run(disjoint_directed_graph) - for v in disjoint_directed_graph.vertices(): - assert departure[v] > arrival[v] - - def test_unique_times(self, disjoint_directed_graph) -> None: - arrival, departure, _ = self._run(disjoint_directed_graph) - all_times = list(arrival.values()) + list(departure.values()) - assert len(all_times) == len(set(all_times)) + def test_missing_start(self, search_graph) -> None: + with pytest.raises(KeyError): + depth_first_search(search_graph, "z") diff --git a/tests/algorithms/test_sort.py b/tests/algorithms/test_sort.py index 34fdcb9..c87d337 100644 --- a/tests/algorithms/test_sort.py +++ b/tests/algorithms/test_sort.py @@ -4,34 +4,48 @@ import json -from graphworks.algorithms.sort import topological +import pytest + +from graphworks.algorithms.sort import topological_sort from graphworks.graph import Graph class TestTopologicalSort: def test_standard_dag(self, directed_dag) -> None: - assert topological(directed_dag) == ["F", "E", "C", "D", "B", "A"] + assert topological_sort(directed_dag) == ["F", "E", "C", "D", "B", "A"] def test_valid_order(self, directed_dag) -> None: - result = topological(directed_dag) + result = topological_sort(directed_dag) position = {v: i for i, v in enumerate(result)} for v in directed_dag.vertices(): for n in directed_dag.neighbors(v): assert position[v] < position[n] def test_all_vertices_present(self, directed_dag) -> None: - assert sorted(topological(directed_dag)) == sorted(directed_dag.vertices()) + assert sorted(topological_sort(directed_dag)) == sorted(directed_dag.vertices()) def test_linear_chain(self) -> None: data = {"directed": True, "graph": {"A": ["B"], "B": ["C"], "C": ["D"], "D": []}} - assert topological(Graph(input_graph=json.dumps(data))) == ["A", "B", "C", "D"] + assert topological_sort(Graph(input_graph=json.dumps(data))) == ["A", "B", "C", "D"] def test_single_vertex(self) -> None: data = {"directed": True, "graph": {"A": []}} - assert topological(Graph(input_graph=json.dumps(data))) == ["A"] + assert topological_sort(Graph(input_graph=json.dumps(data))) == ["A"] def test_parallel_roots(self) -> None: data = {"directed": True, "graph": {"A": ["C"], "B": ["C"], "C": []}} - result = topological(Graph(input_graph=json.dumps(data))) + result = topological_sort(Graph(input_graph=json.dumps(data))) assert result.index("C") > result.index("A") assert result.index("C") > result.index("B") + + def test_cycle_raises(self, directed_cycle_graph) -> None: + with pytest.raises(ValueError, match="cycle"): + topological_sort(directed_cycle_graph) + + def test_self_loop_raises(self) -> None: + with pytest.raises(ValueError, match="cycle"): + topological_sort(Graph(directed=True).add_edge("A", "A")) + + def test_undirected_raises(self, big_graph) -> None: + with pytest.raises(ValueError, match="directed"): + topological_sort(big_graph) diff --git a/tests/data/g4.json b/tests/data/g4.json index 30b6845..2f59ce2 100644 --- a/tests/data/g4.json +++ b/tests/data/g4.json @@ -3,8 +3,8 @@ "directed": false, "weighted": true, "graph": { - "A": [{"label": "A", "weight": 1}, {"label": "B", "weight": 2}], - "B": [{"label": "A", "weight": 2}, {"label": "C", "weight": 3}], - "C": [{"label": "B", "weight": 3}] + "A": [{"target": "A", "weight": 1}, {"target": "B", "weight": 2}], + "B": [{"target": "A", "weight": 2}, {"target": "C", "weight": 3}], + "C": [{"target": "B", "weight": 3}] } } diff --git a/tests/export/test_graphviz_utils.py b/tests/export/test_graphviz_utils.py index bba9c3b..609d5be 100644 --- a/tests/export/test_graphviz_utils.py +++ b/tests/export/test_graphviz_utils.py @@ -68,3 +68,22 @@ def test_all_vertices_present_in_dot(self, tmp_dir: Path) -> None: content = gv_path.read_text(encoding="utf-8") for v in ("A", "B", "C"): assert v in content + + def test_undirected_edge_written_once(self, tmp_dir: Path) -> None: + """An undirected edge appears once even though it is stored under both endpoints.""" + graph = Graph("once").add_edge("A", "B") + content = save_to_dot(graph, str(tmp_dir)).read_text(encoding="utf-8") + assert content.count("--") == 1 + + def test_labels_and_weights_exported(self, tmp_dir: Path) -> None: + """Vertex labels, edge labels, and weights appear in the DOT source.""" + graph = Graph("rich", directed=True) + graph.add_vertex("A", label="Alpha") + graph.add_edge("A", "B", weight=3, label="toll") + content = save_to_dot(graph, str(tmp_dir)).read_text(encoding="utf-8") + assert "Alpha" in content + assert "toll, w=3" in content + + def test_label_made_filesystem_safe(self, tmp_dir: Path) -> None: + """An empty label still yields a named file inside out_dir.""" + assert save_to_dot(Graph(), str(tmp_dir)) == tmp_dir / "graph.gv" diff --git a/tests/export/test_json_utils.py b/tests/export/test_json_utils.py index 77097bf..e6df181 100644 --- a/tests/export/test_json_utils.py +++ b/tests/export/test_json_utils.py @@ -8,6 +8,9 @@ if TYPE_CHECKING: from pathlib import Path +import pytest + +from graphworks.edge import Edge from graphworks.export.json_utils import save_to_json from graphworks.graph import Graph @@ -69,8 +72,8 @@ def test_adjacency_preserved(self, tmp_dir: Path) -> None: out_path = tmp_dir / "tri.json" raw = json.loads(out_path.read_text(encoding="utf-8")) - assert raw["graph"]["a"] == ["b", "c"] - assert raw["graph"]["b"] == ["a", "c"] + # Undirected edges are written once, under their source. + assert raw["graph"] == {"a": ["b", "c"], "b": ["c"], "c": []} def test_isolated_vertices_preserved(self, tmp_dir: Path) -> None: """Isolated vertices appear in the JSON output with empty lists.""" @@ -81,3 +84,45 @@ def test_isolated_vertices_preserved(self, tmp_dir: Path) -> None: out_path = tmp_dir / "iso.json" raw = json.loads(out_path.read_text(encoding="utf-8")) assert all(raw["graph"][v] == [] for v in ["a", "b", "c"]) + + def test_returns_written_path(self, tmp_dir: Path, simple_edge_graph) -> None: + """save_to_json returns the path it wrote.""" + out_path = save_to_json(simple_edge_graph, str(tmp_dir)) + assert out_path == tmp_dir / "my graph.json" + assert out_path.exists() + + @pytest.mark.parametrize(("label", "name"), [("", "graph.json"), ("a/b", "a_b.json")]) + def test_label_made_filesystem_safe(self, tmp_dir: Path, label: str, name: str) -> None: + """Empty labels and path separators never escape out_dir.""" + assert save_to_json(Graph(label), str(tmp_dir)) == tmp_dir / name + + def test_edge_and_vertex_data_roundtrip(self, tmp_dir: Path) -> None: + """Weights, labels, and attributes survive a JSON roundtrip.""" + original = Graph("rich", weighted=True) + original.add_vertex("A", label="Alpha", attrs={"x": 1}) + original.add_edge(Edge.create("A", "B", weight=2.5, label="toll", attrs={"lanes": 2})) + original.add_edge("B", "C") + + reloaded = Graph(input_file=str(save_to_json(original, str(tmp_dir)))) + + assert reloaded == original + edge = reloaded.edge("B", "A") + assert edge is not None + assert (edge.weight, edge.label, dict(edge.attrs)) == (2.5, "toll", {"lanes": 2}) + vertex = reloaded.vertex("A") + assert vertex is not None + assert (vertex.label, dict(vertex.attrs)) == ("Alpha", {"x": 1}) + + def test_plain_edges_stay_compact(self, tmp_dir: Path, simple_edge_graph) -> None: + """Edges without data are written as bare names and no vertices key is emitted.""" + raw = json.loads(save_to_json(simple_edge_graph, str(tmp_dir)).read_text("utf-8")) + assert raw["graph"] == {"A": ["B"], "B": []} + assert "vertices" not in raw + + def test_directed_roundtrip(self, tmp_dir: Path, directed_cycle_graph) -> None: + """Directed graphs round-trip edge-for-edge.""" + directed_cycle_graph.add_edge("C", "A", weight=4) + path = save_to_json(directed_cycle_graph, str(tmp_dir)) + reloaded = Graph(input_file=str(path)) + assert reloaded == directed_cycle_graph + assert reloaded.edge("C", "A").weight == 4 diff --git a/tests/export/test_text_utils.py b/tests/export/test_text_utils.py index a7829de..f56876e 100644 --- a/tests/export/test_text_utils.py +++ b/tests/export/test_text_utils.py @@ -147,21 +147,11 @@ def test_output_is_deterministic(self, raw: dict) -> None: class TestPublicSurface: - """The convenience wrappers exposed on Graph and the package root.""" + """to_text is exposed at the package root and in graphworks.export.""" - def test_graph_method_matches_function(self, connected_graph: Graph) -> None: - """Graph.to_text() delegates to the export function.""" - assert connected_graph.to_text() == to_text(connected_graph) - - def test_graph_method_forwards_options(self) -> None: - """Graph.to_text() forwards its display options to the export function.""" - g = Graph("g", directed=True, weighted=True) - g.add_vertex("A", label="Start") - g.add_edge("A", "B", weight=3) - assert g.to_text(vertex_labels=False, weights=False) == to_text( - g, vertex_labels=False, weights=False - ) - assert g.to_text(vertex_labels=False, weights=False) == "g\n└── A\n └── B" + def test_not_a_graph_method(self) -> None: + """Rendering is a free function, like every other algorithm and exporter.""" + assert not hasattr(Graph, "to_text") def test_top_level_export(self, simple_edge_graph: Graph) -> None: """to_text is importable directly from the package root.""" @@ -169,3 +159,9 @@ def test_top_level_export(self, simple_edge_graph: Graph) -> None: assert "to_text" in graphworks.__all__ assert graphworks.to_text(simple_edge_graph) == to_text(simple_edge_graph) + + def test_export_package(self, simple_edge_graph: Graph) -> None: + """to_text is importable from graphworks.export.""" + from graphworks import export + + assert export.to_text(simple_edge_graph) == to_text(simple_edge_graph) diff --git a/tests/test_graph.py b/tests/test_graph.py index 0ee750c..dc8bfd9 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -3,10 +3,7 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pathlib import Path +from pathlib import Path import pytest @@ -43,7 +40,7 @@ def test_repr(self) -> None: def test_str_shows_adjacency_list(self, simple_edge_json) -> None: graph = Graph(input_graph=json.dumps(simple_edge_json)) - assert str(graph) == "my graph\nA -> B\nB -> 0" + assert str(graph) == "my graph\nA -> B\nB -> A" def test_str_empty_vertex_shows_zero(self) -> None: graph = Graph("g") @@ -56,6 +53,10 @@ def test_str_sorted(self) -> None: assert lines[1].startswith("A") assert lines[2].startswith("B") + def test_str_separates_neighbors(self) -> None: + g = Graph("s").add_edge("AB", "C").add_edge("AB", "D") + assert "AB -> C, D" in str(g) + # --------------------------------------------------------------------------- # Construction — JSON @@ -242,10 +243,16 @@ def test_size(self, simple_edge_graph) -> None: def test_neighbors(self, simple_edge_graph) -> None: assert simple_edge_graph.neighbors("A") == ["B"] - assert simple_edge_graph.neighbors("B") == [] + assert simple_edge_graph.neighbors("B") == ["A"] + + def test_neighbors_directed_one_way(self) -> None: + g = Graph(directed=True).add_edge("A", "B") + assert g.neighbors("A") == ["B"] + assert g.neighbors("B") == [] def test_neighbors_missing_vertex(self) -> None: - assert Graph().neighbors("Z") == [] + with pytest.raises(KeyError): + Graph().neighbors("Z") # --------------------------------------------------------------------------- @@ -255,7 +262,7 @@ def test_neighbors_missing_vertex(self) -> None: class TestAdjacencyMatrix: def test_values(self, simple_edge_graph) -> None: - assert simple_edge_graph.adjacency_matrix() == [[0, 1], [0, 0]] + assert simple_edge_graph.adjacency_matrix() == [[0, 1], [1, 0]] def test_square(self, big_graph) -> None: n = big_graph.order @@ -268,6 +275,10 @@ def test_index_roundtrip(self, simple_edge_graph) -> None: idx = simple_edge_graph.vertex_to_index(v) assert simple_edge_graph.index_to_vertex(idx) == v + def test_vertex_to_index_missing(self, simple_edge_graph) -> None: + with pytest.raises(KeyError): + simple_edge_graph.vertex_to_index("Z") + def test_directed_asymmetric(self) -> None: data = {"directed": True, "graph": {"A": ["B"], "B": []}} matrix = Graph(input_graph=json.dumps(data)).adjacency_matrix() @@ -289,7 +300,97 @@ def test_getitem_returns_neighbors(self) -> None: data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} g = Graph(input_graph=json.dumps(data)) assert len(g["A"]) == 3 - assert g["B"] == [] + assert g["B"] == ["A"] def test_getitem_missing(self) -> None: - assert Graph()["MISSING"] == [] + with pytest.raises(KeyError): + Graph()["MISSING"] + + +# --------------------------------------------------------------------------- +# Undirected symmetry, JSON edge data, and equality +# --------------------------------------------------------------------------- + + +class TestUndirectedSymmetry: + def test_add_edge_is_symmetric(self) -> None: + g = Graph().add_edge("A", "B", weight=2) + assert g.neighbors("B") == ["A"] + assert g.edge("A", "B") is g.edge("B", "A") + assert g.size == 1 + + def test_json_one_direction_is_enough(self) -> None: + g = Graph(input_graph=json.dumps({"graph": {"A": ["B"], "B": []}})) + assert g.neighbors("B") == ["A"] + + def test_json_both_directions_first_wins(self) -> None: + data = { + "graph": { + "A": [{"target": "B", "weight": 1}], + "B": [{"target": "A", "weight": 9}], + } + } + g = Graph(input_graph=json.dumps(data)) + assert g.size == 1 + assert g.edge("B", "A").weight == 1 + + def test_matrix_input_symmetric(self) -> None: + g = Graph(input_matrix=[[0, 1], [0, 0]]) + assert g.adjacency_matrix() == [[0, 1], [1, 0]] + assert g.size == 1 + + def test_union_keeps_symmetry(self) -> None: + merged = Graph().add_edge("A", "B") | Graph().add_edge("B", "C") + assert sorted(merged.neighbors("B")) == ["A", "C"] + + +class TestJsonEdgeData: + def test_neighbor_objects_parsed(self) -> None: + data = { + "weighted": True, + "vertices": {"A": {"label": "Alpha", "attrs": {"x": 1}}}, + "graph": {"A": [{"target": "B", "weight": 2.5, "label": "toll"}], "B": []}, + } + g = Graph(input_graph=json.dumps(data)) + edge = g.edge("A", "B") + assert edge is not None + assert (edge.weight, edge.label) == (2.5, "toll") + vertex = g.vertex("A") + assert vertex is not None + assert vertex.label == "Alpha" + assert vertex.attrs["x"] == 1 + + def test_weighted_fixture(self) -> None: + g = Graph(input_file=str(Path(__file__).parent / "data" / "g4.json")) + assert g.edge("B", "C").weight == 3 + assert g.edge("A", "A").weight == 1 + + def test_unknown_target_raises(self) -> None: + with pytest.raises(ValueError, match="not in the graph"): + Graph(input_graph=json.dumps({"graph": {"A": ["Z"]}})) + + def test_invalid_neighbor_entry_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid neighbor entry"): + Graph(input_graph=json.dumps({"graph": {"A": [{"weight": 1}], "B": []}})) + + +class TestGraphEquality: + def test_equal_structure(self) -> None: + assert Graph("x").add_edge("A", "B") == Graph("y").add_edge("B", "A") + + def test_weights_ignored(self) -> None: + assert Graph().add_edge("A", "B", weight=1) == Graph().add_edge("A", "B", weight=2) + + def test_direction_matters(self) -> None: + assert Graph(directed=True).add_edge("A", "B") != Graph(directed=True).add_edge("B", "A") + assert Graph().add_edge("A", "B") != Graph(directed=True).add_edge("A", "B") + + def test_vertices_matter(self) -> None: + assert Graph().add_vertex("A") != Graph().add_vertex("B") + + def test_not_equal_to_other_types(self) -> None: + assert Graph() != "graph" + + def test_unhashable(self) -> None: + with pytest.raises(TypeError): + hash(Graph()) diff --git a/tests/test_graph_fluent_chaining.py b/tests/test_graph_fluent_chaining.py index 7f81049..19a84fd 100644 --- a/tests/test_graph_fluent_chaining.py +++ b/tests/test_graph_fluent_chaining.py @@ -115,12 +115,16 @@ def test_mixed_specs(self) -> None: [ ("A", "B"), ("B", "C", {"weight": 5.0}), - Edge.create("C", "D", directed=True, label="arc"), + Edge.create("C", "D", label="arc"), ] ) assert g.order == 4 assert g.size == 3 + def test_edge_directed_mismatch_raises(self) -> None: + with pytest.raises(ValueError, match="directed flags differ"): + Graph().add_edges([Edge("C", "D", directed=True)]) + def test_empty_iterable(self) -> None: g = Graph() g.add_edges([]) @@ -285,12 +289,20 @@ def test_edge_create_factory(self) -> None: assert found.label == "bridge" def test_edge_object_preserves_directed(self) -> None: - g = Graph() - e = Edge("A", "B", directed=True) - g.add_edge(e) + g = Graph(directed=True) + g.add_edge(Edge("A", "B", directed=True)) found = g.edge("A", "B") assert found is not None assert found.directed + assert g.edge("B", "A") is None + + def test_edge_object_undirected_both_ways(self) -> None: + g = Graph().add_edge(Edge("A", "B", weight=2)) + assert g.edge("A", "B") is g.edge("B", "A") + + def test_directed_edge_into_undirected_graph_raises(self) -> None: + with pytest.raises(ValueError, match="an undirected graph"): + Graph().add_edge(Edge("A", "B", directed=True)) def test_edge_object_preserves_attrs(self) -> None: g = Graph()