Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 11 additions & 58 deletions src/pgraph/PGraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
70 changes: 70 additions & 0 deletions src/pgraph/_plot.py
Original file line number Diff line number Diff line change
@@ -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)
89 changes: 89 additions & 0 deletions tests/test_lazy_imports.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion tests/test_plot_arrows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading