diff --git a/AGENTS.md b/AGENTS.md index 0082366a..bdd6aa41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Part of the RVC ecosystem. **Read [rvc-ecosystem/AGENTS.md](https://github.com/p - **Records that must agree.** A vertex has `_edgelist` (leaving, or incident if undirected) and `_inlist` (entering, directed only); the graph has `_vertexlist`, `_vertexdict` (names) and `_edges` (an insertion-ordered dict). `g.validate()` checks them all; use it in tests of anything that edits the structure. - **Two staleness flags, consumed independently.** `_connectivitychange` (components, reset by `_graphcolor`) and `_spatialchange` (the k-d tree). Set both wherever a vertex is added or removed; only connectivity cares about edges. Never share one flag. - **`Vertex.__setattr__` is hot.** It exists to tell the graph that `coord` was assigned. Anything run per vertex (`Vertex.__init__`, `add_vertex`, components) writes `vertex.__dict__` directly to skip it; keep doing that, and declare new attributes at class level so mypy knows them. +- **`import pgraph` loads only numpy.** matplotlib, spatialmath and scipy are imported by the methods that need them (`plot`, `highlight_*`, `_edge_artist`, `build_kdtree`), and the classes that need matplotlib to be defined are in `_plot.py`. `tests/test_lazy_imports.py` runs a fresh interpreter to check it: do not add a module-level import of them. - **Pickle and `copy` use a flat description** (`__getstate__`/`__setstate__`: vertices, edges by index, replayed through `_attach`); `subgraph` reuses it. Derived or transient state on a graph (the k-d tree) must be excluded there. A bare vertex or edge refuses to be pickled or copied. - **Tests** are `unittest` classes in `tests/`, run with `pytest`; write expected values by hand or from an oracle in the test, never from the code under test. Import names explicitly (a star import is a Codacy `F405`); route `pickle` through one helper marked `# nosec B301 nosemgrep`. Codecov gates patch coverage, so every changed line needs a test. `mypy src` is a CI check. - **Performance claims are measured.** `examples/benchmark.py` (`--md`, `--directed`) produces the README tables; regenerate them after a performance change and compare with `v0.6.5` (`git archive v0.6.5 src`), not with a development build. diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index 21266597..2420708a 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -5,11 +5,6 @@ import sys import warnings import numpy as np -import matplotlib.pyplot as plt -from matplotlib.patches import FancyArrowPatch -from matplotlib.path import Path -from matplotlib.transforms import Affine2D, ScaledTranslation -from mpl_toolkits.mplot3d import proj3d import copy import heapq import importlib @@ -20,7 +15,8 @@ from typing import IO, Any, Callable, ClassVar, overload from numpy.typing import ArrayLike, NDArray -from spatialmath.base.graphics import axes_logic +# matplotlib and spatialmath are imported by the methods that plot, and by +# pgraph._plot, so that importing pgraph does not load them. def _L1(v: NDArray) -> float: @@ -159,58 +155,6 @@ def _renamed( return old -def _loop_path(angle: float, radius: float, markersize: float) -> Path: - """ - The path of a loop at a vertex, in points from the vertex (private function) - - A circle of ``radius`` whose centre is that far from the vertex, in the - direction ``angle`` degrees, less the part inside the vertex's marker. It - is counter-clockwise, so a directed loop has its head where it returns. - """ - m = markersize / 2 - phi = np.degrees(np.arccos(np.clip(1 - m * m / (2 * radius * radius), -1, 1))) - arc = Path.arc(angle + 180 + phi, angle + 540 - phi) - centre = radius * np.array([np.cos(np.radians(angle)), np.sin(np.radians(angle))]) - return Path(np.asarray(arc.vertices) * radius + centre, arc.codes) - - -class _Arrow3D(FancyArrowPatch): - """An edge between two points of a 3D plot, which follows a rotation""" - - def __init__(self, p1: ArrayLike, p2: ArrayLike, **kwargs: Any): - super().__init__((0, 0), (0, 0), **kwargs) - self._p1, self._p2 = np.asarray(p1, float), np.asarray(p2, float) - - def do_3d_projection(self, renderer: Any = None) -> float: - ax: Any = self.axes - xs, ys, zs = proj3d.proj_transform( - [self._p1[0], self._p2[0]], - [self._p1[1], self._p2[1]], - [self._p1[2], self._p2[2]], - ax.M, - ) - self.set_positions((xs[0], ys[0]), (xs[1], ys[1])) - return float(np.min(zs)) - - -class _Loop3D(FancyArrowPatch): - """A loop at a vertex of a 3D plot, which follows a rotation""" - - def __init__(self, anchor: ArrayLike, path: Path, **kwargs: Any): - super().__init__(path=path, **kwargs) - self._anchor = np.asarray(anchor, float) - - def do_3d_projection(self, renderer: Any = None) -> float: - ax: Any = self.axes - x, y, z = proj3d.proj_transform(*self._anchor, ax.M) - self.set_transform( - Affine2D().scale(1 / 72) - + ax.figure.dpi_scale_trans - + ScaledTranslation(x, y, ax.transData) - ) - return float(z) - - class Graph: """ Base class for all graphs @@ -2423,6 +2367,9 @@ def _edge_artist( :param kwargs: options of the patch: color, linewidth, arrowstyle, ... :return: the artist, to be added to ``ax`` """ + from matplotlib.patches import FancyArrowPatch + from matplotlib.transforms import Affine2D, ScaledTranslation + from ._plot import _Arrow3D, _Loop3D, _loop_path v1, v2 = edge.v1, edge.v2 assert v1 is not None and v2 is not None shrink = markersize / 2 + 1 @@ -2580,6 +2527,9 @@ def plot( :seealso: :meth:`highlight_path` :meth:`highlight_edge` """ + import matplotlib.pyplot as plt + from matplotlib.patches import FancyArrowPatch + from spatialmath.base.graphics import axes_logic # the options that have been renamed vertex_opt = _renamed("plot", "vopt", vopt, "vertex_opt", vertex_opt) edge_opt = _renamed("plot", "eopt", eopt, "edge_opt", edge_opt) @@ -2753,6 +2703,7 @@ def highlight_path( :seealso: :meth:`highlight_vertex` :meth:`highlight_edge` """ + import matplotlib.pyplot as plt for i in range(len(path)): if i < len(path) - 1: e = path[i].edgeto(path[i + 1]) @@ -2811,6 +2762,7 @@ def highlight_edge( :seealso: :meth:`plot` :meth:`highlight_vertex` :meth:`highlight_path` """ + import matplotlib.pyplot as plt if edge.v1 is None or edge.v2 is None: raise ValueError("edge does not connect two vertices") # over the edge as plot() draws it, so that it follows an arc @@ -2879,6 +2831,7 @@ def highlight_vertex( >>> g.highlight_vertex(g[2]) """ + import matplotlib.pyplot as plt if isinstance(vertex, Iterable): for n in vertex: if isinstance(n, str): diff --git a/src/pgraph/_plot.py b/src/pgraph/_plot.py new file mode 100644 index 00000000..e5eb4d03 --- /dev/null +++ b/src/pgraph/_plot.py @@ -0,0 +1,70 @@ +""" +Matplotlib pieces of the plot of a graph (private module) + +Imported by :meth:`pgraph.Graph.plot` and the methods that draw an edge, so that +``import pgraph`` does not load matplotlib: this module is the only part of +the package whose classes need it to be defined at all. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from matplotlib.patches import FancyArrowPatch +from matplotlib.path import Path +from matplotlib.transforms import Affine2D, ScaledTranslation +from mpl_toolkits.mplot3d import proj3d +from numpy.typing import ArrayLike + + +def _loop_path(angle: float, radius: float, markersize: float) -> Path: + """ + The path of a loop at a vertex, in points from the vertex (private function) + + A circle of ``radius`` whose centre is that far from the vertex, in the + direction ``angle`` degrees, less the part inside the vertex's marker. It + is counter-clockwise, so a directed loop has its head where it returns. + """ + m = markersize / 2 + phi = np.degrees(np.arccos(np.clip(1 - m * m / (2 * radius * radius), -1, 1))) + arc = Path.arc(angle + 180 + phi, angle + 540 - phi) + centre = radius * np.array([np.cos(np.radians(angle)), np.sin(np.radians(angle))]) + return Path(np.asarray(arc.vertices) * radius + centre, arc.codes) + + +class _Arrow3D(FancyArrowPatch): + """An edge between two points of a 3D plot, which follows a rotation""" + + def __init__(self, p1: ArrayLike, p2: ArrayLike, **kwargs: Any): + super().__init__((0, 0), (0, 0), **kwargs) + self._p1, self._p2 = np.asarray(p1, float), np.asarray(p2, float) + + def do_3d_projection(self, renderer: Any = None) -> float: + ax: Any = self.axes + xs, ys, zs = proj3d.proj_transform( + [self._p1[0], self._p2[0]], + [self._p1[1], self._p2[1]], + [self._p1[2], self._p2[2]], + ax.M, + ) + self.set_positions((xs[0], ys[0]), (xs[1], ys[1])) + return float(np.min(zs)) + + +class _Loop3D(FancyArrowPatch): + """A loop at a vertex of a 3D plot, which follows a rotation""" + + def __init__(self, anchor: ArrayLike, path: Path, **kwargs: Any): + super().__init__(path=path, **kwargs) + self._anchor = np.asarray(anchor, float) + + def do_3d_projection(self, renderer: Any = None) -> float: + ax: Any = self.axes + x, y, z = proj3d.proj_transform(*self._anchor, ax.M) + self.set_transform( + Affine2D().scale(1 / 72) + + ax.figure.dpi_scale_trans + + ScaledTranslation(x, y, ax.transData) + ) + return float(z) diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py new file mode 100644 index 00000000..fbeb447e --- /dev/null +++ b/tests/test_lazy_imports.py @@ -0,0 +1,89 @@ +""" +Importing pgraph does not load matplotlib, spatialmath or scipy: they are +imported by the methods that plot (matplotlib, spatialmath) or build a spatial +index (scipy), so a script that only builds graphs and plans paths does not pay +for them. + +Each check runs a new interpreter, since the modules of this one are loaded. +""" + +import json +import subprocess # nosec B404 nosemgrep - runs this interpreter on a fixed snippet +import sys +import unittest + + +def loaded(code: str, *modules: str) -> dict[str, bool]: + """Run ``code``, then say which of ``modules`` are in sys.modules""" + probe = ( + code + + "\nimport sys, json\n" + + f"print(json.dumps({{m: m in sys.modules for m in {list(modules)!r}}}))" + ) + out = subprocess.run( # nosec B603 nosemgrep + [sys.executable, "-c", probe], capture_output=True, text=True, check=True + ) + return json.loads(out.stdout.strip().splitlines()[-1]) + + +HEAVY = ("matplotlib", "matplotlib.pyplot", "mpl_toolkits", "spatialmath", "scipy") + + +class TestLazyImports(unittest.TestCase): + def test_import_pgraph(self): + got = loaded("import pgraph", *HEAVY) + self.assertEqual({m for m, v in got.items() if v}, set()) + + def test_building_and_planning_does_not_import_them(self): + got = loaded( + """ +import pgraph +g = pgraph.DGraph(dim=2) +a = g.add_vertex([0, 0], name="a") +b = g.add_vertex([3, 4], name="b") +g.add_edge(a, b) +g.path_Astar("a", "b"); g.path_BFS("a", "b"); g.path_UCS("a", "b") +g.closest([1, 1]); g.within([[0, 0], [5, 5]]); g.near([0, 0], 10) +g.subgraph([a, b]).copy(); g.adjacency(); g.laplacian(); g.incidence() +g.export(format="dot") +import pickle; pickle.loads(pickle.dumps(g)) +""", + *HEAVY, + ) + self.assertEqual({m for m, v in got.items() if v}, set()) + + def test_plotting_imports_matplotlib(self): + got = loaded( + """ +import matplotlib +matplotlib.use("Agg") +import pgraph +g = pgraph.UGraph(dim=2) +a = g.add_vertex([0, 0]); b = g.add_vertex([1, 1]); g.add_edge(a, b) +g.plot(block=None) +""", + "matplotlib.pyplot", + ) + self.assertTrue(got["matplotlib.pyplot"]) + + def test_plotting_works_when_matplotlib_is_imported_late(self): + # the whole point: the import happens inside plot(), not before + got = loaded( + """ +import pgraph +import sys +assert "matplotlib" not in sys.modules +g = pgraph.DGraph(dim=3) +a = g.add_vertex([0, 0, 0]); b = g.add_vertex([1, 1, 1]); g.add_edge(a, b); g.add_edge(b, a); g.add_edge(a, a) +import matplotlib +matplotlib.use("Agg") +g.plot(block=None) +g.highlight_path([a, b], block=None) +""", + "matplotlib", + ) + self.assertTrue(got["matplotlib"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plot_arrows.py b/tests/test_plot_arrows.py index a92c18f3..cd9eb387 100644 --- a/tests/test_plot_arrows.py +++ b/tests/test_plot_arrows.py @@ -19,7 +19,7 @@ from matplotlib.patches import ArrowStyle, ConnectionStyle, FancyArrowPatch from pgraph import DGraph, UGraph -from pgraph.PGraph import _Arrow3D, _Loop3D +from pgraph._plot import _Arrow3D, _Loop3D def build(cls, edges, loops=(), dim=2):