From 1ace2599f93fb546b5aad9bfe7c203f4cc28b7e4 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Mon, 23 Mar 2026 19:06:00 -0600 Subject: [PATCH 1/8] Add new edge class --- conftest.py | 48 +++--- pyproject.toml | 48 +++++- src/graphworks/edge.py | 220 +++++++++++++++++++++++++-- tests/test_edge.py | 336 +++++++++++++++++++++++++++++++++++++++-- tests/test_paths.py | 4 +- 5 files changed, 601 insertions(+), 55 deletions(-) diff --git a/conftest.py b/conftest.py index 8f425d9..deebfdd 100644 --- a/conftest.py +++ b/conftest.py @@ -41,7 +41,7 @@ # --------------------------------------------------------------------------- -@pytest.fixture() +@pytest.fixture def tmp_dir() -> Generator[Path]: """Yield a fresh temporary directory and clean it up afterwards. @@ -58,7 +58,7 @@ def tmp_dir() -> Generator[Path]: # --------------------------------------------------------------------------- -@pytest.fixture() +@pytest.fixture def simple_edge_json() -> dict: """Minimal two-vertex undirected graph with one edge (A → B). @@ -68,7 +68,7 @@ def simple_edge_json() -> dict: return {"label": "my graph", "graph": {"A": ["B"], "B": []}} -@pytest.fixture() +@pytest.fixture def triangle_json() -> dict: """Complete undirected graph on three vertices (K₃). @@ -84,7 +84,7 @@ def triangle_json() -> dict: } -@pytest.fixture() +@pytest.fixture def isolated_json() -> dict: """Three-vertex graph with no edges (all isolated vertices). @@ -94,7 +94,7 @@ def isolated_json() -> dict: return {"graph": {"a": [], "b": [], "c": []}} -@pytest.fixture() +@pytest.fixture def connected_json() -> dict: """Six-vertex connected undirected graph that includes self-loops. @@ -113,7 +113,7 @@ def connected_json() -> dict: } -@pytest.fixture() +@pytest.fixture def big_graph_json() -> dict: """Six-vertex connected undirected graph used for diameter tests. @@ -132,7 +132,7 @@ def big_graph_json() -> dict: } -@pytest.fixture() +@pytest.fixture def lollipop_json() -> dict: """Lollipop-shaped graph that contains a cycle (d→b) but *no* self-loops. @@ -155,7 +155,7 @@ def lollipop_json() -> dict: } -@pytest.fixture() +@pytest.fixture def self_loop_json() -> dict: """Two-vertex graph where vertex *a* has a self-loop — **not** simple. @@ -170,7 +170,7 @@ def self_loop_json() -> dict: } -@pytest.fixture() +@pytest.fixture def straight_line_json() -> dict: """Linear path graph a-b-c-d: simple, no self-loops, no cycles. @@ -180,7 +180,7 @@ def straight_line_json() -> dict: return {"graph": {"a": ["b"], "b": ["c"], "c": ["d"], "d": []}} -@pytest.fixture() +@pytest.fixture def directed_dag_json() -> dict: """Directed acyclic graph for topological sort and DAG tests. @@ -200,7 +200,7 @@ def directed_dag_json() -> dict: } -@pytest.fixture() +@pytest.fixture def directed_cycle_json() -> dict: """Directed graph containing a cycle — **not** a DAG. @@ -221,7 +221,7 @@ def directed_cycle_json() -> dict: } -@pytest.fixture() +@pytest.fixture def circuit_json() -> dict: """Directed graph with a single Eulerian circuit A → B → C → A. @@ -234,7 +234,7 @@ def circuit_json() -> dict: } -@pytest.fixture() +@pytest.fixture def search_graph_json() -> dict: """Four-vertex graph used for BFS / DFS traversal tests. @@ -251,7 +251,7 @@ def search_graph_json() -> dict: } -@pytest.fixture() +@pytest.fixture def disjoint_directed_json() -> dict: """Directed graph with two disjoint components for arrival/departure DFS. @@ -278,7 +278,7 @@ def disjoint_directed_json() -> dict: # --------------------------------------------------------------------------- -@pytest.fixture() +@pytest.fixture def simple_edge_graph(simple_edge_json: dict) -> Graph: """Two-vertex undirected :class:`Graph` with one edge (A → B). @@ -288,7 +288,7 @@ def simple_edge_graph(simple_edge_json: dict) -> Graph: return Graph(input_graph=json.dumps(simple_edge_json)) -@pytest.fixture() +@pytest.fixture def triangle_graph(triangle_json: dict) -> Graph: """Complete undirected :class:`Graph` on three vertices (K₃). @@ -298,7 +298,7 @@ def triangle_graph(triangle_json: dict) -> Graph: return Graph(input_graph=json.dumps(triangle_json)) -@pytest.fixture() +@pytest.fixture def isolated_graph(isolated_json: dict) -> Graph: """Three-vertex :class:`Graph` with no edges. @@ -308,7 +308,7 @@ def isolated_graph(isolated_json: dict) -> Graph: return Graph(input_graph=json.dumps(isolated_json)) -@pytest.fixture() +@pytest.fixture def connected_graph(connected_json: dict) -> Graph: """Six-vertex connected undirected :class:`Graph`. @@ -318,7 +318,7 @@ def connected_graph(connected_json: dict) -> Graph: return Graph(input_graph=json.dumps(connected_json)) -@pytest.fixture() +@pytest.fixture def big_graph(big_graph_json: dict) -> Graph: """Six-vertex connected undirected :class:`Graph` for diameter tests. @@ -328,7 +328,7 @@ def big_graph(big_graph_json: dict) -> Graph: return Graph(input_graph=json.dumps(big_graph_json)) -@pytest.fixture() +@pytest.fixture def directed_dag(directed_dag_json: dict) -> Graph: """Directed acyclic :class:`Graph`. @@ -338,7 +338,7 @@ def directed_dag(directed_dag_json: dict) -> Graph: return Graph(input_graph=json.dumps(directed_dag_json)) -@pytest.fixture() +@pytest.fixture def directed_cycle_graph(directed_cycle_json: dict) -> Graph: """Directed :class:`Graph` containing a cycle. @@ -348,7 +348,7 @@ def directed_cycle_graph(directed_cycle_json: dict) -> Graph: return Graph(input_graph=json.dumps(directed_cycle_json)) -@pytest.fixture() +@pytest.fixture def circuit_graph(circuit_json: dict) -> Graph: """Directed :class:`Graph` with an Eulerian circuit A → B → C → A. @@ -358,7 +358,7 @@ def circuit_graph(circuit_json: dict) -> Graph: return Graph(input_graph=json.dumps(circuit_json)) -@pytest.fixture() +@pytest.fixture def search_graph(search_graph_json: dict) -> Graph: """Four-vertex :class:`Graph` for BFS / DFS tests. @@ -368,7 +368,7 @@ def search_graph(search_graph_json: dict) -> Graph: return Graph(input_graph=json.dumps(search_graph_json)) -@pytest.fixture() +@pytest.fixture def disjoint_directed_graph(disjoint_directed_json: dict) -> Graph: """Directed :class:`Graph` with two disjoint components. diff --git a/pyproject.toml b/pyproject.toml index a74694f..667c419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,14 +79,56 @@ line-length = 100 target-version = "py314" [tool.ruff.lint] -select = [ "E", "W", "F", "I", "UP", "B", "C4", "SIM", "TCH", "ANN", "D",] -ignore = [ "D203", "D401", "D213",] +select = [ + "E", + "W", + "TCH", + "F", + "B", + "ANN", + "C4", + "SIM", + "N", + "DTZ", + "ERA", + "PT", + "PTH", + "TRY", + "RET", + "T20", + "PL", + "G", + "D", +] +ignore = [ + "ANN002", + "ANN003", + "ANN204", + "ANN401", + "TRY003", + "N818", + "TRY300", + "TRY301", + "TRY400", + "D401", + "D415", + "D104", + "D103", + "D400", + "D101", + "D404", + "D107", + "D203", + "D213", +] [tool.ruff.lint.pydocstyle] convention = "pep257" [tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = [ "ANN", "D",] +"tests/**/*.py" = [ "S101", "T201", "ARG", "ANN", "B023", "B011", "SIM117", "DTZ", "PL", "D",] +"conftest.py" = [ "ANN", "D",] +"examples/**" = [ "ANN", "T20", "PL", "D",] [tool.isort] profile = "black" diff --git a/src/graphworks/edge.py b/src/graphworks/edge.py index f039264..cb848f8 100644 --- a/src/graphworks/edge.py +++ b/src/graphworks/edge.py @@ -1,27 +1,227 @@ -"""Implementation of graph edge between 2 vertices.""" +"""Graph edge connecting two vertices. + +An :class:`Edge` is an immutable value object representing a connection between two vertices. +Edges may be directed or undirected and may carry an optional numeric weight, a human-readable +label, and an arbitrary attribute mapping. + +Identity semantics +------------------ +Two edges are considered **equal** when they share the same structural identity: ``(source, +target, directed)``. Weight, label, and extra attributes are *descriptive* — they do not affect +equality or hashing. This mirrors graph-theoretic convention where an edge is identified by its +endpoints and orientation, not by its annotations. + +Immutability +------------ +:class:`Edge` is a **frozen** dataclass with ``__slots__``. Once created, its fields cannot be +reassigned. The *attrs* mapping is exposed as a read-only :class:`~types.MappingProxyType` so +callers cannot mutate it in place either. To "update" an edge, create a new instance — idiomatic +for frozen dataclasses and compatible with use as ``dict`` keys and ``set`` members. + +Backward compatibility +---------------------- +The previous API exposed ``vertex1`` and ``vertex2`` field names. These are retained as +**read-only property aliases** for ``source`` and ``target`` respectively. New code should +prefer ``source`` / ``target``. +""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any + +def _freeze_attrs(raw: dict[str, Any] | None) -> MappingProxyType[str, Any]: + """Return a read-only view of *raw*, defaulting to an empty mapping. -@dataclass + :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 {}) + + +@dataclass(frozen=True, slots=True) class Edge: - """Implementation of graph edge between 2 vertices. + """Immutable edge between two vertices. + + An undirected edge is a *line*; a directed edge is an *arc*. Both flavors support optional + weights, labels, and free-form attributes. - An undirected edge is a line. A directed edge is an arc or arrow. Supports weighted (float) - edges. + :param source: Name of the source (or first) vertex. + :type source: str + :param target: Name of the target (or second) vertex. + :type target: str + :param directed: Whether this edge is directed. Defaults to ``False``. + :type directed: bool + :param weight: Optional numeric weight. + :type weight: float | None + :param label: Optional human-readable label. + :type label: str | None + :param attrs: Arbitrary key/value metadata. Stored internally as an immutable + :class:`~types.MappingProxyType`. + :type attrs: MappingProxyType[str, Any] """ - vertex1: str - vertex2: str + source: str + target: str directed: bool = False weight: float | None = None + label: str | None = None + attrs: MappingProxyType[str, Any] = field( + default_factory=lambda: MappingProxyType({}), + ) + + # ------------------------------------------------------------------ + # Alternate constructor + # ------------------------------------------------------------------ + + @classmethod + def create( # noqa: PLR0913 + cls, + source: str, + target: str, + *, + directed: bool = False, + weight: float | None = None, + label: str | None = None, + attrs: dict[str, Any] | None = None, + ) -> Edge: + """Build an :class:`Edge`, accepting a plain ``dict`` for *attrs*. + + This factory freezes the provided *attrs* dict into a read-only + :class:`~types.MappingProxyType` so callers don't need to wrap it themselves. + + :param source: Name of the source vertex. + :type source: str + :param target: Name of the target vertex. + :type target: str + :param directed: Whether the edge is directed. + :type directed: bool + :param weight: Optional numeric weight. + :type weight: float | None + :param label: Optional human-readable label. + :type label: str | None + :param attrs: Mutable attribute dict (will be frozen). + :type attrs: dict[str, Any] | None + :return: A new :class:`Edge` instance. + :rtype: Edge + """ + return cls( + source=source, + target=target, + directed=directed, + weight=weight, + label=label, + attrs=_freeze_attrs(attrs), + ) + + # ------------------------------------------------------------------ + # Backward-compatible aliases + # ------------------------------------------------------------------ + + @property + def vertex1(self) -> str: + """Alias for :attr:`source` (backward compatibility). + + .. deprecated:: + Use :attr:`source` instead. + + :return: Source vertex name. + :rtype: str + """ + return self.source + + @property + def vertex2(self) -> str: + """Alias for :attr:`target` (backward compatibility). + + .. deprecated:: + Use :attr:`target` instead. + + :return: Target vertex name. + :rtype: str + """ + return self.target + + # ------------------------------------------------------------------ + # Predicates + # ------------------------------------------------------------------ def has_weight(self) -> bool: - """Returns ``True`` if the edge has a ``weight`` attribute. + """Return whether this edge carries a weight. - :return: ``True`` if the edge has a ``weight`` attribute, otherwise ``False``. + :return: ``True`` if :attr:`weight` is not ``None``. :rtype: bool """ return self.weight is not None + + def is_self_loop(self) -> bool: + """Return whether this edge connects a vertex to itself. + + :return: ``True`` if :attr:`source` equals :attr:`target`. + :rtype: bool + """ + return self.source == self.target + + # ------------------------------------------------------------------ + # Identity — equality and hashing on structural triple only + # ------------------------------------------------------------------ + + def __eq__(self, other: object) -> bool: + """Return ``True`` if *other* has the same structural identity. + + Structural identity is the tuple ``(source, target, directed)``. Weight, label, and attrs + are **not** considered. + + :param other: Object to compare against. + :type other: object + :return: ``True`` if structurally equal. + :rtype: bool + """ + if not isinstance(other, Edge): + return NotImplemented + return ( + self.source == other.source + and self.target == other.target + and self.directed == other.directed + ) + + def __hash__(self) -> int: + """Return a hash based on structural identity. + + :return: Hash of ``(source, target, directed)``. + :rtype: int + """ + return hash((self.source, self.target, self.directed)) + + # ------------------------------------------------------------------ + # Display + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + """Return a developer-friendly representation. + + :return: String like ``Edge('A' -> 'B', weight=3.0)``. + :rtype: str + """ + arrow = "->" if self.directed else "--" + parts = [f"Edge('{self.source}' {arrow} '{self.target}'"] + if self.weight is not None: + parts.append(f"weight={self.weight}") + if self.label is not None: + parts.append(f"label={self.label!r}") + if self.attrs: + parts.append(f"attrs={dict(self.attrs)}") + return ", ".join(parts) + ")" + + def __str__(self) -> str: + """Return a human-readable string. + + :return: String like ``A -> B`` or ``A -- B``. + :rtype: str + """ + arrow = "->" if self.directed else "--" + return f"{self.source} {arrow} {self.target}" diff --git a/tests/test_edge.py b/tests/test_edge.py index ede7b29..ca96af7 100644 --- a/tests/test_edge.py +++ b/tests/test_edge.py @@ -1,27 +1,31 @@ -""" -tests.test_edge -~~~~~~~~~~~~~~~ - -Unit tests for :class:`graphworks.edge.Edge`. +"""Unit tests for :class:`graphworks.edge.Edge`. -:author: Nathan Gilbert +Covers construction (direct and via factory), structural identity (equality and hashing), +the ``has_weight`` and ``is_self_loop`` predicates, the backward-compatible +``vertex1``/``vertex2`` aliases, immutable ``attrs``, and string representations. """ from __future__ import annotations +from types import MappingProxyType + import pytest from graphworks.edge import Edge +# --------------------------------------------------------------------------- +# Construction — direct instantiation +# --------------------------------------------------------------------------- + class TestEdgeConstruction: """Tests for Edge construction and default values.""" def test_basic_construction(self) -> None: - """An Edge stores vertex1 and vertex2 correctly.""" + """An Edge stores source and target correctly.""" e = Edge("a", "b") - assert e.vertex1 == "a" - assert e.vertex2 == "b" + assert e.source == "a" + assert e.target == "b" def test_directed_defaults_to_false(self) -> None: """The directed flag defaults to False.""" @@ -33,6 +37,17 @@ def test_weight_defaults_to_none(self) -> None: e = Edge("a", "b") assert e.weight is None + def test_label_defaults_to_none(self) -> None: + """The label defaults to None.""" + e = Edge("a", "b") + assert e.label is None + + def test_attrs_defaults_to_empty_mapping(self) -> None: + """The attrs field defaults to an empty MappingProxyType.""" + e = Edge("a", "b") + assert e.attrs == {} + assert isinstance(e.attrs, MappingProxyType) + def test_explicit_directed(self) -> None: """The directed flag can be set to True explicitly.""" e = Edge("a", "b", True) @@ -43,13 +58,143 @@ def test_explicit_weight(self) -> None: e = Edge("a", "b", False, 42.5) assert e.weight == pytest.approx(42.5) + def test_explicit_label(self) -> None: + """A label string is stored and accessible.""" + e = Edge("a", "b", label="highway") + assert e.label == "highway" + + def test_explicit_attrs(self) -> None: + """A MappingProxyType attrs is stored and accessible.""" + attrs = MappingProxyType({"color": "red", "capacity": 10}) + e = Edge("a", "b", attrs=attrs) + assert e.attrs["color"] == "red" + assert e.attrs["capacity"] == 10 + + +# --------------------------------------------------------------------------- +# Construction — factory method +# --------------------------------------------------------------------------- + + +class TestEdgeCreateFactory: + """Tests for the Edge.create() alternate constructor.""" + + def test_create_basic(self) -> None: + """Edge.create builds a valid edge with defaults.""" + e = Edge.create("x", "y") + assert e.source == "x" + assert e.target == "y" + assert not e.directed + assert e.weight is None + assert e.label is None + assert e.attrs == {} + + def test_create_with_plain_dict_attrs(self) -> None: + """Edge.create freezes a plain dict into a MappingProxyType.""" + e = Edge.create("x", "y", attrs={"color": "blue"}) + assert e.attrs["color"] == "blue" + assert isinstance(e.attrs, MappingProxyType) + + def test_create_with_none_attrs(self) -> None: + """Edge.create with attrs=None yields an empty mapping.""" + e = Edge.create("x", "y", attrs=None) + assert e.attrs == {} + assert isinstance(e.attrs, MappingProxyType) + + def test_create_with_all_fields(self) -> None: + """Edge.create accepts all keyword arguments.""" + e = Edge.create( + "a", + "b", + directed=True, + weight=3.14, + label="bridge", + attrs={"toll": 5}, + ) + assert e.source == "a" + assert e.target == "b" + assert e.directed + assert e.weight == pytest.approx(3.14) + assert e.label == "bridge" + assert e.attrs["toll"] == 5 + + def test_create_does_not_mutate_original_dict(self) -> None: + """Mutating the input dict after create() has no effect on the Edge.""" + raw = {"key": "original"} + e = Edge.create("a", "b", attrs=raw) + raw["key"] = "mutated" + assert e.attrs["key"] == "original" + + +# --------------------------------------------------------------------------- +# Immutability +# --------------------------------------------------------------------------- + + +class TestEdgeImmutability: + """Tests that Edge instances are truly frozen.""" + + def test_cannot_set_source(self) -> None: + """Attempting to reassign source raises an error.""" + e = Edge("a", "b") + with pytest.raises(AttributeError): + e.source = "z" # type: ignore[misc] + + def test_cannot_set_weight(self) -> None: + """Attempting to reassign weight raises an error.""" + e = Edge("a", "b") + with pytest.raises(AttributeError): + e.weight = 99.0 # type: ignore[misc] + + def test_cannot_mutate_attrs(self) -> None: + """Attempting to set a key on attrs raises a TypeError.""" + e = Edge.create("a", "b", attrs={"color": "red"}) + with pytest.raises(TypeError): + e.attrs["color"] = "blue" # type: ignore[index] + + def test_cannot_add_new_attr(self) -> None: + """Attempting to add a new key to attrs raises a TypeError.""" + e = Edge("a", "b") + with pytest.raises(TypeError): + e.attrs["new_key"] = "value" # type: ignore[index] + + +# --------------------------------------------------------------------------- +# Backward-compatible aliases +# --------------------------------------------------------------------------- + + +class TestEdgeBackwardCompat: + """Tests for vertex1/vertex2 property aliases.""" + + def test_vertex1_aliases_source(self) -> None: + """vertex1 returns the same value as source.""" + e = Edge("a", "b") + assert e.vertex1 == e.source + + def test_vertex2_aliases_target(self) -> None: + """vertex2 returns the same value as target.""" + e = Edge("a", "b") + assert e.vertex2 == e.target + + def test_vertex1_read_only(self) -> None: + """vertex1 cannot be assigned to.""" + e = Edge("a", "b") + with pytest.raises((AttributeError, TypeError)): + e.vertex1 = "z" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Predicates +# --------------------------------------------------------------------------- + class TestEdgeHasWeight: """Tests for the has_weight predicate.""" def test_no_weight_returns_false(self) -> None: """has_weight() is False when weight is None.""" - e = Edge("a", "b", False) + e = Edge("a", "b") assert not e.has_weight() def test_with_weight_returns_true(self) -> None: @@ -62,18 +207,177 @@ def test_zero_weight_returns_true(self) -> None: e = Edge("a", "b", False, 0.0) assert e.has_weight() + def test_negative_weight_returns_true(self) -> None: + """A negative weight is still considered 'has weight'.""" + e = Edge("a", "b", False, -1.5) + assert e.has_weight() + + +class TestEdgeIsSelfLoop: + """Tests for the is_self_loop predicate.""" + + def test_self_loop_true(self) -> None: + """is_self_loop returns True when source equals target.""" + e = Edge("a", "a") + assert e.is_self_loop() + + def test_not_self_loop(self) -> None: + """is_self_loop returns False for distinct endpoints.""" + e = Edge("a", "b") + assert not e.is_self_loop() + + def test_directed_self_loop(self) -> None: + """A directed self-loop is still a self-loop.""" + e = Edge("x", "x", directed=True) + assert e.is_self_loop() + + +# --------------------------------------------------------------------------- +# Structural identity — equality +# --------------------------------------------------------------------------- + class TestEdgeEquality: - """Tests for Edge dataclass equality semantics.""" + """Tests for Edge equality based on structural identity. + + Equality uses ``(source, target, directed)`` only. Weight, label, + and attrs are ignored. + """ def test_equal_edges(self) -> None: - """Two Edge instances with the same fields are equal.""" + """Two Edges with the same structural triple are equal.""" assert Edge("A", "B") == Edge("A", "B") - def test_direction_matters(self) -> None: + def test_equal_ignores_weight(self) -> None: + """Edges with different weights but same structure are equal.""" + assert Edge("a", "b", False, 1.0) == Edge("a", "b", False, 2.0) + + def test_equal_ignores_label(self) -> None: + """Edges with different labels but same structure are equal.""" + assert Edge("a", "b", label="x") == Edge("a", "b", label="y") + + def test_equal_ignores_attrs(self) -> None: + """Edges with different attrs but same structure are equal.""" + e1 = Edge.create("a", "b", attrs={"k": 1}) + e2 = Edge.create("a", "b", attrs={"k": 2}) + assert e1 == e2 + + def test_direction_matters_for_equality(self) -> None: """Edge("A","B") != Edge("B","A") due to vertex ordering.""" assert Edge("A", "B") != Edge("B", "A") - def test_weight_affects_equality(self) -> None: - """Edges with different weights are not equal.""" - assert Edge("a", "b", False, 1.0) != Edge("a", "b", False, 2.0) + def test_directed_flag_matters_for_equality(self) -> None: + """Same endpoints but different directed flag are not equal.""" + assert Edge("a", "b", directed=False) != Edge("a", "b", directed=True) + + def test_not_equal_to_non_edge(self) -> None: + """Comparing an Edge to a non-Edge returns NotImplemented.""" + e = Edge("a", "b") + assert e != "not an edge" + assert e != 42 + assert e != ("a", "b") + + def test_not_equal_to_none(self) -> None: + """Comparing an Edge to None is False.""" + e = Edge("a", "b") + assert e != None # noqa: E711 + + +# --------------------------------------------------------------------------- +# Structural identity — hashing +# --------------------------------------------------------------------------- + + +class TestEdgeHashing: + """Tests for Edge hash behaviour based on structural identity.""" + + def test_equal_edges_same_hash(self) -> None: + """Structurally equal edges have the same hash.""" + assert hash(Edge("a", "b")) == hash(Edge("a", "b")) + + def test_different_weight_same_hash(self) -> None: + """Edges differing only in weight share a hash.""" + e1 = Edge("a", "b", False, 1.0) + e2 = Edge("a", "b", False, 99.0) + assert hash(e1) == hash(e2) + + def test_usable_as_dict_key(self) -> None: + """Edges can serve as dictionary keys.""" + e = Edge("a", "b") + d = {e: "found"} + assert d[Edge("a", "b")] == "found" + + def test_usable_in_set(self) -> None: + """Duplicate structural edges collapse in a set.""" + s = {Edge("a", "b"), Edge("a", "b"), Edge("a", "b", False, 5.0)} + assert len(s) == 1 + + def test_different_edges_in_set(self) -> None: + """Structurally different edges remain distinct in a set.""" + s = {Edge("a", "b"), Edge("b", "a"), Edge("a", "b", directed=True)} + assert len(s) == 3 + + def test_directed_vs_undirected_different_hash(self) -> None: + """Same endpoints but different directed flag have different hashes.""" + h1 = hash(Edge("a", "b", directed=False)) + h2 = hash(Edge("a", "b", directed=True)) + # Hash collision is theoretically possible but extremely unlikely + assert h1 != h2 + + +# --------------------------------------------------------------------------- +# String representations +# --------------------------------------------------------------------------- + + +class TestEdgeRepr: + """Tests for __repr__ and __str__.""" + + def test_repr_undirected(self) -> None: + """repr of an undirected edge uses '--' arrow.""" + e = Edge("A", "B") + r = repr(e) + assert "A" in r + assert "B" in r + assert "--" in r + + def test_repr_directed(self) -> None: + """repr of a directed edge uses '->' arrow.""" + e = Edge("A", "B", directed=True) + r = repr(e) + assert "->" in r + + def test_repr_includes_weight(self) -> None: + """repr includes the weight when present.""" + e = Edge("A", "B", weight=3.5) + assert "3.5" in repr(e) + + def test_repr_includes_label(self) -> None: + """repr includes the label when present.""" + e = Edge("A", "B", label="highway") + assert "highway" in repr(e) + + def test_repr_includes_attrs(self) -> None: + """repr includes attrs when non-empty.""" + e = Edge.create("A", "B", attrs={"color": "red"}) + r = repr(e) + assert "color" in r + assert "red" in r + + def test_repr_minimal(self) -> None: + """repr omits weight, label, attrs when they are defaults.""" + e = Edge("A", "B") + r = repr(e) + assert "weight" not in r + assert "label" not in r + assert "attrs" not in r + + def test_str_undirected(self) -> None: + """str of an undirected edge is 'source -- target'.""" + e = Edge("A", "B") + assert str(e) == "A -- B" + + def test_str_directed(self) -> None: + """str of a directed edge is 'source -> target'.""" + e = Edge("A", "B", directed=True) + assert str(e) == "A -> B" diff --git a/tests/test_paths.py b/tests/test_paths.py index 47cfcde..771ae4a 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -64,7 +64,7 @@ def test_no_isolated_vertices(self, big_graph) -> None: class TestFindPath: """Tests for find_path.""" - @pytest.fixture() + @pytest.fixture def path_graph(self) -> Graph: """Graph used for path-finding tests. @@ -109,7 +109,7 @@ def test_missing_start_vertex(self, path_graph) -> None: class TestFindAllPaths: """Tests for find_all_paths.""" - @pytest.fixture() + @pytest.fixture def multi_path_graph(self) -> Graph: """Graph with multiple paths between vertices. From 671d78dba8eb2356d882f9e4c6358ef5ee2a2efd Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Mon, 23 Mar 2026 19:28:00 -0600 Subject: [PATCH 2/8] Refactor graph classes --- src/graphworks/edge.py | 11 +- src/graphworks/graph.py | 556 ++++++++++++++++++++++++------------ src/graphworks/utilities.py | 20 ++ src/graphworks/vertex.py | 173 +++++++++++ tests/test_graph.py | 421 ++++++++++++--------------- tests/test_vertex.py | 323 +++++++++++++++++++++ 6 files changed, 1080 insertions(+), 424 deletions(-) create mode 100644 src/graphworks/utilities.py create mode 100644 src/graphworks/vertex.py create mode 100644 tests/test_vertex.py diff --git a/src/graphworks/edge.py b/src/graphworks/edge.py index cb848f8..dcb31da 100644 --- a/src/graphworks/edge.py +++ b/src/graphworks/edge.py @@ -31,16 +31,7 @@ from types import MappingProxyType from typing import Any - -def _freeze_attrs(raw: dict[str, Any] | None) -> MappingProxyType[str, Any]: - """Return a read-only view of *raw*, defaulting to an empty mapping. - - :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/src/graphworks/graph.py b/src/graphworks/graph.py index e25721a..76cf526 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -1,9 +1,36 @@ """Core graph data structure for the graphworks library. -Provides :class:`Graph`, which stores graphs internally as an adjacency list (``dict[str, -list[str]]``) and exposes a numpy-free adjacency matrix interface via -:data:`~graphworks.types.AdjacencyMatrix`. Optional numpy interop is available through -:mod:`graphworks.numpy_compat`. +Provides :class:`Graph`, which stores graphs internally using first-class +:class:`~graphworks.vertex.Vertex` and :class:`~graphworks.edge.Edge` objects in a dual-index +adjacency structure: + +* ``_vertices``: ``dict[str, Vertex]`` — O(1) lookup by name. +* ``_adj``: ``dict[str, dict[str, Edge]]`` — ``_adj[u][v]`` gives the + :class:`~graphworks.edge.Edge` from *u* to *v* in O(1). + +The adjacency matrix interface uses only stdlib types — no numpy required. Optional numpy interop +is available through :mod:`graphworks.numpy_compat`. + +Construction +------------ +A :class:`Graph` can be built from: + +* a JSON file path (``input_file``), +* a JSON string (``input_graph``), +* a stdlib adjacency matrix (``input_matrix``), or +* programmatically via :meth:`add_vertex` / :meth:`add_edge`. + +For numpy ``ndarray`` input, convert first with :func:`graphworks.numpy_compat.ndarray_to_matrix`. + +Example:: + + >>> import json + >>> from graphworks.graph import Graph # noqa + ... + >>> data = {"label": "demo", "graph": {"A": ["B"], "B": []}} + >>> g = Graph(input_graph=json.dumps(data)) + >>> g.vertices() # ['A', 'B'] + >>> g.edges() # [Edge('A' -- 'B')] """ from __future__ import annotations @@ -12,9 +39,11 @@ import random import uuid from collections import defaultdict +from pathlib import Path from typing import TYPE_CHECKING from graphworks.edge import Edge +from graphworks.vertex import Vertex if TYPE_CHECKING: from collections.abc import Iterator @@ -23,31 +52,38 @@ class Graph: - """Implementation of both undirected and directed graphs. - - Graphs are stored internally as an adjacency-list dictionary (``dict[str, list[str]]``). - The matrix representation is derived on demand and uses only stdlib types — no numpy required. - - A :class:`Graph` can be constructed from: - - * a JSON file path (``input_file``), - * a JSON string (``input_graph``), or - * a stdlib adjacency matrix (``input_matrix``). - - For numpy ``ndarray`` input, convert first with - :func:`graphworks.numpy_compat.ndarray_to_matrix`. - - Example:: - - >>> import json - >>> from graphworks.graph import Graph - ... - >>> data = {"label": "demo", "graph": {"A": ["B"], "B": []}} - >>> g = Graph(input_graph=json.dumps(data)) - >>> print(g.vertices()) # ['A', 'B'] - >>> print(g.edges()) # [Edge(vertex1='A', vertex2='B', ...)] + """Mutable graph supporting both directed and undirected edges. + + Vertices and edges are stored as first-class objects in a dual-index structure that provides + O(1) vertex lookup, O(1) edge existence checks, and efficient neighbor iteration. + + The public API preserves backward compatibility: :meth:`vertices` returns a ``list[str]`` of + names, :meth:`edges` returns ``list[Edge]``, and ``graph[v]`` returns a ``list[str]`` of + neighbor names. The richer :class:`~graphworks.vertex.Vertex` and + :class:`~graphworks.edge.Edge` objects are available via :meth:`get_vertex` and + :meth:`get_edge`. + + :param label: Human-readable name for this graph. + :type label: str | None + :param input_file: Path to a JSON file describing the graph. + :type input_file: str | None + :param input_graph: JSON string describing the graph. + :type input_graph: str | None + :param input_matrix: Square adjacency matrix (``list[list[int]]``). Non-zero values are + treated as edges. + :type input_matrix: AdjacencyMatrix | None + :raises ValueError: If *input_matrix* is not square, or if edge endpoints in a JSON graph + reference vertices that do not exist. """ + __slots__ = ( + "_label", + "_is_directed", + "_is_weighted", + "_vertices", + "_adj", + ) + def __init__( self, label: str | None = None, @@ -62,7 +98,7 @@ def __init__( :param label: Human-readable name for this graph. :type label: str | None - :param input_file: Absolute path to a JSON file describing the graph. + :param input_file: Path to a JSON file describing the graph. :type input_file: str | None :param input_graph: JSON string describing the graph. :type input_graph: str | None @@ -70,81 +106,186 @@ def __init__( treated as edges. :type input_matrix: AdjacencyMatrix | None :raises ValueError: If *input_matrix* is not square, or if edge endpoints in a JSON graph - reference vertices that do not exist. + reference vertices not in the vertex set. """ - self.__label: str = label if label is not None else "" - self.__is_directed: bool = False - self.__is_weighted: bool = False - self.__graph: defaultdict[str, list[str]] = defaultdict(list) + self._label: str = label if label is not None else "" + self._is_directed: bool = False + self._is_weighted: bool = False + self._vertices: dict[str, Vertex] = {} + self._adj: dict[str, dict[str, Edge]] = {} if input_file is not None: - with open(input_file, encoding="utf-8") as in_file: - json_data = json.loads(in_file.read()) - self.__extract_fields_from_json(json_data) + json_data = json.loads(Path(input_file).read_text(encoding="utf-8")) + self._extract_fields_from_json(json_data) elif input_graph is not None: json_data = json.loads(input_graph) - self.__extract_fields_from_json(json_data) + self._extract_fields_from_json(json_data) elif input_matrix is not None: - if not self.__validate_matrix(input_matrix): - raise ValueError( - "input_matrix is malformed: must be a non-empty square list[list[int]]." - ) - self.__matrix_to_graph(input_matrix) - - if not self.__validate(): - raise ValueError( - "Graph is invalid: edge endpoints reference vertices that do not exist in the " - "vertex set." + if not self._validate_matrix(input_matrix): + msg = "input_matrix is malformed: must be a non-empty square list[list[int]]." + 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) # ------------------------------------------------------------------ - # Public interface — vertices, edges, metadata + # Public interface — vertex access # ------------------------------------------------------------------ def vertices(self) -> list[str]: - """Return the list of vertex names in insertion order. + """Return all vertex names in insertion order. + + For access to the underlying :class:`~graphworks.vertex.Vertex` + objects, use :meth:`get_vertex` or :meth:`get_vertices`. - :return: All vertex names in the graph. + :return: Vertex name strings. :rtype: list[str] """ - return list(self.__graph.keys()) + return list(self._vertices) + + def get_vertex(self, name: str) -> Vertex | None: + """Return the :class:`~graphworks.vertex.Vertex` with *name*, or ``None``. + + :param name: Vertex name to look up. + :type name: str + :return: The vertex object, or ``None`` if not found. + :rtype: Vertex | None + """ + return self._vertices.get(name) + + def get_vertices(self) -> list[Vertex]: + """Return all :class:`~graphworks.vertex.Vertex` objects in insertion order. + + :return: List of vertex objects. + :rtype: list[Vertex] + """ + return list(self._vertices.values()) + + def add_vertex(self, vertex: str | Vertex) -> None: + """Add a vertex to the graph if it does not already exist. + + Accepts either a plain name string (which is wrapped in a + :class:`~graphworks.vertex.Vertex` automatically) or an existing + :class:`~graphworks.vertex.Vertex` instance. + + :param vertex: Vertex name or :class:`Vertex` object. + :type vertex: str | Vertex + :return: Nothing. + :rtype: None + """ + if isinstance(vertex, Vertex): + name = vertex.name + obj = vertex + else: + name = vertex + obj = Vertex(name) + + if name not in self._vertices: + self._vertices[name] = obj + self._adj[name] = {} + + # ------------------------------------------------------------------ + # Public interface — edge access + # ------------------------------------------------------------------ def edges(self) -> list[Edge]: """Return all edges in the graph. - For undirected graphs each edge is returned once (the canonical direction is *vertex1 → - vertex2* in insertion order). + For undirected graphs each edge is returned once (the canonical direction is source → + target in insertion order). :return: List of :class:`~graphworks.edge.Edge` objects. :rtype: list[Edge] """ - return self.__generate_edges() + return self._collect_edges() - def get_graph(self) -> defaultdict[str, list[str]]: - """Return the raw adjacency-list dictionary. + def get_edge(self, source: str, target: str) -> Edge | None: + """Return the :class:`~graphworks.edge.Edge` from *source* to *target*, or ``None``. + + For undirected graphs, ``get_edge("A", "B")`` checks both the ``A → B`` and ``B → A`` + slots in the adjacency structure. - :return: The underlying ``defaultdict`` mapping vertex names to their neighbor lists. - :rtype: DefaultDict[str, list[str]] + :param source: Source vertex name. + :type source: str + :param target: Target vertex name. + :type target: str + :return: The edge object, or ``None`` if no such edge exists. + :rtype: Edge | None """ - return self.__graph + edge = self._adj.get(source, {}).get(target) + if edge is not None: + return edge + if not self._is_directed: + return self._adj.get(target, {}).get(source) + return None + + def add_edge( + self, + vertex1: str, + vertex2: str, + *, + weight: float | None = None, + label: str | None = None, + ) -> None: + """Add an edge from *vertex1* to *vertex2*. + + Both vertices are created automatically if they do not yet exist. For undirected graphs, + the edge is stored once under ``_adj[vertex1][vertex2]``; the reverse lookup is handled + by :meth:`get_edge` and :meth:`get_neighbors`. + + :param vertex1: Source vertex name. + :type vertex1: str + :param vertex2: Destination vertex name. + :type vertex2: str + :param weight: Optional numeric weight for the edge. + :type weight: float | None + :param label: Optional human-readable label for the edge. + :type label: str | None + :return: Nothing. + :rtype: None + """ + self.add_vertex(vertex1) + self.add_vertex(vertex2) + edge = Edge( + source=vertex1, + target=vertex2, + directed=self._is_directed, + weight=weight, + label=label, + ) + self._adj[vertex1][vertex2] = edge + + # ------------------------------------------------------------------ + # Public interface — metadata + # ------------------------------------------------------------------ def get_label(self) -> str: - """Return the graph's label. + """Return the graph's human-readable label. - :return: Human-readable label string (empty string if not set). + :return: Label string (empty string if not set). :rtype: str """ - return self.__label + return self._label def set_directed(self, is_directed: bool) -> None: - """Set whether this graph is directed. + """Set whether this graph should be treated as directed. + + .. warning:: + Changing directedness on a graph that already contains edges + does **not** retroactively add or remove reverse edges. Use + this only during initial construction. :param is_directed: ``True`` for a directed graph, ``False`` for undirected. :type is_directed: bool - :return: Nothing + :return: Nothing. :rtype: None """ - self.__is_directed = is_directed + self._is_directed = is_directed def is_directed(self) -> bool: """Return whether this graph is directed. @@ -152,75 +293,93 @@ def is_directed(self) -> bool: :return: ``True`` if directed, ``False`` otherwise. :rtype: bool """ - return self.__is_directed + return self._is_directed def is_weighted(self) -> bool: - """Return whether this graph has weighted edges. + """Return whether this graph was declared as weighted. + + A graph is weighted when its JSON definition includes ``"weighted": true``. Individual + edges may carry weights regardless of this flag. :return: ``True`` if weighted, ``False`` otherwise. :rtype: bool """ - return self.__is_weighted + return self._is_weighted - def add_vertex(self, vertex: str) -> None: - """Add a vertex to the graph if it does not already exist. + def order(self) -> int: + """Return the number of vertices in this graph. - :param vertex: Name of the vertex to add. - :type vertex: str - :return: Nothing - :rtype: None + :return: Vertex count (|V|). + :rtype: int """ - if vertex not in self.__graph: - self.__graph[vertex] = [] + return len(self._vertices) - def add_edge(self, vertex1: str, vertex2: str) -> None: - """Add a directed edge from *vertex1* to *vertex2*. + def size(self) -> int: + """Return the number of edges in this graph. - Both vertices are created automatically if they do not exist. + For undirected graphs each edge is counted once. - :param vertex1: Source vertex name. - :type vertex1: str - :param vertex2: Destination vertex name. - :type vertex2: str - :return: Nothing - :rtype: None + :return: Edge count (|E|). + :rtype: int """ - if vertex1 in self.__graph: - self.__graph[vertex1].append(vertex2) - else: - self.__graph[vertex1] = [vertex2] + return len(self.edges()) - if vertex2 not in self.__graph: - self.__graph[vertex2] = [] + # ------------------------------------------------------------------ + # Neighbour access + # ------------------------------------------------------------------ - def order(self) -> int: - """Return the order of the graph (number of vertices). + def get_neighbors(self, v: str) -> list[str]: + """Return the names of all vertices adjacent to *v*. - :return: Number of vertices. - :rtype: int + For directed graphs this returns out-neighbors only. For undirected graphs both ``_adj[ + v]`` targets and vertices that have *v* as a target are included. + + :param v: Vertex name. + :type v: str + :return: List of adjacent vertex names. + :rtype: list[str] """ - return len(self.vertices()) + return self._neighbor_names(v) - def size(self) -> int: - """Return the size of the graph (number of edges). + def get_random_vertex(self) -> str: + """Return a vertex name chosen uniformly at random. - :return: Number of edges. - :rtype: int + :return: A random vertex name. + :rtype: str + :raises IndexError: If the graph has no vertices. """ - return len(self.edges()) + return random.choice(self.vertices()) + + # ------------------------------------------------------------------ + # Backward-compatible raw access + # ------------------------------------------------------------------ + + def get_graph(self) -> defaultdict[str, list[str]]: + """Return a ``defaultdict`` adjacency-list view for backward compatibility. + + .. note:: + New code should prefer :meth:`vertices`, :meth:`edges`, :meth:`get_neighbors`, + or direct ``graph[v]`` access. + + :return: Mapping of vertex names to neighbor-name lists. + :rtype: defaultdict[str, list[str]] + """ + result: defaultdict[str, list[str]] = defaultdict(list) + for name in self._vertices: + result[name] = self._neighbor_names(name) + return result # ------------------------------------------------------------------ # Matrix representation (stdlib only — no numpy) # ------------------------------------------------------------------ def get_adjacency_matrix(self) -> AdjacencyMatrix: - """Return a stdlib adjacency matrix for this graph. + """Compute and return a stdlib adjacency matrix. - The matrix is always freshly computed from the current adjacency - list. Row and column indices correspond to :meth:`vertices` order. + 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. - ``matrix[i][j] == 1`` means an edge exists from ``vertices()[i]`` - to ``vertices()[j]``; ``0`` means no edge. + The matrix is freshly computed on each call. :return: Square adjacency matrix as ``list[list[int]]``. :rtype: AdjacencyMatrix @@ -229,9 +388,10 @@ def get_adjacency_matrix(self) -> AdjacencyMatrix: n = len(verts) index = {v: i for i, v in enumerate(verts)} matrix: AdjacencyMatrix = [[0] * n for _ in range(n)] - for v in verts: - for neighbour in self.__graph[v]: - matrix[index[v]][index[neighbour]] = 1 + for src, targets in self._adj.items(): + src_idx = index[src] + for tgt in targets: + matrix[src_idx][index[tgt]] = 1 return matrix def vertex_to_matrix_index(self, v: str) -> int: @@ -241,6 +401,7 @@ def vertex_to_matrix_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. """ return self.vertices().index(v) @@ -251,56 +412,37 @@ def matrix_index_to_vertex(self, index: int) -> str: :type index: int :return: Vertex name. :rtype: str + :raises IndexError: If *index* is out of range. """ return self.vertices()[index] - # ------------------------------------------------------------------ - # Neighbour access - # ------------------------------------------------------------------ - - def get_neighbors(self, v: str) -> list[str]: - """Return the neighbours of vertex *v*. - - :param v: Vertex name. - :type v: str - :return: List of vertices that *v* has an edge to. - :rtype: list[str] - """ - return self.__graph[v] - - def get_random_vertex(self) -> str: - """Return a uniformly random vertex from the graph. - - :return: A vertex name chosen at random. - :rtype: str - """ - return random.choice(self.vertices()) - # ------------------------------------------------------------------ # Dunder methods # ------------------------------------------------------------------ def __repr__(self) -> str: - """Return the graph label as its canonical representation. + """Return the graph label as its canonical string representation. :return: Graph label string. :rtype: str """ - return self.__label + return self._label def __str__(self) -> str: """Return a human-readable adjacency-list view of the graph. - :return: Multi-line string with ``vertex -> neighbours`` per line, preceded by the graph - label. + Each line shows ``vertex -> neighbor_names`` (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 """ lines: list[str] = [] - for key in sorted(self.__graph.keys()): - neighbours = self.__graph[key] + for name in sorted(self._vertices): + neighbours = self._neighbor_names(name) rhs = "".join(neighbours) if neighbours else "0" - lines.append(f"{key} -> {rhs}") - return f"{self.__label}\n" + "\n".join(lines) + lines.append(f"{name} -> {rhs}") + return f"{self._label}\n" + "\n".join(lines) def __iter__(self) -> Iterator[str]: """Iterate over vertex names in insertion order. @@ -308,72 +450,115 @@ def __iter__(self) -> Iterator[str]: :return: An iterator yielding vertex name strings. :rtype: Iterator[str] """ - return iter(self.vertices()) + return iter(self._vertices) def __getitem__(self, node: str) -> list[str]: - """Return the neighbor list for *node*. + """Return neighbour names for *node*, or ``[]`` if absent. + + This enables the common ``graph[v]`` idiom used throughout the algorithm modules. :param node: Vertex name. :type node: str - :return: List of neighbor vertex names, or an empty list if *node* is not in the graph. + :return: List of neighbour vertex names. :rtype: list[str] """ - return self.__graph.get(node, []) + if node not in self._vertices: + return [] + return self._neighbor_names(node) # ------------------------------------------------------------------ - # Private helpers + # Protected helpers # ------------------------------------------------------------------ - def __extract_fields_from_json(self, json_data: dict) -> None: - """Populate the graph from a parsed JSON dictionary. + def _neighbor_names(self, v: str) -> list[str]: + """Return the neighbour name list for vertex *v*. - :param json_data: Parsed JSON representation of the graph. - :type json_data: dict - :return: Nothing - :rtype: None + Returns the names of all vertices that *v* has a direct edge to, as recorded in ``_adj[ + v]``. For undirected graphs, the caller (or the JSON input) is responsible for declaring + both directions of each edge; this method does **not** synthesize reverse edges. + + :param v: Vertex name. + :type v: str + :return: Neighbor name strings. + :rtype: list[str] """ - self.__label = json_data.get("label", "") - self.__is_directed = json_data.get("directed", False) - self.__is_weighted = json_data.get("weighted", False) - raw_graph = json_data.get("graph", {}) - self.__graph: defaultdict[str, list[str]] = defaultdict( - list, - raw_graph, - ) + if v not in self._adj: + return [] + return list(self._adj[v]) - def __generate_edges(self) -> list[Edge]: - """Build and return the edge list from the adjacency list. + def _collect_edges(self) -> list[Edge]: + """Build and return the full edge list from ``_adj``. - For undirected graphs each pair is included only once. + For undirected graphs each pair ``(u, v)`` is returned once, preferring the + insertion-order direction. :return: List of :class:`~graphworks.edge.Edge` instances. :rtype: list[Edge] """ edges: list[Edge] = [] - for vertex in self.__graph: - for neighbour in self.__graph[vertex]: - if ( - not self.is_directed() - and Edge(neighbour, vertex) not in edges - or self.is_directed() - ): - edges.append(Edge(vertex, neighbour)) + seen: set[tuple[str, str]] = set() + for src, targets in self._adj.items(): + for tgt, edge in targets.items(): + if self._is_directed: + 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 __validate(self) -> bool: + 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 after construction. + + :param json_data: Parsed JSON representation of the graph. + :type json_data: dict + :return: Nothing. + :rtype: None + """ + self._label = json_data.get("label", "") + self._is_directed = json_data.get("directed", False) + self._is_weighted = json_data.get("weighted", False) + raw_graph: dict[str, list[str]] = json_data.get("graph", {}) + + # First pass: create all vertices (only keys — not targets). + for name in raw_graph: + self.add_vertex(name) + + # Second pass: create edges. Target vertices that are not keys + # will be caught by _validate(). + for src, targets in raw_graph.items(): + for tgt in targets: + edge = Edge( + source=src, + target=tgt, + directed=self._is_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 vertex in self.__graph: - for neighbor in self.__graph[vertex]: - if neighbor not in self.__graph: + for src, targets in self._adj.items(): + if src not in self._vertices: + return False + for tgt in targets: + if tgt not in self._vertices: return False return True @staticmethod - def __validate_matrix(matrix: AdjacencyMatrix) -> bool: + def _validate_matrix(matrix: AdjacencyMatrix) -> bool: """Return whether *matrix* is a non-empty square 2-D list. :param matrix: Candidate adjacency matrix. @@ -386,22 +571,29 @@ def __validate_matrix(matrix: AdjacencyMatrix) -> bool: n = len(matrix) return all(len(row) == n for row in matrix) - def __matrix_to_graph(self, matrix: AdjacencyMatrix) -> None: - """Populate the adjacency list from a stdlib adjacency matrix. + def _matrix_to_graph(self, matrix: AdjacencyMatrix) -> None: + """Populate the graph from a stdlib adjacency matrix. - Vertex names are generated as UUID strings to guarantee uniqueness. + Vertex names are generated as UUID strings to guarantee uniqueness when no external + naming scheme is available. :param matrix: Square adjacency matrix where non-zero values denote edges. :type matrix: AdjacencyMatrix - :return: Nothing + :return: Nothing. :rtype: None """ n = len(matrix) names = [str(uuid.uuid4()) for _ in range(n)] + + for name in names: + self.add_vertex(name) + for r_idx in range(n): - vertex = names[r_idx] - # Ensure the vertex exists even when its entire row is zeros. - self.__graph.setdefault(vertex, []) for c_idx, val in enumerate(matrix[r_idx]): if val > 0: - self.__graph[vertex].append(names[c_idx]) + edge = Edge( + source=names[r_idx], + target=names[c_idx], + directed=self._is_directed, + ) + self._adj[names[r_idx]][names[c_idx]] = edge diff --git a/src/graphworks/utilities.py b/src/graphworks/utilities.py new file mode 100644 index 0000000..386a0a8 --- /dev/null +++ b/src/graphworks/utilities.py @@ -0,0 +1,20 @@ +"""Module containing utility functions for graphworks.""" + +from __future__ import annotations + +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 {}) diff --git a/src/graphworks/vertex.py b/src/graphworks/vertex.py new file mode 100644 index 0000000..b83bd30 --- /dev/null +++ b/src/graphworks/vertex.py @@ -0,0 +1,173 @@ +"""Graph vertex (node) with optional label and metadata. + +A :class:`Vertex` is an immutable value object representing a single node in a graph. Each +vertex has a unique *name* that serves as its identity key, an optional human-readable *label* ( +defaulting to the name), and an arbitrary attribute mapping for user-defined metadata. + +Identity semantics +------------------ +Two vertices are considered **equal** when they share the same :attr:`name`. The :attr:`label` +and :attr:`attrs` fields are *descriptive* — they do not affect equality or hashing. This +mirrors graph-theoretic convention: a vertex is identified by its name alone, regardless of any +annotations attached to it. + +Immutability +------------ +:class:`Vertex` is a **frozen** dataclass with ``__slots__``. Once created, its fields cannot be +reassigned. The *attrs* mapping is exposed as a read-only :class:`~types.MappingProxyType` so +callers cannot mutate it in place either. To "update" a vertex, create a new instance — idiomatic +for frozen dataclasses and compatible with use as ``dict`` keys and ``set`` members. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any + +from graphworks.utilities import _freeze_attrs + + +@dataclass(frozen=True, slots=True) +class Vertex: + """Immutable graph vertex (node). + + A vertex is the fundamental unit of a graph. It carries a unique :attr:`name` used as the + identity key, an optional :attr:`label` for display purposes, and a free-form :attr:`attrs` + mapping for arbitrary metadata (e.g. color, coordinates, community ID). + + :param name: Unique identifier for this vertex. + :type name: str + :param label: Human-readable display name. Defaults to :attr:`name` when ``None`` is passed. + :type label: str | None + :param attrs: Arbitrary key/value metadata. Stored internally as an immutable + :class:`~types.MappingProxyType`. + :type attrs: MappingProxyType[str, Any] + """ + + name: str + label: str | None = None + attrs: MappingProxyType[str, Any] = field( + default_factory=lambda: MappingProxyType({}), + ) + + # ------------------------------------------------------------------ + # Alternate constructor + # ------------------------------------------------------------------ + + @classmethod + def create( + cls, + name: str, + *, + label: str | None = None, + attrs: dict[str, Any] | None = None, + ) -> Vertex: + """Build a :class:`Vertex`, accepting a plain ``dict`` for *attrs*. + + This factory freezes the provided *attrs* dict into a read-only + :class:`~types.MappingProxyType` so callers don't need to wrap it themselves. + + :param name: Unique identifier for this vertex. + :type name: str + :param label: Human-readable display name. + :type label: str | None + :param attrs: Mutable attribute dict (will be defensively copied + and frozen). + :type attrs: dict[str, Any] | None + :return: A new :class:`Vertex` instance. + :rtype: Vertex + """ + return cls( + name=name, + label=label, + attrs=_freeze_attrs(attrs), + ) + + # ------------------------------------------------------------------ + # Derived properties + # ------------------------------------------------------------------ + + @property + def display_name(self) -> str: + """Return the label if set, otherwise the name. + + Convenience property for rendering and export code that needs a single human-readable + string for this vertex. + + :return: The :attr:`label` if not ``None``, else :attr:`name`. + :rtype: str + """ + return self.label if self.label is not None else self.name + + # ------------------------------------------------------------------ + # Identity — equality and hashing on name only + # ------------------------------------------------------------------ + + def __eq__(self, other: object) -> bool: + """Return ``True`` if *other* is a :class:`Vertex` with the same name. + + Only :attr:`name` is compared. :attr:`label` and :attr:`attrs` are descriptive and do + not affect identity. + + :param other: Object to compare against. + :type other: object + :return: ``True`` if names match. + :rtype: bool + """ + if not isinstance(other, Vertex): + return NotImplemented + return self.name == other.name + + def __hash__(self) -> int: + """Return a hash based on :attr:`name` only. + + :return: Hash of the vertex name. + :rtype: int + """ + return hash(self.name) + + # ------------------------------------------------------------------ + # Ordering — enables sorted() on vertex collections + # ------------------------------------------------------------------ + + def __lt__(self, other: object) -> bool: + """Return ``True`` if this vertex's name sorts before *other*'s. + + Supports ``sorted()`` and ``min()``/``max()`` on vertex collections without requiring a + ``key=`` function. + + :param other: Object to compare against. + :type other: object + :return: ``True`` if ``self.name < other.name``. + :rtype: bool + """ + if not isinstance(other, Vertex): + return NotImplemented + return self.name < other.name + + # ------------------------------------------------------------------ + # Display + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + """Return a developer-friendly representation. + + :return: String like ``Vertex('A')`` or ``Vertex('A', label='Alice', attrs={'color': + 'red'})``. + :rtype: str + """ + parts = [f"Vertex({self.name!r}"] + if self.label is not None: + parts.append(f"label={self.label!r}") + if self.attrs: + parts.append(f"attrs={dict(self.attrs)}") + return ", ".join(parts) + ")" + + def __str__(self) -> str: + """Return the display name of this vertex. + + :return: The :attr:`label` if set, otherwise :attr:`name`. + :rtype: str + """ + return self.display_name diff --git a/tests/test_graph.py b/tests/test_graph.py index 2dd3b88..2da7ea8 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,21 +1,8 @@ """Unit and integration tests for :class:`graphworks.graph.Graph`. -Covers construction (JSON string, JSON file, adjacency matrix), vertex/edge -manipulation, the stdlib adjacency-matrix interface, validation, iteration, -and string representations. - -.. note:: - Edge equality comparisons in these tests use attribute inspection rather - than ``==`` between ``Edge`` instances produced by the library and ``Edge`` - instances constructed in test code. This avoids a subtle identity issue - that arises when the library's internal ``from graphworks.edge import Edge`` - and the test's ``from graphworks.edge import Edge`` resolve to two - different class objects — a situation that only occurs in non-installed - (non-editable) development environments. In a properly configured project - (``uv sync`` / ``pip install -e .``) both paths collapse to the same - installed module and ``==`` works as expected. - -:author: Nathan Gilbert +Covers construction (JSON string, JSON file, adjacency matrix), vertex/edge manipulation, +the stdlib adjacency-matrix interface, validation, iteration, string representations, and the new +first-class :class:`Vertex` / :class:`Edge` access methods. """ from __future__ import annotations @@ -28,7 +15,9 @@ import pytest +from graphworks.edge import Edge from graphworks.graph import Graph +from graphworks.vertex import Vertex # --------------------------------------------------------------------------- # Helpers @@ -36,17 +25,14 @@ def _edge_pairs(graph: Graph) -> list[tuple[str, str]]: - """Return the edges of *graph* as ``(vertex1, vertex2)`` tuples. - - Using tuples instead of ``Edge`` objects avoids class-identity issues - when the test suite is run without an editable install. + """Return the edges of *graph* as ``(source, target)`` tuples. :param graph: The graph whose edges to extract. :type graph: Graph - :return: List of ``(vertex1, vertex2)`` pairs. + :return: List of ``(source, target)`` pairs. :rtype: list[tuple[str, str]] """ - return [(e.vertex1, e.vertex2) for e in graph.edges()] + return [(e.source, e.target) for e in graph.edges()] # --------------------------------------------------------------------------- @@ -58,58 +44,34 @@ class TestGraphLabel: """Tests for graph label, repr, and str behaviour.""" def test_label_from_positional_arg(self) -> None: - """Graph label is stored and returned correctly. - - :return: None - :rtype: None - """ + """Graph label is stored and returned correctly.""" graph = Graph("my graph") assert graph.get_label() == "my graph" def test_label_defaults_to_empty_string(self) -> None: - """Constructing without a label yields an empty string. - - :return: None - :rtype: None - """ + """Constructing without a label yields an empty string.""" graph = Graph() assert graph.get_label() == "" def test_repr_returns_label(self) -> None: - """repr() of a graph is its label. - - :return: None - :rtype: None - """ + """repr() of a graph is its label.""" graph = Graph("demo") assert repr(graph) == "demo" def test_str_shows_adjacency_list(self, simple_edge_json) -> None: - """str() renders a labelled, sorted adjacency list. - - :return: None - :rtype: None - """ + """str() renders a labelled, sorted adjacency list.""" expected = "my graph\nA -> B\nB -> 0" graph = Graph(input_graph=json.dumps(simple_edge_json)) assert str(graph) == expected def test_str_empty_vertex_shows_zero(self) -> None: - """Vertices with no neighbours render as '-> 0'. - - :return: None - :rtype: None - """ + """Vertices with no neighbours render as '-> 0'.""" graph = Graph("g") graph.add_vertex("X") assert "X -> 0" in str(graph) def test_str_multiple_vertices_sorted(self) -> None: - """str() renders vertices in sorted order. - - :return: None - :rtype: None - """ + """str() renders vertices in sorted order.""" data = {"label": "g", "graph": {"B": ["A"], "A": []}} graph = Graph(input_graph=json.dumps(data)) lines = str(graph).splitlines() @@ -127,81 +89,49 @@ class TestGraphJsonConstruction: """Tests for building a Graph from a JSON string.""" def test_label_parsed(self, simple_edge_json) -> None: - """JSON 'label' key is correctly stored. - - :return: None - :rtype: None - """ + """JSON 'label' key is correctly stored.""" graph = Graph(input_graph=json.dumps(simple_edge_json)) assert graph.get_label() == "my graph" def test_undirected_flag_default(self, simple_edge_json) -> None: - """Graph without 'directed' key is undirected by default. - - :return: None - :rtype: None - """ + """Graph without 'directed' key is undirected by default.""" graph = Graph(input_graph=json.dumps(simple_edge_json)) assert not graph.is_directed() def test_adjacency_list_stored(self, simple_edge_json) -> None: - """get_graph() returns the raw adjacency dict from the JSON. - - :return: None - :rtype: None - """ + """get_graph() returns the raw adjacency dict from the JSON.""" graph = Graph(input_graph=json.dumps(simple_edge_json)) assert graph.get_graph() == simple_edge_json["graph"] def test_edge_is_produced(self, simple_edge_json) -> None: - """One edge A→B is produced from the JSON definition. - - :return: None - :rtype: None - """ + """One edge A→B is produced from the JSON definition.""" graph = Graph(input_graph=json.dumps(simple_edge_json)) pairs = _edge_pairs(graph) assert len(pairs) == 1 assert pairs[0] == ("A", "B") def test_directed_flag_parsed(self) -> None: - """'directed' key in JSON sets the directed flag. - - :return: None - :rtype: None - """ + """'directed' key in JSON sets the directed flag.""" data = {"directed": True, "graph": {"X": ["Y"], "Y": []}} graph = Graph(input_graph=json.dumps(data)) assert graph.is_directed() def test_weighted_flag_parsed(self) -> None: - """'weighted' key in JSON sets the weighted flag. - - :return: None - :rtype: None - """ + """'weighted' key in JSON sets the weighted flag.""" data = {"weighted": True, "graph": {"X": [], "Y": []}} graph = Graph(input_graph=json.dumps(data)) assert graph.is_weighted() def test_missing_label_defaults_to_empty(self) -> None: - """JSON without 'label' key uses empty string as label. - - :return: None - :rtype: None - """ + """JSON without 'label' key uses empty string as label.""" data = {"graph": {"A": ["B"], "B": []}} graph = Graph(input_graph=json.dumps(data)) assert graph.get_label() == "" def test_invalid_edge_raises_value_error(self) -> None: - """Edge referencing a missing vertex raises ValueError. - - :return: None - :rtype: None - """ + """Edge referencing a missing vertex raises ValueError.""" bad = {"graph": {"A": ["B", "C", "D"], "B": []}} - with pytest.raises(ValueError): + with pytest.raises(ValueError): # noqa: PT011 Graph(input_graph=json.dumps(bad)) @@ -214,11 +144,7 @@ class TestGraphFileConstruction: """Tests for building a Graph from a JSON file.""" def test_read_from_file(self, tmp_dir: Path, simple_edge_json) -> None: - """Graph is correctly loaded from a JSON file on disk. - - :return: None - :rtype: None - """ + """Graph is correctly loaded from a JSON file on disk.""" file_path = tmp_dir / "g.json" file_path.write_text(json.dumps(simple_edge_json), encoding="utf-8") graph = Graph(input_file=str(file_path)) @@ -227,11 +153,7 @@ def test_read_from_file(self, tmp_dir: Path, simple_edge_json) -> None: assert graph.get_graph() == simple_edge_json["graph"] def test_file_vertices_match(self, tmp_dir: Path, simple_edge_json) -> None: - """Vertices loaded from file match the JSON definition. - - :return: None - :rtype: None - """ + """Vertices loaded from file match the JSON definition.""" file_path = tmp_dir / "g.json" file_path.write_text(json.dumps(simple_edge_json), encoding="utf-8") graph = Graph(input_file=str(file_path)) @@ -247,59 +169,35 @@ class TestGraphMatrixConstruction: """Tests for building a Graph from a stdlib adjacency matrix.""" def test_simple_two_by_two_matrix(self) -> None: - """A 2×2 symmetric matrix yields one undirected edge. - - :return: None - :rtype: None - """ + """A 2x2 symmetric matrix yields one undirected edge.""" matrix = [[0, 1], [1, 0]] graph = Graph(input_matrix=matrix) assert len(graph.vertices()) == 2 assert len(graph.edges()) == 1 def test_zero_matrix_no_edges(self) -> None: - """A zero matrix produces no edges. - - :return: None - :rtype: None - """ + """A zero matrix produces no edges.""" matrix = [[0, 0], [0, 0]] graph = Graph(input_matrix=matrix) assert len(graph.edges()) == 0 def test_non_square_raises_value_error(self) -> None: - """A non-square matrix raises ValueError. - - :return: None - :rtype: None - """ - with pytest.raises(ValueError): + """A non-square matrix raises ValueError.""" + with pytest.raises(ValueError): # noqa: PT011 Graph(input_matrix=[[0, 1, 0], [1, 0]]) def test_wrong_row_count_raises_value_error(self) -> None: - """A matrix where row count != column count raises ValueError. - - :return: None - :rtype: None - """ - with pytest.raises(ValueError): + """A matrix where row count != column count raises ValueError.""" + with pytest.raises(ValueError): # noqa: PT011 Graph(input_matrix=[[0, 1], [1, 0], [1, 0]]) def test_empty_matrix_raises_value_error(self) -> None: - """An empty matrix raises ValueError. - - :return: None - :rtype: None - """ - with pytest.raises(ValueError): + """An empty matrix raises ValueError.""" + with pytest.raises(ValueError): # noqa: PT011 Graph(input_matrix=[]) def test_vertices_are_uuid_strings(self) -> None: - """Matrix-constructed graphs use UUID strings as vertex names. - - :return: None - :rtype: None - """ + """Matrix-constructed graphs use UUID strings as vertex names.""" graph = Graph(input_matrix=[[0, 1], [1, 0]]) # UUIDs are 36 characters long assert all(len(v) == 36 for v in graph.vertices()) @@ -314,32 +212,20 @@ class TestVertexEdgeManipulation: """Tests for add_vertex, add_edge, vertices(), edges(), order(), size().""" def test_add_single_vertex(self) -> None: - """Adding a single vertex is reflected in vertices(). - - :return: None - :rtype: None - """ + """Adding a single vertex is reflected in vertices().""" graph = Graph("g") graph.add_vertex("A") assert graph.vertices() == ["A"] def test_add_duplicate_vertex_is_idempotent(self) -> None: - """Adding a vertex that already exists does not duplicate it. - - :return: None - :rtype: None - """ + """Adding a vertex that already exists does not duplicate it.""" graph = Graph("g") graph.add_vertex("A") graph.add_vertex("A") assert graph.vertices().count("A") == 1 def test_add_edge_between_existing_vertices(self) -> None: - """add_edge creates one edge between two pre-existing vertices. - - :return: None - :rtype: None - """ + """add_edge creates one edge between two pre-existing vertices.""" graph = Graph("g") graph.add_vertex("A") graph.add_vertex("B") @@ -347,22 +233,14 @@ def test_add_edge_between_existing_vertices(self) -> None: assert len(graph.edges()) == 1 def test_add_edge_creates_missing_vertices(self) -> None: - """add_edge auto-creates vertices that do not yet exist. - - :return: None - :rtype: None - """ + """add_edge auto-creates vertices that do not yet exist.""" graph = Graph("g") graph.add_edge("X", "Y") assert len(graph.edges()) == 1 assert len(graph.vertices()) == 2 def test_multiple_edges(self) -> None: - """Multiple add_edge calls accumulate correctly. - - :return: None - :rtype: None - """ + """Multiple add_edge calls accumulate correctly.""" graph = Graph("g") graph.add_vertex("A") graph.add_vertex("B") @@ -372,45 +250,25 @@ def test_multiple_edges(self) -> None: assert len(graph.vertices()) == 4 def test_order_and_size(self, simple_edge_graph) -> None: - """order() and size() return vertex and edge counts. - - :return: None - :rtype: None - """ + """order() and size() return vertex and edge counts.""" assert simple_edge_graph.order() == 2 assert simple_edge_graph.size() == 1 def test_get_neighbors_populated(self, simple_edge_graph) -> None: - """get_neighbors returns the correct neighbour list. - - :return: None - :rtype: None - """ + """get_neighbors returns the correct neighbour list.""" assert simple_edge_graph.get_neighbors("A") == ["B"] def test_get_neighbors_empty(self, simple_edge_graph) -> None: - """get_neighbors returns [] for a vertex with no out-edges. - - :return: None - :rtype: None - """ + """get_neighbors returns [] for a vertex with no out-edges.""" assert simple_edge_graph.get_neighbors("B") == [] def test_get_random_vertex_is_in_graph(self, big_graph) -> None: - """get_random_vertex returns a vertex that exists in the graph. - - :return: None - :rtype: None - """ + """get_random_vertex returns a vertex that exists in the graph.""" v = big_graph.get_random_vertex() assert v in big_graph.vertices() def test_set_directed(self) -> None: - """set_directed toggles the is_directed flag. - - :return: None - :rtype: None - """ + """set_directed toggles the is_directed flag.""" graph = Graph("g") graph.add_vertex("A") assert not graph.is_directed() @@ -420,6 +278,110 @@ def test_set_directed(self) -> None: assert not graph.is_directed() +# --------------------------------------------------------------------------- +# First-class Vertex and Edge access +# --------------------------------------------------------------------------- + + +class TestFirstClassAccess: + """Tests for get_vertex, get_vertices, get_edge, and Vertex-based add_vertex.""" + + def test_get_vertex_returns_vertex_object(self) -> None: + """get_vertex returns a Vertex with the correct name.""" + graph = Graph("g") + graph.add_vertex("A") + v = graph.get_vertex("A") + assert v is not None + assert v.name == "A" + + def test_get_vertex_missing_returns_none(self) -> None: + """get_vertex returns None for a vertex not in the graph.""" + graph = Graph("g") + assert graph.get_vertex("Z") is None + + def test_get_vertices_returns_all_objects(self) -> None: + """get_vertices returns Vertex objects for every vertex.""" + data = {"graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(data)) + verts = graph.get_vertices() + assert len(verts) == 2 + assert all(isinstance(v, Vertex) for v in verts) + assert {v.name for v in verts} == {"A", "B"} + + def test_add_vertex_with_vertex_object(self) -> None: + """add_vertex accepts a Vertex instance directly.""" + graph = Graph("g") + v = Vertex.create("hub", label="Central Hub", attrs={"rank": 1}) + graph.add_vertex(v) + retrieved = graph.get_vertex("hub") + assert retrieved is not None + assert retrieved.label == "Central Hub" + assert retrieved.attrs["rank"] == 1 + + def test_add_vertex_object_idempotent(self) -> None: + """Adding a Vertex with a name that already exists is a no-op.""" + graph = Graph("g") + graph.add_vertex(Vertex("A", label="first")) + graph.add_vertex(Vertex("A", label="second")) + v = graph.get_vertex("A") + assert v is not None + assert v.label == "first" + + def test_get_edge_returns_edge_object(self) -> None: + """get_edge returns the Edge stored between two vertices.""" + graph = Graph("g") + graph.add_edge("A", "B") + e = graph.get_edge("A", "B") + assert e is not None + assert e.source == "A" + assert e.target == "B" + + def test_get_edge_undirected_reverse_lookup(self) -> None: + """get_edge on an undirected graph finds edge in both directions.""" + graph = Graph("g") + graph.add_edge("A", "B") + assert graph.get_edge("B", "A") is not None + + def test_get_edge_directed_no_reverse(self) -> None: + """get_edge on a directed graph does not find the reverse edge.""" + data = {"directed": True, "graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(data)) + assert graph.get_edge("A", "B") is not None + assert graph.get_edge("B", "A") is None + + def test_get_edge_missing_returns_none(self) -> None: + """get_edge returns None when no edge exists.""" + graph = Graph("g") + graph.add_vertex("A") + graph.add_vertex("B") + assert graph.get_edge("A", "B") is None + + def test_add_edge_with_weight(self) -> None: + """add_edge stores a weight on the created Edge.""" + graph = Graph("g") + graph.add_edge("A", "B", weight=3.5) + e = graph.get_edge("A", "B") + assert e is not None + assert e.weight == pytest.approx(3.5) + + def test_add_edge_with_label(self) -> None: + """add_edge stores a label on the created Edge.""" + graph = Graph("g") + graph.add_edge("A", "B", label="highway") + e = graph.get_edge("A", "B") + assert e is not None + assert e.label == "highway" + + def test_edges_return_edge_objects(self, simple_edge_graph) -> None: + """edges() returns actual Edge instances.""" + edge_list = simple_edge_graph.edges() + assert len(edge_list) == 1 + e = edge_list[0] + assert isinstance(e, Edge) + assert e.source == "A" + assert e.target == "B" + + # --------------------------------------------------------------------------- # Adjacency matrix interface (stdlib only) # --------------------------------------------------------------------------- @@ -429,45 +391,29 @@ class TestAdjacencyMatrix: """Tests for the stdlib adjacency matrix interface.""" def test_values_for_simple_edge(self, simple_edge_graph) -> None: - """Matrix has 1 for A→B and 0 elsewhere. - - :return: None - :rtype: None - """ + """Matrix has 1 for A->B and 0 elsewhere.""" matrix = simple_edge_graph.get_adjacency_matrix() assert matrix == [[0, 1], [0, 0]] def test_matrix_is_square(self, big_graph) -> None: - """Adjacency matrix dimensions equal the vertex count. - - :return: None - :rtype: None - """ + """Adjacency matrix dimensions equal the vertex count.""" n = big_graph.order() matrix = big_graph.get_adjacency_matrix() assert len(matrix) == n assert all(len(row) == n for row in matrix) def test_vertex_index_roundtrip(self, simple_edge_graph) -> None: - """vertex_to_matrix_index and matrix_index_to_vertex are inverses. - - :return: None - :rtype: None - """ + """vertex_to_matrix_index and matrix_index_to_vertex are inverses.""" for v in simple_edge_graph.vertices(): idx = simple_edge_graph.vertex_to_matrix_index(v) assert simple_edge_graph.matrix_index_to_vertex(idx) == v def test_directed_graph_matrix_asymmetric(self) -> None: - """A directed graph produces an asymmetric adjacency matrix. - - :return: None - :rtype: None - """ + """A directed graph produces an asymmetric adjacency matrix.""" data = {"directed": True, "graph": {"A": ["B"], "B": []}} graph = Graph(input_graph=json.dumps(data)) matrix = graph.get_adjacency_matrix() - # A→B exists (1) but B→A does not (0) + # A->B exists (1) but B->A does not (0) assert matrix[0][1] == 1 assert matrix[1][0] == 0 @@ -481,31 +427,19 @@ class TestGraphIteration: """Tests for __iter__ and __getitem__.""" def test_iter_visits_all_vertices(self) -> None: - """Iterating over a graph yields every vertex exactly once. - - :return: None - :rtype: None - """ + """Iterating over a graph yields every vertex exactly once.""" data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} graph = Graph(input_graph=json.dumps(data)) assert sorted(graph) == ["A", "B", "C", "D"] def test_iter_count(self) -> None: - """Number of iterations equals the number of vertices. - - :return: None - :rtype: None - """ + """Number of iterations equals the number of vertices.""" data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} graph = Graph(input_graph=json.dumps(data)) assert sum(1 for _ in graph) == 4 def test_iter_yields_correct_neighbour_counts(self) -> None: - """Neighbour lists obtained via iteration have correct lengths. - - :return: None - :rtype: None - """ + """Neighbour lists obtained via iteration have correct lengths.""" data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} graph = Graph(input_graph=json.dumps(data)) counts = {key: len(graph[key]) for key in graph} @@ -513,21 +447,44 @@ def test_iter_yields_correct_neighbour_counts(self) -> None: assert counts["B"] == 0 def test_getitem_returns_neighbours(self) -> None: - """graph[vertex] returns the neighbour list. - - :return: None - :rtype: None - """ + """graph[vertex] returns the neighbour list.""" data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} graph = Graph(input_graph=json.dumps(data)) assert len(graph["A"]) == 3 assert graph["B"] == [] def test_getitem_missing_vertex_returns_empty(self) -> None: - """graph[missing] returns an empty list rather than raising. - - :return: None - :rtype: None - """ + """graph[missing] returns an empty list rather than raising.""" graph = Graph("g") assert graph["MISSING"] == [] + + +# --------------------------------------------------------------------------- +# Backward compatibility — get_graph() +# --------------------------------------------------------------------------- + + +class TestGetGraphCompat: + """Tests that get_graph() returns the expected defaultdict structure.""" + + def test_get_graph_matches_json_input(self, simple_edge_json) -> None: + """get_graph() returns the same structure as the original JSON.""" + graph = Graph(input_graph=json.dumps(simple_edge_json)) + result = graph.get_graph() + assert dict(result) == simple_edge_json["graph"] + + def test_get_graph_is_defaultdict(self, simple_edge_json) -> None: + """get_graph() returns a defaultdict for backward compat.""" + from collections import defaultdict + + graph = Graph(input_graph=json.dumps(simple_edge_json)) + result = graph.get_graph() + assert isinstance(result, defaultdict) + + def test_get_graph_undirected_neighbors_symmetric(self) -> None: + """For an undirected graph, get_graph reflects both directions.""" + data = {"graph": {"A": ["B"], "B": ["A"]}} + graph = Graph(input_graph=json.dumps(data)) + gg = graph.get_graph() + assert "B" in gg["A"] + assert "A" in gg["B"] diff --git a/tests/test_vertex.py b/tests/test_vertex.py new file mode 100644 index 0000000..8a9cccf --- /dev/null +++ b/tests/test_vertex.py @@ -0,0 +1,323 @@ +"""Unit tests for :class:`graphworks.vertex.Vertex`. + +Covers construction (direct and via factory), identity semantics (equality and hashing on name +only), ordering, the ``display_name`` property, immutability guarantees, ``attrs`` isolation, +and string representations. +""" + +from __future__ import annotations + +from types import MappingProxyType + +import pytest + +from graphworks.vertex import Vertex + +# --------------------------------------------------------------------------- +# Construction — direct instantiation +# --------------------------------------------------------------------------- + + +class TestVertexConstruction: + """Tests for Vertex construction and default values.""" + + def test_basic_construction(self) -> None: + """A Vertex stores its name correctly.""" + v = Vertex("a") + assert v.name == "a" + + def test_label_defaults_to_none(self) -> None: + """The label defaults to None when not provided.""" + v = Vertex("a") + assert v.label is None + + def test_attrs_defaults_to_empty_mapping(self) -> None: + """The attrs field defaults to an empty MappingProxyType.""" + v = Vertex("a") + assert v.attrs == {} + assert isinstance(v.attrs, MappingProxyType) + + def test_explicit_label(self) -> None: + """An explicit label string is stored and accessible.""" + v = Vertex("node_1", label="Start Node") + assert v.label == "Start Node" + + def test_explicit_attrs(self) -> None: + """A MappingProxyType attrs is stored and accessible.""" + attrs = MappingProxyType({"color": "red", "weight": 5}) + v = Vertex("a", attrs=attrs) + assert v.attrs["color"] == "red" + assert v.attrs["weight"] == 5 + + def test_all_fields(self) -> None: + """All fields can be set at once via direct construction.""" + attrs = MappingProxyType({"x": 10, "y": 20}) + v = Vertex("hub", label="Central Hub", attrs=attrs) + assert v.name == "hub" + assert v.label == "Central Hub" + assert v.attrs["x"] == 10 + + +# --------------------------------------------------------------------------- +# Construction — factory method +# --------------------------------------------------------------------------- + + +class TestVertexCreateFactory: + """Tests for the Vertex.create() alternate constructor.""" + + def test_create_basic(self) -> None: + """Vertex.create builds a valid vertex with defaults.""" + v = Vertex.create("x") + assert v.name == "x" + assert v.label is None + assert v.attrs == {} + + def test_create_with_plain_dict_attrs(self) -> None: + """Vertex.create freezes a plain dict into a MappingProxyType.""" + v = Vertex.create("x", attrs={"color": "blue"}) + assert v.attrs["color"] == "blue" + assert isinstance(v.attrs, MappingProxyType) + + def test_create_with_none_attrs(self) -> None: + """Vertex.create with attrs=None yields an empty mapping.""" + v = Vertex.create("x", attrs=None) + assert v.attrs == {} + assert isinstance(v.attrs, MappingProxyType) + + def test_create_with_all_fields(self) -> None: + """Vertex.create accepts all keyword arguments.""" + v = Vertex.create("hub", label="Central", attrs={"rank": 1}) + assert v.name == "hub" + assert v.label == "Central" + assert v.attrs["rank"] == 1 + + def test_create_does_not_mutate_original_dict(self) -> None: + """Mutating the input dict after create() has no effect on the Vertex.""" + raw = {"key": "original"} + v = Vertex.create("a", attrs=raw) + raw["key"] = "mutated" + assert v.attrs["key"] == "original" + + +# --------------------------------------------------------------------------- +# Immutability +# --------------------------------------------------------------------------- + + +class TestVertexImmutability: + """Tests that Vertex instances are truly frozen.""" + + def test_cannot_set_name(self) -> None: + """Attempting to reassign name raises an error.""" + v = Vertex("a") + with pytest.raises(AttributeError): + v.name = "z" # noqa # ty:ignore[invalid-assignment] + + def test_cannot_set_label(self) -> None: + """Attempting to reassign label raises an error.""" + v = Vertex("a", label="original") + with pytest.raises(AttributeError): + v.label = "changed" # noqa # ty:ignore[invalid-assignment] + + def test_cannot_mutate_attrs(self) -> None: + """Attempting to set a key on attrs raises a TypeError.""" + v = Vertex.create("a", attrs={"color": "red"}) + with pytest.raises(TypeError): + v.attrs["color"] = "blue" # type: ignore[index] + + def test_cannot_add_new_attr(self) -> None: + """Attempting to add a new key to attrs raises a TypeError.""" + v = Vertex("a") + with pytest.raises(TypeError): + v.attrs["new_key"] = "value" # type: ignore[index] + + +# --------------------------------------------------------------------------- +# display_name property +# --------------------------------------------------------------------------- + + +class TestVertexDisplayName: + """Tests for the display_name derived property.""" + + def test_display_name_falls_back_to_name(self) -> None: + """display_name returns name when label is None.""" + v = Vertex("node_42") + assert v.display_name == "node_42" + + def test_display_name_uses_label(self) -> None: + """display_name returns label when it is set.""" + v = Vertex("n1", label="Start") + assert v.display_name == "Start" + + def test_display_name_with_empty_string_label(self) -> None: + """An empty-string label is still used (it is not None).""" + v = Vertex("n1", label="") + assert v.display_name == "" + + +# --------------------------------------------------------------------------- +# Identity — equality +# --------------------------------------------------------------------------- + + +class TestVertexEquality: + """Tests for Vertex equality based on name only. + + Label and attrs are descriptive and do not affect identity. + """ + + def test_equal_by_name(self) -> None: + """Two Vertices with the same name are equal.""" + assert Vertex("a") == Vertex("a") + + def test_equal_ignores_label(self) -> None: + """Vertices with different labels but the same name are equal.""" + assert Vertex("a", label="foo") == Vertex("a", label="bar") + + def test_equal_ignores_attrs(self) -> None: + """Vertices with different attrs but the same name are equal.""" + v1 = Vertex.create("a", attrs={"k": 1}) + v2 = Vertex.create("a", attrs={"k": 2}) + assert v1 == v2 + + def test_different_names_not_equal(self) -> None: + """Vertices with different names are not equal.""" + assert Vertex("a") != Vertex("b") + + def test_case_sensitive(self) -> None: + """Vertex names are case-sensitive.""" + assert Vertex("A") != Vertex("a") + + def test_not_equal_to_non_vertex(self) -> None: + """Comparing a Vertex to a non-Vertex returns NotImplemented.""" + v = Vertex("a") + assert v != "a" + assert v != 42 + + def test_not_equal_to_none(self) -> None: + """Comparing a Vertex to None is False.""" + v = Vertex("a") + assert v != None # noqa: E711 + + def test_not_equal_to_string_of_same_name(self) -> None: + """A Vertex is not equal to a bare string, even if the name matches.""" + v = Vertex("hello") + assert v != "hello" + + +# --------------------------------------------------------------------------- +# Identity — hashing +# --------------------------------------------------------------------------- + + +class TestVertexHashing: + """Tests for Vertex hash behaviour based on name only.""" + + def test_equal_vertices_same_hash(self) -> None: + """Vertices with the same name have the same hash.""" + assert hash(Vertex("a")) == hash(Vertex("a")) + + def test_different_label_same_hash(self) -> None: + """Vertices differing only in label share a hash.""" + assert hash(Vertex("a", label="x")) == hash(Vertex("a", label="y")) + + def test_usable_as_dict_key(self) -> None: + """Vertices can serve as dictionary keys.""" + v = Vertex("a") + d = {v: "found"} + assert d[Vertex("a")] == "found" + + def test_usable_in_set(self) -> None: + """Duplicate-name vertices collapse in a set.""" + s = {Vertex("a"), Vertex("a"), Vertex("a", label="different")} + assert len(s) == 1 + + def test_different_names_in_set(self) -> None: + """Vertices with different names remain distinct in a set.""" + s = {Vertex("a"), Vertex("b"), Vertex("c")} + assert len(s) == 3 + + +# --------------------------------------------------------------------------- +# Ordering +# --------------------------------------------------------------------------- + + +class TestVertexOrdering: + """Tests for __lt__ which enables sorted() on vertex collections.""" + + def test_less_than(self) -> None: + """Vertex 'a' sorts before Vertex 'b'.""" + assert Vertex("a") < Vertex("b") + + def test_not_less_than(self) -> None: + """Vertex 'b' does not sort before Vertex 'a'.""" + assert not (Vertex("b") < Vertex("a")) + + def test_equal_not_less_than(self) -> None: + """A Vertex is not less than itself.""" + assert not (Vertex("a") < Vertex("a")) + + def test_sorted_collection(self) -> None: + """sorted() on a list of Vertices produces name-alphabetical order.""" + vertices = [Vertex("c"), Vertex("a"), Vertex("b")] + result = sorted(vertices) + assert [v.name for v in result] == ["a", "b", "c"] + + def test_min_and_max(self) -> None: + """min() and max() work on Vertex collections.""" + vertices = [Vertex("c"), Vertex("a"), Vertex("b")] + assert min(vertices).name == "a" + assert max(vertices).name == "c" + + def test_lt_with_non_vertex_returns_not_implemented(self) -> None: + """Comparing a Vertex to a non-Vertex via < returns NotImplemented.""" + v = Vertex("a") + assert v.__lt__("a") is NotImplemented + + +# --------------------------------------------------------------------------- +# String representations +# --------------------------------------------------------------------------- + + +class TestVertexRepr: + """Tests for __repr__ and __str__.""" + + def test_repr_name_only(self) -> None: + """repr with just a name is compact.""" + v = Vertex("A") + assert repr(v) == "Vertex('A')" + + def test_repr_with_label(self) -> None: + """repr includes label when present.""" + v = Vertex("A", label="Alice") + r = repr(v) + assert "Vertex('A'" in r + assert "label='Alice'" in r + + def test_repr_with_attrs(self) -> None: + """repr includes attrs when non-empty.""" + v = Vertex.create("A", attrs={"color": "red"}) + r = repr(v) + assert "color" in r + assert "red" in r + + def test_repr_minimal_omits_defaults(self) -> None: + """repr omits label and attrs when they are defaults.""" + v = Vertex("A") + r = repr(v) + assert "label" not in r + assert "attrs" not in r + + def test_str_uses_display_name_fallback(self) -> None: + """str() returns name when label is not set.""" + v = Vertex("node_1") + assert str(v) == "node_1" + + def test_str_uses_label(self) -> None: + """str() returns label when it is set.""" + v = Vertex("n1", label="Start") + assert str(v) == "Start" From 2419acae913b3b95826e77c141871e55b6384e32 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Mon, 23 Mar 2026 19:36:33 -0600 Subject: [PATCH 3/8] Use black locally --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 16d6c3b..4150af1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,6 +38,7 @@ repos: rev: 26.3.1 hooks: - id: black + language: system - repo: https://github.com/pycqa/isort rev: 8.0.1 hooks: From 7a1429b97ed95225d4d999546ffc917ddafee5a6 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Mon, 23 Mar 2026 20:05:21 -0600 Subject: [PATCH 4/8] Big breaking changes --- conftest.py | 181 ++------ src/graphworks/algorithms/__init__.py | 32 +- src/graphworks/algorithms/directed.py | 65 +-- src/graphworks/algorithms/paths.py | 33 +- src/graphworks/algorithms/properties.py | 78 ++-- src/graphworks/algorithms/search.py | 69 +-- src/graphworks/algorithms/sort.py | 40 +- src/graphworks/edge.py | 38 -- src/graphworks/graph.py | 346 +++++++-------- src/graphworks/vertex.py | 27 +- tests/test_directed.py | 60 +-- tests/test_edge.py | 295 ++----------- tests/test_export.py | 106 ----- tests/test_graph.py | 533 ++++++++---------------- tests/test_numpy_compat.py | 44 +- tests/test_paths.py | 86 +--- tests/test_properties.py | 501 ++++------------------ tests/test_search.py | 177 ++------ tests/test_sort.py | 53 +-- tests/test_vertex.py | 277 ++---------- 20 files changed, 778 insertions(+), 2263 deletions(-) delete mode 100644 tests/test_export.py diff --git a/conftest.py b/conftest.py index deebfdd..3baf9c2 100644 --- a/conftest.py +++ b/conftest.py @@ -1,24 +1,7 @@ """Shared pytest fixtures for the graphworks test suite. -All fixtures used across multiple test modules live here so pytest discovers -them automatically without explicit imports. - -This test suite is written to run against the **installed** package. In the -typical development workflow:: - - uv sync # installs graphworks in editable mode - uv run pytest # runs the suite against the editable install - -Alternatively, add the following to ``[tool.pytest.ini_options]`` in -``pyproject.toml`` so that pytest adds ``src/`` to ``sys.path`` for -environments that do not use an editable install:: - - pythonpath = ["src"] - -Both approaches ensure that ``from graphworks.x import Y`` and the -library's internal ``from graphworks.x import Y`` resolve to the **same** -module object, which is required for dataclass ``__eq__`` to work correctly -across the test/library boundary. +All fixtures used across multiple test modules live here so pytest discovers them automatically +without explicit imports. """ from __future__ import annotations @@ -43,38 +26,26 @@ @pytest.fixture def tmp_dir() -> Generator[Path]: - """Yield a fresh temporary directory and clean it up afterwards. - - :return: Path to a temporary directory. - :rtype: Path - """ + """Yield a fresh temporary directory and clean it up afterwards.""" d = tempfile.mkdtemp() yield Path(d) shutil.rmtree(d) # --------------------------------------------------------------------------- -# Raw JSON graph definitions (dicts) shared across test modules +# Raw JSON graph definitions # --------------------------------------------------------------------------- @pytest.fixture def simple_edge_json() -> dict: - """Minimal two-vertex undirected graph with one edge (A → B). - - :return: Graph definition dictionary. - :rtype: dict - """ + """Minimal two-vertex undirected graph with one edge (A → B).""" return {"label": "my graph", "graph": {"A": ["B"], "B": []}} @pytest.fixture def triangle_json() -> dict: - """Complete undirected graph on three vertices (K₃). - - :return: Graph definition dictionary. - :rtype: dict - """ + """Complete undirected graph on three vertices (K₃).""" return { "graph": { "a": ["b", "c"], @@ -86,21 +57,13 @@ def triangle_json() -> dict: @pytest.fixture def isolated_json() -> dict: - """Three-vertex graph with no edges (all isolated vertices). - - :return: Graph definition dictionary. - :rtype: dict - """ + """Three-vertex graph with no edges.""" return {"graph": {"a": [], "b": [], "c": []}} @pytest.fixture def connected_json() -> dict: - """Six-vertex connected undirected graph that includes self-loops. - - :return: Graph definition dictionary. - :rtype: dict - """ + """Six-vertex connected undirected graph with self-loops.""" return { "graph": { "a": ["d", "f"], @@ -115,11 +78,7 @@ def connected_json() -> dict: @pytest.fixture def big_graph_json() -> dict: - """Six-vertex connected undirected graph used for diameter tests. - - :return: Graph definition dictionary. - :rtype: dict - """ + """Six-vertex connected undirected graph used for diameter tests.""" return { "graph": { "a": ["c"], @@ -134,16 +93,7 @@ def big_graph_json() -> dict: @pytest.fixture def lollipop_json() -> dict: - """Lollipop-shaped graph that contains a cycle (d→b) but *no* self-loops. - - ``is_simple`` in this library only checks for self-loops (a vertex listed - in its own neighbour list), so this graph **is** considered simple despite - the cycle. Use :func:`self_loop_json` when you need a graph that is - definitively not simple. - - :return: Graph definition dictionary. - :rtype: dict - """ + """Lollipop-shaped graph with a cycle but no self-loops.""" return { "graph": { "z": ["a"], @@ -157,36 +107,19 @@ def lollipop_json() -> dict: @pytest.fixture def self_loop_json() -> dict: - """Two-vertex graph where vertex *a* has a self-loop — **not** simple. - - :return: Graph definition dictionary. - :rtype: dict - """ - return { - "graph": { - "a": ["a", "b"], - "b": ["a"], - } - } + """Two-vertex graph where vertex *a* has a self-loop.""" + return {"graph": {"a": ["a", "b"], "b": ["a"]}} @pytest.fixture def straight_line_json() -> dict: - """Linear path graph a-b-c-d: simple, no self-loops, no cycles. - - :return: Graph definition dictionary. - :rtype: dict - """ + """Linear path graph a-b-c-d.""" return {"graph": {"a": ["b"], "b": ["c"], "c": ["d"], "d": []}} @pytest.fixture def directed_dag_json() -> dict: - """Directed acyclic graph for topological sort and DAG tests. - - :return: Graph definition dictionary. - :rtype: dict - """ + """Directed acyclic graph for topological sort and DAG tests.""" return { "directed": True, "graph": { @@ -202,13 +135,7 @@ def directed_dag_json() -> dict: @pytest.fixture def directed_cycle_json() -> dict: - """Directed graph containing a cycle — **not** a DAG. - - The cycle is A → B → D → A (back-edge D→A). - - :return: Graph definition dictionary. - :rtype: dict - """ + """Directed graph containing a cycle (A → B → D → A).""" return { "directed": True, "graph": { @@ -223,11 +150,7 @@ def directed_cycle_json() -> dict: @pytest.fixture def circuit_json() -> dict: - """Directed graph with a single Eulerian circuit A → B → C → A. - - :return: Graph definition dictionary. - :rtype: dict - """ + """Directed graph with a single Eulerian circuit A → B → C → A.""" return { "directed": True, "graph": {"A": ["B"], "B": ["C"], "C": ["A"]}, @@ -236,11 +159,7 @@ def circuit_json() -> dict: @pytest.fixture def search_graph_json() -> dict: - """Four-vertex graph used for BFS / DFS traversal tests. - - :return: Graph definition dictionary. - :rtype: dict - """ + """Four-vertex graph used for BFS / DFS traversal tests.""" return { "graph": { "a": ["b", "c"], @@ -253,11 +172,7 @@ def search_graph_json() -> dict: @pytest.fixture def disjoint_directed_json() -> dict: - """Directed graph with two disjoint components for arrival/departure DFS. - - :return: Graph definition dictionary. - :rtype: dict - """ + """Directed graph with two disjoint components.""" return { "directed": True, "graph": { @@ -280,99 +195,59 @@ def disjoint_directed_json() -> dict: @pytest.fixture def simple_edge_graph(simple_edge_json: dict) -> Graph: - """Two-vertex undirected :class:`Graph` with one edge (A → B). - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Two-vertex undirected Graph with one edge (A → B).""" return Graph(input_graph=json.dumps(simple_edge_json)) @pytest.fixture def triangle_graph(triangle_json: dict) -> Graph: - """Complete undirected :class:`Graph` on three vertices (K₃). - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Complete undirected Graph on three vertices (K₃).""" return Graph(input_graph=json.dumps(triangle_json)) @pytest.fixture def isolated_graph(isolated_json: dict) -> Graph: - """Three-vertex :class:`Graph` with no edges. - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Three-vertex Graph with no edges.""" return Graph(input_graph=json.dumps(isolated_json)) @pytest.fixture def connected_graph(connected_json: dict) -> Graph: - """Six-vertex connected undirected :class:`Graph`. - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Six-vertex connected undirected Graph.""" return Graph(input_graph=json.dumps(connected_json)) @pytest.fixture def big_graph(big_graph_json: dict) -> Graph: - """Six-vertex connected undirected :class:`Graph` for diameter tests. - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Six-vertex connected undirected Graph for diameter tests.""" return Graph(input_graph=json.dumps(big_graph_json)) @pytest.fixture def directed_dag(directed_dag_json: dict) -> Graph: - """Directed acyclic :class:`Graph`. - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Directed acyclic Graph.""" return Graph(input_graph=json.dumps(directed_dag_json)) @pytest.fixture def directed_cycle_graph(directed_cycle_json: dict) -> Graph: - """Directed :class:`Graph` containing a cycle. - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Directed Graph containing a cycle.""" return Graph(input_graph=json.dumps(directed_cycle_json)) @pytest.fixture def circuit_graph(circuit_json: dict) -> Graph: - """Directed :class:`Graph` with an Eulerian circuit A → B → C → A. - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Directed Graph with an Eulerian circuit.""" return Graph(input_graph=json.dumps(circuit_json)) @pytest.fixture def search_graph(search_graph_json: dict) -> Graph: - """Four-vertex :class:`Graph` for BFS / DFS tests. - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Four-vertex Graph for BFS / DFS tests.""" return Graph(input_graph=json.dumps(search_graph_json)) @pytest.fixture def disjoint_directed_graph(disjoint_directed_json: dict) -> Graph: - """Directed :class:`Graph` with two disjoint components. - - :return: Constructed Graph instance. - :rtype: Graph - """ + """Directed Graph with two disjoint components.""" return Graph(input_graph=json.dumps(disjoint_directed_json)) diff --git a/src/graphworks/algorithms/__init__.py b/src/graphworks/algorithms/__init__.py index 59b1e06..d01e2d6 100644 --- a/src/graphworks/algorithms/__init__.py +++ b/src/graphworks/algorithms/__init__.py @@ -4,15 +4,10 @@ ---------- - :mod:`~graphworks.algorithms.properties` — structural predicates and metrics - (``is_connected``, ``density``, ``diameter``, ``degree_sequence``, etc.) - :mod:`~graphworks.algorithms.paths` — path finding and edge utilities - (``find_path``, ``find_all_paths``, ``generate_edges``, etc.) - :mod:`~graphworks.algorithms.search` — graph traversal - (``breadth_first_search``, ``depth_first_search``, etc.) - :mod:`~graphworks.algorithms.directed` — directed-graph algorithms - (``is_dag``, ``find_circuit``, etc.) - :mod:`~graphworks.algorithms.sort` — sorting algorithms - (``topological``, etc.) """ from graphworks.algorithms.directed import find_circuit, is_dag @@ -48,35 +43,30 @@ from graphworks.algorithms.sort import topological __all__ = [ - # properties + "arrival_departure_dfs", + "breadth_first_search", "degree_sequence", "density", + "depth_first_search", "diameter", + "find_all_paths", + "find_circuit", + "find_isolated_vertices", + "find_path", + "generate_edges", "get_complement", "invert", "is_complete", "is_connected", - "is_dense", + "is_dag", "is_degree_sequence", + "is_dense", "is_erdos_gallai", "is_regular", "is_simple", "is_sparse", "max_degree", "min_degree", - "vertex_degree", - # paths - "find_all_paths", - "find_isolated_vertices", - "find_path", - "generate_edges", - # search - "arrival_departure_dfs", - "breadth_first_search", - "depth_first_search", - # directed - "find_circuit", - "is_dag", - # sort "topological", + "vertex_degree", ] diff --git a/src/graphworks/algorithms/directed.py b/src/graphworks/algorithms/directed.py index ce8523d..e732201 100644 --- a/src/graphworks/algorithms/directed.py +++ b/src/graphworks/algorithms/directed.py @@ -1,4 +1,9 @@ -"""Directed graph utilities.""" +"""Directed graph utilities. + +Provides DAG detection and Eulerian circuit finding via Hierholzer's algorithm. All functions +are pure — they take a :class:`~graphworks.graph.Graph` and return results without modifying the +input. +""" from __future__ import annotations @@ -11,72 +16,68 @@ def is_dag(graph: Graph) -> bool: - """Returns true if graph is a directed acyclic graph. + """Return ``True`` if *graph* is a directed acyclic graph. + + Uses arrival/departure DFS to detect back-edges. - :param graph: + :param graph: The graph to test. :type graph: Graph - :return: True/False if graph is a directed, acyclic graph + :return: ``True`` if the graph is both directed and acyclic. :rtype: bool """ - if not graph.is_directed(): + if not graph.directed: return False departure = dict.fromkeys(graph.vertices(), 0) discovered = dict.fromkeys(graph.vertices(), False) - time = -1 - - # not needed in this case arrival = dict.fromkeys(graph.vertices(), 0) + time = -1 - # visit all connected components of the graph, build departure dict for n in graph.vertices(): if not discovered[n]: - time = arrival_departure_dfs(graph, n, discovered, arrival, departure, time) + time = arrival_departure_dfs( + graph, + n, + discovered, + arrival, + departure, + time, + ) - # check if the given directed graph is DAG or not for n in graph.vertices(): - - # check if (u, v) forms a back-edge. - for v in graph.get_neighbors(n): - # If the departure time of vertex `v` is greater than equal - # to the departure time of `u`, they form a back edge. - - # `departure[u]` will be equal to `departure[v]` only if - # `u = v`, i.e., a vertex with an edge to itself + for v in graph.neighbors(n): if departure[n] <= departure[v]: return False - # No back edges return True def build_neighbor_matrix(graph: Graph) -> dict[str, list[str]]: - """Builds adjacency matrix for directed acyclic graph. + """Build a mutable adjacency dict for *graph*. - :param graph: The 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: adjacency matrix + :return: Mutable adjacency mapping. :rtype: dict[str, list[str]] """ - adjacency_matrix = {} - for v in graph.vertices(): - adjacency_matrix[v] = graph.get_neighbors(v) - - return adjacency_matrix + return {v: list(graph.neighbors(v)) for v in graph.vertices()} def find_circuit(graph: Graph) -> list[str]: - """Using Hierholzer’s algorithm to find an eulerian circuit. + """Find an Eulerian circuit using Hierholzer's algorithm. - :param graph: + :param graph: The graph to search for an Eulerian circuit. :type graph: Graph - :return: A list of vertices in the eulerian circuit of this graph + :return: List of vertex names forming the circuit, or ``[]`` if the graph has no vertices. :rtype: list[str] """ if len(graph.vertices()) == 0: return [] - circuit = [] + circuit: list[str] = [] adjacency_matrix = build_neighbor_matrix(graph) current_path: list[str] = [graph.vertices()[0]] while len(current_path) > 0: diff --git a/src/graphworks/algorithms/paths.py b/src/graphworks/algorithms/paths.py index 9d3c551..6929a04 100644 --- a/src/graphworks/algorithms/paths.py +++ b/src/graphworks/algorithms/paths.py @@ -1,11 +1,8 @@ """Path-finding and edge-generation utilities. -This module provides functions for discovering paths between vertices, -generating edge lists, and finding structurally isolated vertices. All -functions operate on the adjacency-list representation and require no -external dependencies. - -:author: Nathan Gilbert +Provides functions for discovering paths between vertices, generating edge lists, and finding +structurally isolated vertices. All functions operate on the adjacency-list representation and +require no external dependencies. """ from __future__ import annotations @@ -18,9 +15,9 @@ def generate_edges(graph: Graph) -> list[Edge]: - """Return all edges in *graph* as a list of :class:`~graphworks.edge.Edge` objects. + """Return all edges in *graph*. - This is a convenience wrapper around :meth:`~graphworks.graph.Graph.edges`. + Convenience wrapper around :meth:`~graphworks.graph.Graph.edges`. :param graph: The graph to enumerate edges from. :type graph: Graph @@ -49,8 +46,8 @@ def find_path( ) -> 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. Returns an empty list if no path + exists or if *start* is not in the graph. :param graph: The graph to search. :type graph: Graph @@ -58,11 +55,9 @@ 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``. + :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. + :return: Ordered list of vertex names from *start* to *end*, or ``[]`` if no path exists. :rtype: list[str] """ if path is None: @@ -92,8 +87,8 @@ def find_all_paths( ) -> 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. Returns an empty list if no path exists or if + *start* is not in the graph. :param graph: The graph to search. :type graph: Graph @@ -101,11 +96,9 @@ 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``. + :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 path being an ordered list of - vertex names. + :return: List of all simple paths, each an ordered list of vertex names. :rtype: list[list[str]] """ if path is None: diff --git a/src/graphworks/algorithms/properties.py b/src/graphworks/algorithms/properties.py index c7ceca5..c6d4823 100644 --- a/src/graphworks/algorithms/properties.py +++ b/src/graphworks/algorithms/properties.py @@ -1,23 +1,24 @@ """Graph property queries and structural metrics. -This module provides predicate functions (``is_*``) and quantitative metrics -(``density``, ``diameter``, ``degree_sequence``, etc.) that inspect a -:class:`~graphworks.graph.Graph` without modifying it. +Provides predicate functions (``is_*``) and quantitative metrics (``density``, ``diameter``, +``degree_sequence``, etc.) that inspect a :class:`~graphworks.graph.Graph` without modifying it. -All functions are pure: they take a graph (and optional parameters) and -return a value. None of the functions here require numpy. +All functions are pure: they take a graph (and optional parameters) and return a value. None of +the functions here require numpy. """ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final -from graphworks.algorithms.paths import find_all_paths # avoid circular +from graphworks.algorithms.paths import find_all_paths from graphworks.graph import Graph if TYPE_CHECKING: from graphworks.types import AdjacencyMatrix +DENSITY_CUTOFF: Final[float] = 0.5 + # --------------------------------------------------------------------------- # Degree helpers # --------------------------------------------------------------------------- @@ -37,7 +38,6 @@ def vertex_degree(graph: Graph, vertex: str) -> int: """ adj = graph[vertex] degree = len(adj) - # each self-loop adds an extra 1 degree += sum(1 for v in adj if v == vertex) return degree @@ -88,8 +88,7 @@ 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 has a non-negative, even sum and is non-increasing. :param sequence: Candidate degree sequence. :type sequence: list[int] @@ -98,15 +97,17 @@ def is_degree_sequence(sequence: list[int]) -> bool: """ if not sequence: return True - return sum(sequence) % 2 == 0 and sequence == sorted(sequence, reverse=True) + return sum(sequence) % 2 == 0 and sequence == sorted( + sequence, + reverse=True, + ) def is_erdos_gallai(sequence: list[int]) -> bool: """Return whether *sequence* satisfies the Erdős–Gallai theorem. - A non-increasing sequence of non-negative integers is a valid degree - sequence of a simple graph if and only if its sum is even and the - Erdős–Gallai condition holds for every prefix. + A non-increasing sequence of non-negative integers is a valid degree sequence of a simple + graph if and only if its sum is even and the Erdős–Gallai condition holds for every prefix. :param sequence: Candidate degree sequence (need not be sorted). :type sequence: list[int] @@ -169,11 +170,11 @@ def is_connected( :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`. + :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``. + :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*. :rtype: bool @@ -190,7 +191,9 @@ def is_connected( 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 + graph, + vertex, + vertices_encountered, ): return True else: @@ -201,10 +204,8 @@ def is_connected( 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. - - Runtime: O(n²). + 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. :param graph: The graph to inspect. :type graph: Graph @@ -213,7 +214,7 @@ def is_complete(graph: Graph) -> bool: """ v_count = len(graph.vertices()) max_edges = v_count**2 - v_count - if not graph.is_directed(): + if not graph.directed: max_edges //= 2 if len(graph.edges()) != max_edges: @@ -230,20 +231,18 @@ def is_sparse(graph: Graph) -> bool: :return: ``True`` if the graph is sparse. :rtype: bool """ - return graph.size() <= (graph.order() ** 2 / 2) + return graph.size <= (graph.order**2 / 2) def is_dense(graph: Graph) -> bool: - """Return whether the graph is dense (``|E| = Θ(|V|²)``). - - Computed as density ≥ 0.5. + """Return whether the graph is dense (density ≥ 0.5). :param graph: The graph to inspect. :type graph: Graph :return: ``True`` if the graph is dense. :rtype: bool """ - return density(graph) >= 0.5 + return density(graph) >= DENSITY_CUTOFF # --------------------------------------------------------------------------- @@ -254,8 +253,7 @@ 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 ``2|E| / (|V|² - |V|)``. Returns ``0.0`` for graphs with fewer than two vertices. :param graph: The graph to inspect. :type graph: Graph @@ -263,7 +261,7 @@ def density(graph: Graph) -> float: :rtype: float """ v_count = len(graph.vertices()) - if v_count < 2: + if v_count < 2: # noqa: PLR2004 return 0.0 e_count = len(graph.edges()) return 2.0 * (e_count / (v_count**2 - v_count)) @@ -273,6 +271,7 @@ 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. :param graph: The graph to inspect. :type graph: Graph @@ -286,7 +285,7 @@ def diameter(graph: Graph) -> int: 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))) + shortest_paths.append(min(all_paths, key=len)) if not shortest_paths: return 0 @@ -315,17 +314,22 @@ def invert(matrix: AdjacencyMatrix) -> AdjacencyMatrix: def get_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*. + 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. :param graph: The source graph. :type graph: Graph :return: Complement graph. :rtype: Graph """ - adj = graph.get_adjacency_matrix() + adj = graph.adjacency_matrix() complement_matrix = invert(adj) return Graph( - label=f"{graph.get_label()} complement", + f"{graph.label} complement", input_matrix=complement_matrix, ) diff --git a/src/graphworks/algorithms/search.py b/src/graphworks/algorithms/search.py index 6180c67..e5c3f41 100644 --- a/src/graphworks/algorithms/search.py +++ b/src/graphworks/algorithms/search.py @@ -1,4 +1,9 @@ -"""This module implements DFS with arrival and departure times.""" +"""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 +:class:`~graphworks.graph.Graph` and return results without modifying the input. +""" from __future__ import annotations @@ -9,22 +14,20 @@ def breadth_first_search(graph: Graph, start: str) -> list[str]: - """Breadth-first search with arrival and departure times. + """Return vertices reachable from *start* in breadth-first order. - :param graph: + :param graph: The graph to traverse. :type graph: Graph - :param start: the vertex to start the traversal from + :param start: The vertex to begin the traversal from. :type start: str - :return: The list of vertex paths + :return: List of vertex names in BFS visit order. :rtype: list[str] """ - # Mark all the vertices as not visited visited = dict.fromkeys(graph.vertices(), False) - # Mark the start vertices as visited and enqueue it visited[start] = True queue = [start] - walk = [] + walk: list[str] = [] while queue: cur = queue.pop(0) walk.append(cur) @@ -37,25 +40,28 @@ def breadth_first_search(graph: Graph, start: str) -> list[str]: def depth_first_search(graph: Graph, start: str) -> list[str]: - """Depth-first search with arrival and departure times. + """Return vertices reachable from *start* in depth-first order. - :param graph: + :param graph: The graph to traverse. :type graph: Graph - :param start: the vertex to start the traversal from + :param start: The vertex to begin the traversal from. :type start: str - :return: The list of vertex paths + :return: List of vertex names in DFS visit order. :rtype: list[str] """ - visited, stack = [], [start] + visited: list[str] = [] + stack = [start] while stack: vertex = stack.pop() if vertex not in visited: visited.append(vertex) - stack.extend(filter(lambda x: x not in visited, graph[vertex])) + stack.extend( + filter(lambda x: x not in visited, graph[vertex]), + ) return visited -def arrival_departure_dfs( +def arrival_departure_dfs( # noqa: PLR0913 graph: Graph, v: str, discovered: dict[str, bool], @@ -63,36 +69,41 @@ def arrival_departure_dfs( departure: dict[str, int], time: int, ) -> int: - """Method for DFS with arrival and departure times for each vertex. + """Perform DFS recording arrival and departure times for each vertex. - O(V+E) -- E could be as big as V^2 + This variant is used internally by :func:`~graphworks.algorithms.directed.is_dag` to detect + back-edges. Complexity: O(V + E). - :param graph: The graph + :param graph: The graph to traverse. :type graph: Graph - :param v: The vertex to traverse from + :param v: The vertex to traverse from. :type v: str - :param discovered: The discovered vertex + :param discovered: Mutable map tracking which vertices have been visited. :type discovered: dict[str, bool] - :param arrival: The arrival vertex + :param arrival: Mutable map storing each vertex's arrival timestamp. :type arrival: dict[str, int] - :param departure: The departure vertex + :param departure: Mutable map storing each vertex's departure timestamp. :type departure: dict[str, int] - :param time: initialized to -1 + :param time: Current timestamp counter (typically initialized to ``-1``). :type time: int - :return: The departure time + :return: The updated timestamp counter after this subtree is fully explored. :rtype: int """ time += 1 - - # when did we arrive at vertex 'v'? arrival[v] = time discovered[v] = True - for n in graph.get_neighbors(v): + for n in graph.neighbors(v): if not discovered.get(n, False): - time = arrival_departure_dfs(graph, n, discovered, arrival, departure, time) + time = arrival_departure_dfs( + graph, + n, + discovered, + arrival, + departure, + time, + ) time += 1 - # increment time and then set departure departure[v] = time return time diff --git a/src/graphworks/algorithms/sort.py b/src/graphworks/algorithms/sort.py index ba294ed..0acfad4 100644 --- a/src/graphworks/algorithms/sort.py +++ b/src/graphworks/algorithms/sort.py @@ -1,4 +1,4 @@ -"""Sorting algorithms.""" +"""Sorting algorithms for directed graphs.""" from __future__ import annotations @@ -9,42 +9,46 @@ def topological(graph: Graph) -> list[str]: - """Topological sort. + """Return a topological ordering of the vertices in *graph*. - O(V+E) + Uses a recursive DFS-based approach. Complexity: O(V + E). - :param graph: + :param graph: A directed acyclic graph. :type graph: Graph - :return: List of vertices sorted topologically + :return: List of vertices sorted topologically. :rtype: list[str] """ - def mark_visited(g: Graph, v: str, v_map: dict[str, bool], t_sort_results: list[str]) -> None: - """Mark visited vertex as visited. + 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 graphc + :param g: The graph. :type g: Graph - :param v: Vertex + :param v: Current vertex. :type v: str - :param v_map: Mapping from vertex to vertex index + :param v_map: Mutable visited-flag map. :type v_map: dict[str, bool] - :param t_sort_results: List of vertices sorted topologically - :type t_sort_results: list[str] - :return: Nothing + :param result: Accumulator for the reverse topological order. + :type result: list[str] + :return: Nothing. :rtype: None """ v_map[v] = True - for n in g.get_neighbors(v): + for n in g.neighbors(v): if not v_map[n]: - mark_visited(g, n, v_map, t_sort_results) - t_sort_results.append(v) + _mark_visited(g, n, v_map, result) + result.append(v) visited = dict.fromkeys(graph.vertices(), False) result: list[str] = [] for v in graph.vertices(): if not visited[v]: - mark_visited(graph, v, visited, result) + _mark_visited(graph, v, visited, result) - # Contains topo sort results in reverse order return result[::-1] diff --git a/src/graphworks/edge.py b/src/graphworks/edge.py index dcb31da..b3f4cbe 100644 --- a/src/graphworks/edge.py +++ b/src/graphworks/edge.py @@ -17,12 +17,6 @@ reassigned. The *attrs* mapping is exposed as a read-only :class:`~types.MappingProxyType` so callers cannot mutate it in place either. To "update" an edge, create a new instance — idiomatic for frozen dataclasses and compatible with use as ``dict`` keys and ``set`` members. - -Backward compatibility ----------------------- -The previous API exposed ``vertex1`` and ``vertex2`` field names. These are retained as -**read-only property aliases** for ``source`` and ``target`` respectively. New code should -prefer ``source`` / ``target``. """ from __future__ import annotations @@ -65,10 +59,6 @@ class Edge: default_factory=lambda: MappingProxyType({}), ) - # ------------------------------------------------------------------ - # Alternate constructor - # ------------------------------------------------------------------ - @classmethod def create( # noqa: PLR0913 cls, @@ -109,34 +99,6 @@ def create( # noqa: PLR0913 attrs=_freeze_attrs(attrs), ) - # ------------------------------------------------------------------ - # Backward-compatible aliases - # ------------------------------------------------------------------ - - @property - def vertex1(self) -> str: - """Alias for :attr:`source` (backward compatibility). - - .. deprecated:: - Use :attr:`source` instead. - - :return: Source vertex name. - :rtype: str - """ - return self.source - - @property - def vertex2(self) -> str: - """Alias for :attr:`target` (backward compatibility). - - .. deprecated:: - Use :attr:`target` instead. - - :return: Target vertex name. - :rtype: str - """ - return self.target - # ------------------------------------------------------------------ # Predicates # ------------------------------------------------------------------ diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py index 76cf526..c74674d 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -6,10 +6,7 @@ * ``_vertices``: ``dict[str, Vertex]`` — O(1) lookup by name. * ``_adj``: ``dict[str, dict[str, Edge]]`` — ``_adj[u][v]`` gives the - :class:`~graphworks.edge.Edge` from *u* to *v* in O(1). - -The adjacency matrix interface uses only stdlib types — no numpy required. Optional numpy interop -is available through :mod:`graphworks.numpy_compat`. +:class:`~graphworks.edge.Edge` from *u* to *v* in O(1). Construction ------------ @@ -25,20 +22,21 @@ Example:: >>> import json - >>> from graphworks.graph import Graph # noqa + >>> from graphworks.graph import Graph ... >>> data = {"label": "demo", "graph": {"A": ["B"], "B": []}} >>> g = Graph(input_graph=json.dumps(data)) >>> g.vertices() # ['A', 'B'] >>> g.edges() # [Edge('A' -- 'B')] + >>> g.label # 'demo' + >>> g.order # 2 + >>> g.size # 1 """ from __future__ import annotations import json -import random import uuid -from collections import defaultdict from pathlib import Path from typing import TYPE_CHECKING @@ -57,14 +55,8 @@ class Graph: Vertices and edges are stored as first-class objects in a dual-index structure that provides O(1) vertex lookup, O(1) edge existence checks, and efficient neighbor iteration. - The public API preserves backward compatibility: :meth:`vertices` returns a ``list[str]`` of - names, :meth:`edges` returns ``list[Edge]``, and ``graph[v]`` returns a ``list[str]`` of - neighbor names. The richer :class:`~graphworks.vertex.Vertex` and - :class:`~graphworks.edge.Edge` objects are available via :meth:`get_vertex` and - :meth:`get_edge`. - :param label: Human-readable name for this graph. - :type label: str | None + :type label: str :param input_file: Path to a JSON file describing the graph. :type input_file: str | None :param input_graph: JSON string describing the graph. @@ -72,32 +64,41 @@ class Graph: :param input_matrix: Square adjacency matrix (``list[list[int]]``). Non-zero values are treated as edges. :type input_matrix: AdjacencyMatrix | None + :param directed: Whether this graph is directed. Defaults to ``False``. Can be overridden by + the ``"directed"`` key in JSON input. + :type directed: bool + :param weighted: Whether this graph uses weighted edges. Defaults to ``False``. Can be + overridden by the ``"weighted"`` key in JSON input. + :type weighted: bool :raises ValueError: If *input_matrix* is not square, or if edge endpoints in a JSON graph reference vertices that do not exist. """ __slots__ = ( "_label", - "_is_directed", - "_is_weighted", + "_directed", + "_weighted", "_vertices", "_adj", ) - def __init__( + def __init__( # noqa: PLR0913 self, - label: str | None = None, + label: str = "", + *, input_file: str | None = None, input_graph: str | None = None, input_matrix: AdjacencyMatrix | None = None, + directed: bool = False, + weighted: bool = False, ) -> None: """Initialize a :class:`Graph`. - Exactly one of *input_file*, *input_graph*, or *input_matrix* should be provided. If + At most one of *input_file*, *input_graph*, or *input_matrix* should be provided. If none is given an empty graph is created. :param label: Human-readable name for this graph. - :type label: str | None + :type label: str :param input_file: Path to a JSON file describing the graph. :type input_file: str | None :param input_graph: JSON string describing the graph. @@ -105,12 +106,16 @@ def __init__( :param input_matrix: Square adjacency matrix (``list[list[int]]``). Non-zero values are treated as edges. :type input_matrix: AdjacencyMatrix | None + :param directed: Whether this graph is directed. + :type directed: bool + :param weighted: Whether this graph uses weighted edges. + :type weighted: bool :raises ValueError: If *input_matrix* is not square, or if edge endpoints in a JSON graph reference vertices not in the vertex set. """ - self._label: str = label if label is not None else "" - self._is_directed: bool = False - self._is_weighted: bool = False + self._label: str = label + self._directed: bool = directed + self._weighted: bool = weighted self._vertices: dict[str, Vertex] = {} self._adj: dict[str, dict[str, Edge]] = {} @@ -122,33 +127,81 @@ def __init__( self._extract_fields_from_json(json_data) elif input_matrix is not None: if not self._validate_matrix(input_matrix): - msg = "input_matrix is malformed: must be a non-empty square list[list[int]]." + msg = "input_matrix is malformed: must be a non-empty " "square list[list[int]]." 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." + "Graph is invalid: edge endpoints reference vertices that do not exist in the " + "vertex set." ) raise ValueError(msg) # ------------------------------------------------------------------ - # Public interface — vertex access + # Properties — metadata + # ------------------------------------------------------------------ + + @property + def label(self) -> str: + """Human-readable name for this graph. + + :return: Label string (empty string if not set). + :rtype: str + """ + return self._label + + @property + def directed(self) -> bool: + """Whether this graph is directed. + + :return: ``True`` if directed, ``False`` otherwise. + :rtype: bool + """ + return self._directed + + @property + def weighted(self) -> bool: + """Whether this graph was declared as weighted. + + :return: ``True`` if weighted, ``False`` otherwise. + :rtype: bool + """ + return self._weighted + + @property + def order(self) -> int: + """Number of vertices in this graph (|V|). + + :return: Vertex count. + :rtype: int + """ + return len(self._vertices) + + @property + def size(self) -> int: + """Number of edges in this graph (|E|). + + For undirected graphs each edge is counted once. + + :return: Edge count. + :rtype: int + """ + return len(self.edges()) + + # ------------------------------------------------------------------ + # Vertex access # ------------------------------------------------------------------ def vertices(self) -> list[str]: """Return all vertex names in insertion order. - For access to the underlying :class:`~graphworks.vertex.Vertex` - objects, use :meth:`get_vertex` or :meth:`get_vertices`. - :return: Vertex name strings. :rtype: list[str] """ return list(self._vertices) - def get_vertex(self, name: str) -> Vertex | None: + def vertex(self, name: str) -> Vertex | None: """Return the :class:`~graphworks.vertex.Vertex` with *name*, or ``None``. :param name: Vertex name to look up. @@ -158,14 +211,6 @@ def get_vertex(self, name: str) -> Vertex | None: """ return self._vertices.get(name) - def get_vertices(self) -> list[Vertex]: - """Return all :class:`~graphworks.vertex.Vertex` objects in insertion order. - - :return: List of vertex objects. - :rtype: list[Vertex] - """ - return list(self._vertices.values()) - def add_vertex(self, vertex: str | Vertex) -> None: """Add a vertex to the graph if it does not already exist. @@ -190,7 +235,7 @@ def add_vertex(self, vertex: str | Vertex) -> None: self._adj[name] = {} # ------------------------------------------------------------------ - # Public interface — edge access + # Edge access # ------------------------------------------------------------------ def edges(self) -> list[Edge]: @@ -204,11 +249,11 @@ def edges(self) -> list[Edge]: """ return self._collect_edges() - def get_edge(self, source: str, target: str) -> Edge | None: - """Return the :class:`~graphworks.edge.Edge` from *source* to *target*, or ``None``. + def edge(self, source: str, target: str) -> Edge | None: + """Return the :class:`~graphworks.edge.Edge` from *source* to *target*. - For undirected graphs, ``get_edge("A", "B")`` checks both the ``A → B`` and ``B → A`` - slots in the adjacency structure. + For undirected graphs, checks both the ``source → target`` and ``target → source`` slots + in the adjacency structure. :param source: Source vertex name. :type source: str @@ -217,31 +262,29 @@ def get_edge(self, source: str, target: str) -> Edge | None: :return: The edge object, or ``None`` if no such edge exists. :rtype: Edge | None """ - edge = self._adj.get(source, {}).get(target) - if edge is not None: - return edge - if not self._is_directed: + 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 def add_edge( self, - vertex1: str, - vertex2: str, + source: str, + target: str, *, weight: float | None = None, label: str | None = None, ) -> None: - """Add an edge from *vertex1* to *vertex2*. + """Add an edge from *source* to *target*. - Both vertices are created automatically if they do not yet exist. For undirected graphs, - the edge is stored once under ``_adj[vertex1][vertex2]``; the reverse lookup is handled - by :meth:`get_edge` and :meth:`get_neighbors`. + Both vertices are created automatically if they do not yet exist. - :param vertex1: Source vertex name. - :type vertex1: str - :param vertex2: Destination vertex name. - :type vertex2: str + :param source: Source vertex name. + :type source: str + :param target: Destination vertex name. + :type target: str :param weight: Optional numeric weight for the edge. :type weight: float | None :param label: Optional human-readable label for the edge. @@ -249,131 +292,42 @@ def add_edge( :return: Nothing. :rtype: None """ - self.add_vertex(vertex1) - self.add_vertex(vertex2) + self.add_vertex(source) + self.add_vertex(target) edge = Edge( - source=vertex1, - target=vertex2, - directed=self._is_directed, + source=source, + target=target, + directed=self._directed, weight=weight, label=label, ) - self._adj[vertex1][vertex2] = edge - - # ------------------------------------------------------------------ - # Public interface — metadata - # ------------------------------------------------------------------ - - def get_label(self) -> str: - """Return the graph's human-readable label. - - :return: Label string (empty string if not set). - :rtype: str - """ - return self._label - - def set_directed(self, is_directed: bool) -> None: - """Set whether this graph should be treated as directed. - - .. warning:: - Changing directedness on a graph that already contains edges - does **not** retroactively add or remove reverse edges. Use - this only during initial construction. - - :param is_directed: ``True`` for a directed graph, ``False`` for undirected. - :type is_directed: bool - :return: Nothing. - :rtype: None - """ - self._is_directed = is_directed - - def is_directed(self) -> bool: - """Return whether this graph is directed. - - :return: ``True`` if directed, ``False`` otherwise. - :rtype: bool - """ - return self._is_directed - - def is_weighted(self) -> bool: - """Return whether this graph was declared as weighted. - - A graph is weighted when its JSON definition includes ``"weighted": true``. Individual - edges may carry weights regardless of this flag. - - :return: ``True`` if weighted, ``False`` otherwise. - :rtype: bool - """ - return self._is_weighted - - def order(self) -> int: - """Return the number of vertices in this graph. - - :return: Vertex count (|V|). - :rtype: int - """ - return len(self._vertices) - - def size(self) -> int: - """Return the number of edges in this graph. - - For undirected graphs each edge is counted once. - - :return: Edge count (|E|). - :rtype: int - """ - return len(self.edges()) + self._adj[source][target] = edge # ------------------------------------------------------------------ # Neighbour access # ------------------------------------------------------------------ - def get_neighbors(self, v: str) -> list[str]: + def neighbors(self, v: str) -> list[str]: """Return the names of all vertices adjacent to *v*. - For directed graphs this returns out-neighbors only. For undirected graphs both ``_adj[ - v]`` targets and vertices that have *v* as a target are included. + 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. :param v: Vertex name. :type v: str :return: List of adjacent vertex names. :rtype: list[str] """ - return self._neighbor_names(v) - - def get_random_vertex(self) -> str: - """Return a vertex name chosen uniformly at random. - - :return: A random vertex name. - :rtype: str - :raises IndexError: If the graph has no vertices. - """ - return random.choice(self.vertices()) - - # ------------------------------------------------------------------ - # Backward-compatible raw access - # ------------------------------------------------------------------ - - def get_graph(self) -> defaultdict[str, list[str]]: - """Return a ``defaultdict`` adjacency-list view for backward compatibility. - - .. note:: - New code should prefer :meth:`vertices`, :meth:`edges`, :meth:`get_neighbors`, - or direct ``graph[v]`` access. - - :return: Mapping of vertex names to neighbor-name lists. - :rtype: defaultdict[str, list[str]] - """ - result: defaultdict[str, list[str]] = defaultdict(list) - for name in self._vertices: - result[name] = self._neighbor_names(name) - return result + if v not in self._adj: + return [] + return list(self._adj[v]) # ------------------------------------------------------------------ # Matrix representation (stdlib only — no numpy) # ------------------------------------------------------------------ - def get_adjacency_matrix(self) -> AdjacencyMatrix: + 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 @@ -394,7 +348,7 @@ def get_adjacency_matrix(self) -> AdjacencyMatrix: matrix[src_idx][index[tgt]] = 1 return matrix - def vertex_to_matrix_index(self, v: str) -> int: + def vertex_to_index(self, v: str) -> int: """Return the row/column index of vertex *v* in the adjacency matrix. :param v: Vertex name. @@ -405,7 +359,7 @@ def vertex_to_matrix_index(self, v: str) -> int: """ return self.vertices().index(v) - def matrix_index_to_vertex(self, index: int) -> str: + def index_to_vertex(self, index: int) -> str: """Return the vertex name at row/column *index* in the adjacency matrix. :param index: Zero-based matrix index. @@ -421,12 +375,12 @@ def matrix_index_to_vertex(self, index: int) -> str: # ------------------------------------------------------------------ def __repr__(self) -> str: - """Return the graph label as its canonical string representation. + """Return a developer-friendly representation. - :return: Graph label string. + :return: String like ``Graph('my graph', order=5, size=7)``. :rtype: str """ - return self._label + return f"Graph({self._label!r}, order={self.order}, size={self.size})" def __str__(self) -> str: """Return a human-readable adjacency-list view of the graph. @@ -439,8 +393,8 @@ def __str__(self) -> str: """ lines: list[str] = [] for name in sorted(self._vertices): - neighbours = self._neighbor_names(name) - rhs = "".join(neighbours) if neighbours else "0" + nbrs = self.neighbors(name) + rhs = "".join(nbrs) if nbrs else "0" lines.append(f"{name} -> {rhs}") return f"{self._label}\n" + "\n".join(lines) @@ -453,38 +407,40 @@ def __iter__(self) -> Iterator[str]: return iter(self._vertices) def __getitem__(self, node: str) -> list[str]: - """Return neighbour names for *node*, or ``[]`` if absent. + """Return neighbor names for *node*, or ``[]`` if absent. This enables the common ``graph[v]`` idiom used throughout the algorithm modules. :param node: Vertex name. :type node: str - :return: List of neighbour vertex names. + :return: List of neighbor vertex names. :rtype: list[str] """ if node not in self._vertices: return [] - return self._neighbor_names(node) + return self.neighbors(node) - # ------------------------------------------------------------------ - # Protected helpers - # ------------------------------------------------------------------ + def __contains__(self, item: str) -> bool: + """Return ``True`` if *item* is a vertex name in this graph. - def _neighbor_names(self, v: str) -> list[str]: - """Return the neighbour name list for vertex *v*. + :param item: Vertex name to check. + :type item: str + :return: ``True`` if the vertex exists. + :rtype: bool + """ + return item in self._vertices - Returns the names of all vertices that *v* has a direct edge to, as recorded in ``_adj[ - v]``. For undirected graphs, the caller (or the JSON input) is responsible for declaring - both directions of each edge; this method does **not** synthesize reverse edges. + def __len__(self) -> int: + """Return the number of vertices (same as :attr:`order`). - :param v: Vertex name. - :type v: str - :return: Neighbor name strings. - :rtype: list[str] + :return: Vertex count. + :rtype: int """ - if v not in self._adj: - return [] - return list(self._adj[v]) + return len(self._vertices) + + # ------------------------------------------------------------------ + # Protected helpers + # ------------------------------------------------------------------ def _collect_edges(self) -> list[Edge]: """Build and return the full edge list from ``_adj``. @@ -499,7 +455,7 @@ def _collect_edges(self) -> list[Edge]: seen: set[tuple[str, str]] = set() for src, targets in self._adj.items(): for tgt, edge in targets.items(): - if self._is_directed: + if self._directed: edges.append(edge) else: pair = (min(src, tgt), max(src, tgt)) @@ -516,7 +472,7 @@ def _extract_fields_from_json(self, json_data: dict) -> None: 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 after construction. + inconsistency. :param json_data: Parsed JSON representation of the graph. :type json_data: dict @@ -524,22 +480,19 @@ def _extract_fields_from_json(self, json_data: dict) -> None: :rtype: None """ self._label = json_data.get("label", "") - self._is_directed = json_data.get("directed", False) - self._is_weighted = json_data.get("weighted", False) + self._directed = json_data.get("directed", False) + self._weighted = json_data.get("weighted", False) raw_graph: dict[str, list[str]] = json_data.get("graph", {}) - # First pass: create all vertices (only keys — not targets). for name in raw_graph: self.add_vertex(name) - # Second pass: create edges. Target vertices that are not keys - # will be caught by _validate(). for src, targets in raw_graph.items(): for tgt in targets: edge = Edge( source=src, target=tgt, - directed=self._is_directed, + directed=self._directed, ) self._adj[src][tgt] = edge @@ -574,8 +527,7 @@ def _validate_matrix(matrix: AdjacencyMatrix) -> bool: def _matrix_to_graph(self, matrix: AdjacencyMatrix) -> None: """Populate the graph from a stdlib adjacency matrix. - Vertex names are generated as UUID strings to guarantee uniqueness when no external - naming scheme is available. + Vertex names are generated as UUID strings to guarantee uniqueness. :param matrix: Square adjacency matrix where non-zero values denote edges. :type matrix: AdjacencyMatrix @@ -594,6 +546,6 @@ def _matrix_to_graph(self, matrix: AdjacencyMatrix) -> None: edge = Edge( source=names[r_idx], target=names[c_idx], - directed=self._is_directed, + directed=self._directed, ) self._adj[names[r_idx]][names[c_idx]] = edge diff --git a/src/graphworks/vertex.py b/src/graphworks/vertex.py index b83bd30..74617bf 100644 --- a/src/graphworks/vertex.py +++ b/src/graphworks/vertex.py @@ -14,7 +14,7 @@ Immutability ------------ :class:`Vertex` is a **frozen** dataclass with ``__slots__``. Once created, its fields cannot be -reassigned. The *attrs* mapping is exposed as a read-only :class:`~types.MappingProxyType` so +reassigned. The *attrs* mapping is exposed as a read-only :class:`~types.MappingProxyType` so callers cannot mutate it in place either. To "update" a vertex, create a new instance — idiomatic for frozen dataclasses and compatible with use as ``dict`` keys and ``set`` members. """ @@ -25,7 +25,19 @@ from types import MappingProxyType from typing import Any -from graphworks.utilities import _freeze_attrs + +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 {}) @dataclass(frozen=True, slots=True) @@ -51,10 +63,6 @@ class Vertex: default_factory=lambda: MappingProxyType({}), ) - # ------------------------------------------------------------------ - # Alternate constructor - # ------------------------------------------------------------------ - @classmethod def create( cls, @@ -72,8 +80,7 @@ def create( :type name: str :param label: Human-readable display name. :type label: str | None - :param attrs: Mutable attribute dict (will be defensively copied - and frozen). + :param attrs: Mutable attribute dict (will be defensively copied and frozen). :type attrs: dict[str, Any] | None :return: A new :class:`Vertex` instance. :rtype: Vertex @@ -84,10 +91,6 @@ def create( attrs=_freeze_attrs(attrs), ) - # ------------------------------------------------------------------ - # Derived properties - # ------------------------------------------------------------------ - @property def display_name(self) -> str: """Return the label if set, otherwise the name. diff --git a/tests/test_directed.py b/tests/test_directed.py index 937dc36..b4d7a21 100644 --- a/tests/test_directed.py +++ b/tests/test_directed.py @@ -1,13 +1,4 @@ -""" -tests.test_directed -~~~~~~~~~~~~~~~~~~~ - -Unit tests for :mod:`graphworks.algorithms.directed`. - -Covers is_dag and find_circuit (Hierholzer's algorithm). - -:author: Nathan Gilbert -""" +"""Unit tests for :mod:`graphworks.algorithms.directed`.""" from __future__ import annotations @@ -18,56 +9,35 @@ class TestIsDag: - """Tests for is_dag.""" - def test_dag_returns_true(self, directed_dag) -> None: - """A directed acyclic graph returns True.""" assert is_dag(directed_dag) - def test_cyclic_graph_returns_false(self, directed_cycle_graph) -> None: - """A directed graph with a back-edge returns False.""" + def test_cyclic_returns_false(self, directed_cycle_graph) -> None: assert not is_dag(directed_cycle_graph) - def test_undirected_graph_returns_false(self, big_graph) -> None: - """is_dag returns False for undirected graphs.""" + def test_undirected_returns_false(self, big_graph) -> None: assert not is_dag(big_graph) def test_removing_cycle_makes_dag(self, directed_cycle_json) -> None: - """Removing the back-edge from a cyclic graph makes it a DAG.""" - directed_cycle_json["graph"]["D"] = ["E"] # break A→B→D→A - graph = Graph(input_graph=json.dumps(directed_cycle_json)) - assert is_dag(graph) + directed_cycle_json["graph"]["D"] = ["E"] + assert is_dag(Graph(input_graph=json.dumps(directed_cycle_json))) - def test_simple_linear_dag(self) -> None: - """A simple A→B→C chain is a DAG.""" + def test_linear_dag(self) -> None: data = {"directed": True, "graph": {"A": ["B"], "B": ["C"], "C": []}} - graph = Graph(input_graph=json.dumps(data)) - assert is_dag(graph) + assert is_dag(Graph(input_graph=json.dumps(data))) class TestFindCircuit: - """Tests for find_circuit (Hierholzer's algorithm).""" - def test_simple_circuit(self, circuit_graph) -> None: - """Eulerian circuit A→B→C→A is found correctly.""" circuit = find_circuit(circuit_graph) - # Hierholzer may return any valid rotation; check structure - assert len(circuit) == 4 - assert circuit[0] == circuit[-1] # circuit forms a closed loop + assert len(circuit) == 4 and circuit[0] == circuit[-1] - def test_circuit_visits_all_vertices(self, circuit_graph) -> None: - """Every vertex appears in the circuit.""" - circuit = find_circuit(circuit_graph) - assert set(circuit) == {"A", "B", "C"} + def test_visits_all_vertices(self, circuit_graph) -> None: + assert set(find_circuit(circuit_graph)) == {"A", "B", "C"} - def test_empty_graph_returns_empty(self) -> None: - """find_circuit on an empty graph returns an empty list.""" - graph = Graph("empty") - assert find_circuit(graph) == [] + def test_empty_graph(self) -> None: + assert find_circuit(Graph()) == [] - def test_specific_circuit_order(self, circuit_json) -> None: - """The exact Hierholzer circuit for A→B→C→A matches expected order.""" - graph = Graph(input_graph=json.dumps(circuit_json)) - circuit = find_circuit(graph) - expected = ["A", "C", "B", "A"] - assert circuit == expected + def test_specific_order(self, circuit_json) -> None: + circuit = find_circuit(Graph(input_graph=json.dumps(circuit_json))) + assert circuit == ["A", "C", "B", "A"] diff --git a/tests/test_edge.py b/tests/test_edge.py index ca96af7..289c6c3 100644 --- a/tests/test_edge.py +++ b/tests/test_edge.py @@ -1,8 +1,6 @@ """Unit tests for :class:`graphworks.edge.Edge`. -Covers construction (direct and via factory), structural identity (equality and hashing), -the ``has_weight`` and ``is_self_loop`` predicates, the backward-compatible -``vertex1``/``vertex2`` aliases, immutable ``attrs``, and string representations. +:author: Nathan Gilbert """ from __future__ import annotations @@ -13,371 +11,168 @@ from graphworks.edge import Edge -# --------------------------------------------------------------------------- -# Construction — direct instantiation -# --------------------------------------------------------------------------- - class TestEdgeConstruction: """Tests for Edge construction and default values.""" def test_basic_construction(self) -> None: - """An Edge stores source and target correctly.""" e = Edge("a", "b") assert e.source == "a" assert e.target == "b" def test_directed_defaults_to_false(self) -> None: - """The directed flag defaults to False.""" - e = Edge("a", "b") - assert not e.directed + assert not Edge("a", "b").directed def test_weight_defaults_to_none(self) -> None: - """The weight defaults to None.""" - e = Edge("a", "b") - assert e.weight is None + assert Edge("a", "b").weight is None def test_label_defaults_to_none(self) -> None: - """The label defaults to None.""" - e = Edge("a", "b") - assert e.label is None + assert Edge("a", "b").label is None def test_attrs_defaults_to_empty_mapping(self) -> None: - """The attrs field defaults to an empty MappingProxyType.""" e = Edge("a", "b") assert e.attrs == {} assert isinstance(e.attrs, MappingProxyType) def test_explicit_directed(self) -> None: - """The directed flag can be set to True explicitly.""" - e = Edge("a", "b", True) - assert e.directed + assert Edge("a", "b", True).directed def test_explicit_weight(self) -> None: - """A numeric weight is stored and accessible.""" - e = Edge("a", "b", False, 42.5) - assert e.weight == pytest.approx(42.5) + assert Edge("a", "b", False, 42.5).weight == pytest.approx(42.5) def test_explicit_label(self) -> None: - """A label string is stored and accessible.""" - e = Edge("a", "b", label="highway") - assert e.label == "highway" + assert Edge("a", "b", label="highway").label == "highway" def test_explicit_attrs(self) -> None: - """A MappingProxyType attrs is stored and accessible.""" attrs = MappingProxyType({"color": "red", "capacity": 10}) e = Edge("a", "b", attrs=attrs) assert e.attrs["color"] == "red" - assert e.attrs["capacity"] == 10 - - -# --------------------------------------------------------------------------- -# Construction — factory method -# --------------------------------------------------------------------------- class TestEdgeCreateFactory: """Tests for the Edge.create() alternate constructor.""" def test_create_basic(self) -> None: - """Edge.create builds a valid edge with defaults.""" e = Edge.create("x", "y") assert e.source == "x" assert e.target == "y" assert not e.directed assert e.weight is None - assert e.label is None - assert e.attrs == {} - def test_create_with_plain_dict_attrs(self) -> None: - """Edge.create freezes a plain dict into a MappingProxyType.""" + def test_create_freezes_dict(self) -> None: e = Edge.create("x", "y", attrs={"color": "blue"}) - assert e.attrs["color"] == "blue" - assert isinstance(e.attrs, MappingProxyType) - - def test_create_with_none_attrs(self) -> None: - """Edge.create with attrs=None yields an empty mapping.""" - e = Edge.create("x", "y", attrs=None) - assert e.attrs == {} assert isinstance(e.attrs, MappingProxyType) - def test_create_with_all_fields(self) -> None: - """Edge.create accepts all keyword arguments.""" - e = Edge.create( - "a", - "b", - directed=True, - weight=3.14, - label="bridge", - attrs={"toll": 5}, - ) - assert e.source == "a" - assert e.target == "b" - assert e.directed - assert e.weight == pytest.approx(3.14) - assert e.label == "bridge" - assert e.attrs["toll"] == 5 - - def test_create_does_not_mutate_original_dict(self) -> None: - """Mutating the input dict after create() has no effect on the Edge.""" + def test_create_copies_dict(self) -> None: raw = {"key": "original"} e = Edge.create("a", "b", attrs=raw) raw["key"] = "mutated" assert e.attrs["key"] == "original" - -# --------------------------------------------------------------------------- -# Immutability -# --------------------------------------------------------------------------- + def test_create_all_fields(self) -> None: + e = Edge.create("a", "b", directed=True, weight=3.14, label="bridge", attrs={"toll": 5}) + assert e.directed + assert e.weight == pytest.approx(3.14) + assert e.label == "bridge" + assert e.attrs["toll"] == 5 class TestEdgeImmutability: """Tests that Edge instances are truly frozen.""" def test_cannot_set_source(self) -> None: - """Attempting to reassign source raises an error.""" - e = Edge("a", "b") - with pytest.raises(AttributeError): - e.source = "z" # type: ignore[misc] - - def test_cannot_set_weight(self) -> None: - """Attempting to reassign weight raises an error.""" - e = Edge("a", "b") with pytest.raises(AttributeError): - e.weight = 99.0 # type: ignore[misc] + Edge("a", "b").source = "z" # noqa # type: ignore[misc] def test_cannot_mutate_attrs(self) -> None: - """Attempting to set a key on attrs raises a TypeError.""" e = Edge.create("a", "b", attrs={"color": "red"}) with pytest.raises(TypeError): e.attrs["color"] = "blue" # type: ignore[index] - def test_cannot_add_new_attr(self) -> None: - """Attempting to add a new key to attrs raises a TypeError.""" - e = Edge("a", "b") - with pytest.raises(TypeError): - e.attrs["new_key"] = "value" # type: ignore[index] +class TestEdgePredicates: + """Tests for has_weight and is_self_loop.""" -# --------------------------------------------------------------------------- -# Backward-compatible aliases -# --------------------------------------------------------------------------- + def test_no_weight(self) -> None: + assert not Edge("a", "b").has_weight() + def test_has_weight(self) -> None: + assert Edge("a", "b", False, 50.0).has_weight() -class TestEdgeBackwardCompat: - """Tests for vertex1/vertex2 property aliases.""" + def test_zero_weight_counts(self) -> None: + assert Edge("a", "b", False, 0.0).has_weight() - def test_vertex1_aliases_source(self) -> None: - """vertex1 returns the same value as source.""" - e = Edge("a", "b") - assert e.vertex1 == e.source - - def test_vertex2_aliases_target(self) -> None: - """vertex2 returns the same value as target.""" - e = Edge("a", "b") - assert e.vertex2 == e.target - - def test_vertex1_read_only(self) -> None: - """vertex1 cannot be assigned to.""" - e = Edge("a", "b") - with pytest.raises((AttributeError, TypeError)): - e.vertex1 = "z" # type: ignore[misc] - - -# --------------------------------------------------------------------------- -# Predicates -# --------------------------------------------------------------------------- - - -class TestEdgeHasWeight: - """Tests for the has_weight predicate.""" - - def test_no_weight_returns_false(self) -> None: - """has_weight() is False when weight is None.""" - e = Edge("a", "b") - assert not e.has_weight() - - def test_with_weight_returns_true(self) -> None: - """has_weight() is True when a weight is set.""" - e = Edge("a", "b", True, 50.0) - assert e.has_weight() - - def test_zero_weight_returns_true(self) -> None: - """A weight of 0.0 is still considered 'has weight'.""" - e = Edge("a", "b", False, 0.0) - assert e.has_weight() - - def test_negative_weight_returns_true(self) -> None: - """A negative weight is still considered 'has weight'.""" - e = Edge("a", "b", False, -1.5) - assert e.has_weight() - - -class TestEdgeIsSelfLoop: - """Tests for the is_self_loop predicate.""" - - def test_self_loop_true(self) -> None: - """is_self_loop returns True when source equals target.""" - e = Edge("a", "a") - assert e.is_self_loop() + def test_self_loop(self) -> None: + assert Edge("a", "a").is_self_loop() def test_not_self_loop(self) -> None: - """is_self_loop returns False for distinct endpoints.""" - e = Edge("a", "b") - assert not e.is_self_loop() - - def test_directed_self_loop(self) -> None: - """A directed self-loop is still a self-loop.""" - e = Edge("x", "x", directed=True) - assert e.is_self_loop() - - -# --------------------------------------------------------------------------- -# Structural identity — equality -# --------------------------------------------------------------------------- + assert not Edge("a", "b").is_self_loop() class TestEdgeEquality: - """Tests for Edge equality based on structural identity. - - Equality uses ``(source, target, directed)`` only. Weight, label, - and attrs are ignored. - """ + """Tests for structural identity equality.""" def test_equal_edges(self) -> None: - """Two Edges with the same structural triple are equal.""" assert Edge("A", "B") == Edge("A", "B") - def test_equal_ignores_weight(self) -> None: - """Edges with different weights but same structure are equal.""" + def test_ignores_weight(self) -> None: assert Edge("a", "b", False, 1.0) == Edge("a", "b", False, 2.0) - def test_equal_ignores_label(self) -> None: - """Edges with different labels but same structure are equal.""" + def test_ignores_label(self) -> None: assert Edge("a", "b", label="x") == Edge("a", "b", label="y") - def test_equal_ignores_attrs(self) -> None: - """Edges with different attrs but same structure are equal.""" - e1 = Edge.create("a", "b", attrs={"k": 1}) - e2 = Edge.create("a", "b", attrs={"k": 2}) - assert e1 == e2 + def test_ignores_attrs(self) -> None: + assert Edge.create("a", "b", attrs={"k": 1}) == Edge.create("a", "b", attrs={"k": 2}) - def test_direction_matters_for_equality(self) -> None: - """Edge("A","B") != Edge("B","A") due to vertex ordering.""" + def test_endpoint_order_matters(self) -> None: assert Edge("A", "B") != Edge("B", "A") - def test_directed_flag_matters_for_equality(self) -> None: - """Same endpoints but different directed flag are not equal.""" + def test_directed_flag_matters(self) -> None: assert Edge("a", "b", directed=False) != Edge("a", "b", directed=True) def test_not_equal_to_non_edge(self) -> None: - """Comparing an Edge to a non-Edge returns NotImplemented.""" - e = Edge("a", "b") - assert e != "not an edge" - assert e != 42 - assert e != ("a", "b") - - def test_not_equal_to_none(self) -> None: - """Comparing an Edge to None is False.""" - e = Edge("a", "b") - assert e != None # noqa: E711 - - -# --------------------------------------------------------------------------- -# Structural identity — hashing -# --------------------------------------------------------------------------- + assert Edge("a", "b") != "not an edge" class TestEdgeHashing: - """Tests for Edge hash behaviour based on structural identity.""" + """Tests for structural identity hashing.""" def test_equal_edges_same_hash(self) -> None: - """Structurally equal edges have the same hash.""" assert hash(Edge("a", "b")) == hash(Edge("a", "b")) - def test_different_weight_same_hash(self) -> None: - """Edges differing only in weight share a hash.""" - e1 = Edge("a", "b", False, 1.0) - e2 = Edge("a", "b", False, 99.0) - assert hash(e1) == hash(e2) - def test_usable_as_dict_key(self) -> None: - """Edges can serve as dictionary keys.""" - e = Edge("a", "b") - d = {e: "found"} + d = {Edge("a", "b"): "found"} assert d[Edge("a", "b")] == "found" - def test_usable_in_set(self) -> None: - """Duplicate structural edges collapse in a set.""" + def test_deduplication_in_set(self) -> None: s = {Edge("a", "b"), Edge("a", "b"), Edge("a", "b", False, 5.0)} assert len(s) == 1 def test_different_edges_in_set(self) -> None: - """Structurally different edges remain distinct in a set.""" s = {Edge("a", "b"), Edge("b", "a"), Edge("a", "b", directed=True)} assert len(s) == 3 - def test_directed_vs_undirected_different_hash(self) -> None: - """Same endpoints but different directed flag have different hashes.""" - h1 = hash(Edge("a", "b", directed=False)) - h2 = hash(Edge("a", "b", directed=True)) - # Hash collision is theoretically possible but extremely unlikely - assert h1 != h2 - - -# --------------------------------------------------------------------------- -# String representations -# --------------------------------------------------------------------------- - -class TestEdgeRepr: +class TestEdgeDisplay: """Tests for __repr__ and __str__.""" def test_repr_undirected(self) -> None: - """repr of an undirected edge uses '--' arrow.""" - e = Edge("A", "B") - r = repr(e) - assert "A" in r - assert "B" in r - assert "--" in r + assert "--" in repr(Edge("A", "B")) def test_repr_directed(self) -> None: - """repr of a directed edge uses '->' arrow.""" - e = Edge("A", "B", directed=True) - r = repr(e) - assert "->" in r + assert "->" in repr(Edge("A", "B", directed=True)) def test_repr_includes_weight(self) -> None: - """repr includes the weight when present.""" - e = Edge("A", "B", weight=3.5) - assert "3.5" in repr(e) - - def test_repr_includes_label(self) -> None: - """repr includes the label when present.""" - e = Edge("A", "B", label="highway") - assert "highway" in repr(e) - - def test_repr_includes_attrs(self) -> None: - """repr includes attrs when non-empty.""" - e = Edge.create("A", "B", attrs={"color": "red"}) - r = repr(e) - assert "color" in r - assert "red" in r + assert "3.5" in repr(Edge("A", "B", weight=3.5)) def test_repr_minimal(self) -> None: - """repr omits weight, label, attrs when they are defaults.""" - e = Edge("A", "B") - r = repr(e) + r = repr(Edge("A", "B")) assert "weight" not in r assert "label" not in r assert "attrs" not in r def test_str_undirected(self) -> None: - """str of an undirected edge is 'source -- target'.""" - e = Edge("A", "B") - assert str(e) == "A -- B" + assert str(Edge("A", "B")) == "A -- B" def test_str_directed(self) -> None: - """str of a directed edge is 'source -> target'.""" - e = Edge("A", "B", directed=True) - assert str(e) == "A -> B" + assert str(Edge("A", "B", directed=True)) == "A -> B" diff --git a/tests/test_export.py b/tests/test_export.py deleted file mode 100644 index cfb2572..0000000 --- a/tests/test_export.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -tests.test_export -~~~~~~~~~~~~~~~~~ - -Unit and integration tests for :mod:`graphworks.export`. - -Covers save_to_json and save_to_dot (Graphviz .gv output). - -:author: Nathan Gilbert -""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pathlib import Path - -import pytest - -from graphworks.export.graphviz_utils import save_to_dot -from graphworks.export.json_utils import save_to_json -from graphworks.graph import Graph - - -class TestSaveToJson: - """Tests for save_to_json.""" - - def test_output_file_is_created(self, simple_edge_graph, tmp_dir: Path) -> None: - """save_to_json creates a .json file in the output directory.""" - save_to_json(simple_edge_graph, str(tmp_dir)) - out = tmp_dir / f"{simple_edge_graph.get_label()}.json" - assert out.exists() - - def test_output_content_is_valid_json(self, simple_edge_graph, tmp_dir: Path) -> None: - """The written file is valid JSON.""" - save_to_json(simple_edge_graph, str(tmp_dir)) - out = tmp_dir / f"{simple_edge_graph.get_label()}.json" - data = json.loads(out.read_text(encoding="utf-8")) - assert isinstance(data, dict) - - def test_output_contains_label(self, simple_edge_graph, tmp_dir: Path) -> None: - """Serialised JSON includes the graph label.""" - save_to_json(simple_edge_graph, str(tmp_dir)) - out = tmp_dir / f"{simple_edge_graph.get_label()}.json" - data = json.loads(out.read_text(encoding="utf-8")) - assert data["label"] == "my graph" - - def test_output_contains_directed_flag(self, simple_edge_graph, tmp_dir: Path) -> None: - """Serialised JSON includes the directed flag.""" - save_to_json(simple_edge_graph, str(tmp_dir)) - out = tmp_dir / f"{simple_edge_graph.get_label()}.json" - data = json.loads(out.read_text(encoding="utf-8")) - assert "directed" in data - assert data["directed"] is False - - def test_output_matches_expected_string(self, simple_edge_graph, tmp_dir: Path) -> None: - """Serialised output exactly matches expected JSON string.""" - expected = '{"label": "my graph", "directed": false,' ' "graph": {"A": ["B"], "B": []}}' - save_to_json(simple_edge_graph, str(tmp_dir)) - out = tmp_dir / f"{simple_edge_graph.get_label()}.json" - assert out.read_text(encoding="utf-8") == expected - - def test_directed_graph_serialised_correctly(self, tmp_dir: Path) -> None: - """A directed graph serialises with directed=true.""" - data = {"directed": True, "label": "d", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(data)) - save_to_json(graph, str(tmp_dir)) - out = tmp_dir / "d.json" - result = json.loads(out.read_text(encoding="utf-8")) - assert result["directed"] is True - - -class TestSaveToDot: - """Tests for save_to_dot (Graphviz export).""" - - @pytest.fixture(autouse=True) - def _skip_if_no_graphviz(self) -> None: - """Skip the test class if the graphviz package is not installed.""" - pytest.importorskip("graphviz") - - def test_dot_file_is_created(self, simple_edge_graph, tmp_dir: Path) -> None: - """save_to_dot creates a .gv file in the output directory.""" - - save_to_dot(simple_edge_graph, str(tmp_dir)) - # graphviz appends .gv to the path we pass - out = tmp_dir / f"{simple_edge_graph.get_label()}.gv" - assert out.exists() - - def test_dot_content_matches_expected(self, simple_edge_graph, tmp_dir: Path) -> None: - """The .gv file contains the expected Graphviz dot language content.""" - - expected = "// my graph\ngraph {\n\tA [label=A]\n\tA -- B\n\tB [label=B]\n}\n" - save_to_dot(simple_edge_graph, str(tmp_dir)) - out = tmp_dir / f"{simple_edge_graph.get_label()}.gv" - assert out.read_text(encoding="utf-8") == expected - - def test_directed_graph_skipped_by_save_to_dot(self, tmp_dir: Path) -> None: - """save_to_dot silently skips directed graphs (undirected only).""" - - data = {"directed": True, "label": "d", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(data)) - save_to_dot(graph, str(tmp_dir)) - # no file should be produced for directed graphs - assert not (tmp_dir / "d.gv").exists() diff --git a/tests/test_graph.py b/tests/test_graph.py index 2da7ea8..0ee750c 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,9 +1,4 @@ -"""Unit and integration tests for :class:`graphworks.graph.Graph`. - -Covers construction (JSON string, JSON file, adjacency matrix), vertex/edge manipulation, -the stdlib adjacency-matrix interface, validation, iteration, string representations, and the new -first-class :class:`Vertex` / :class:`Edge` access methods. -""" +"""Unit tests for :class:`graphworks.graph.Graph`.""" from __future__ import annotations @@ -20,471 +15,281 @@ from graphworks.vertex import Vertex # --------------------------------------------------------------------------- -# Helpers +# Label, repr, and str # --------------------------------------------------------------------------- -def _edge_pairs(graph: Graph) -> list[tuple[str, str]]: - """Return the edges of *graph* as ``(source, target)`` tuples. - - :param graph: The graph whose edges to extract. - :type graph: Graph - :return: List of ``(source, target)`` pairs. - :rtype: list[tuple[str, str]] - """ - return [(e.source, e.target) for e in graph.edges()] - - -# --------------------------------------------------------------------------- -# Label, repr, and str -# --------------------------------------------------------------------------- +class TestGraphMetadata: + def test_label_from_positional_arg(self) -> None: + assert Graph("my graph").label == "my graph" + def test_label_defaults_to_empty(self) -> None: + assert Graph().label == "" -class TestGraphLabel: - """Tests for graph label, repr, and str behaviour.""" + def test_directed_default(self) -> None: + assert not Graph().directed - def test_label_from_positional_arg(self) -> None: - """Graph label is stored and returned correctly.""" - graph = Graph("my graph") - assert graph.get_label() == "my graph" + def test_directed_from_constructor(self) -> None: + assert Graph(directed=True).directed - def test_label_defaults_to_empty_string(self) -> None: - """Constructing without a label yields an empty string.""" - graph = Graph() - assert graph.get_label() == "" + def test_weighted_default(self) -> None: + assert not Graph().weighted - def test_repr_returns_label(self) -> None: - """repr() of a graph is its label.""" - graph = Graph("demo") - assert repr(graph) == "demo" + def test_repr(self) -> None: + g = Graph("demo") + g.add_vertex("A") + assert "demo" in repr(g) + assert "order=1" in repr(g) def test_str_shows_adjacency_list(self, simple_edge_json) -> None: - """str() renders a labelled, sorted adjacency list.""" - expected = "my graph\nA -> B\nB -> 0" graph = Graph(input_graph=json.dumps(simple_edge_json)) - assert str(graph) == expected + assert str(graph) == "my graph\nA -> B\nB -> 0" def test_str_empty_vertex_shows_zero(self) -> None: - """Vertices with no neighbours render as '-> 0'.""" graph = Graph("g") graph.add_vertex("X") assert "X -> 0" in str(graph) - def test_str_multiple_vertices_sorted(self) -> None: - """str() renders vertices in sorted order.""" + def test_str_sorted(self) -> None: data = {"label": "g", "graph": {"B": ["A"], "A": []}} - graph = Graph(input_graph=json.dumps(data)) - lines = str(graph).splitlines() - # First line is label; vertex lines must be sorted + lines = str(Graph(input_graph=json.dumps(data))).splitlines() assert lines[1].startswith("A") assert lines[2].startswith("B") # --------------------------------------------------------------------------- -# Construction — JSON string +# Construction — JSON # --------------------------------------------------------------------------- -class TestGraphJsonConstruction: - """Tests for building a Graph from a JSON string.""" - +class TestJsonConstruction: def test_label_parsed(self, simple_edge_json) -> None: - """JSON 'label' key is correctly stored.""" - graph = Graph(input_graph=json.dumps(simple_edge_json)) - assert graph.get_label() == "my graph" - - def test_undirected_flag_default(self, simple_edge_json) -> None: - """Graph without 'directed' key is undirected by default.""" - graph = Graph(input_graph=json.dumps(simple_edge_json)) - assert not graph.is_directed() - - def test_adjacency_list_stored(self, simple_edge_json) -> None: - """get_graph() returns the raw adjacency dict from the JSON.""" - graph = Graph(input_graph=json.dumps(simple_edge_json)) - assert graph.get_graph() == simple_edge_json["graph"] + assert Graph(input_graph=json.dumps(simple_edge_json)).label == "my graph" - def test_edge_is_produced(self, simple_edge_json) -> None: - """One edge A→B is produced from the JSON definition.""" - graph = Graph(input_graph=json.dumps(simple_edge_json)) - pairs = _edge_pairs(graph) - assert len(pairs) == 1 - assert pairs[0] == ("A", "B") + def test_undirected_default(self, simple_edge_json) -> None: + assert not Graph(input_graph=json.dumps(simple_edge_json)).directed - def test_directed_flag_parsed(self) -> None: - """'directed' key in JSON sets the directed flag.""" + def test_directed_parsed(self) -> None: data = {"directed": True, "graph": {"X": ["Y"], "Y": []}} - graph = Graph(input_graph=json.dumps(data)) - assert graph.is_directed() + assert Graph(input_graph=json.dumps(data)).directed - def test_weighted_flag_parsed(self) -> None: - """'weighted' key in JSON sets the weighted flag.""" + def test_weighted_parsed(self) -> None: data = {"weighted": True, "graph": {"X": [], "Y": []}} - graph = Graph(input_graph=json.dumps(data)) - assert graph.is_weighted() + assert Graph(input_graph=json.dumps(data)).weighted - def test_missing_label_defaults_to_empty(self) -> None: - """JSON without 'label' key uses empty string as label.""" - data = {"graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(data)) - assert graph.get_label() == "" + def test_edge_produced(self, simple_edge_json) -> None: + graph = Graph(input_graph=json.dumps(simple_edge_json)) + edges = graph.edges() + assert len(edges) == 1 + assert edges[0].source == "A" + assert edges[0].target == "B" - def test_invalid_edge_raises_value_error(self) -> None: - """Edge referencing a missing vertex raises ValueError.""" + def test_invalid_edge_raises(self) -> None: bad = {"graph": {"A": ["B", "C", "D"], "B": []}} with pytest.raises(ValueError): # noqa: PT011 Graph(input_graph=json.dumps(bad)) -# --------------------------------------------------------------------------- -# Construction — JSON file -# --------------------------------------------------------------------------- - - -class TestGraphFileConstruction: - """Tests for building a Graph from a JSON file.""" - +class TestFileConstruction: def test_read_from_file(self, tmp_dir: Path, simple_edge_json) -> None: - """Graph is correctly loaded from a JSON file on disk.""" - file_path = tmp_dir / "g.json" - file_path.write_text(json.dumps(simple_edge_json), encoding="utf-8") - graph = Graph(input_file=str(file_path)) - assert graph.get_label() == "my graph" - assert not graph.is_directed() - assert graph.get_graph() == simple_edge_json["graph"] - - def test_file_vertices_match(self, tmp_dir: Path, simple_edge_json) -> None: - """Vertices loaded from file match the JSON definition.""" - file_path = tmp_dir / "g.json" - file_path.write_text(json.dumps(simple_edge_json), encoding="utf-8") - graph = Graph(input_file=str(file_path)) - assert set(graph.vertices()) == {"A", "B"} + path = tmp_dir / "g.json" + path.write_text(json.dumps(simple_edge_json), encoding="utf-8") + graph = Graph(input_file=str(path)) + assert graph.label == "my graph" + assert graph.order == 2 # --------------------------------------------------------------------------- -# Construction — stdlib adjacency matrix +# Construction — matrix # --------------------------------------------------------------------------- -class TestGraphMatrixConstruction: - """Tests for building a Graph from a stdlib adjacency matrix.""" - - def test_simple_two_by_two_matrix(self) -> None: - """A 2x2 symmetric matrix yields one undirected edge.""" - matrix = [[0, 1], [1, 0]] - graph = Graph(input_matrix=matrix) - assert len(graph.vertices()) == 2 - assert len(graph.edges()) == 1 +class TestMatrixConstruction: + def test_symmetric_matrix(self) -> None: + graph = Graph(input_matrix=[[0, 1], [1, 0]]) + assert graph.order == 2 + assert graph.size == 1 - def test_zero_matrix_no_edges(self) -> None: - """A zero matrix produces no edges.""" - matrix = [[0, 0], [0, 0]] - graph = Graph(input_matrix=matrix) - assert len(graph.edges()) == 0 + def test_zero_matrix(self) -> None: + assert Graph(input_matrix=[[0, 0], [0, 0]]).size == 0 - def test_non_square_raises_value_error(self) -> None: - """A non-square matrix raises ValueError.""" + def test_non_square_raises(self) -> None: with pytest.raises(ValueError): # noqa: PT011 Graph(input_matrix=[[0, 1, 0], [1, 0]]) - def test_wrong_row_count_raises_value_error(self) -> None: - """A matrix where row count != column count raises ValueError.""" - with pytest.raises(ValueError): # noqa: PT011 - Graph(input_matrix=[[0, 1], [1, 0], [1, 0]]) - - def test_empty_matrix_raises_value_error(self) -> None: - """An empty matrix raises ValueError.""" + def test_empty_raises(self) -> None: with pytest.raises(ValueError): # noqa: PT011 Graph(input_matrix=[]) - def test_vertices_are_uuid_strings(self) -> None: - """Matrix-constructed graphs use UUID strings as vertex names.""" + def test_uuid_vertex_names(self) -> None: graph = Graph(input_matrix=[[0, 1], [1, 0]]) - # UUIDs are 36 characters long assert all(len(v) == 36 for v in graph.vertices()) # --------------------------------------------------------------------------- -# Vertex and edge manipulation +# Vertex manipulation # --------------------------------------------------------------------------- -class TestVertexEdgeManipulation: - """Tests for add_vertex, add_edge, vertices(), edges(), order(), size().""" +class TestVertexManipulation: + def test_add_vertex_string(self) -> None: + g = Graph() + g.add_vertex("A") + assert g.vertices() == ["A"] - def test_add_single_vertex(self) -> None: - """Adding a single vertex is reflected in vertices().""" - graph = Graph("g") - graph.add_vertex("A") - assert graph.vertices() == ["A"] + def test_add_vertex_object(self) -> None: + g = Graph() + g.add_vertex(Vertex.create("hub", label="Central", attrs={"rank": 1})) + v = g.vertex("hub") + assert v is not None + assert v.label == "Central" - def test_add_duplicate_vertex_is_idempotent(self) -> None: - """Adding a vertex that already exists does not duplicate it.""" - graph = Graph("g") - graph.add_vertex("A") - graph.add_vertex("A") - assert graph.vertices().count("A") == 1 + def test_duplicate_vertex_idempotent(self) -> None: + g = Graph() + g.add_vertex("A") + g.add_vertex("A") + assert g.vertices().count("A") == 1 - def test_add_edge_between_existing_vertices(self) -> None: - """add_edge creates one edge between two pre-existing vertices.""" - graph = Graph("g") - graph.add_vertex("A") - graph.add_vertex("B") - graph.add_edge("A", "B") - assert len(graph.edges()) == 1 + def test_vertex_lookup(self) -> None: + g = Graph() + g.add_vertex("A") + assert g.vertex("A") is not None + assert g.vertex("Z") is None - def test_add_edge_creates_missing_vertices(self) -> None: - """add_edge auto-creates vertices that do not yet exist.""" - graph = Graph("g") - graph.add_edge("X", "Y") - assert len(graph.edges()) == 1 - assert len(graph.vertices()) == 2 + def test_contains(self) -> None: + g = Graph() + g.add_vertex("A") + assert "A" in g + assert "Z" not in g - def test_multiple_edges(self) -> None: - """Multiple add_edge calls accumulate correctly.""" - graph = Graph("g") - graph.add_vertex("A") - graph.add_vertex("B") - graph.add_edge("A", "B") - graph.add_edge("X", "Y") - assert len(graph.edges()) == 2 - assert len(graph.vertices()) == 4 - - def test_order_and_size(self, simple_edge_graph) -> None: - """order() and size() return vertex and edge counts.""" - assert simple_edge_graph.order() == 2 - assert simple_edge_graph.size() == 1 - - def test_get_neighbors_populated(self, simple_edge_graph) -> None: - """get_neighbors returns the correct neighbour list.""" - assert simple_edge_graph.get_neighbors("A") == ["B"] - - def test_get_neighbors_empty(self, simple_edge_graph) -> None: - """get_neighbors returns [] for a vertex with no out-edges.""" - assert simple_edge_graph.get_neighbors("B") == [] - - def test_get_random_vertex_is_in_graph(self, big_graph) -> None: - """get_random_vertex returns a vertex that exists in the graph.""" - v = big_graph.get_random_vertex() - assert v in big_graph.vertices() - - def test_set_directed(self) -> None: - """set_directed toggles the is_directed flag.""" - graph = Graph("g") - graph.add_vertex("A") - assert not graph.is_directed() - graph.set_directed(True) - assert graph.is_directed() - graph.set_directed(False) - assert not graph.is_directed() + def test_len(self) -> None: + g = Graph() + g.add_vertex("A") + g.add_vertex("B") + assert len(g) == 2 # --------------------------------------------------------------------------- -# First-class Vertex and Edge access +# Edge manipulation # --------------------------------------------------------------------------- -class TestFirstClassAccess: - """Tests for get_vertex, get_vertices, get_edge, and Vertex-based add_vertex.""" - - def test_get_vertex_returns_vertex_object(self) -> None: - """get_vertex returns a Vertex with the correct name.""" - graph = Graph("g") - graph.add_vertex("A") - v = graph.get_vertex("A") - assert v is not None - assert v.name == "A" - - def test_get_vertex_missing_returns_none(self) -> None: - """get_vertex returns None for a vertex not in the graph.""" - graph = Graph("g") - assert graph.get_vertex("Z") is None - - def test_get_vertices_returns_all_objects(self) -> None: - """get_vertices returns Vertex objects for every vertex.""" - data = {"graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(data)) - verts = graph.get_vertices() - assert len(verts) == 2 - assert all(isinstance(v, Vertex) for v in verts) - assert {v.name for v in verts} == {"A", "B"} - - def test_add_vertex_with_vertex_object(self) -> None: - """add_vertex accepts a Vertex instance directly.""" - graph = Graph("g") - v = Vertex.create("hub", label="Central Hub", attrs={"rank": 1}) - graph.add_vertex(v) - retrieved = graph.get_vertex("hub") - assert retrieved is not None - assert retrieved.label == "Central Hub" - assert retrieved.attrs["rank"] == 1 - - def test_add_vertex_object_idempotent(self) -> None: - """Adding a Vertex with a name that already exists is a no-op.""" - graph = Graph("g") - graph.add_vertex(Vertex("A", label="first")) - graph.add_vertex(Vertex("A", label="second")) - v = graph.get_vertex("A") - assert v is not None - assert v.label == "first" - - def test_get_edge_returns_edge_object(self) -> None: - """get_edge returns the Edge stored between two vertices.""" - graph = Graph("g") - graph.add_edge("A", "B") - e = graph.get_edge("A", "B") - assert e is not None - assert e.source == "A" - assert e.target == "B" - - def test_get_edge_undirected_reverse_lookup(self) -> None: - """get_edge on an undirected graph finds edge in both directions.""" - graph = Graph("g") - graph.add_edge("A", "B") - assert graph.get_edge("B", "A") is not None +class TestEdgeManipulation: + def test_add_edge(self) -> None: + g = Graph() + g.add_edge("A", "B") + assert g.size == 1 + assert g.order == 2 - def test_get_edge_directed_no_reverse(self) -> None: - """get_edge on a directed graph does not find the reverse edge.""" - data = {"directed": True, "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(data)) - assert graph.get_edge("A", "B") is not None - assert graph.get_edge("B", "A") is None - - def test_get_edge_missing_returns_none(self) -> None: - """get_edge returns None when no edge exists.""" - graph = Graph("g") - graph.add_vertex("A") - graph.add_vertex("B") - assert graph.get_edge("A", "B") is None + def test_add_edge_auto_creates_vertices(self) -> None: + g = Graph() + g.add_edge("X", "Y") + assert set(g.vertices()) == {"X", "Y"} def test_add_edge_with_weight(self) -> None: - """add_edge stores a weight on the created Edge.""" - graph = Graph("g") - graph.add_edge("A", "B", weight=3.5) - e = graph.get_edge("A", "B") + g = Graph() + g.add_edge("A", "B", weight=3.5) + e = g.edge("A", "B") assert e is not None assert e.weight == pytest.approx(3.5) def test_add_edge_with_label(self) -> None: - """add_edge stores a label on the created Edge.""" - graph = Graph("g") - graph.add_edge("A", "B", label="highway") - e = graph.get_edge("A", "B") + g = Graph() + g.add_edge("A", "B", label="highway") + e = g.edge("A", "B") assert e is not None assert e.label == "highway" + def test_edge_lookup(self) -> None: + g = Graph() + g.add_edge("A", "B") + assert g.edge("A", "B") is not None + + def test_edge_undirected_reverse_lookup(self) -> None: + g = Graph() + g.add_edge("A", "B") + assert g.edge("B", "A") is not None + + def test_edge_directed_no_reverse(self) -> None: + data = {"directed": True, "graph": {"A": ["B"], "B": []}} + g = Graph(input_graph=json.dumps(data)) + assert g.edge("A", "B") is not None + assert g.edge("B", "A") is None + + def test_edge_missing(self) -> None: + g = Graph() + g.add_vertex("A") + g.add_vertex("B") + assert g.edge("A", "B") is None + def test_edges_return_edge_objects(self, simple_edge_graph) -> None: - """edges() returns actual Edge instances.""" - edge_list = simple_edge_graph.edges() - assert len(edge_list) == 1 - e = edge_list[0] - assert isinstance(e, Edge) - assert e.source == "A" - assert e.target == "B" + edges = simple_edge_graph.edges() + assert len(edges) == 1 + assert isinstance(edges[0], Edge) # --------------------------------------------------------------------------- -# Adjacency matrix interface (stdlib only) +# Properties # --------------------------------------------------------------------------- -class TestAdjacencyMatrix: - """Tests for the stdlib adjacency matrix interface.""" - - def test_values_for_simple_edge(self, simple_edge_graph) -> None: - """Matrix has 1 for A->B and 0 elsewhere.""" - matrix = simple_edge_graph.get_adjacency_matrix() - assert matrix == [[0, 1], [0, 0]] +class TestGraphProperties: + def test_order(self, simple_edge_graph) -> None: + assert simple_edge_graph.order == 2 - def test_matrix_is_square(self, big_graph) -> None: - """Adjacency matrix dimensions equal the vertex count.""" - n = big_graph.order() - matrix = big_graph.get_adjacency_matrix() - assert len(matrix) == n - assert all(len(row) == n for row in matrix) + def test_size(self, simple_edge_graph) -> None: + assert simple_edge_graph.size == 1 - def test_vertex_index_roundtrip(self, simple_edge_graph) -> None: - """vertex_to_matrix_index and matrix_index_to_vertex are inverses.""" - for v in simple_edge_graph.vertices(): - idx = simple_edge_graph.vertex_to_matrix_index(v) - assert simple_edge_graph.matrix_index_to_vertex(idx) == v + def test_neighbors(self, simple_edge_graph) -> None: + assert simple_edge_graph.neighbors("A") == ["B"] + assert simple_edge_graph.neighbors("B") == [] - def test_directed_graph_matrix_asymmetric(self) -> None: - """A directed graph produces an asymmetric adjacency matrix.""" - data = {"directed": True, "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(data)) - matrix = graph.get_adjacency_matrix() - # A->B exists (1) but B->A does not (0) - assert matrix[0][1] == 1 - assert matrix[1][0] == 0 + def test_neighbors_missing_vertex(self) -> None: + assert Graph().neighbors("Z") == [] # --------------------------------------------------------------------------- -# Iteration protocol +# Adjacency matrix # --------------------------------------------------------------------------- -class TestGraphIteration: - """Tests for __iter__ and __getitem__.""" - - def test_iter_visits_all_vertices(self) -> None: - """Iterating over a graph yields every vertex exactly once.""" - data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} - graph = Graph(input_graph=json.dumps(data)) - assert sorted(graph) == ["A", "B", "C", "D"] - - def test_iter_count(self) -> None: - """Number of iterations equals the number of vertices.""" - data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} - graph = Graph(input_graph=json.dumps(data)) - assert sum(1 for _ in graph) == 4 +class TestAdjacencyMatrix: + def test_values(self, simple_edge_graph) -> None: + assert simple_edge_graph.adjacency_matrix() == [[0, 1], [0, 0]] - def test_iter_yields_correct_neighbour_counts(self) -> None: - """Neighbour lists obtained via iteration have correct lengths.""" - data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} - graph = Graph(input_graph=json.dumps(data)) - counts = {key: len(graph[key]) for key in graph} - assert counts["A"] == 3 - assert counts["B"] == 0 + def test_square(self, big_graph) -> None: + n = big_graph.order + matrix = big_graph.adjacency_matrix() + assert len(matrix) == n + assert all(len(row) == n for row in matrix) - def test_getitem_returns_neighbours(self) -> None: - """graph[vertex] returns the neighbour list.""" - data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} - graph = Graph(input_graph=json.dumps(data)) - assert len(graph["A"]) == 3 - assert graph["B"] == [] + def test_index_roundtrip(self, simple_edge_graph) -> None: + for v in simple_edge_graph.vertices(): + idx = simple_edge_graph.vertex_to_index(v) + assert simple_edge_graph.index_to_vertex(idx) == v - def test_getitem_missing_vertex_returns_empty(self) -> None: - """graph[missing] returns an empty list rather than raising.""" - graph = Graph("g") - assert graph["MISSING"] == [] + def test_directed_asymmetric(self) -> None: + data = {"directed": True, "graph": {"A": ["B"], "B": []}} + matrix = Graph(input_graph=json.dumps(data)).adjacency_matrix() + assert matrix[0][1] == 1 + assert matrix[1][0] == 0 # --------------------------------------------------------------------------- -# Backward compatibility — get_graph() +# Iteration # --------------------------------------------------------------------------- -class TestGetGraphCompat: - """Tests that get_graph() returns the expected defaultdict structure.""" - - def test_get_graph_matches_json_input(self, simple_edge_json) -> None: - """get_graph() returns the same structure as the original JSON.""" - graph = Graph(input_graph=json.dumps(simple_edge_json)) - result = graph.get_graph() - assert dict(result) == simple_edge_json["graph"] +class TestIteration: + def test_iter_all_vertices(self) -> None: + data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} + assert sorted(Graph(input_graph=json.dumps(data))) == ["A", "B", "C", "D"] - def test_get_graph_is_defaultdict(self, simple_edge_json) -> None: - """get_graph() returns a defaultdict for backward compat.""" - from collections import defaultdict + 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"] == [] - graph = Graph(input_graph=json.dumps(simple_edge_json)) - result = graph.get_graph() - assert isinstance(result, defaultdict) - - def test_get_graph_undirected_neighbors_symmetric(self) -> None: - """For an undirected graph, get_graph reflects both directions.""" - data = {"graph": {"A": ["B"], "B": ["A"]}} - graph = Graph(input_graph=json.dumps(data)) - gg = graph.get_graph() - assert "B" in gg["A"] - assert "A" in gg["B"] + def test_getitem_missing(self) -> None: + assert Graph()["MISSING"] == [] diff --git a/tests/test_numpy_compat.py b/tests/test_numpy_compat.py index 637fabb..1faf210 100644 --- a/tests/test_numpy_compat.py +++ b/tests/test_numpy_compat.py @@ -16,8 +16,6 @@ from __future__ import annotations -import json - import pytest numpy = pytest.importorskip("numpy", reason="numpy not installed — skipping matrix tests") @@ -33,7 +31,7 @@ class TestNdarrayToMatrix: def test_basic_conversion(self) -> None: """A simple 2×2 ndarray converts to the expected list-of-lists. - :return: None + :return: Nothing :rtype: None """ arr = np.array([[0, 1], [1, 0]]) @@ -43,7 +41,7 @@ def test_basic_conversion(self) -> None: def test_nonzero_values_coerced_to_one(self) -> None: """Values greater than 0 are coerced to 1. - :return: None + :return: Nothing :rtype: None """ arr = np.array([[0, 5], [2, 0]]) @@ -53,7 +51,7 @@ def test_nonzero_values_coerced_to_one(self) -> None: def test_zero_values_remain_zero(self) -> None: """Zero values in the ndarray produce 0 in the matrix. - :return: None + :return: Nothing :rtype: None """ arr = np.zeros((3, 3), dtype=int) @@ -63,7 +61,7 @@ def test_zero_values_remain_zero(self) -> None: def test_non_square_raises_value_error(self) -> None: """A non-square ndarray raises ValueError. - :return: None + :return: Nothing :rtype: None """ arr = np.array([[0, 1, 0, 0, 0], [1, 0]], dtype=object) @@ -73,7 +71,7 @@ def test_non_square_raises_value_error(self) -> None: def test_three_dimensional_raises_value_error(self) -> None: """A 3-D ndarray raises ValueError (must be 2-D). - :return: None + :return: Nothing :rtype: None """ arr = np.zeros((2, 2, 2)) @@ -83,7 +81,7 @@ def test_three_dimensional_raises_value_error(self) -> None: def test_result_is_list_of_lists(self) -> None: """The returned value is a ``list[list[int]]``, not an ndarray. - :return: None + :return: Nothing :rtype: None """ arr = np.eye(2, dtype=int) @@ -98,7 +96,7 @@ class TestMatrixToNdarray: def test_basic_conversion(self) -> None: """A list-of-lists converts to the expected numpy array. - :return: None + :return: Nothing :rtype: None """ matrix = [[0, 1], [1, 0]] @@ -108,7 +106,7 @@ def test_basic_conversion(self) -> None: def test_dtype_is_integer(self) -> None: """The returned array has an integer dtype. - :return: None + :return: Nothing :rtype: None """ result = matrix_to_ndarray([[0, 1], [1, 0]]) @@ -117,7 +115,7 @@ def test_dtype_is_integer(self) -> None: def test_zeros_matrix(self) -> None: """An all-zeros matrix converts without modification. - :return: None + :return: Nothing :rtype: None """ matrix = [[0, 0], [0, 0]] @@ -127,7 +125,7 @@ def test_zeros_matrix(self) -> None: def test_result_is_ndarray(self) -> None: """The returned value is a numpy ndarray. - :return: None + :return: Nothing :rtype: None """ result = matrix_to_ndarray([[0, 1], [1, 0]]) @@ -136,7 +134,7 @@ def test_result_is_ndarray(self) -> None: def test_shape_preserved(self) -> None: """The shape of the output array matches the input matrix dimensions. - :return: None + :return: Nothing :rtype: None """ matrix = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] @@ -150,7 +148,7 @@ class TestGraphNumpyIntegration: def test_graph_from_ndarray_via_compat(self) -> None: """Graph built from an ndarray (via ndarray_to_matrix) is valid. - :return: None + :return: Nothing :rtype: None """ arr = np.array([[0, 1], [1, 0]], dtype=object) @@ -159,22 +157,10 @@ def test_graph_from_ndarray_via_compat(self) -> None: assert len(graph.vertices()) == 2 assert len(graph.edges()) == 1 - def test_adjacency_matrix_roundtrip(self) -> None: - """get_adjacency_matrix → matrix_to_ndarray preserves structure. - - :return: None - :rtype: None - """ - data = {"graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(data)) - stdlib_matrix = graph.get_adjacency_matrix() - arr = matrix_to_ndarray(stdlib_matrix) - np.testing.assert_array_equal(arr, np.array([[0, 1], [0, 0]])) - def test_symmetric_matrix_roundtrip(self) -> None: """A symmetric adjacency matrix survives a full ndarray roundtrip. - :return: None + :return: Nothing :rtype: None """ original = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] @@ -185,7 +171,7 @@ def test_symmetric_matrix_roundtrip(self) -> None: def test_graph_order_from_ndarray(self) -> None: """A 4×4 ndarray produces a graph with 4 vertices. - :return: None + :return: Nothing :rtype: None """ arr = np.zeros((4, 4), dtype=int) @@ -193,4 +179,4 @@ def test_graph_order_from_ndarray(self) -> None: arr[1, 0] = 1 matrix = ndarray_to_matrix(arr) graph = Graph(input_matrix=matrix) - assert graph.order() == 4 + assert graph.order == 4 diff --git a/tests/test_paths.py b/tests/test_paths.py index 771ae4a..ea365ed 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -1,13 +1,4 @@ -""" -tests.test_paths -~~~~~~~~~~~~~~~~ - -Unit tests for :mod:`graphworks.algorithms.paths`. - -Covers generate_edges, find_isolated_vertices, find_path, and find_all_paths. - -:author: Nathan Gilbert -""" +"""Unit tests for :mod:`graphworks.algorithms.paths`.""" from __future__ import annotations @@ -25,54 +16,33 @@ class TestGenerateEdges: - """Tests for generate_edges.""" - - def test_single_edge_graph(self) -> None: - """generate_edges returns one edge for a one-edge graph.""" + def test_single_edge(self) -> None: data = {"graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(data)) - assert len(generate_edges(graph)) == 1 + assert len(generate_edges(Graph(input_graph=json.dumps(data)))) == 1 - def test_no_edge_graph(self, isolated_graph) -> None: - """generate_edges returns an empty list for an isolated graph.""" + def test_no_edges(self, isolated_graph) -> None: assert generate_edges(isolated_graph) == [] def test_matches_graph_edges(self, big_graph) -> None: - """generate_edges output matches graph.edges().""" assert generate_edges(big_graph) == big_graph.edges() class TestFindIsolatedVertices: - """Tests for find_isolated_vertices.""" - def test_all_isolated(self, isolated_graph) -> None: - """Every vertex is isolated when there are no edges.""" - isolated = find_isolated_vertices(isolated_graph) - assert sorted(isolated) == ["a", "b", "c"] + assert sorted(find_isolated_vertices(isolated_graph)) == ["a", "b", "c"] - def test_partial_isolation(self) -> None: - """Only the vertex with no neighbours is returned as isolated.""" + def test_partial(self) -> None: data = {"graph": {"A": ["B"], "B": ["A"], "C": []}} - graph = Graph(input_graph=json.dumps(data)) - assert find_isolated_vertices(graph) == ["C"] + assert find_isolated_vertices(Graph(input_graph=json.dumps(data))) == ["C"] - def test_no_isolated_vertices(self, big_graph) -> None: - """A fully connected graph has no isolated vertices.""" + def test_none_isolated(self, big_graph) -> None: assert find_isolated_vertices(big_graph) == [] class TestFindPath: - """Tests for find_path.""" - @pytest.fixture def path_graph(self) -> Graph: - """Graph used for path-finding tests. - - :return: Constructed Graph. - :rtype: Graph - """ data = { - "label": "test", "directed": False, "graph": { "a": ["d"], @@ -86,38 +56,22 @@ def path_graph(self) -> Graph: return Graph(input_graph=json.dumps(data)) def test_path_exists(self, path_graph) -> None: - """find_path returns a valid path when one exists.""" - path = find_path(path_graph, "a", "b") - assert path == ["a", "d", "c", "b"] + assert find_path(path_graph, "a", "b") == ["a", "d", "c", "b"] - def test_no_path_to_isolated_vertex(self, path_graph) -> None: - """find_path returns [] when the destination is unreachable.""" - path = find_path(path_graph, "a", "f") - assert path == [] + def test_no_path(self, path_graph) -> None: + assert find_path(path_graph, "a", "f") == [] - def test_same_start_and_end(self, path_graph) -> None: - """find_path returns [vertex] when start equals end.""" - path = find_path(path_graph, "c", "c") - assert path == ["c"] + def test_same_start_end(self, path_graph) -> None: + assert find_path(path_graph, "c", "c") == ["c"] - def test_missing_start_vertex(self, path_graph) -> None: - """find_path returns [] when the start vertex is not in the graph.""" - path = find_path(path_graph, "z", "a") - assert path == [] + def test_missing_start(self, path_graph) -> None: + assert find_path(path_graph, "z", "a") == [] class TestFindAllPaths: - """Tests for find_all_paths.""" - @pytest.fixture def multi_path_graph(self) -> Graph: - """Graph with multiple paths between vertices. - - :return: Constructed Graph. - :rtype: Graph - """ data = { - "label": "test2", "directed": False, "graph": { "a": ["d", "f"], @@ -131,15 +85,11 @@ def multi_path_graph(self) -> Graph: return Graph(input_graph=json.dumps(data)) def test_multiple_paths(self, multi_path_graph) -> None: - """find_all_paths returns all simple paths between two vertices.""" paths = find_all_paths(multi_path_graph, "a", "b") assert paths == [["a", "d", "c", "b"], ["a", "f", "d", "c", "b"]] - def test_missing_start_returns_empty(self, multi_path_graph) -> None: - """find_all_paths returns [] when the start vertex is absent.""" + def test_missing_start(self, multi_path_graph) -> None: assert find_all_paths(multi_path_graph, "z", "b") == [] - def test_same_start_and_end(self, multi_path_graph) -> None: - """find_all_paths([v, v]) returns a single path containing only v.""" - paths = find_all_paths(multi_path_graph, "a", "a") - assert paths == [["a"]] + def test_same_start_end(self, multi_path_graph) -> None: + assert find_all_paths(multi_path_graph, "a", "a") == [["a"]] diff --git a/tests/test_properties.py b/tests/test_properties.py index 3b0a03e..e2f3876 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,29 +1,4 @@ -""" -tests.test_properties -~~~~~~~~~~~~~~~~~~~~~ - -Unit tests for :mod:`graphworks.algorithms.properties`. - -Covers degree helpers, sequence predicates, structural predicates, -density/diameter metrics, and matrix operations. - -Implementation notes on tested behaviour ------------------------------------------ -* ``is_degree_sequence`` — requires the sum of the sequence to be **even** - and the sequence to be non-increasing. ``[3, 1, 1]`` has an odd sum (5) - and therefore returns ``False``. - -* ``is_simple`` — checks only for **self-loops** (a vertex listed in its own - adjacency list). A graph with a cycle but no self-loop (e.g. the lollipop - graph) is considered simple by this predicate. - -* ``invert`` / ``get_complement`` — ``invert`` flips every cell in the - adjacency matrix including the diagonal, so the complement of an isolated - graph contains both cross-edges *and* self-loops. Tests reflect this - documented behaviour. - -:author: Nathan Gilbert -""" +"""Unit tests for :mod:`graphworks.algorithms.properties`.""" from __future__ import annotations @@ -57,108 +32,40 @@ class TestVertexDegree: - """Tests for vertex_degree.""" - - def test_degree_without_self_loop(self) -> None: - """Vertex with no self-loop has degree equal to its out-edge count. - - :return: None - :rtype: None - """ + def test_without_self_loop(self) -> None: data = {"graph": {"a": ["b", "c"], "b": ["a"], "c": ["a"]}} - graph = Graph(input_graph=json.dumps(data)) - assert vertex_degree(graph, "b") == 1 + assert vertex_degree(Graph(input_graph=json.dumps(data)), "b") == 1 def test_self_loop_counts_twice(self) -> None: - """A self-loop contributes 2 to the vertex degree. - - :return: None - :rtype: None - """ data = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} - graph = Graph(input_graph=json.dumps(data)) - # self-loop (×2) + b + c = 4 - assert vertex_degree(graph, "a") == 4 + assert vertex_degree(Graph(input_graph=json.dumps(data)), "a") == 4 - def test_isolated_vertex_degree_zero(self, isolated_graph) -> None: - """An isolated vertex has degree 0. - - :return: None - :rtype: None - """ + def test_isolated_zero(self, isolated_graph) -> None: assert vertex_degree(isolated_graph, "a") == 0 - def test_vertex_with_multiple_neighbours(self, big_graph) -> None: - """A hub vertex has degree equal to its neighbour count. - - :return: None - :rtype: None - """ - # vertex 'c' in big_graph has neighbours: a, b, d, e → degree 4 + def test_multiple_neighbours(self, big_graph) -> None: assert vertex_degree(big_graph, "c") == 4 class TestMinMaxDegree: - """Tests for min_degree and max_degree.""" - - def test_min_degree(self) -> None: - """min_degree returns the smallest degree in the graph. - - :return: None - :rtype: None - """ + def test_min(self) -> None: data = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} - graph = Graph(input_graph=json.dumps(data)) - assert min_degree(graph) == 1 + assert min_degree(Graph(input_graph=json.dumps(data))) == 1 - def test_max_degree(self) -> None: - """max_degree returns the largest degree in the graph. - - :return: None - :rtype: None - """ + def test_max(self) -> None: data = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} - graph = Graph(input_graph=json.dumps(data)) - assert max_degree(graph) == 4 - - def test_min_equals_max_for_regular_graph(self, isolated_graph) -> None: - """min_degree and max_degree are equal for a regular graph. + assert max_degree(Graph(input_graph=json.dumps(data))) == 4 - :return: None - :rtype: None - """ + def test_equal_for_regular(self, isolated_graph) -> None: assert min_degree(isolated_graph) == max_degree(isolated_graph) - def test_min_less_than_max_for_irregular(self, big_graph) -> None: - """min_degree < max_degree for an irregular graph. - - :return: None - :rtype: None - """ - assert min_degree(big_graph) < max_degree(big_graph) - class TestDegreeSequence: - """Tests for degree_sequence.""" - - def test_sequence_is_sorted_descending(self) -> None: - """degree_sequence returns degrees in non-increasing order. - - :return: None - :rtype: None - """ + def test_sorted_descending(self) -> None: data = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} - graph = Graph(input_graph=json.dumps(data)) - seq = degree_sequence(graph) - assert seq == (4, 1, 1) - assert list(seq) == sorted(seq, reverse=True) - - def test_isolated_graph_all_zeros(self, isolated_graph) -> None: - """All-isolated graph has a degree sequence of all zeros. + assert degree_sequence(Graph(input_graph=json.dumps(data))) == (4, 1, 1) - :return: None - :rtype: None - """ + def test_all_zeros(self, isolated_graph) -> None: assert all(d == 0 for d in degree_sequence(isolated_graph)) @@ -168,65 +75,36 @@ def test_isolated_graph_all_zeros(self, isolated_graph) -> None: class TestIsDegreeSequence: - """Tests for is_degree_sequence. - - A valid degree sequence must: - * be non-increasing, AND - * have an even sum (handshaking lemma). - - Note: ``[3, 1, 1]`` sums to 5 (odd) → ``False``. - """ - @pytest.mark.parametrize( - "seq,expected", + ("seq", "expected"), [ ([], True), - ([2, 2], True), # sum=4 (even), non-increasing - ([4, 2, 2], True), # sum=8 (even), non-increasing - ([1, 2, 3], False), # not non-increasing - ([3, 1, 1], False), # sum=5 (odd) - ([1], False), # sum=1 (odd) - ([0, 0, 0], True), # all-zero, sum=0 (even) + ([2, 2], True), + ([4, 2, 2], True), + ([1, 2, 3], False), + ([3, 1, 1], False), + ([1], False), + ([0, 0, 0], True), ], ) - def test_various_sequences(self, seq: list[int], expected: bool) -> None: - """Parametrised check for valid and invalid degree sequences. - - :param seq: Candidate degree sequence. - :type seq: list[int] - :param expected: Expected return value. - :type expected: bool - :return: None - :rtype: None - """ + def test_various(self, seq, expected) -> None: assert is_degree_sequence(seq) is expected class TestIsErdosGallai: - """Tests for is_erdos_gallai.""" - @pytest.mark.parametrize( - "seq,expected", + ("seq", "expected"), [ ([], True), - ([1], False), # odd sum - ([2, 2, 4], False), # violates EG condition + ([1], False), + ([2, 2, 4], False), ([32, 8, 4, 2, 2], False), - ([6, 6, 6, 6, 5, 5, 2, 2], True), # graphical sequence - ([0, 0, 0], True), # empty graph on 3 vertices - ([1, 1], True), # K₂: two vertices each of degree 1 + ([6, 6, 6, 6, 5, 5, 2, 2], True), + ([0, 0, 0], True), + ([1, 1], True), ], ) - def test_various_sequences(self, seq: list[int], expected: bool) -> None: - """Parametrised check of the Erdős–Gallai theorem. - - :param seq: Candidate degree sequence. - :type seq: list[int] - :param expected: Expected return value. - :type expected: bool - :return: None - :rtype: None - """ + def test_various(self, seq, expected) -> None: assert is_erdos_gallai(seq) is expected @@ -236,180 +114,68 @@ def test_various_sequences(self, seq: list[int], expected: bool) -> None: class TestIsRegular: - """Tests for is_regular.""" - - def test_isolated_graph_is_regular(self, isolated_graph) -> None: - """All-isolated graph is regular (all degrees are 0). - - :return: None - :rtype: None - """ + def test_isolated_is_regular(self, isolated_graph) -> None: assert is_regular(isolated_graph) - def test_complete_triangle_is_regular(self, triangle_graph) -> None: - """Complete graph K₃ is 2-regular. - - :return: None - :rtype: None - """ + def test_triangle_is_regular(self, triangle_graph) -> None: assert is_regular(triangle_graph) - def test_irregular_graph(self, big_graph) -> None: - """Graph with mixed degrees is not regular. - - :return: None - :rtype: None - """ + def test_irregular(self, big_graph) -> None: assert not is_regular(big_graph) class TestIsSimple: - """Tests for is_simple. - - ``is_simple`` returns ``True`` when **no vertex appears in its own - neighbour list** (i.e. no self-loop). A graph may contain cycles and - still be considered simple by this predicate. - """ - - def test_straight_line_is_simple(self, straight_line_json) -> None: - """A path graph with no self-loops is simple. - - :return: None - :rtype: None - """ - graph = Graph(input_graph=json.dumps(straight_line_json)) - assert is_simple(graph) - - def test_lollipop_graph_is_simple(self, lollipop_json) -> None: - """The lollipop graph has a cycle but no self-loop, so is simple. - - :return: None - :rtype: None - """ - graph = Graph(input_graph=json.dumps(lollipop_json)) - assert is_simple(graph) - - def test_self_loop_makes_graph_not_simple(self, self_loop_json) -> None: - """A graph where a vertex lists itself as a neighbour is not simple. + def test_straight_line(self, straight_line_json) -> None: + assert is_simple(Graph(input_graph=json.dumps(straight_line_json))) - :return: None - :rtype: None - """ - graph = Graph(input_graph=json.dumps(self_loop_json)) - assert not is_simple(graph) + def test_lollipop(self, lollipop_json) -> None: + assert is_simple(Graph(input_graph=json.dumps(lollipop_json))) - def test_connected_graph_with_self_loops_not_simple(self, connected_json) -> None: - """The connected fixture includes self-loops so it is not simple. + def test_self_loop(self, self_loop_json) -> None: + assert not is_simple(Graph(input_graph=json.dumps(self_loop_json))) - :return: None - :rtype: None - """ - graph = Graph(input_graph=json.dumps(connected_json)) - # vertex 'b' has 'b' in its neighbour list; vertex 'c' has 'c' - assert not is_simple(graph) + def test_connected_with_self_loops(self, connected_json) -> None: + assert not is_simple(Graph(input_graph=json.dumps(connected_json))) class TestIsConnected: - """Tests for is_connected.""" - - def test_connected_graph(self, connected_graph) -> None: - """A connected graph returns True. - - :return: None - :rtype: None - """ + def test_connected(self, connected_graph) -> None: assert is_connected(connected_graph) - def test_isolated_vertices_not_connected(self, isolated_graph) -> None: - """Isolated vertices make the graph disconnected. - - :return: None - :rtype: None - """ + def test_isolated_not_connected(self, isolated_graph) -> None: assert not is_connected(isolated_graph) - def test_big_graph_is_connected(self, big_graph) -> None: - """The big_graph fixture is connected. - - :return: None - :rtype: None - """ + def test_big_graph(self, big_graph) -> None: assert is_connected(big_graph) class TestIsComplete: - """Tests for is_complete.""" - - def test_triangle_is_complete(self, triangle_graph) -> None: - """K₃ (triangle) is a complete graph. - - :return: None - :rtype: None - """ + def test_triangle(self, triangle_graph) -> None: assert is_complete(triangle_graph) - def test_isolated_graph_is_not_complete(self, isolated_graph) -> None: - """Isolated vertices form an incomplete graph. - - :return: None - :rtype: None - """ + def test_isolated_not_complete(self, isolated_graph) -> None: assert not is_complete(isolated_graph) - def test_partial_graph_is_not_complete(self, simple_edge_graph) -> None: - """A graph missing edges is not complete. - - :return: None - :rtype: None - """ + def test_partial(self, simple_edge_graph) -> None: assert not is_complete(simple_edge_graph) - def test_complete_directed_graph(self) -> None: - """Complete directed graph (every ordered pair has an arc) is complete. - - :return: None - :rtype: None - """ + def test_complete_directed(self) -> None: data = {"directed": True, "graph": {"a": ["b"], "b": ["a"]}} - graph = Graph(input_graph=json.dumps(data)) - assert is_complete(graph) + assert is_complete(Graph(input_graph=json.dumps(data))) - def test_incomplete_directed_graph(self) -> None: - """Directed graph missing some arcs is not complete. - - :return: None - :rtype: None - """ + def test_incomplete_directed(self) -> None: data = {"directed": True, "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(data)) - assert not is_complete(graph) - + assert not is_complete(Graph(input_graph=json.dumps(data))) -class TestIsSparseAndDense: - """Tests for is_sparse and is_dense.""" - def test_isolated_graph_is_sparse(self, isolated_graph) -> None: - """Zero-edge graph is sparse. - - :return: None - :rtype: None - """ +class TestSparseAndDense: + def test_isolated_sparse(self, isolated_graph) -> None: assert is_sparse(isolated_graph) - def test_complete_triangle_is_dense(self, triangle_graph) -> None: - """Complete graph has density 1.0 and is therefore dense. - - :return: None - :rtype: None - """ + def test_triangle_dense(self, triangle_graph) -> None: assert is_dense(triangle_graph) def test_sparse_not_dense(self, isolated_graph) -> None: - """A sparse graph is not dense. - - :return: None - :rtype: None - """ assert not is_dense(isolated_graph) @@ -419,165 +185,62 @@ def test_sparse_not_dense(self, isolated_graph) -> None: class TestDensity: - """Tests for density.""" - - def test_connected_graph_density(self, connected_graph) -> None: - """Density of the connected fixture is approximately 0.467. - - :return: None - :rtype: None - """ + def test_connected(self, connected_graph) -> None: assert density(connected_graph) == pytest.approx(0.4666666666666667) - def test_complete_graph_density(self, triangle_graph) -> None: - """Density of a complete graph is 1.0. - - :return: None - :rtype: None - """ + def test_complete(self, triangle_graph) -> None: assert density(triangle_graph) == pytest.approx(1.0) - def test_isolated_graph_density_zero(self, isolated_graph) -> None: - """Density of a graph with no edges is 0.0. - - :return: None - :rtype: None - """ + def test_isolated_zero(self, isolated_graph) -> None: assert density(isolated_graph) == pytest.approx(0.0) - def test_single_vertex_density_zero(self) -> None: - """Graph with fewer than two vertices has density 0.0. - - :return: None - :rtype: None - """ - graph = Graph("g") - graph.add_vertex("A") - assert density(graph) == pytest.approx(0.0) + def test_single_vertex_zero(self) -> None: + g = Graph() + g.add_vertex("A") + assert density(g) == pytest.approx(0.0) class TestDiameter: - """Tests for diameter.""" - - def test_big_graph_diameter(self, big_graph) -> None: - """Diameter of the big_graph fixture is 3. - - :return: None - :rtype: None - """ + def test_big_graph(self, big_graph) -> None: assert diameter(big_graph) == 3 - def test_single_edge_diameter(self, simple_edge_graph) -> None: - """A graph with one edge has diameter 1. - - :return: None - :rtype: None - """ + def test_single_edge(self, simple_edge_graph) -> None: assert diameter(simple_edge_graph) == 1 - def test_disconnected_graph_diameter_zero(self, isolated_graph) -> None: - """A graph with no paths between vertices returns diameter 0. - - :return: None - :rtype: None - """ + def test_disconnected_zero(self, isolated_graph) -> None: assert diameter(isolated_graph) == 0 # --------------------------------------------------------------------------- -# Matrix operations and complement +# Matrix operations # --------------------------------------------------------------------------- class TestInvert: - """Tests for the invert (matrix complement) function. - - ``invert`` flips every cell including the main diagonal. - """ - - def test_invert_zeros_to_ones(self) -> None: - """Inverting a zero matrix produces an all-ones matrix. - - :return: None - :rtype: None - """ + def test_zeros_to_ones(self) -> None: assert invert([[0, 0], [0, 0]]) == [[1, 1], [1, 1]] - def test_invert_ones_to_zeros(self) -> None: - """Inverting an all-ones matrix produces a zero matrix. - - :return: None - :rtype: None - """ + def test_ones_to_zeros(self) -> None: assert invert([[1, 1], [1, 1]]) == [[0, 0], [0, 0]] - def test_invert_mixed(self) -> None: - """Invert flips 0↔1 for every cell including diagonal. - - :return: None - :rtype: None - """ - assert invert([[0, 1], [1, 0]]) == [[1, 0], [0, 1]] - - def test_invert_is_own_inverse(self) -> None: - """Applying invert twice returns the original matrix. - - :return: None - :rtype: None - """ + def test_own_inverse(self) -> None: original = [[0, 1], [0, 0]] assert invert(invert(original)) == original class TestGetComplement: - """Tests for get_complement. - - Note: because ``invert`` flips the diagonal, the complement of an - isolated graph contains **self-loops** in addition to cross-edges. - The complement of a complete graph similarly gains self-loops. - This is the documented behaviour of the current ``invert`` implementation. - """ - - def test_complement_label(self, isolated_graph) -> None: - """Complement label appends ' complement' to the original label. - - :return: None - :rtype: None - """ - complement = get_complement(isolated_graph) - assert "complement" in complement.get_label() - - def test_complement_of_isolated_has_cross_edges(self, isolated_graph) -> None: - """Complement of an isolated graph has edges between distinct vertices. - - The complement matrix has 1s everywhere (including the diagonal), so - the complement graph contains both cross-edges and self-loops. - - :return: None - :rtype: None - """ - complement = get_complement(isolated_graph) - cross_edges = [e for e in complement.edges() if e.vertex1 != e.vertex2] - assert len(cross_edges) > 0 - - def test_complement_of_complete_has_only_self_loops(self, triangle_graph) -> None: - """Complement of a complete K₃ has no cross-edges (only diagonal). - - K₃ adjacency matrix has 1s everywhere except the diagonal; inverting - gives 1s only on the diagonal → only self-loops remain. - - :return: None - :rtype: None - """ - complement = get_complement(triangle_graph) - cross_edges = [e for e in complement.edges() if e.vertex1 != e.vertex2] - assert len(cross_edges) == 0 - - def test_complement_vertex_count_preserved(self, big_graph) -> None: - """Complement has the same number of vertices as the original. - - :return: None - :rtype: None - """ - complement = get_complement(big_graph) - assert complement.order() == big_graph.order() + def test_label(self, isolated_graph) -> None: + assert "complement" in get_complement(isolated_graph).label + + 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_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_vertex_count_preserved(self, big_graph) -> None: + assert get_complement(big_graph).order == big_graph.order diff --git a/tests/test_search.py b/tests/test_search.py index 69e41f3..a79fc98 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,13 +1,4 @@ -""" -tests.test_search -~~~~~~~~~~~~~~~~~ - -Unit tests for :mod:`graphworks.algorithms.search`. - -Covers breadth_first_search, depth_first_search, and arrival_departure_dfs. - -:author: Nathan Gilbert -""" +"""Unit tests for :mod:`graphworks.algorithms.search`.""" from __future__ import annotations @@ -22,129 +13,46 @@ class TestBreadthFirstSearch: - """Tests for breadth_first_search.""" - def test_bfs_from_c(self, search_graph) -> None: - """BFS from 'c' visits all reachable vertices in level order. - - :return: Nothing - :rtype: None - """ - walk = breadth_first_search(search_graph, "c") - assert walk == ["c", "a", "d", "b"] - - def test_bfs_visits_all_vertices(self, search_graph) -> None: - """BFS visits every vertex in the connected graph. + assert breadth_first_search(search_graph, "c") == ["c", "a", "d", "b"] - :return: Nothing - :rtype: None - """ - walk = breadth_first_search(search_graph, "a") - assert sorted(walk) == ["a", "b", "c", "d"] - - def test_bfs_single_vertex(self) -> None: - """BFS on a single-vertex graph returns just that vertex. + def test_visits_all(self, search_graph) -> None: + assert sorted(breadth_first_search(search_graph, "a")) == ["a", "b", "c", "d"] - :return: Nothing - :rtype: None - """ + def test_single_vertex(self) -> None: g = Graph(input_graph=json.dumps({"graph": {"x": []}})) assert breadth_first_search(g, "x") == ["x"] - def test_bfs_start_vertex_is_first(self, search_graph) -> None: - """BFS walk always begins with the given start vertex. - - :return: Nothing - :rtype: None - """ - walk = breadth_first_search(search_graph, "b") - assert walk[0] == "b" + def test_start_is_first(self, search_graph) -> None: + assert breadth_first_search(search_graph, "b")[0] == "b" - def test_bfs_no_duplicates(self, search_graph) -> None: - """BFS never visits a vertex more than once. - - :return: Nothing - :rtype: None - """ + def test_no_duplicates(self, search_graph) -> None: walk = breadth_first_search(search_graph, "a") assert len(walk) == len(set(walk)) class TestDepthFirstSearch: - """Tests for depth_first_search.""" - def test_dfs_from_c(self, search_graph) -> None: - """DFS from 'c' visits vertices in depth-first order. - - :return: Nothing - :rtype: None - """ - walk = depth_first_search(search_graph, "c") - assert walk == ["c", "d", "a", "b"] + assert depth_first_search(search_graph, "c") == ["c", "d", "a", "b"] - def test_dfs_visits_all_vertices(self, search_graph) -> None: - """DFS visits every vertex in the connected graph. + def test_visits_all(self, search_graph) -> None: + assert sorted(depth_first_search(search_graph, "a")) == ["a", "b", "c", "d"] - :return: Nothing - :rtype: None - """ - walk = depth_first_search(search_graph, "a") - assert sorted(walk) == ["a", "b", "c", "d"] - - def test_dfs_start_vertex_is_first(self, search_graph) -> None: - """DFS walk always begins with the given start vertex. - - :return: Nothing - :rtype: None - """ - walk = depth_first_search(search_graph, "a") - assert walk[0] == "a" - - def test_dfs_no_duplicates(self, search_graph) -> None: - """DFS never visits a vertex more than once. + def test_start_is_first(self, search_graph) -> None: + assert depth_first_search(search_graph, "a")[0] == "a" - :return: Nothing - :rtype: None - """ + def test_no_duplicates(self, search_graph) -> None: walk = depth_first_search(search_graph, "a") assert len(walk) == len(set(walk)) - def test_dfs_shared_neighbour_visited_only_once(self) -> None: - """DFS skips a vertex that was pushed onto the stack twice. - - When two vertices both point at the same neighbour, that neighbour may - be pushed onto the stack multiple times before being popped. The - already-visited guard (``if vertex not in visited``) ensures the vertex - is processed exactly once even when it is encountered a second time. - This exercises the ``False`` branch of that guard. - - Graph topology: ``a → [b, c]``, ``c → [b]`` — vertex *b* is reachable - from both *a* (directly) and *c* (indirectly via *a*'s push of *c*). - - :return: Nothing - :rtype: None - """ + def test_shared_neighbour_visited_once(self) -> None: data = {"graph": {"a": ["b", "c"], "b": [], "c": ["b"]}} - graph = Graph(input_graph=json.dumps(data)) - walk = depth_first_search(graph, "a") - # b must appear exactly once despite being pushed twice + walk = depth_first_search(Graph(input_graph=json.dumps(data)), "a") assert walk.count("b") == 1 - assert sorted(walk) == ["a", "b", "c"] class TestArrivalDepartureDFS: - """Tests for arrival_departure_dfs.""" - - def _run_full_traversal( - self, graph: Graph - ) -> tuple[dict[str, int], dict[str, int], dict[str, bool]]: - """Helper: run arrival_departure_dfs over all components of *graph*. - - :param graph: The graph to traverse. - :type graph: Graph - :return: Tuple of (arrival, departure, discovered) dictionaries. - :rtype: tuple[dict[str, int], dict[str, int], dict[str, bool]] - """ + def _run(self, graph): arrival = dict.fromkeys(graph.vertices(), 0) departure = dict.fromkeys(graph.vertices(), 0) discovered = dict.fromkeys(graph.vertices(), False) @@ -154,53 +62,22 @@ def _run_full_traversal( time = arrival_departure_dfs(graph, v, discovered, arrival, departure, time) return arrival, departure, discovered - def test_arrival_departure_times(self, disjoint_directed_graph) -> None: - """Arrival and departure times are correctly assigned for both components. - - :return: Nothing - :rtype: None - """ - arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) + 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), - ] + expected = [(0, 11), (1, 2), (3, 10), (4, 7), (8, 9), (5, 6), (12, 15), (13, 14)] assert result == expected - def test_all_vertices_discovered(self, disjoint_directed_graph) -> None: - """Every vertex is discovered after a full traversal. - - :return: Nothing - :rtype: None - """ - _, _, discovered = self._run_full_traversal(disjoint_directed_graph) + def test_all_discovered(self, disjoint_directed_graph) -> None: + _, _, discovered = self._run(disjoint_directed_graph) assert all(discovered.values()) - def test_departure_always_after_arrival(self, disjoint_directed_graph) -> None: - """Departure time is strictly greater than arrival time for every vertex. - - :return: Nothing - :rtype: None - """ - arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) + 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] - ), f"Vertex {v!r}: departure {departure[v]} not > arrival {arrival[v]}" - - def test_times_are_unique(self, disjoint_directed_graph) -> None: - """No two events (arrival or departure) share the same timestamp. + assert departure[v] > arrival[v] - :return: Nothing - :rtype: None - """ - arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) + 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)) diff --git a/tests/test_sort.py b/tests/test_sort.py index 74d3cf5..34fdcb9 100644 --- a/tests/test_sort.py +++ b/tests/test_sort.py @@ -1,13 +1,4 @@ -""" -tests.test_sort -~~~~~~~~~~~~~~~ - -Unit tests for :mod:`graphworks.algorithms.sort`. - -Covers the topological sort algorithm. - -:author: Nathan Gilbert -""" +"""Unit tests for :mod:`graphworks.algorithms.sort`.""" from __future__ import annotations @@ -18,51 +9,29 @@ class TestTopologicalSort: - """Tests for the topological sort algorithm.""" - def test_standard_dag(self, directed_dag) -> None: - """topological returns a valid topological order for the fixture DAG.""" - result = topological(directed_dag) - assert result == ["F", "E", "C", "D", "B", "A"] + assert topological(directed_dag) == ["F", "E", "C", "D", "B", "A"] - def test_result_is_valid_topological_order(self, directed_dag) -> None: - """Every edge (u→v) has u appearing before v in the result.""" + def test_valid_order(self, directed_dag) -> None: result = topological(directed_dag) position = {v: i for i, v in enumerate(result)} for v in directed_dag.vertices(): - for neighbour in directed_dag.get_neighbors(v): - assert ( - position[v] < position[neighbour] - ), f"Edge {v}→{neighbour} is out of order in topological sort" + for n in directed_dag.neighbors(v): + assert position[v] < position[n] def test_all_vertices_present(self, directed_dag) -> None: - """Every vertex appears exactly once in the topological order.""" - result = topological(directed_dag) - assert sorted(result) == sorted(directed_dag.vertices()) + assert sorted(topological(directed_dag)) == sorted(directed_dag.vertices()) def test_linear_chain(self) -> None: - """A simple A→B→C→D chain sorts as [A, B, C, D].""" - data = { - "directed": True, - "graph": {"A": ["B"], "B": ["C"], "C": ["D"], "D": []}, - } - graph = Graph(input_graph=json.dumps(data)) - result = topological(graph) - assert result == ["A", "B", "C", "D"] + data = {"directed": True, "graph": {"A": ["B"], "B": ["C"], "C": ["D"], "D": []}} + assert topological(Graph(input_graph=json.dumps(data))) == ["A", "B", "C", "D"] def test_single_vertex(self) -> None: - """A single-vertex graph sorts as [that vertex].""" data = {"directed": True, "graph": {"A": []}} - graph = Graph(input_graph=json.dumps(data)) - assert topological(graph) == ["A"] + assert topological(Graph(input_graph=json.dumps(data))) == ["A"] def test_parallel_roots(self) -> None: - """Two independent root vertices both appear before their descendants.""" - data = { - "directed": True, - "graph": {"A": ["C"], "B": ["C"], "C": []}, - } - graph = Graph(input_graph=json.dumps(data)) - result = topological(graph) + data = {"directed": True, "graph": {"A": ["C"], "B": ["C"], "C": []}} + result = topological(Graph(input_graph=json.dumps(data))) assert result.index("C") > result.index("A") assert result.index("C") > result.index("B") diff --git a/tests/test_vertex.py b/tests/test_vertex.py index 8a9cccf..c00b1ae 100644 --- a/tests/test_vertex.py +++ b/tests/test_vertex.py @@ -1,8 +1,6 @@ """Unit tests for :class:`graphworks.vertex.Vertex`. -Covers construction (direct and via factory), identity semantics (equality and hashing on name -only), ordering, the ``display_name`` property, immutability guarantees, ``attrs`` isolation, -and string representations. +:author: Nathan Gilbert """ from __future__ import annotations @@ -13,311 +11,124 @@ from graphworks.vertex import Vertex -# --------------------------------------------------------------------------- -# Construction — direct instantiation -# --------------------------------------------------------------------------- - class TestVertexConstruction: - """Tests for Vertex construction and default values.""" - - def test_basic_construction(self) -> None: - """A Vertex stores its name correctly.""" - v = Vertex("a") - assert v.name == "a" + def test_basic(self) -> None: + assert Vertex("a").name == "a" def test_label_defaults_to_none(self) -> None: - """The label defaults to None when not provided.""" - v = Vertex("a") - assert v.label is None + assert Vertex("a").label is None - def test_attrs_defaults_to_empty_mapping(self) -> None: - """The attrs field defaults to an empty MappingProxyType.""" + def test_attrs_defaults_empty(self) -> None: v = Vertex("a") assert v.attrs == {} assert isinstance(v.attrs, MappingProxyType) def test_explicit_label(self) -> None: - """An explicit label string is stored and accessible.""" - v = Vertex("node_1", label="Start Node") - assert v.label == "Start Node" + assert Vertex("n1", label="Start").label == "Start" def test_explicit_attrs(self) -> None: - """A MappingProxyType attrs is stored and accessible.""" - attrs = MappingProxyType({"color": "red", "weight": 5}) - v = Vertex("a", attrs=attrs) - assert v.attrs["color"] == "red" - assert v.attrs["weight"] == 5 - - def test_all_fields(self) -> None: - """All fields can be set at once via direct construction.""" - attrs = MappingProxyType({"x": 10, "y": 20}) - v = Vertex("hub", label="Central Hub", attrs=attrs) - assert v.name == "hub" - assert v.label == "Central Hub" - assert v.attrs["x"] == 10 - - -# --------------------------------------------------------------------------- -# Construction — factory method -# --------------------------------------------------------------------------- + attrs = MappingProxyType({"color": "red"}) + assert Vertex("a", attrs=attrs).attrs["color"] == "red" class TestVertexCreateFactory: - """Tests for the Vertex.create() alternate constructor.""" - def test_create_basic(self) -> None: - """Vertex.create builds a valid vertex with defaults.""" v = Vertex.create("x") assert v.name == "x" assert v.label is None assert v.attrs == {} - def test_create_with_plain_dict_attrs(self) -> None: - """Vertex.create freezes a plain dict into a MappingProxyType.""" + def test_create_freezes_dict(self) -> None: v = Vertex.create("x", attrs={"color": "blue"}) - assert v.attrs["color"] == "blue" assert isinstance(v.attrs, MappingProxyType) - def test_create_with_none_attrs(self) -> None: - """Vertex.create with attrs=None yields an empty mapping.""" - v = Vertex.create("x", attrs=None) - assert v.attrs == {} - assert isinstance(v.attrs, MappingProxyType) - - def test_create_with_all_fields(self) -> None: - """Vertex.create accepts all keyword arguments.""" - v = Vertex.create("hub", label="Central", attrs={"rank": 1}) - assert v.name == "hub" - assert v.label == "Central" - assert v.attrs["rank"] == 1 - - def test_create_does_not_mutate_original_dict(self) -> None: - """Mutating the input dict after create() has no effect on the Vertex.""" + def test_create_copies_dict(self) -> None: raw = {"key": "original"} v = Vertex.create("a", attrs=raw) raw["key"] = "mutated" assert v.attrs["key"] == "original" - -# --------------------------------------------------------------------------- -# Immutability -# --------------------------------------------------------------------------- + def test_create_all_fields(self) -> None: + v = Vertex.create("hub", label="Central", attrs={"rank": 1}) + assert v.label == "Central" + assert v.attrs["rank"] == 1 class TestVertexImmutability: - """Tests that Vertex instances are truly frozen.""" - def test_cannot_set_name(self) -> None: - """Attempting to reassign name raises an error.""" - v = Vertex("a") - with pytest.raises(AttributeError): - v.name = "z" # noqa # ty:ignore[invalid-assignment] - - def test_cannot_set_label(self) -> None: - """Attempting to reassign label raises an error.""" - v = Vertex("a", label="original") with pytest.raises(AttributeError): - v.label = "changed" # noqa # ty:ignore[invalid-assignment] + Vertex("a").name = "z" # noqa # type: ignore[misc] def test_cannot_mutate_attrs(self) -> None: - """Attempting to set a key on attrs raises a TypeError.""" v = Vertex.create("a", attrs={"color": "red"}) with pytest.raises(TypeError): v.attrs["color"] = "blue" # type: ignore[index] - def test_cannot_add_new_attr(self) -> None: - """Attempting to add a new key to attrs raises a TypeError.""" - v = Vertex("a") - with pytest.raises(TypeError): - v.attrs["new_key"] = "value" # type: ignore[index] - - -# --------------------------------------------------------------------------- -# display_name property -# --------------------------------------------------------------------------- - class TestVertexDisplayName: - """Tests for the display_name derived property.""" - - def test_display_name_falls_back_to_name(self) -> None: - """display_name returns name when label is None.""" - v = Vertex("node_42") - assert v.display_name == "node_42" - - def test_display_name_uses_label(self) -> None: - """display_name returns label when it is set.""" - v = Vertex("n1", label="Start") - assert v.display_name == "Start" - - def test_display_name_with_empty_string_label(self) -> None: - """An empty-string label is still used (it is not None).""" - v = Vertex("n1", label="") - assert v.display_name == "" + def test_falls_back_to_name(self) -> None: + assert Vertex("node_42").display_name == "node_42" + def test_uses_label(self) -> None: + assert Vertex("n1", label="Start").display_name == "Start" -# --------------------------------------------------------------------------- -# Identity — equality -# --------------------------------------------------------------------------- + def test_empty_string_label(self) -> None: + assert Vertex("n1", label="").display_name == "" class TestVertexEquality: - """Tests for Vertex equality based on name only. - - Label and attrs are descriptive and do not affect identity. - """ - def test_equal_by_name(self) -> None: - """Two Vertices with the same name are equal.""" assert Vertex("a") == Vertex("a") - def test_equal_ignores_label(self) -> None: - """Vertices with different labels but the same name are equal.""" + def test_ignores_label(self) -> None: assert Vertex("a", label="foo") == Vertex("a", label="bar") - def test_equal_ignores_attrs(self) -> None: - """Vertices with different attrs but the same name are equal.""" - v1 = Vertex.create("a", attrs={"k": 1}) - v2 = Vertex.create("a", attrs={"k": 2}) - assert v1 == v2 + def test_ignores_attrs(self) -> None: + assert Vertex.create("a", attrs={"k": 1}) == Vertex.create("a", attrs={"k": 2}) - def test_different_names_not_equal(self) -> None: - """Vertices with different names are not equal.""" + def test_different_names(self) -> None: assert Vertex("a") != Vertex("b") def test_case_sensitive(self) -> None: - """Vertex names are case-sensitive.""" assert Vertex("A") != Vertex("a") - def test_not_equal_to_non_vertex(self) -> None: - """Comparing a Vertex to a non-Vertex returns NotImplemented.""" - v = Vertex("a") - assert v != "a" - assert v != 42 - - def test_not_equal_to_none(self) -> None: - """Comparing a Vertex to None is False.""" - v = Vertex("a") - assert v != None # noqa: E711 - - def test_not_equal_to_string_of_same_name(self) -> None: - """A Vertex is not equal to a bare string, even if the name matches.""" - v = Vertex("hello") - assert v != "hello" - - -# --------------------------------------------------------------------------- -# Identity — hashing -# --------------------------------------------------------------------------- + def test_not_equal_to_string(self) -> None: + assert Vertex("a") != "a" class TestVertexHashing: - """Tests for Vertex hash behaviour based on name only.""" - - def test_equal_vertices_same_hash(self) -> None: - """Vertices with the same name have the same hash.""" + def test_same_hash(self) -> None: assert hash(Vertex("a")) == hash(Vertex("a")) - def test_different_label_same_hash(self) -> None: - """Vertices differing only in label share a hash.""" - assert hash(Vertex("a", label="x")) == hash(Vertex("a", label="y")) - def test_usable_as_dict_key(self) -> None: - """Vertices can serve as dictionary keys.""" - v = Vertex("a") - d = {v: "found"} - assert d[Vertex("a")] == "found" - - def test_usable_in_set(self) -> None: - """Duplicate-name vertices collapse in a set.""" - s = {Vertex("a"), Vertex("a"), Vertex("a", label="different")} - assert len(s) == 1 - - def test_different_names_in_set(self) -> None: - """Vertices with different names remain distinct in a set.""" - s = {Vertex("a"), Vertex("b"), Vertex("c")} - assert len(s) == 3 - + assert {Vertex("a"): "found"}[Vertex("a")] == "found" -# --------------------------------------------------------------------------- -# Ordering -# --------------------------------------------------------------------------- + def test_set_dedup(self) -> None: + assert len({Vertex("a"), Vertex("a"), Vertex("a", label="x")}) == 1 class TestVertexOrdering: - """Tests for __lt__ which enables sorted() on vertex collections.""" - def test_less_than(self) -> None: - """Vertex 'a' sorts before Vertex 'b'.""" assert Vertex("a") < Vertex("b") - def test_not_less_than(self) -> None: - """Vertex 'b' does not sort before Vertex 'a'.""" - assert not (Vertex("b") < Vertex("a")) - - def test_equal_not_less_than(self) -> None: - """A Vertex is not less than itself.""" - assert not (Vertex("a") < Vertex("a")) - - def test_sorted_collection(self) -> None: - """sorted() on a list of Vertices produces name-alphabetical order.""" - vertices = [Vertex("c"), Vertex("a"), Vertex("b")] - result = sorted(vertices) + def test_sorted(self) -> None: + result = sorted([Vertex("c"), Vertex("a"), Vertex("b")]) assert [v.name for v in result] == ["a", "b", "c"] - def test_min_and_max(self) -> None: - """min() and max() work on Vertex collections.""" - vertices = [Vertex("c"), Vertex("a"), Vertex("b")] - assert min(vertices).name == "a" - assert max(vertices).name == "c" - - def test_lt_with_non_vertex_returns_not_implemented(self) -> None: - """Comparing a Vertex to a non-Vertex via < returns NotImplemented.""" - v = Vertex("a") - assert v.__lt__("a") is NotImplemented - - -# --------------------------------------------------------------------------- -# String representations -# --------------------------------------------------------------------------- + def test_lt_non_vertex(self) -> None: + assert Vertex("a").__lt__("a") is NotImplemented -class TestVertexRepr: - """Tests for __repr__ and __str__.""" - +class TestVertexDisplay: def test_repr_name_only(self) -> None: - """repr with just a name is compact.""" - v = Vertex("A") - assert repr(v) == "Vertex('A')" + assert repr(Vertex("A")) == "Vertex('A')" def test_repr_with_label(self) -> None: - """repr includes label when present.""" - v = Vertex("A", label="Alice") - r = repr(v) - assert "Vertex('A'" in r - assert "label='Alice'" in r - - def test_repr_with_attrs(self) -> None: - """repr includes attrs when non-empty.""" - v = Vertex.create("A", attrs={"color": "red"}) - r = repr(v) - assert "color" in r - assert "red" in r - - def test_repr_minimal_omits_defaults(self) -> None: - """repr omits label and attrs when they are defaults.""" - v = Vertex("A") - r = repr(v) - assert "label" not in r - assert "attrs" not in r - - def test_str_uses_display_name_fallback(self) -> None: - """str() returns name when label is not set.""" - v = Vertex("node_1") - assert str(v) == "node_1" - - def test_str_uses_label(self) -> None: - """str() returns label when it is set.""" - v = Vertex("n1", label="Start") - assert str(v) == "Start" + assert "label='Alice'" in repr(Vertex("A", label="Alice")) + + def test_str_uses_display_name(self) -> None: + assert str(Vertex("n1", label="Start")) == "Start" + + def test_str_falls_back(self) -> None: + assert str(Vertex("n1")) == "n1" From d04d3164af3d76e3c07fe43259ee0ca6d5f82dcf Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Wed, 25 Mar 2026 20:57:06 -0600 Subject: [PATCH 5/8] Update deps --- .pre-commit-config.yaml | 4 ++-- uv.lock | 52 ++++++++++++++++++++--------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4150af1..6f17219 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,12 +26,12 @@ repos: args: [ --fix, --exit-non-zero-on-fix ] # No official hook yet - repo: https://github.com/NSPBot911/ty-pre-commit - rev: v0.0.24 + rev: v0.0.25 hooks: - id: ty-check language: system - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.10.12 + rev: 0.11.1 hooks: - id: uv-lock - repo: https://github.com/psf/black diff --git a/uv.lock b/uv.lock index 8531fe0..d2fae4d 100644 --- a/uv.lock +++ b/uv.lock @@ -736,7 +736,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -744,9 +744,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] @@ -844,14 +844,14 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.9.9" +version = "3.9.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/26/3fb37400637a3fbb099bd454298b21c420decde96c4b5acedeefee14d714/sphinx_autodoc_typehints-3.9.9.tar.gz", hash = "sha256:c862859c7d679a1495de5bcac150f6b1a6ebc24a1547379ca2aac1831588aa0d", size = 69333, upload-time = "2026-03-20T15:14:15.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/e9/d29ae58dd12971d2cbb872884676a70d1a5e4719b4d82e197264cdf0431a/sphinx_autodoc_typehints-3.9.11.tar.gz", hash = "sha256:28516c916b41fa83271ee2ab9191b73807e4113d3bfb94222ac87d8d9795b6e7", size = 70261, upload-time = "2026-03-24T16:57:28.462Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/64/2dc63a88a3010e9b2ea86788d5ef1ec37bc9b9c6b544cea4f764ff343ea4/sphinx_autodoc_typehints-3.9.9-py3-none-any.whl", hash = "sha256:53c849d74ab67b51fade73c398d08aa3003158c1af88fb84876440d7382143c5", size = 36846, upload-time = "2026-03-20T15:14:14.384Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e3/ff212b51c16717681792eaf18691e6b5affbbb3d4290147c457fa9127372/sphinx_autodoc_typehints-3.9.11-py3-none-any.whl", hash = "sha256:b5cbc7a56a9338021ab7a4e6aa132aa7829fa2f8b64eca927faab64cd3971b80", size = 37279, upload-time = "2026-03-24T16:57:27.147Z" }, ] [[package]] @@ -936,26 +936,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.24" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, - { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, - { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, - { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, - { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, - { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, - { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, - { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, - { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, - { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, - { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, - { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, - { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, - { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, +version = "0.0.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/a4/6c289cbd1474285223124a4ffb55c078dbe9ae1d925d0b6a948643c7f115/ty-0.0.25-py3-none-linux_armv6l.whl", hash = "sha256:26d6d5aede5d54fb055779460f896d9c1473c6fb996716bd11cb90f027d8fee7", size = 10452747, upload-time = "2026-03-24T22:32:32.662Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/74cb9de356b9ceb3f281ab048f8c4ac2207122161b0ac0066886ce129abe/ty-0.0.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aedcfbc7b6b96dbc55b0da78fa02bd049373ff3d8a827f613dadd8bd17d10758", size = 10271349, upload-time = "2026-03-24T22:32:13.041Z" }, + { url = "https://files.pythonhosted.org/packages/0e/93/ffc5a20cc9e14fa9b32b0c54884864bede30d144ce2ae013805bce0c86d0/ty-0.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a8fb3c1e28f73618941811e2568dca195178a1a6314651d4ee97086a4497253", size = 9730308, upload-time = "2026-03-24T22:32:19.24Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/52e05ef32a5f172fce70633a4e19d8e04364271a4322ae12382c7344b0de/ty-0.0.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814870b7f347b5d0276304cddb98a0958f08de183bf159abc920ebe321247ad4", size = 10247664, upload-time = "2026-03-24T22:32:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/c2/64/0d0a47ed0aa1d634c666c2cc15d3b0af4b95d0fd3dbb796032bd493f3433/ty-0.0.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:781150e23825dc110cd5e1f50ca3d61664f7a5db5b4a55d5dbf7d3b1e246b917", size = 10261961, upload-time = "2026-03-24T22:32:43.935Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ba/4666b96f0499465efb97c244554107c541d74a1add393e62276b3de9b54f/ty-0.0.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc81ff2a0143911321251dc81d1c259fa5cdc56d043019a733c845d55409e2a", size = 10746076, upload-time = "2026-03-24T22:32:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ed/aa958ccbcd85cc206600e48fbf0a1c27aef54b4b90112d9a73f69ed0c739/ty-0.0.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03c5c5b5c10355ea030cbe3cd93b2e759b9492c66688288ea03a68086069f2e", size = 11287331, upload-time = "2026-03-24T22:32:21.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/e4/f4a004e1952e6042f5bfeeb7d09cffb379270ef009d9f8568471863e86e6/ty-0.0.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fc1ef49cd6262eb9223ccf6e258ac899aaa53e7dc2151ba65a2c9fa248dfa75", size = 11028804, upload-time = "2026-03-24T22:32:39.088Z" }, + { url = "https://files.pythonhosted.org/packages/56/32/5c15bb8ea20ed54d43c734f253a2a5da95d41474caecf4ef3682df9f68f5/ty-0.0.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad98da1393161096235a387cc36abecd31861060c68416761eccdb7c1bc326b", size = 10845246, upload-time = "2026-03-24T22:32:41.33Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fe/4ddd83e810c8682fcfada0d1c9d38936a34a024d32d7736075c1e53a038e/ty-0.0.25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d4336aa5381eb4eab107c3dec75fe22943a648ef6646f5a8431ef1c8cdabb66", size = 10233515, upload-time = "2026-03-24T22:32:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/ad/db/9fe54f6fb952e5b218f2e661e64ed656512edf2046cfbb9c159558e255db/ty-0.0.25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e10ed39564227de2b7bd89398250b65daaedbef15a25cef8eee70078f5d9e0b2", size = 10275289, upload-time = "2026-03-24T22:32:28.21Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e0/090d7b33791b42bc7ec29463ac6a634738e16b289e027608ebe542682773/ty-0.0.25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aca04e9ed9b61c706064a1c0b71a247c3f92f373d0222103f3bc54b649421796", size = 10461195, upload-time = "2026-03-24T22:32:24.252Z" }, + { url = "https://files.pythonhosted.org/packages/42/31/5bf12bce01b80b72a7a4e627380779b41510e730f6000862a1d078e423f7/ty-0.0.25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:18a5443e4ef339c1bd8c57fc13112c22080617ea582bfc22b497d82d65361325", size = 10931471, upload-time = "2026-03-24T22:32:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/ab60c11f8a6dd2a0ae96daac83458ef2e9be1ae70481d1ad9c59d3eaf20f/ty-0.0.25-py3-none-win32.whl", hash = "sha256:a685b9a611b69195b5a557e05dbb7ebcd12815f6c32fb27fdf15edeb1fa33d8f", size = 9835974, upload-time = "2026-03-24T22:32:36.86Z" }, + { url = "https://files.pythonhosted.org/packages/41/55/625acc2ef34646268bc2baa8fdd6e22fb47cd5965e2acd3be92c687fb6b0/ty-0.0.25-py3-none-win_amd64.whl", hash = "sha256:0d4d37a1f1ab7f2669c941c38c65144ff223eb51ececd7ccfc0d623afbc0f729", size = 10815449, upload-time = "2026-03-24T22:32:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" }, ] [[package]] From 3bf1bdf24db0cb69df800540137c1805108c3f5d Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Wed, 25 Mar 2026 21:31:30 -0600 Subject: [PATCH 6/8] Refactor other modules --- .pre-commit-config.yaml | 1 + examples/demo.py | 86 ++++++++++++++---- pyproject.toml | 3 + src/graphworks/__init__.py | 2 - src/graphworks/algorithms/properties.py | 2 +- src/graphworks/export/__init__.py | 9 +- src/graphworks/export/graphviz_utils.py | 44 +++++---- src/graphworks/export/json_utils.py | 40 ++++++--- src/graphworks/numpy_compat.py | 34 ++++--- tests/algorithms/__init__.py | 0 tests/{ => algorithms}/test_directed.py | 3 +- tests/{ => algorithms}/test_paths.py | 0 tests/{ => algorithms}/test_properties.py | 0 tests/{ => algorithms}/test_search.py | 0 tests/{ => algorithms}/test_sort.py | 0 tests/export/__init__.py | 0 tests/export/test_graphviz_utils.py | 70 +++++++++++++++ tests/export/test_json_utils.py | 83 +++++++++++++++++ tests/test_edge.py | 5 +- tests/test_numpy_compat.py | 104 +++------------------- tests/test_vertex.py | 5 +- 21 files changed, 323 insertions(+), 168 deletions(-) create mode 100644 tests/algorithms/__init__.py rename tests/{ => algorithms}/test_directed.py (95%) rename tests/{ => algorithms}/test_paths.py (100%) rename tests/{ => algorithms}/test_properties.py (100%) rename tests/{ => algorithms}/test_search.py (100%) rename tests/{ => algorithms}/test_sort.py (100%) create mode 100644 tests/export/__init__.py create mode 100644 tests/export/test_graphviz_utils.py create mode 100644 tests/export/test_json_utils.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6f17219..b9adc01 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,6 +30,7 @@ repos: hooks: - id: ty-check language: system + pass_filenames: false - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.11.1 hooks: diff --git a/examples/demo.py b/examples/demo.py index c5b4474..f9e0557 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -13,8 +13,8 @@ uv sync --extra viz -This script is **not** shipped with the library. It lives in the ``examples/`` -directory and is excluded from both the sdist and wheel builds. +This script is **not** shipped with the library. It lives in the ``examples/`` directory and is +excluded from both the sdist and wheel builds. """ from __future__ import annotations @@ -45,6 +45,7 @@ vertex_degree, ) from graphworks.graph import Graph +from graphworks.vertex import Vertex console = Console() @@ -96,10 +97,10 @@ def _graph_panel(graph: Graph, title: str, border_style: str = "blue") -> None: :return: Nothing. :rtype: None """ - arrow = "→" if graph.is_directed() else "—" + arrow = "→" if graph.directed else "—" tree = Tree(f"[bold]{title}[/bold]") for v in sorted(graph.vertices()): - neighbours = graph.get_neighbors(v) + neighbours = graph.neighbors(v) if neighbours: label = f"[green]{v}[/green] {arrow} " + ", ".join( f"[cyan]{n}[/cyan]" for n in neighbours @@ -135,26 +136,42 @@ def demo_construction() -> Graph: }, } graph = Graph(input_graph=json.dumps(json_def)) - _kv("label", graph.get_label()) - _kv("vertices", graph.order()) - _kv("edges", graph.size()) - _kv("directed", graph.is_directed()) + _kv("label", graph.label) + _kv("vertices", graph.order) + _kv("edges", graph.size) + _kv("directed", graph.directed) console.print() _graph_panel(graph, "social network") - # Other construction methods + # Matrix construction console.print() matrix = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] matrix_graph = Graph(input_matrix=matrix) - _kv("from matrix", f"{matrix_graph.order()} vertices, {matrix_graph.size()} edges") + _kv("from matrix", f"{matrix_graph.order} vertices, {matrix_graph.size} edges") + # Programmatic construction with Vertex objects manual = Graph("manual") - for v in ("X", "Y", "Z"): - manual.add_vertex(v) + manual.add_vertex(Vertex.create("X", label="Entry")) + manual.add_vertex(Vertex.create("Y", label="Middle")) + manual.add_vertex(Vertex.create("Z", label="Exit")) manual.add_edge("X", "Y") manual.add_edge("Y", "Z") - _kv("programmatic", f"{manual.order()} vertices, {manual.size()} edges") + _kv("programmatic", f"{manual.order} vertices, {manual.size} edges") + + # Show that Vertex metadata survives + for name in manual.vertices(): + v = manual.vertex(name) + if v is not None: + _kv(f" {name}", f"display_name={v.display_name!r}") + + # Weighted edge construction + weighted = Graph("routes", weighted=True) + weighted.add_edge("Denver", "SLC", weight=525.0, label="I-70/I-15") + weighted.add_edge("SLC", "Boise", weight=340.0, label="I-84") + e = weighted.edge("Denver", "SLC") + if e is not None: + _kv("weighted edge", f"{e} (weight={e.weight}, label={e.label!r})") return graph @@ -209,7 +226,7 @@ def demo_degrees(graph: Graph) -> None: table.add_column("Neighbours") for v in sorted(graph.vertices()): deg = vertex_degree(graph, v) - nbrs = ", ".join(graph.get_neighbors(v)) or "(none)" + nbrs = ", ".join(graph.neighbors(v)) or "(none)" table.add_row(v, str(deg), nbrs) console.print(table) @@ -284,7 +301,7 @@ def demo_complement(graph: Graph) -> None: verts = sorted(graph.vertices()) original_edges: set[tuple[str, str]] = set() for v in verts: - for n in graph.get_neighbors(v): + for n in graph.neighbors(v): edge = (min(v, n), max(v, n)) original_edges.add(edge) @@ -367,7 +384,7 @@ def demo_adjacency_matrix(graph: Graph) -> None: """ _section("8 · Adjacency matrix") - matrix = graph.get_adjacency_matrix() + matrix = graph.adjacency_matrix() verts = sorted(graph.vertices()) table = Table(show_header=True, header_style="bold") @@ -385,6 +402,42 @@ def demo_adjacency_matrix(graph: Graph) -> None: console.print(table) +def demo_edge_inspection(graph: Graph) -> None: + """Show the first-class Edge objects stored in the graph. + + :param graph: A graph whose edges to inspect. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("9 · Edge objects") + + table = Table(show_header=True, header_style="bold") + table.add_column("#", justify="right", style="dim") + table.add_column("Edge", style="cyan") + table.add_column("Directed") + table.add_column("Weight") + table.add_column("Self-loop") + for i, e in enumerate(graph.edges(), 1): + table.add_row( + str(i), + str(e), + "✓" if e.directed else "✗", + str(e.weight) if e.has_weight() else "—", + "✓" if e.is_self_loop() else "✗", + ) + console.print(table) + + # Direct edge lookup + console.print() + src, tgt = graph.vertices()[0], graph.vertices()[1] + e = graph.edge(src, tgt) + if e is not None: + _kv("edge lookup", f"graph.edge({src!r}, {tgt!r}) = {e!r}") + else: + _kv("edge lookup", f"No edge between {src!r} and {tgt!r}") + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -412,6 +465,7 @@ def main() -> None: demo_complement(graph) demo_directed() demo_adjacency_matrix(graph) + demo_edge_inspection(graph) console.print() console.rule("[bold green]Done![/bold green]") diff --git a/pyproject.toml b/pyproject.toml index 667c419..2646b01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,6 +137,9 @@ multi_line_output = 3 known_first_party = [ "graphworks",] skip = [ "src/graphworks/_version.py",] +[tool.ty.src] +exclude = [ "tests/", "examples/",] + [tool.ty.environment] python-platform = "darwin" python-version = "3.14" diff --git a/src/graphworks/__init__.py b/src/graphworks/__init__.py index 8e1c957..58ce973 100644 --- a/src/graphworks/__init__.py +++ b/src/graphworks/__init__.py @@ -1,3 +1 @@ """Graphworks package.""" - -__author__ = "Nathan Gilbert" diff --git a/src/graphworks/algorithms/properties.py b/src/graphworks/algorithms/properties.py index c6d4823..df3a169 100644 --- a/src/graphworks/algorithms/properties.py +++ b/src/graphworks/algorithms/properties.py @@ -285,7 +285,7 @@ def diameter(graph: Graph) -> int: 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=len)) + shortest_paths.append(min(all_paths, key=lambda path: len(path))) # noqa if not shortest_paths: return 0 diff --git a/src/graphworks/export/__init__.py b/src/graphworks/export/__init__.py index f6801d7..0543517 100644 --- a/src/graphworks/export/__init__.py +++ b/src/graphworks/export/__init__.py @@ -1 +1,8 @@ -"""Export helpers.""" +"""Export helpers for serializing graphs to external formats. + +Submodules +---------- + +- :mod:`~graphworks.export.json_utils` — JSON file export +- :mod:`~graphworks.export.graphviz_utils` — Graphviz DOT export +""" diff --git a/src/graphworks/export/graphviz_utils.py b/src/graphworks/export/graphviz_utils.py index 7c8fad9..5c46d28 100755 --- a/src/graphworks/export/graphviz_utils.py +++ b/src/graphworks/export/graphviz_utils.py @@ -1,31 +1,45 @@ -"""Graphviz utilities.""" +"""Graphviz DOT export for graphworks graphs. + +Provides :func:`save_to_dot` for rendering a +:class:`~graphworks.graph.Graph` as a Graphviz DOT file. Requires the ``[viz]`` optional extra:: + + pip install graphworks[viz] +""" from __future__ import annotations -from os import path +from pathlib import Path from typing import TYPE_CHECKING -from graphviz import Graph as GraphViz +from graphviz import Digraph +from graphviz import Graph as UndirectedGraphViz if TYPE_CHECKING: from graphworks.graph import Graph def save_to_dot(graph: Graph, out_dir: str) -> None: - """Save graph to Graphviz dot file. + """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`. - :param graph: the graph to render in dot + :param graph: The graph to render. :type graph: Graph - :param out_dir: the absolute path of the gv file to write + :param out_dir: Directory path where the DOT file will be written. :type out_dir: str - :return: Nothing + :return: Nothing. :rtype: None """ - if not graph.is_directed(): - dot = GraphViz(comment=graph.get_label()) - for node in graph: - dot.node(node, node) - for edge in graph[node]: - dot.edge(node, edge) - - dot.render(path.join(out_dir, f"{graph.get_label()}.gv"), view=False) + if graph.directed: + dot = Digraph(comment=graph.label) + else: + dot = UndirectedGraphViz(comment=graph.label) + + 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) diff --git a/src/graphworks/export/json_utils.py b/src/graphworks/export/json_utils.py index f625ace..2b2361f 100644 --- a/src/graphworks/export/json_utils.py +++ b/src/graphworks/export/json_utils.py @@ -1,9 +1,13 @@ -"""JSON utilities.""" +"""JSON export for graphworks graphs. + +Provides :func:`save_to_json` for serializing a :class:`~graphworks.graph.Graph` to a JSON file +on disk. +""" from __future__ import annotations import json -from os import path +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -11,20 +15,32 @@ def save_to_json(graph: Graph, out_dir: str) -> None: - """Save to json file. + """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:: - :param graph: the graph to write to json + { + "label": "...", + "directed": false, + "graph": { "A": ["B"], "B": ["A"] } + } + + :param graph: The graph to serialize. :type graph: Graph - :param out_dir: the absolute path to the dir to write the file + :param out_dir: Directory path where the JSON file will be written. :type out_dir: str - :return: Nothing + :return: Nothing. :rtype: None """ - g_dict = { - "label": graph.get_label(), - "directed": graph.is_directed(), - "graph": graph.get_graph(), + adjacency: dict[str, list[str]] = {v: graph.neighbors(v) for v in graph.vertices()} + + payload = { + "label": graph.label, + "directed": graph.directed, + "weighted": graph.weighted, + "graph": adjacency, } - with open(path.join(out_dir, f"{graph.get_label()}.json"), "w", encoding="utf8") as out: - out.write(json.dumps(g_dict)) + out_path = Path(out_dir) / f"{graph.label}.json" + out_path.write_text(json.dumps(payload), encoding="utf-8") diff --git a/src/graphworks/numpy_compat.py b/src/graphworks/numpy_compat.py index 9295036..31586b1 100644 --- a/src/graphworks/numpy_compat.py +++ b/src/graphworks/numpy_compat.py @@ -4,19 +4,17 @@ pip install graphworks[matrix] -It provides thin conversion helpers between :data:`~graphworks.types.AdjacencyMatrix` -(``list[list[int]]``) and ``numpy.ndarray`` so callers who already have numpy -arrays can pass them directly to :class:`~graphworks.graph.Graph`. +It provides thin conversion helpers between :data:`~graphworks.types.AdjacencyMatrix` (``list[ +list[int]]``) and ``numpy.ndarray`` so callers who already have numpy arrays can pass them +directly to :class:`~graphworks.graph.Graph`. -Import pattern — always guard with :data:`TYPE_CHECKING` or a try/except so that -code using graphworks does not *require* numpy:: +Import pattern — always guard with :data:`TYPE_CHECKING` or a try/except so that code using +graphworks does not *require* numpy:: >>> try: - ... from graphworks.numpy_compat import ndarray_to_matrix, matrix_to_ndarray - >>> except ImportError: - ... pass # numpy not installed; matrix I/O unavailable - -:author: Nathan Gilbert + ... from graphworks.numpy_compat import ndarray_to_matrix, matrix_to_ndarray + ... except ImportError: + ... pass # numpy not installed; matrix I/O unavailable """ from __future__ import annotations @@ -31,15 +29,14 @@ try: import numpy as np except ImportError as exc: # pragma: no cover - raise ImportError( - "numpy is required for numpy interop. " "Install it with: pip install graphworks[matrix]" - ) from exc + msg = "numpy is required for numpy interop. Install it with: pip install graphworks[matrix]" + raise ImportError(msg) from exc def ndarray_to_matrix(arr: NDArray) -> AdjacencyMatrix: - """Convert numpy ndarray adjacency representation to :data:`~graphworks.types.AdjacencyMatrix`. + """Convert a numpy ndarray to a stdlib :data:`~graphworks.types.AdjacencyMatrix`. - Only integer-valued arrays are supported. Values greater than zero are treated as edges ( + Only integer-valued arrays are supported. Values greater than zero are treated as edges ( coerced to ``1``); zero values mean no edge. :param arr: A square 2-D numpy array representing an adjacency matrix. @@ -48,13 +45,14 @@ def ndarray_to_matrix(arr: NDArray) -> AdjacencyMatrix: :return: A pure-Python adjacency matrix. :rtype: AdjacencyMatrix """ - if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: - raise ValueError(f"Expected a square 2-D array, got shape {arr.shape}") + if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: # noqa: PLR2004 + msg = f"Expected a square 2-D array, got shape {arr.shape}" + raise ValueError(msg) return [[1 if int(val) > 0 else 0 for val in row] for row in arr] def matrix_to_ndarray(matrix: AdjacencyMatrix) -> NDArray: - """Convert an :data:`~graphworks.types.AdjacencyMatrix` to a numpy ndarray. + """Convert a stdlib :data:`~graphworks.types.AdjacencyMatrix` to a numpy ndarray. :param matrix: A square pure-Python adjacency matrix. :type matrix: AdjacencyMatrix diff --git a/tests/algorithms/__init__.py b/tests/algorithms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_directed.py b/tests/algorithms/test_directed.py similarity index 95% rename from tests/test_directed.py rename to tests/algorithms/test_directed.py index b4d7a21..c51fee5 100644 --- a/tests/test_directed.py +++ b/tests/algorithms/test_directed.py @@ -30,7 +30,8 @@ def test_linear_dag(self) -> None: class TestFindCircuit: def test_simple_circuit(self, circuit_graph) -> None: circuit = find_circuit(circuit_graph) - assert len(circuit) == 4 and circuit[0] == circuit[-1] + assert len(circuit) == 4 + assert circuit[0] == circuit[-1] def test_visits_all_vertices(self, circuit_graph) -> None: assert set(find_circuit(circuit_graph)) == {"A", "B", "C"} diff --git a/tests/test_paths.py b/tests/algorithms/test_paths.py similarity index 100% rename from tests/test_paths.py rename to tests/algorithms/test_paths.py diff --git a/tests/test_properties.py b/tests/algorithms/test_properties.py similarity index 100% rename from tests/test_properties.py rename to tests/algorithms/test_properties.py diff --git a/tests/test_search.py b/tests/algorithms/test_search.py similarity index 100% rename from tests/test_search.py rename to tests/algorithms/test_search.py diff --git a/tests/test_sort.py b/tests/algorithms/test_sort.py similarity index 100% rename from tests/test_sort.py rename to tests/algorithms/test_sort.py diff --git a/tests/export/__init__.py b/tests/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/export/test_graphviz_utils.py b/tests/export/test_graphviz_utils.py new file mode 100644 index 0000000..bba9c3b --- /dev/null +++ b/tests/export/test_graphviz_utils.py @@ -0,0 +1,70 @@ +"""Unit tests for :mod:`graphworks.export.graphviz_utils`. + +These tests are automatically skipped when the ``graphviz`` Python package +is not installed. Install with:: + + pip install graphworks[viz] +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +import pytest + +graphviz = pytest.importorskip("graphviz", reason="graphviz not installed — skipping DOT tests") + +from graphworks.export.graphviz_utils import save_to_dot # noqa: E402 +from graphworks.graph import Graph # noqa: E402 + + +class TestSaveToDot: + """Tests for save_to_dot.""" + + def test_dot_file_created(self, tmp_dir: Path, simple_edge_graph) -> None: + """save_to_dot creates a .gv source file.""" + save_to_dot(simple_edge_graph, str(tmp_dir)) + gv_path = tmp_dir / f"{simple_edge_graph.label}.gv" + assert gv_path.exists() + + def test_undirected_graph_uses_graph_keyword(self, tmp_dir: Path) -> None: + """An undirected graph produces DOT source with 'graph' keyword.""" + data = {"label": "ug", "graph": {"A": ["B"], "B": ["A"]}} + graph = Graph(input_graph=json.dumps(data)) + save_to_dot(graph, str(tmp_dir)) + + gv_path = tmp_dir / "ug.gv" + content = gv_path.read_text(encoding="utf-8") + assert "graph" in content.lower() + + def test_directed_graph_uses_digraph_keyword(self, tmp_dir: Path) -> None: + """A directed graph produces DOT source with 'digraph' keyword.""" + data = { + "label": "dg", + "directed": True, + "graph": {"A": ["B"], "B": []}, + } + graph = Graph(input_graph=json.dumps(data)) + save_to_dot(graph, str(tmp_dir)) + + gv_path = tmp_dir / "dg.gv" + content = gv_path.read_text(encoding="utf-8") + assert "digraph" in content.lower() + + def test_all_vertices_present_in_dot(self, tmp_dir: Path) -> None: + """All vertex names appear in the DOT source.""" + data = { + "label": "abc", + "graph": {"A": ["B"], "B": ["C"], "C": []}, + } + graph = Graph(input_graph=json.dumps(data)) + save_to_dot(graph, str(tmp_dir)) + + gv_path = tmp_dir / "abc.gv" + content = gv_path.read_text(encoding="utf-8") + for v in ("A", "B", "C"): + assert v in content diff --git a/tests/export/test_json_utils.py b/tests/export/test_json_utils.py new file mode 100644 index 0000000..77097bf --- /dev/null +++ b/tests/export/test_json_utils.py @@ -0,0 +1,83 @@ +"""Unit tests for :mod:`graphworks.export.json_utils`.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +from graphworks.export.json_utils import save_to_json +from graphworks.graph import Graph + + +class TestSaveToJson: + """Tests for save_to_json.""" + + def test_file_created(self, tmp_dir: Path, simple_edge_graph) -> None: + """save_to_json creates a file named after the graph label.""" + save_to_json(simple_edge_graph, str(tmp_dir)) + out_path = tmp_dir / f"{simple_edge_graph.label}.json" + assert out_path.exists() + + def test_roundtrip(self, tmp_dir: Path, simple_edge_json) -> None: + """A graph exported to JSON can be re-imported identically.""" + original = Graph(input_graph=json.dumps(simple_edge_json)) + save_to_json(original, str(tmp_dir)) + + out_path = tmp_dir / f"{original.label}.json" + reloaded = Graph(input_file=str(out_path)) + + assert reloaded.label == original.label + assert reloaded.directed == original.directed + assert sorted(reloaded.vertices()) == sorted(original.vertices()) + assert reloaded.size == original.size + + def test_directed_flag_preserved(self, tmp_dir: Path) -> None: + """The directed flag survives a JSON roundtrip.""" + data = {"label": "dag", "directed": True, "graph": {"A": ["B"], "B": []}} + original = Graph(input_graph=json.dumps(data)) + save_to_json(original, str(tmp_dir)) + + out_path = tmp_dir / "dag.json" + reloaded = Graph(input_file=str(out_path)) + assert reloaded.directed + + def test_weighted_flag_preserved(self, tmp_dir: Path) -> None: + """The weighted flag survives a JSON roundtrip.""" + data = {"label": "w", "weighted": True, "graph": {"A": [], "B": []}} + original = Graph(input_graph=json.dumps(data)) + save_to_json(original, str(tmp_dir)) + + out_path = tmp_dir / "w.json" + reloaded = Graph(input_file=str(out_path)) + assert reloaded.weighted + + def test_adjacency_preserved(self, tmp_dir: Path) -> None: + """Neighbour lists are correctly serialized.""" + data = { + "label": "tri", + "graph": { + "a": ["b", "c"], + "b": ["a", "c"], + "c": ["a", "b"], + }, + } + original = Graph(input_graph=json.dumps(data)) + save_to_json(original, str(tmp_dir)) + + 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"] + + def test_isolated_vertices_preserved(self, tmp_dir: Path) -> None: + """Isolated vertices appear in the JSON output with empty lists.""" + data = {"label": "iso", "graph": {"a": [], "b": [], "c": []}} + original = Graph(input_graph=json.dumps(data)) + save_to_json(original, str(tmp_dir)) + + 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"]) diff --git a/tests/test_edge.py b/tests/test_edge.py index 289c6c3..627c3b1 100644 --- a/tests/test_edge.py +++ b/tests/test_edge.py @@ -1,7 +1,4 @@ -"""Unit tests for :class:`graphworks.edge.Edge`. - -:author: Nathan Gilbert -""" +"""Unit tests for :class:`graphworks.edge.Edge`.""" from __future__ import annotations diff --git a/tests/test_numpy_compat.py b/tests/test_numpy_compat.py index 1faf210..a5fe647 100644 --- a/tests/test_numpy_compat.py +++ b/tests/test_numpy_compat.py @@ -1,17 +1,9 @@ -""" -tests.test_numpy_compat -~~~~~~~~~~~~~~~~~~~~~~~ - -Unit tests for :mod:`graphworks.numpy_compat`. +"""Unit tests for :mod:`graphworks.numpy_compat`. These tests are automatically skipped when numpy is not installed. Install the optional dependency with:: pip install graphworks[matrix] - # or - uv add graphworks[matrix] - -:author: Nathan Gilbert """ from __future__ import annotations @@ -29,61 +21,28 @@ class TestNdarrayToMatrix: """Tests for ndarray_to_matrix.""" def test_basic_conversion(self) -> None: - """A simple 2×2 ndarray converts to the expected list-of-lists. - - :return: Nothing - :rtype: None - """ arr = np.array([[0, 1], [1, 0]]) - result = ndarray_to_matrix(arr) - assert result == [[0, 1], [1, 0]] + assert ndarray_to_matrix(arr) == [[0, 1], [1, 0]] def test_nonzero_values_coerced_to_one(self) -> None: - """Values greater than 0 are coerced to 1. - - :return: Nothing - :rtype: None - """ arr = np.array([[0, 5], [2, 0]]) - result = ndarray_to_matrix(arr) - assert result == [[0, 1], [1, 0]] + assert ndarray_to_matrix(arr) == [[0, 1], [1, 0]] def test_zero_values_remain_zero(self) -> None: - """Zero values in the ndarray produce 0 in the matrix. - - :return: Nothing - :rtype: None - """ arr = np.zeros((3, 3), dtype=int) - result = ndarray_to_matrix(arr) - assert result == [[0, 0, 0], [0, 0, 0], [0, 0, 0]] + assert ndarray_to_matrix(arr) == [[0, 0, 0], [0, 0, 0], [0, 0, 0]] def test_non_square_raises_value_error(self) -> None: - """A non-square ndarray raises ValueError. - - :return: Nothing - :rtype: None - """ arr = np.array([[0, 1, 0, 0, 0], [1, 0]], dtype=object) - with pytest.raises(ValueError): + with pytest.raises(ValueError): # noqa: PT011 ndarray_to_matrix(arr) def test_three_dimensional_raises_value_error(self) -> None: - """A 3-D ndarray raises ValueError (must be 2-D). - - :return: Nothing - :rtype: None - """ arr = np.zeros((2, 2, 2)) - with pytest.raises(ValueError): + with pytest.raises(ValueError): # noqa: PT011 ndarray_to_matrix(arr) def test_result_is_list_of_lists(self) -> None: - """The returned value is a ``list[list[int]]``, not an ndarray. - - :return: Nothing - :rtype: None - """ arr = np.eye(2, dtype=int) result = ndarray_to_matrix(arr) assert isinstance(result, list) @@ -94,51 +53,24 @@ class TestMatrixToNdarray: """Tests for matrix_to_ndarray.""" def test_basic_conversion(self) -> None: - """A list-of-lists converts to the expected numpy array. - - :return: Nothing - :rtype: None - """ matrix = [[0, 1], [1, 0]] result = matrix_to_ndarray(matrix) np.testing.assert_array_equal(result, np.array([[0, 1], [1, 0]])) def test_dtype_is_integer(self) -> None: - """The returned array has an integer dtype. - - :return: Nothing - :rtype: None - """ result = matrix_to_ndarray([[0, 1], [1, 0]]) assert np.issubdtype(result.dtype, np.integer) def test_zeros_matrix(self) -> None: - """An all-zeros matrix converts without modification. - - :return: Nothing - :rtype: None - """ - matrix = [[0, 0], [0, 0]] - result = matrix_to_ndarray(matrix) + result = matrix_to_ndarray([[0, 0], [0, 0]]) np.testing.assert_array_equal(result, np.zeros((2, 2), dtype=int)) def test_result_is_ndarray(self) -> None: - """The returned value is a numpy ndarray. - - :return: Nothing - :rtype: None - """ result = matrix_to_ndarray([[0, 1], [1, 0]]) assert isinstance(result, np.ndarray) def test_shape_preserved(self) -> None: - """The shape of the output array matches the input matrix dimensions. - - :return: Nothing - :rtype: None - """ - matrix = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] - result = matrix_to_ndarray(matrix) + result = matrix_to_ndarray([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) assert result.shape == (3, 3) @@ -146,37 +78,21 @@ class TestGraphNumpyIntegration: """Integration tests for Graph ↔ numpy ndarray round-trips.""" def test_graph_from_ndarray_via_compat(self) -> None: - """Graph built from an ndarray (via ndarray_to_matrix) is valid. - - :return: Nothing - :rtype: None - """ arr = np.array([[0, 1], [1, 0]], dtype=object) matrix = ndarray_to_matrix(arr) graph = Graph(input_matrix=matrix) - assert len(graph.vertices()) == 2 + assert graph.order == 2 assert len(graph.edges()) == 1 def test_symmetric_matrix_roundtrip(self) -> None: - """A symmetric adjacency matrix survives a full ndarray roundtrip. - - :return: Nothing - :rtype: None - """ original = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] arr = matrix_to_ndarray(original) recovered = ndarray_to_matrix(arr) assert original == recovered def test_graph_order_from_ndarray(self) -> None: - """A 4×4 ndarray produces a graph with 4 vertices. - - :return: Nothing - :rtype: None - """ arr = np.zeros((4, 4), dtype=int) arr[0, 1] = 1 arr[1, 0] = 1 - matrix = ndarray_to_matrix(arr) - graph = Graph(input_matrix=matrix) + graph = Graph(input_matrix=ndarray_to_matrix(arr)) assert graph.order == 4 diff --git a/tests/test_vertex.py b/tests/test_vertex.py index c00b1ae..ff5e8cd 100644 --- a/tests/test_vertex.py +++ b/tests/test_vertex.py @@ -1,7 +1,4 @@ -"""Unit tests for :class:`graphworks.vertex.Vertex`. - -:author: Nathan Gilbert -""" +"""Unit tests for :class:`graphworks.vertex.Vertex`.""" from __future__ import annotations From 496770a8f964ab88e53a874d69f41344b5224a0c Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Wed, 25 Mar 2026 21:40:33 -0600 Subject: [PATCH 7/8] Move the data dir to tests --- src/graphworks/README.md | 3 --- {src/graphworks => tests}/data/g1.json | 0 {src/graphworks => tests}/data/g2.json | 0 {src/graphworks => tests}/data/g3.json | 0 {src/graphworks => tests}/data/g4.json | 0 5 files changed, 3 deletions(-) delete mode 100644 src/graphworks/README.md rename {src/graphworks => tests}/data/g1.json (100%) rename {src/graphworks => tests}/data/g2.json (100%) rename {src/graphworks => tests}/data/g3.json (100%) rename {src/graphworks => tests}/data/g4.json (100%) diff --git a/src/graphworks/README.md b/src/graphworks/README.md deleted file mode 100644 index 98b0952..0000000 --- a/src/graphworks/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Graphworks - -## A library for efficient graph theoretic computation diff --git a/src/graphworks/data/g1.json b/tests/data/g1.json similarity index 100% rename from src/graphworks/data/g1.json rename to tests/data/g1.json diff --git a/src/graphworks/data/g2.json b/tests/data/g2.json similarity index 100% rename from src/graphworks/data/g2.json rename to tests/data/g2.json diff --git a/src/graphworks/data/g3.json b/tests/data/g3.json similarity index 100% rename from src/graphworks/data/g3.json rename to tests/data/g3.json diff --git a/src/graphworks/data/g4.json b/tests/data/g4.json similarity index 100% rename from src/graphworks/data/g4.json rename to tests/data/g4.json From 9471fb50a32b5a1a5bd7d6c4c67a668db03db90a Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Wed, 25 Mar 2026 21:42:10 -0600 Subject: [PATCH 8/8] Remove some stuff from the build --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2646b01..c9298a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,8 +64,8 @@ dev-mode-dirs = [ "src", ".",] version-file = "src/graphworks/_version.py" [tool.hatch.build.targets.sdist] -include = [ "src/", "tests/", "docs/", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml",] -exclude = [ "examples/",] +include = [ "src/", "README.md", "LICENSE", "pyproject.toml",] +exclude = [ "examples/", "tests/",] [tool.hatch.build.targets.wheel] packages = [ "src/graphworks",]