diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4bb965..27666681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ Unreleased +Behaviour change + +- `Graph.plot()` and `Graph.highlight_path()` now default to `block=None` + instead of `False`. They no longer call `plt.show()` unless `block` is + specified, allowing notebooks and other environments to manage display. + Scripts and plain IPython sessions without `%matplotlib` or `plt.ion()` + that relied on `g.plot()` opening a window now need an explicit + `plt.show()` after drawing, or `block=False` for the previous behaviour. + `block=True` still waits for the window to close. + Performance - `closest()` and `near()` no longer make a Python-level pass over the vertices diff --git a/README.md b/README.md index d0ff4260..68d10eb2 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ This needs Python 3.9 or later. The dependencies, NumPy, Matplotlib and Spatial ```python from pgraph import UGraph +import matplotlib.pyplot as plt import json # load the places and routes @@ -75,8 +76,9 @@ for route in data['routes']: # plan a path from Hughenden to Brisbane p, length, _ = g.path_Astar('Hughenden', 'Brisbane') -g.plot(block=False) # plot it -g.highlight_path(p) # overlay the path +g.plot() # create the graph without displaying it yet +g.highlight_path(p) # overlay the path on the same axes +plt.show() # show the finished figure in a script ``` The data file and the complete example are in the notebook [`examples/roads.ipynb`](https://github.com/petercorke/pgraph-python/blob/main/examples/roads.ipynb). diff --git a/docs/llms_intro.md b/docs/llms_intro.md index 61decc8e..775b43cc 100644 --- a/docs/llms_intro.md +++ b/docs/llms_intro.md @@ -44,7 +44,7 @@ g.highlight_path(path) - **Metrics.** `"L2"` (default), `"L1"`, `"SE2"` for `(x, y, theta)` poses, `SE2Metric(weight)` to weight the angle, or a function of one difference vector. A* uses `heuristic` (default: the metric), which must be in the same units as the edge costs and must not exceed the true cost to go, or the path is not the shortest. - **Spatial queries** on an embedded graph: `closest(point)` returns `(vertex, distance)`, `near(point, radius)`, `within(box)` with `box` a 2 x n array of lower and upper corner, `coordinates()` the `(dim, n)` matrix of coordinates, and `subgraph(vertices)`, which takes vertices, names or a boolean mask and returns a new graph. `build_kdtree()` is an optional index (it needs scipy) that `closest` and `near` then use. - **Components and matrices.** `g.component(c)` is the vertices in component number `c`, `v.label` is the component of a vertex, `g.samecomponent(v1, v2)`. `adjacency()`, `incidence()`, `laplacian()`, `degree()` and `connectivity()` return dense NumPy arrays. -- **Output.** `g.plot()` (arrowheads for a directed graph, arcs for parallel edges, circles for loops), `highlight_path(path)`, and `g.export(file)` for GraphViz dot, Mermaid, GraphML, ELK JSON or an image (images need the GraphViz executables). +- **Output.** `g.plot()` (arrowheads for a directed graph, arcs for parallel edges, circles for loops), `highlight_path(path)`, and `g.export(file)` for GraphViz dot, Mermaid, GraphML, ELK JSON or an image (images need the GraphViz executables). Both methods default to `block=None`, leaving figure display to the environment. In scripts, use `plt.show()` after composing a figure, or `block=False` for the previous nonblocking show; `block=True` blocks until closed. - **Subclassing.** `class Foo(Vertex)` and `UGraph(vertex_class=Foo, edge_class=Bar)` make the graph build them; a subclass `__init__` must pass `coord` and `name` (vertices) or `v1, v2, cost, data` (edges) to its parent. - **Copying.** `g.copy()` and `pickle` work for a whole graph; a vertex or edge cannot be copied or pickled on its own, and a graph whose metric is a `lambda` cannot be pickled. diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 71da1c45..16ee783e 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -152,6 +152,9 @@ The graph can be plotted by >>> g.plot() +In a regular Python script, call ``plt.show()`` after drawing to display the +figure. Sphinx's ``plot`` directive and interactive notebooks handle display. + .. plot:: >>> import pgraph diff --git a/examples/roads.ipynb b/examples/roads.ipynb index e640e4cb..28e0a8ea 100644 --- a/examples/roads.ipynb +++ b/examples/roads.ipynb @@ -406,8 +406,8 @@ } ], "source": [ - "g.plot(block=None) # block=None: show it once, with the path\n", - "g.highlight_path(p, alpha=0.5, scale=2, block=None)" + "g.plot() # draw; the notebook displays the completed figure\n", + "g.highlight_path(p, alpha=0.5, scale=2)" ] }, { diff --git a/examples/roads.py b/examples/roads.py index 6c373a61..d90ec39f 100644 --- a/examples/roads.py +++ b/examples/roads.py @@ -1,5 +1,6 @@ import json from pgraph import UGraph, DGraph +import matplotlib.pyplot as plt # load the JSON file with open('queensland.json', 'r') as f: @@ -32,6 +33,7 @@ # plot the graph, and overlay the path g.plot() g.highlight_path(p, alpha=0.5, scale=2) +plt.show() # show the completed figure in a script # turn the vertex parent information into a tree, it's a dict that maps a # vertex to its parent. diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index 22d82838..c715cb6e 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -2517,7 +2517,7 @@ def plot( vopt: dict | None = None, eopt: dict | None = None, text: dict | bool | None = None, - block: bool | None = False, + block: bool | None = None, grid: bool = True, ax: Any = None, *, @@ -2560,14 +2560,19 @@ def plot( :type grid: bool, optional :param ax: axes to draw in, defaults to the current axes or a new figure :type ax: Axes or Axes3D, optional - :param block: block until figure is dismissed, defaults to False. If - None the figure is not shown at all, which is useful when - adding to the plot or saving it. + :param block: None (default) leaves display to the environment without + calling ``plt.show()``; False shows without waiting; True waits + until the window is closed (desktop GUI backends). :type block: bool or None, optional :raises ValueError: the graph is empty, or a vertex has no coordinate or fewer than two coordinate values :raises TypeError: an option is given by both its old and new name + .. versionchanged:: 1.1 + The default for ``block`` changed from ``False`` to ``None``. + The method no longer calls ``plt.show()`` unless ``block`` is + explicitly given; pass ``block=False`` for the old behaviour. + The graph is plotted using Matplotlib according to the dimensionality of the vertex coordinates (set by the `dim` parameter to graph constructor). If the dimensionality is @@ -2610,7 +2615,7 @@ def plot( ... g.add_vertex(np.random.rand(2)); >>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]: ... g.add_edge(g[i], g[j]); - >>> g.plot(block=None) + >>> g.plot() .. plot:: @@ -2622,7 +2627,7 @@ def plot( ... g.add_vertex(np.random.rand(2)); >>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]: ... g.add_edge(g[i], g[j]); - >>> g.plot(block=None) + >>> g.plot() A directed graph, with a pair of antiparallel edges, a pair of parallel edges and a loop @@ -2635,7 +2640,7 @@ def plot( ... g.add_vertex(xy, name=name); >>> for a, b in ["AB", "AB", "AD", "BC", "CB", "DC", "CC"]: ... g.add_edge(a, b); - >>> g.plot(block=None) + >>> g.plot() :seealso: :meth:`highlight_path` :meth:`highlight_edge` :meth:`show` """ @@ -2767,19 +2772,25 @@ def plot( plt.show(block=block) def highlight_path( - self, path: list[Vertex], block: bool | None = False, **kwargs: Any + self, path: list[Vertex], block: bool | None = None, **kwargs: Any ) -> None: """ Highlight a path through the graph :param path: sequence of vertices forming a path :type path: list of Vertex subclass - :param block: block until figure is dismissed, defaults to False. If - None the figure is not shown. + :param block: None (default) leaves display to the environment without + calling ``plt.show()``; False shows without waiting; True waits + until the window is closed (desktop GUI backends). :type block: bool or None, optional :param kwargs: arguments passed to :meth:`highlight_edge` and :meth:`highlight_vertex` + .. versionchanged:: 1.1 + The default for ``block`` changed from ``False`` to ``None``. + The method no longer calls ``plt.show()`` unless ``block`` is + explicitly given; pass ``block=False`` for the old behaviour. + The vertices and edges along the path are overwritten with a different size/width and color. @@ -2793,9 +2804,9 @@ def highlight_path( ... g.add_vertex(np.random.rand(2)); >>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]: ... g.add_edge(g[i], g[j]); - >>> g.plot(block=None) + >>> g.plot() >>> path, length, _ = g.path_Astar(g[2], g[4]) - >>> g.highlight_path(path, block=None) + >>> g.highlight_path(path) .. plot:: @@ -2807,9 +2818,9 @@ def highlight_path( ... g.add_vertex(np.random.rand(2)); >>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]: ... g.add_edge(g[i], g[j]); - >>> g.plot(block=None) + >>> g.plot() >>> path, length, _ = g.path_Astar(g[2], g[4]) - >>> g.highlight_path(path, block=None) + >>> g.highlight_path(path) :seealso: :meth:`highlight_vertex` :meth:`highlight_edge` """ @@ -2853,7 +2864,7 @@ def highlight_edge( >>> edges = [] >>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]: ... edges.append(g.add_edge(g[i], g[j])); - >>> g.plot(block=None) + >>> g.plot() >>> g.highlight_edge(edges[2]) .. plot:: @@ -2867,7 +2878,7 @@ def highlight_edge( >>> edges = [] >>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]: ... edges.append(g.add_edge(g[i], g[j])); - >>> g.plot(block=None) + >>> g.plot() >>> g.highlight_edge(edges[2]) :seealso: :meth:`plot` :meth:`highlight_vertex` :meth:`highlight_path` @@ -2926,7 +2937,7 @@ def highlight_vertex( ... g.add_vertex(np.random.rand(2)); >>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]: ... g.add_edge(g[i], g[j]); - >>> g.plot(block=None) + >>> g.plot() >>> g.highlight_vertex(g[2]) .. plot:: @@ -2939,7 +2950,7 @@ def highlight_vertex( ... g.add_vertex(np.random.rand(2)); >>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]: ... g.add_edge(g[i], g[j]); - >>> g.plot(block=None) + >>> g.plot() >>> g.highlight_vertex(g[2]) """ diff --git a/tests/test_plot_block.py b/tests/test_plot_block.py new file mode 100644 index 00000000..773d51d9 --- /dev/null +++ b/tests/test_plot_block.py @@ -0,0 +1,71 @@ +"""Regression tests for the three-state block= plotting contract.""" + +import unittest +from unittest.mock import patch + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from pgraph import UGraph + + +class TestPlotBlock(unittest.TestCase): + def setUp(self) -> None: + self.graph = UGraph() + a = self.graph.add_vertex([0.0, 0.0], name="a") + b = self.graph.add_vertex([1.0, 1.0], name="b") + self.graph.add_edge(a, b) + self.path = [a, b] + + def tearDown(self) -> None: + plt.close("all") + + def test_default_plot_and_highlight_do_not_call_show(self) -> None: + with patch.object(plt, "show") as show: + self.graph.plot() + axes = plt.gca() + before = len(axes.patches) + self.graph.highlight_path(self.path) + show.assert_not_called() + self.assertIs(plt.gca(), axes) + self.assertGreater(len(axes.patches), before) + + def test_default_plot_then_explicit_highlight_shows_once(self) -> None: + with patch.object(plt, "show") as show: + self.graph.plot() + ax = plt.gca() + count = len(ax.patches) + show.assert_not_called() + self.graph.highlight_path(self.path, block=False) + show.assert_called_once_with(block=False) + self.assertIs(plt.gca(), ax) + self.assertGreater(len(ax.patches), count) + + def test_plot_with_caller_axes_and_none(self) -> None: + fig, ax = plt.subplots() + with patch.object(plt, "show") as show: + self.graph.plot(block=None, ax=ax) + show.assert_not_called() + self.assertIs(ax.figure, fig) + self.assertGreater(len(ax.patches), 0) + + def test_explicit_plot_show_modes(self) -> None: + for block in (False, True): + with self.subTest(block=block): + with patch.object(plt, "show") as show: + self.graph.plot(block=block) + show.assert_called_once_with(block=block) + + def test_explicit_highlight_show_modes(self) -> None: + self.graph.plot() + for block in (False, True): + with self.subTest(block=block): + with patch.object(plt, "show") as show: + self.graph.highlight_path(self.path, block=block) + show.assert_called_once_with(block=block) + + +if __name__ == "__main__": + unittest.main()