From 7759a5871ba9fba38b16daf30878bb87af2becee Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Tue, 13 Oct 2026 12:03:57 +1000 Subject: [PATCH] perf: import pgraph without matplotlib, spatialmath or scipy PGraph.py imported matplotlib, mpl_toolkits and spatialmath at module level, and two of its classes subclassed FancyArrowPatch, so import pgraph took 0.62 s, nearly all of it spatialmath (which brings sympy, scipy, PIL and matplotlib) and matplotlib. They are used only to plot. _loop_path, _Arrow3D and _Loop3D move to pgraph/_plot.py, which plot() and the edge artist import; plot(), highlight_vertex, highlight_edge and highlight_path import pyplot, and plot() spatialmath's axes_logic, when called. import pgraph is now 0.04 s, the cost of numpy. A test runs a fresh interpreter to check what an import, a build, a plan, a spatial query and an export load, and that plotting still imports matplotlib late. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 1 + src/pgraph/PGraph.py | 69 +++++------------------------ src/pgraph/_plot.py | 70 ++++++++++++++++++++++++++++++ tests/test_lazy_imports.py | 89 ++++++++++++++++++++++++++++++++++++++ tests/test_plot_arrows.py | 2 +- 5 files changed, 172 insertions(+), 59 deletions(-) create mode 100644 src/pgraph/_plot.py create mode 100644 tests/test_lazy_imports.py 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):