Skip to content

Commit 7759a58

Browse files
petercorkeclaude
andcommitted
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 <noreply@anthropic.com>
1 parent c9d38e6 commit 7759a58

5 files changed

Lines changed: 172 additions & 59 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Part of the RVC ecosystem. **Read [rvc-ecosystem/AGENTS.md](https://github.com/p
2020
- **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.
2121
- **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.
2222
- **`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.
23+
- **`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.
2324
- **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.
2425
- **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.
2526
- **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.

‎src/pgraph/PGraph.py‎

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,6 @@
55
import sys
66
import warnings
77
import numpy as np
8-
import matplotlib.pyplot as plt
9-
from matplotlib.patches import FancyArrowPatch
10-
from matplotlib.path import Path
11-
from matplotlib.transforms import Affine2D, ScaledTranslation
12-
from mpl_toolkits.mplot3d import proj3d
138
import copy
149
import heapq
1510
import importlib
@@ -20,7 +15,8 @@
2015
from typing import IO, Any, Callable, ClassVar, overload
2116
from numpy.typing import ArrayLike, NDArray
2217

23-
from spatialmath.base.graphics import axes_logic
18+
# matplotlib and spatialmath are imported by the methods that plot, and by
19+
# pgraph._plot, so that importing pgraph does not load them.
2420

2521

2622
def _L1(v: NDArray) -> float:
@@ -159,58 +155,6 @@ def _renamed(
159155
return old
160156

161157

162-
def _loop_path(angle: float, radius: float, markersize: float) -> Path:
163-
"""
164-
The path of a loop at a vertex, in points from the vertex (private function)
165-
166-
A circle of ``radius`` whose centre is that far from the vertex, in the
167-
direction ``angle`` degrees, less the part inside the vertex's marker. It
168-
is counter-clockwise, so a directed loop has its head where it returns.
169-
"""
170-
m = markersize / 2
171-
phi = np.degrees(np.arccos(np.clip(1 - m * m / (2 * radius * radius), -1, 1)))
172-
arc = Path.arc(angle + 180 + phi, angle + 540 - phi)
173-
centre = radius * np.array([np.cos(np.radians(angle)), np.sin(np.radians(angle))])
174-
return Path(np.asarray(arc.vertices) * radius + centre, arc.codes)
175-
176-
177-
class _Arrow3D(FancyArrowPatch):
178-
"""An edge between two points of a 3D plot, which follows a rotation"""
179-
180-
def __init__(self, p1: ArrayLike, p2: ArrayLike, **kwargs: Any):
181-
super().__init__((0, 0), (0, 0), **kwargs)
182-
self._p1, self._p2 = np.asarray(p1, float), np.asarray(p2, float)
183-
184-
def do_3d_projection(self, renderer: Any = None) -> float:
185-
ax: Any = self.axes
186-
xs, ys, zs = proj3d.proj_transform(
187-
[self._p1[0], self._p2[0]],
188-
[self._p1[1], self._p2[1]],
189-
[self._p1[2], self._p2[2]],
190-
ax.M,
191-
)
192-
self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
193-
return float(np.min(zs))
194-
195-
196-
class _Loop3D(FancyArrowPatch):
197-
"""A loop at a vertex of a 3D plot, which follows a rotation"""
198-
199-
def __init__(self, anchor: ArrayLike, path: Path, **kwargs: Any):
200-
super().__init__(path=path, **kwargs)
201-
self._anchor = np.asarray(anchor, float)
202-
203-
def do_3d_projection(self, renderer: Any = None) -> float:
204-
ax: Any = self.axes
205-
x, y, z = proj3d.proj_transform(*self._anchor, ax.M)
206-
self.set_transform(
207-
Affine2D().scale(1 / 72)
208-
+ ax.figure.dpi_scale_trans
209-
+ ScaledTranslation(x, y, ax.transData)
210-
)
211-
return float(z)
212-
213-
214158
class Graph:
215159
"""
216160
Base class for all graphs
@@ -2423,6 +2367,9 @@ def _edge_artist(
24232367
:param kwargs: options of the patch: color, linewidth, arrowstyle, ...
24242368
:return: the artist, to be added to ``ax``
24252369
"""
2370+
from matplotlib.patches import FancyArrowPatch
2371+
from matplotlib.transforms import Affine2D, ScaledTranslation
2372+
from ._plot import _Arrow3D, _Loop3D, _loop_path
24262373
v1, v2 = edge.v1, edge.v2
24272374
assert v1 is not None and v2 is not None
24282375
shrink = markersize / 2 + 1
@@ -2580,6 +2527,9 @@ def plot(
25802527
25812528
:seealso: :meth:`highlight_path` :meth:`highlight_edge`
25822529
"""
2530+
import matplotlib.pyplot as plt
2531+
from matplotlib.patches import FancyArrowPatch
2532+
from spatialmath.base.graphics import axes_logic
25832533
# the options that have been renamed
25842534
vertex_opt = _renamed("plot", "vopt", vopt, "vertex_opt", vertex_opt)
25852535
edge_opt = _renamed("plot", "eopt", eopt, "edge_opt", edge_opt)
@@ -2753,6 +2703,7 @@ def highlight_path(
27532703
27542704
:seealso: :meth:`highlight_vertex` :meth:`highlight_edge`
27552705
"""
2706+
import matplotlib.pyplot as plt
27562707
for i in range(len(path)):
27572708
if i < len(path) - 1:
27582709
e = path[i].edgeto(path[i + 1])
@@ -2811,6 +2762,7 @@ def highlight_edge(
28112762
28122763
:seealso: :meth:`plot` :meth:`highlight_vertex` :meth:`highlight_path`
28132764
"""
2765+
import matplotlib.pyplot as plt
28142766
if edge.v1 is None or edge.v2 is None:
28152767
raise ValueError("edge does not connect two vertices")
28162768
# over the edge as plot() draws it, so that it follows an arc
@@ -2879,6 +2831,7 @@ def highlight_vertex(
28792831
>>> g.highlight_vertex(g[2])
28802832
28812833
"""
2834+
import matplotlib.pyplot as plt
28822835
if isinstance(vertex, Iterable):
28832836
for n in vertex:
28842837
if isinstance(n, str):

‎src/pgraph/_plot.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
Matplotlib pieces of the plot of a graph (private module)
3+
4+
Imported by :meth:`pgraph.Graph.plot` and the methods that draw an edge, so that
5+
``import pgraph`` does not load matplotlib: this module is the only part of
6+
the package whose classes need it to be defined at all.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any
12+
13+
import numpy as np
14+
from matplotlib.patches import FancyArrowPatch
15+
from matplotlib.path import Path
16+
from matplotlib.transforms import Affine2D, ScaledTranslation
17+
from mpl_toolkits.mplot3d import proj3d
18+
from numpy.typing import ArrayLike
19+
20+
21+
def _loop_path(angle: float, radius: float, markersize: float) -> Path:
22+
"""
23+
The path of a loop at a vertex, in points from the vertex (private function)
24+
25+
A circle of ``radius`` whose centre is that far from the vertex, in the
26+
direction ``angle`` degrees, less the part inside the vertex's marker. It
27+
is counter-clockwise, so a directed loop has its head where it returns.
28+
"""
29+
m = markersize / 2
30+
phi = np.degrees(np.arccos(np.clip(1 - m * m / (2 * radius * radius), -1, 1)))
31+
arc = Path.arc(angle + 180 + phi, angle + 540 - phi)
32+
centre = radius * np.array([np.cos(np.radians(angle)), np.sin(np.radians(angle))])
33+
return Path(np.asarray(arc.vertices) * radius + centre, arc.codes)
34+
35+
36+
class _Arrow3D(FancyArrowPatch):
37+
"""An edge between two points of a 3D plot, which follows a rotation"""
38+
39+
def __init__(self, p1: ArrayLike, p2: ArrayLike, **kwargs: Any):
40+
super().__init__((0, 0), (0, 0), **kwargs)
41+
self._p1, self._p2 = np.asarray(p1, float), np.asarray(p2, float)
42+
43+
def do_3d_projection(self, renderer: Any = None) -> float:
44+
ax: Any = self.axes
45+
xs, ys, zs = proj3d.proj_transform(
46+
[self._p1[0], self._p2[0]],
47+
[self._p1[1], self._p2[1]],
48+
[self._p1[2], self._p2[2]],
49+
ax.M,
50+
)
51+
self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
52+
return float(np.min(zs))
53+
54+
55+
class _Loop3D(FancyArrowPatch):
56+
"""A loop at a vertex of a 3D plot, which follows a rotation"""
57+
58+
def __init__(self, anchor: ArrayLike, path: Path, **kwargs: Any):
59+
super().__init__(path=path, **kwargs)
60+
self._anchor = np.asarray(anchor, float)
61+
62+
def do_3d_projection(self, renderer: Any = None) -> float:
63+
ax: Any = self.axes
64+
x, y, z = proj3d.proj_transform(*self._anchor, ax.M)
65+
self.set_transform(
66+
Affine2D().scale(1 / 72)
67+
+ ax.figure.dpi_scale_trans
68+
+ ScaledTranslation(x, y, ax.transData)
69+
)
70+
return float(z)

‎tests/test_lazy_imports.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Importing pgraph does not load matplotlib, spatialmath or scipy: they are
3+
imported by the methods that plot (matplotlib, spatialmath) or build a spatial
4+
index (scipy), so a script that only builds graphs and plans paths does not pay
5+
for them.
6+
7+
Each check runs a new interpreter, since the modules of this one are loaded.
8+
"""
9+
10+
import json
11+
import subprocess # nosec B404 nosemgrep - runs this interpreter on a fixed snippet
12+
import sys
13+
import unittest
14+
15+
16+
def loaded(code: str, *modules: str) -> dict[str, bool]:
17+
"""Run ``code``, then say which of ``modules`` are in sys.modules"""
18+
probe = (
19+
code
20+
+ "\nimport sys, json\n"
21+
+ f"print(json.dumps({{m: m in sys.modules for m in {list(modules)!r}}}))"
22+
)
23+
out = subprocess.run( # nosec B603 nosemgrep
24+
[sys.executable, "-c", probe], capture_output=True, text=True, check=True
25+
)
26+
return json.loads(out.stdout.strip().splitlines()[-1])
27+
28+
29+
HEAVY = ("matplotlib", "matplotlib.pyplot", "mpl_toolkits", "spatialmath", "scipy")
30+
31+
32+
class TestLazyImports(unittest.TestCase):
33+
def test_import_pgraph(self):
34+
got = loaded("import pgraph", *HEAVY)
35+
self.assertEqual({m for m, v in got.items() if v}, set())
36+
37+
def test_building_and_planning_does_not_import_them(self):
38+
got = loaded(
39+
"""
40+
import pgraph
41+
g = pgraph.DGraph(dim=2)
42+
a = g.add_vertex([0, 0], name="a")
43+
b = g.add_vertex([3, 4], name="b")
44+
g.add_edge(a, b)
45+
g.path_Astar("a", "b"); g.path_BFS("a", "b"); g.path_UCS("a", "b")
46+
g.closest([1, 1]); g.within([[0, 0], [5, 5]]); g.near([0, 0], 10)
47+
g.subgraph([a, b]).copy(); g.adjacency(); g.laplacian(); g.incidence()
48+
g.export(format="dot")
49+
import pickle; pickle.loads(pickle.dumps(g))
50+
""",
51+
*HEAVY,
52+
)
53+
self.assertEqual({m for m, v in got.items() if v}, set())
54+
55+
def test_plotting_imports_matplotlib(self):
56+
got = loaded(
57+
"""
58+
import matplotlib
59+
matplotlib.use("Agg")
60+
import pgraph
61+
g = pgraph.UGraph(dim=2)
62+
a = g.add_vertex([0, 0]); b = g.add_vertex([1, 1]); g.add_edge(a, b)
63+
g.plot(block=None)
64+
""",
65+
"matplotlib.pyplot",
66+
)
67+
self.assertTrue(got["matplotlib.pyplot"])
68+
69+
def test_plotting_works_when_matplotlib_is_imported_late(self):
70+
# the whole point: the import happens inside plot(), not before
71+
got = loaded(
72+
"""
73+
import pgraph
74+
import sys
75+
assert "matplotlib" not in sys.modules
76+
g = pgraph.DGraph(dim=3)
77+
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)
78+
import matplotlib
79+
matplotlib.use("Agg")
80+
g.plot(block=None)
81+
g.highlight_path([a, b], block=None)
82+
""",
83+
"matplotlib",
84+
)
85+
self.assertTrue(got["matplotlib"])
86+
87+
88+
if __name__ == "__main__":
89+
unittest.main()

‎tests/test_plot_arrows.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from matplotlib.patches import ArrowStyle, ConnectionStyle, FancyArrowPatch
2020

2121
from pgraph import DGraph, UGraph
22-
from pgraph.PGraph import _Arrow3D, _Loop3D
22+
from pgraph._plot import _Arrow3D, _Loop3D
2323

2424

2525
def build(cls, edges, loops=(), dim=2):

0 commit comments

Comments
 (0)