Skip to content

Commit 5fea937

Browse files
authored
Merge pull request #70 from petercorke/perf/lazy-matplotlib
perf: import pgraph without matplotlib, spatialmath or scipy
2 parents c9d38e6 + 7759a58 commit 5fea937

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)