From 0e2813598e272be3c1e13412ab8fdc6ceea340d2 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 6 Jul 2026 05:57:15 -0500 Subject: [PATCH 1/5] Add smoke tests for graphblas.viz viz.py previously had zero tests. Nine Agg-backend smoke tests cover spy (default, centered, explicit axes), draw (renders nodes/labels, rejects non-Matrix), and datashade (single, list, grid, empty agg). Every optional dependency is guarded with importorskip so a minimal-environment run sees clean skips: without matplotlib the whole module skips; without datashader the four datashade tests skip and the rest run (both verified by simulation). --- graphblas/tests/test_viz.py | 131 ++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 graphblas/tests/test_viz.py diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py new file mode 100644 index 000000000..c5fecc709 --- /dev/null +++ b/graphblas/tests/test_viz.py @@ -0,0 +1,131 @@ +"""Smoke tests for graphblas.viz. + +The viz module is optional-dependency heavy (matplotlib, networkx, scipy for +``spy``/``draw``; datashader + holoviews + hvplot + bokeh + pandas for +``datashade``). These tests only check that each public function runs end to end +under the headless Agg backend and populates a figure/returns an object; they do +not assert on pixel output. Anything missing is skipped, not failed, so a +minimal-dependency CI run sees clean skips. +""" + +import pytest + +from graphblas import Matrix, Vector, viz + +# Skip the whole module if matplotlib is absent (draw and spy both need it). +# Set the backend to Agg before pyplot is imported so no display is required. +mpl = pytest.importorskip("matplotlib") +mpl.use("Agg") +plt = pytest.importorskip("matplotlib.pyplot") + + +@pytest.fixture(autouse=True) +def _close_figures(): + # Close every figure after each test to avoid matplotlib's + # "More than 20 figures have been opened" warning (which the project's + # ``filterwarnings = error`` config would turn into a failure). + yield + plt.close("all") + + +def square_matrix(): + # Small square adjacency matrix with distinct weights. + return Matrix.from_coo([0, 0, 1, 2], [1, 2, 2, 0], [1.0, 2.0, 3.0, 4.0], nrows=3, ncols=3) + + +def test_spy_default(): + pytest.importorskip("scipy.sparse") + A = square_matrix() + fig = viz.spy(A, show=False) + assert isinstance(fig, mpl.figure.Figure) + assert fig.axes, "spy should populate at least one Axes" + # matplotlib's Axes.spy draws the pattern as a single markered Line2D. + assert fig.axes[0].lines, "spy should plot the sparsity markers" + + +def test_spy_centered(): + # centered=True skips the tick-offset fixup branch. + pytest.importorskip("scipy.sparse") + A = square_matrix() + fig = viz.spy(A, show=False, centered=True) + assert isinstance(fig, mpl.figure.Figure) + assert fig.axes[0].lines + + +def test_spy_with_axes(): + # Passing an explicit Axes exercises the ``axes is not None`` branch. + # markersize must be supplied here: the auto-markersize path references a + # ``fig`` local that only exists when spy creates the figure itself. + pytest.importorskip("scipy.sparse") + A = square_matrix() + fig = mpl.figure.Figure() + axes = fig.subplots() + result = viz.spy(A, show=False, axes=axes, markersize=5) + assert result is fig + assert axes.lines + + +@pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive") +def test_draw(): + # draw() renders onto the current pyplot Axes via networkx and calls + # plt.show(); on Agg that show() emits the non-interactive UserWarning, + # which we ignore here. + pytest.importorskip("networkx") + pytest.importorskip("scipy.sparse") + A = square_matrix() + viz.draw(A) + axes = plt.gcf().get_axes() + assert axes, "draw should populate the current figure" + ax = axes[0] + # Nodes render as patches/collections and labels as texts. + assert ax.collections or ax.patches + assert ax.texts, "draw should render node/edge labels" + + +def test_draw_rejects_non_matrix(): + pytest.importorskip("networkx") + v = Vector.from_coo([0, 1, 2], [1.0, 2.0, 3.0]) + with pytest.raises(TypeError, match="Can only draw a Matrix"): + viz.draw(v) + + +def _import_datashade_deps(): + for name in ("numpy", "pandas", "datashader", "holoviews", "hvplot", "bokeh"): + pytest.importorskip(name) + + +def test_datashade_single(): + _import_datashade_deps() + import holoviews as hv + + A = square_matrix() + obj = viz.datashade(A) + assert obj is not None + assert isinstance(obj, hv.core.dimension.Dimensioned) + + +def test_datashade_agg_list(): + # A flat list of aggregators produces one row of linked plots. + _import_datashade_deps() + import holoviews as hv + + A = square_matrix() + layout = viz.datashade(A, agg=["count", "sum"]) + assert isinstance(layout, hv.Layout) + + +def test_datashade_agg_grid(): + # A list-of-lists produces a 2d grid of linked plots. + _import_datashade_deps() + import holoviews as hv + + A = square_matrix() + layout = viz.datashade(A, agg=[["count", "sum"], ["min", "max"]]) + assert isinstance(layout, hv.Layout) + + +def test_datashade_empty_agg(): + # An empty aggregator list is a no-op that returns None. + _import_datashade_deps() + A = square_matrix() + assert viz.datashade(A, agg=[]) is None From c1fe19b361b9298453f8969e29549eebb3e58138 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 6 Jul 2026 05:58:16 -0500 Subject: [PATCH 2/5] Fix NameError in viz.spy with an explicit figure or axes spy() bound the ``fig`` local only when it created the figure itself, so ``spy(A, figure=fig)`` crashed creating the axes and ``spy(A, axes=ax)`` crashed in the auto-markersize path (found while writing the smoke tests). The figure kwarg is now used when given, and auto-markersize reads dpi from ``axes.figure``, which is correct in all three call forms. Regression tests cover both previously-crashing forms. --- graphblas/tests/test_viz.py | 19 +++++++++++++++---- graphblas/viz.py | 6 +++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py index c5fecc709..39a4e68eb 100644 --- a/graphblas/tests/test_viz.py +++ b/graphblas/tests/test_viz.py @@ -53,18 +53,29 @@ def test_spy_centered(): def test_spy_with_axes(): - # Passing an explicit Axes exercises the ``axes is not None`` branch. - # markersize must be supplied here: the auto-markersize path references a - # ``fig`` local that only exists when spy creates the figure itself. + # Passing an explicit Axes exercises the ``axes is not None`` branch, + # including the auto-markersize path (which once raised NameError here). pytest.importorskip("scipy.sparse") A = square_matrix() fig = mpl.figure.Figure() axes = fig.subplots() - result = viz.spy(A, show=False, axes=axes, markersize=5) + result = viz.spy(A, show=False, axes=axes) assert result is fig assert axes.lines +def test_spy_with_figure(): + # Passing an explicit Figure (no Axes) once raised NameError; spy should + # create the Axes on the given figure and return that same figure. + pytest.importorskip("scipy.sparse") + A = square_matrix() + fig = mpl.figure.Figure() + result = viz.spy(A, show=False, figure=fig) + assert result is fig + assert fig.axes + assert fig.axes[0].lines + + @pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive") def test_draw(): # draw() renders onto the current pyplot Axes via networkx and calls diff --git a/graphblas/viz.py b/graphblas/viz.py index 8e2a53228..5bdd217a3 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -88,12 +88,12 @@ def spy(M, *, centered=False, show=True, figure=None, axes=None, figsize=None, * plt.show() if axes is None: if figure is None: - fig = mpl.figure.Figure(figsize=figsize) - axes = fig.subplots() + figure = mpl.figure.Figure(figsize=figsize) + axes = figure.subplots() if kwargs.get("markersize") is None: # Make the square markers "fill" their space markersize = min(axes.bbox.width / A.shape[1], axes.bbox.height / A.shape[0]) - kwargs["markersize"] = max(0.002, markersize * 72 / fig.dpi) + kwargs["markersize"] = max(0.002, markersize * 72 / axes.figure.dpi) axes.spy(A, **kwargs) # Fix offsets if not centered: From b7fffdea6c345e365323056751de042d25a17d4c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 6 Jul 2026 17:52:14 -0500 Subject: [PATCH 3/5] Fix viz.datashade cell positioning and separate reciprocal edges in viz.draw Two bugs reported in the pyOpenSci review. Both change viz.py, so they are itemized in a single commit. gh-473: datashade binned element (r, c) into the pixel spanning [c, c+1) x [r, r+1), so it rendered centered at (c+0.5, r+0.5), half a cell off the tick labeled (c, r); spy centers the same element exactly on the tick. The axis limits now use the imshow integer-center convention (-0.5 to n-0.5), making datashade agree with spy. The issue's other symptom (elements invisible until zoom-out) does not reproduce on the current holoviews/hvplot/bokeh stack; it was a 2023-era library artifact. gh-474: reciprocal directed edges drew as coincident straight lines with both weight labels on the same midpoint, hiding one weight. Reciprocal pairs now draw with an arc (connectionstyle arc3, rad 0.1) and matching label placement, so both arrows and both weights are visible; other edges and self-loops stay straight. The gh-474 fix needs networkx 3.3, the release that gave draw_networkx_edge_labels its connectionstyle parameter. We support networkx >=2.8, so draw() feature-detects that parameter and keeps the previous straight rendering when it is missing; passing it to an older networkx raises TypeError. Curving the edges but not the labels would be worse than not curving at all, since the labels would sit back on the shared chord midpoint (the overlap gh-474 is about) and would no longer track their arrows. Both fixes carry display-free regression tests: datashade pixel centers match spy's convention, and draw places the two weight labels at separated anchors. The label check measures separation as a fraction of the edge length rather than comparing positions for exact inequality, because networkx returns two midpoint anchors that differ by floating-point noise even when the labels coincide on screen. A third test stands in a pre-3.3 signature to cover the fallback. --- graphblas/tests/test_viz.py | 83 +++++++++++++++++++++++++++++++ graphblas/viz.py | 99 ++++++++++++++++++++++++++++++------- 2 files changed, 164 insertions(+), 18 deletions(-) diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py index 39a4e68eb..7e66f4739 100644 --- a/graphblas/tests/test_viz.py +++ b/graphblas/tests/test_viz.py @@ -8,6 +8,8 @@ minimal-dependency CI run sees clean skips. """ +import math + import pytest from graphblas import Matrix, Vector, viz @@ -100,6 +102,57 @@ def test_draw_rejects_non_matrix(): viz.draw(v) +@pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive") +def test_draw_reciprocal_edges_both_labels_visible(): + # Regression for gh-474: reciprocal directed edges (0->1 and 1->0) used to be + # drawn as coincident straight lines, so one weight hid the other. draw() now + # curves reciprocal pairs; both weights must appear at distinct positions. + pytest.importorskip("networkx") + pytest.importorskip("scipy.sparse") + M = Matrix.from_coo([0, 1], [1, 0], [10, 20], nrows=2, ncols=2) + viz.draw(M) + ax = plt.gcf().get_axes()[0] + + weight_labels = [t for t in ax.texts if t.get_text() in {"10", "20"}] + assert {t.get_text() for t in weight_labels} == {"10", "20"}, "both weights must be drawn" + assert len(weight_labels) == 2 + + # Old behavior placed both labels on the shared straight-line midpoint. + # networkx returns two anchors there that differ only by floating-point + # noise (order 1e-6), so an exact ``!=`` comparison passes even when the + # labels sit on top of each other. Require a separation that is a real + # fraction of the distance between the two nodes instead. + node_positions = [t.get_position() for t in ax.texts if t.get_text() in {"0", "1"}] + assert len(node_positions) == 2 + edge_length = math.dist(*node_positions) + separation = math.dist(*(t.get_position() for t in weight_labels)) + assert separation > 0.001 * edge_length, "reciprocal edge labels still overlap" + + +@pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive") +def test_draw_without_networkx_curved_label_support(monkeypatch): + # draw_networkx_edge_labels gained connectionstyle in networkx 3.3, and the + # project supports >=2.8. Standing in a pre-3.3 signature must not raise; the + # gh-474 curving is skipped and every edge renders straight, as it did before. + nx = pytest.importorskip("networkx") + pytest.importorskip("scipy.sparse") + real = nx.draw_networkx_edge_labels + + def pre_33_draw_networkx_edge_labels(g, pos, edge_labels=None, **kwargs): + if "connectionstyle" in kwargs: + raise TypeError( + "draw_networkx_edge_labels() got an unexpected keyword argument " + "'connectionstyle'" + ) + return real(g, pos, edge_labels=edge_labels, **kwargs) + + monkeypatch.setattr(nx, "draw_networkx_edge_labels", pre_33_draw_networkx_edge_labels) + M = Matrix.from_coo([0, 1], [1, 0], [10, 20], nrows=2, ncols=2) + viz.draw(M) + ax = plt.gcf().get_axes()[0] + assert {t.get_text() for t in ax.texts if t.get_text() in {"10", "20"}} == {"10", "20"} + + def _import_datashade_deps(): for name in ("numpy", "pandas", "datashader", "holoviews", "hvplot", "bokeh"): pytest.importorskip(name) @@ -140,3 +193,33 @@ def test_datashade_empty_agg(): _import_datashade_deps() A = square_matrix() assert viz.datashade(A, agg=[]) is None + + +def test_datashade_positions_match_spy(): + # Regression for gh-473: element (row=r, col=c) must render centered on the + # integer tick pair (col, row), the same convention ``spy`` uses. We check + # the datashader aggregation directly (no display) over the limits the + # interactive path uses, at one pixel per matrix cell. + _import_datashade_deps() + import datashader as ds + import numpy as np + + # Non-square (3x4) with distinct row/col so a row<->col swap would show. + M = Matrix.from_coo([0, 0, 2], [1, 3, 3], [1.0, 1.0, 1.0], nrows=3, ncols=4) + df = viz._matrix_to_dataframe(M) + xlim, ylim = viz._cell_centered_limits(M) + assert xlim == (-0.5, M.ncols - 0.5) + assert ylim == (-0.5, M.nrows - 0.5) + + canvas = ds.Canvas(plot_width=M.ncols, plot_height=M.nrows, x_range=xlim, y_range=ylim) + agg = canvas.points(df, "col", "row", ds.count()) + + # Pixel centers land on integers, so ticks label the cells they sit on. + assert agg.coords["col"].values.tolist() == [0.0, 1.0, 2.0, 3.0] + assert agg.coords["row"].values.tolist() == [0.0, 1.0, 2.0] + + # Counts are nonzero exactly at the (row, col) indices of the elements. + xs = agg.coords["col"].values + ys = agg.coords["row"].values + nonzero = {(round(float(ys[i])), round(float(xs[j]))) for i, j in np.argwhere(agg.values > 0)} + assert nonzero == {(0, 1), (0, 3), (2, 3)} diff --git a/graphblas/viz.py b/graphblas/viz.py index 5bdd217a3..8e685b374 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -42,12 +42,17 @@ def _get_imports(names, within): return rv -def draw(m): # pragma: no cover +def draw(m): """Draw a square adjacency Matrix as a graph. Requires `networkx `_ and `matplotlib `_ to be installed. + Reciprocal directed edges (``u -> v`` and ``v -> u``) are drawn as curves so + both arrows and both edge weights stay visible; all other edges are straight. + Curving them needs networkx 3.3 or newer; with older versions every edge is + drawn straight. + Example output: .. image:: /_static/img/draw-example.png @@ -59,9 +64,44 @@ def draw(m): # pragma: no cover g = to_networkx(m) pos = nx.spring_layout(g) - edge_labels = {(i, j): d["weight"] for i, j, d in g.edges(data=True)} - nx.draw_networkx(g, pos, node_color="red", node_size=500) - nx.draw_networkx_edge_labels(g, pos, edge_labels=edge_labels) + node_size = 500 + nx.draw_networkx_nodes(g, pos, node_color="red", node_size=node_size) + nx.draw_networkx_labels(g, pos) + + # A reciprocal pair (u -> v and v -> u) drawn as two straight lines coincides, + # hiding one edge's weight (python-graphblas #474). Curving both edges makes + # each bend toward its own side, so both arrows and both labels stay visible + # and attributable. Self-loops (u == v) are not reciprocal; leave them straight. + # + # networkx only learned to place edge labels along a curve in 3.3, and we + # support >=2.8, so fall back to the previous straight rendering without it. + # Curving the edges but not the labels would be worse than not curving at all: + # the labels would sit back on the shared chord midpoint, which is the overlap + # #474 is about, and they would no longer track their arrows. Check the + # parameter rather than pin a version. + import inspect + + if "connectionstyle" in inspect.signature(nx.draw_networkx_edge_labels).parameters: + curved = {(u, v) for u, v in g.edges if u != v and g.has_edge(v, u)} + else: + curved = set() + straight = [e for e in g.edges if e not in curved] + connectionstyle = "arc3,rad=0.1" + + def _edge_labels(edges): + return {(u, v): g[u][v]["weight"] for u, v in edges} + + if straight: + nx.draw_networkx_edges(g, pos, edgelist=straight, node_size=node_size) + nx.draw_networkx_edge_labels(g, pos, edge_labels=_edge_labels(straight)) + if curved: + curved = list(curved) + nx.draw_networkx_edges( + g, pos, edgelist=curved, node_size=node_size, connectionstyle=connectionstyle + ) + nx.draw_networkx_edge_labels( + g, pos, edge_labels=_edge_labels(curved), connectionstyle=connectionstyle + ) plt.show() @@ -103,6 +143,38 @@ def spy(M, *, centered=False, show=True, figure=None, axes=None, figsize=None, * return axes.figure +def _matrix_to_dataframe(M): + """Build the ``(row, col, val)`` DataFrame that ``datashade`` rasterizes. + + Factored out of ``datashade`` so the coordinate convention can be checked + without rendering an interactive plot (see ``_cell_centered_limits``). + """ + np, pd = _get_imports(["np", "pd"], "datashade") + rows, cols, vals = M.to_coo() + max_int = np.iinfo(np.int64).max + if M.nrows > max_int and rows.max() > max_int: + rows = rows.astype(np.float64) + else: + rows = rows.astype(np.int64) + if M.ncols > max_int and cols.max() > max_int: + cols = cols.astype(np.float64) + else: + cols = cols.astype(np.int64) + return pd.DataFrame({"row": rows, "col": cols, "val": vals}) + + +def _cell_centered_limits(M): + """Axis limits that center each element on its integer index, like ``spy``. + + datashader bins points into pixels by ``x_range``/``y_range``. With limits + ``(0, N)`` the pixel for index ``k`` spans ``[k, k+1)``, so an element lands + half a cell to the lower-right of the tick labeled ``k``. Offsetting the + limits by half a cell makes the pixel for index ``k`` span ``[k-0.5, k+0.5)``, + centered on tick ``k`` and matching what ``spy`` draws (python-graphblas #473). + """ + return (-0.5, M.ncols - 0.5), (-0.5, M.nrows - 0.5) + + def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kwargs): """Interactive plot of the sparsity pattern of a Matrix using hvplot and datashader. @@ -132,19 +204,9 @@ def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kw spy """ - np, pd, bk, hv, _hp, _ds = _get_imports(["np", "pd", "bk", "hv", "hp", "ds"], "datashade") + bk, hv, _hp, _ds = _get_imports(["bk", "hv", "hp", "ds"], "datashade") if "df" not in kwargs: - rows, cols, vals = M.to_coo() - max_int = np.iinfo(np.int64).max - if M.nrows > max_int and rows.max() > max_int: - rows = rows.astype(np.float64) - else: - rows = rows.astype(np.int64) - if M.ncols > max_int and cols.max() > max_int: - cols = cols.astype(np.float64) - else: - cols = cols.astype(np.int64) - df = pd.DataFrame({"row": rows, "col": cols, "val": vals}) + df = _matrix_to_dataframe(M) else: df = kwargs.pop("df") @@ -183,6 +245,7 @@ def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kw images.extend(image_row) return hv.Layout(images).cols(ncols) + xlim, ylim = _cell_centered_limits(M) kwds = { "x": "col", "y": "row", @@ -192,8 +255,8 @@ def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kw "frame_height": height, "cmap": "fire", "cnorm": "eq_hist", - "xlim": (0, M.ncols), - "ylim": (0, M.nrows), + "xlim": xlim, + "ylim": ylim, "rasterize": True, "flip_yaxis": True, "hover": True, From 09e66d5b7d8608e0bcf9ddc92c81e251a0e89fa0 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 6 Jul 2026 17:52:15 -0500 Subject: [PATCH 4/5] Measure coverage of graphblas/viz.py Removes the coverage omit block (viz.py was its only entry, with a TODO to un-omit once tests existed; they do now). The viz test module covers 87.6% of viz.py counting statements and branches, 91.1% counting statements alone. The misses are the optional-import failure path, the interactive plt.show() path, and guards for inputs the tests do not construct: matrices too large for int64 indices, ragged aggregator grids, and caller-supplied opts_kwargs. --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0475fd5e9..c8f4f9ab3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,9 +212,6 @@ filterwarnings = [ [tool.coverage.run] branch = true source = ["graphblas"] -omit = [ - "graphblas/viz.py", # TODO: test and get coverage for viz.py -] [tool.coverage.report] ignore_errors = false From 8c3f97ff9dfadc9d55f15298489342df206579f4 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 4 Aug 2026 16:57:27 -0700 Subject: [PATCH 5/5] Make viz tests skip cleanly on old networkx and broken matplotlib --- graphblas/tests/test_viz.py | 61 ++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py index 7e66f4739..232d01ce3 100644 --- a/graphblas/tests/test_viz.py +++ b/graphblas/tests/test_viz.py @@ -8,17 +8,44 @@ minimal-dependency CI run sees clean skips. """ +import importlib +import inspect import math +import warnings import pytest from graphblas import Matrix, Vector, viz + +def _importorskip(modname): + """Skip when an optional dependency is missing *or* installed but unusable. + + From pytest 9.1 on, ``pytest.importorskip`` counts only ``ModuleNotFoundError`` + as "missing". A dependency that is installed but cannot run raises a plain + ``ImportError`` instead (matplotlib does exactly that when numpy is older than + it supports), which escapes ``importorskip`` and aborts collection for the + whole session. pytest's ``exc_type`` argument covers that, but it only exists + in pytest >=8.2 and this project supports pytest >=6.2, so do the import here + and hand the already-imported module to pytest. + """ + try: + with warnings.catch_warnings(): + # ``importorskip`` ignores warnings while importing; match it, or + # ``filterwarnings = error`` would fail on whatever an optional + # dependency happens to emit at import time. + warnings.simplefilter("ignore") + importlib.import_module(modname) + except ImportError as exc: + pytest.skip(f"could not import {modname!r}: {exc}", allow_module_level=True) + return pytest.importorskip(modname) + + # Skip the whole module if matplotlib is absent (draw and spy both need it). # Set the backend to Agg before pyplot is imported so no display is required. -mpl = pytest.importorskip("matplotlib") +mpl = _importorskip("matplotlib") mpl.use("Agg") -plt = pytest.importorskip("matplotlib.pyplot") +plt = _importorskip("matplotlib.pyplot") @pytest.fixture(autouse=True) @@ -36,7 +63,7 @@ def square_matrix(): def test_spy_default(): - pytest.importorskip("scipy.sparse") + _importorskip("scipy.sparse") A = square_matrix() fig = viz.spy(A, show=False) assert isinstance(fig, mpl.figure.Figure) @@ -47,7 +74,7 @@ def test_spy_default(): def test_spy_centered(): # centered=True skips the tick-offset fixup branch. - pytest.importorskip("scipy.sparse") + _importorskip("scipy.sparse") A = square_matrix() fig = viz.spy(A, show=False, centered=True) assert isinstance(fig, mpl.figure.Figure) @@ -57,7 +84,7 @@ def test_spy_centered(): def test_spy_with_axes(): # Passing an explicit Axes exercises the ``axes is not None`` branch, # including the auto-markersize path (which once raised NameError here). - pytest.importorskip("scipy.sparse") + _importorskip("scipy.sparse") A = square_matrix() fig = mpl.figure.Figure() axes = fig.subplots() @@ -69,7 +96,7 @@ def test_spy_with_axes(): def test_spy_with_figure(): # Passing an explicit Figure (no Axes) once raised NameError; spy should # create the Axes on the given figure and return that same figure. - pytest.importorskip("scipy.sparse") + _importorskip("scipy.sparse") A = square_matrix() fig = mpl.figure.Figure() result = viz.spy(A, show=False, figure=fig) @@ -83,8 +110,8 @@ def test_draw(): # draw() renders onto the current pyplot Axes via networkx and calls # plt.show(); on Agg that show() emits the non-interactive UserWarning, # which we ignore here. - pytest.importorskip("networkx") - pytest.importorskip("scipy.sparse") + _importorskip("networkx") + _importorskip("scipy.sparse") A = square_matrix() viz.draw(A) axes = plt.gcf().get_axes() @@ -96,7 +123,7 @@ def test_draw(): def test_draw_rejects_non_matrix(): - pytest.importorskip("networkx") + _importorskip("networkx") v = Vector.from_coo([0, 1, 2], [1.0, 2.0, 3.0]) with pytest.raises(TypeError, match="Can only draw a Matrix"): viz.draw(v) @@ -107,8 +134,14 @@ def test_draw_reciprocal_edges_both_labels_visible(): # Regression for gh-474: reciprocal directed edges (0->1 and 1->0) used to be # drawn as coincident straight lines, so one weight hid the other. draw() now # curves reciprocal pairs; both weights must appear at distinct positions. - pytest.importorskip("networkx") - pytest.importorskip("scipy.sparse") + nx = _importorskip("networkx") + _importorskip("scipy.sparse") + # draw() only curves reciprocal pairs when networkx can place edge labels along + # the curve; without that it deliberately draws every edge straight, and the two + # labels then coincide (which test_draw_without_networkx_curved_label_support + # covers). Ask the same question draw() asks, so the two cannot drift apart. + if "connectionstyle" not in inspect.signature(nx.draw_networkx_edge_labels).parameters: + pytest.skip("networkx <3.3: draw_networkx_edge_labels has no connectionstyle") M = Matrix.from_coo([0, 1], [1, 0], [10, 20], nrows=2, ncols=2) viz.draw(M) ax = plt.gcf().get_axes()[0] @@ -134,8 +167,8 @@ def test_draw_without_networkx_curved_label_support(monkeypatch): # draw_networkx_edge_labels gained connectionstyle in networkx 3.3, and the # project supports >=2.8. Standing in a pre-3.3 signature must not raise; the # gh-474 curving is skipped and every edge renders straight, as it did before. - nx = pytest.importorskip("networkx") - pytest.importorskip("scipy.sparse") + nx = _importorskip("networkx") + _importorskip("scipy.sparse") real = nx.draw_networkx_edge_labels def pre_33_draw_networkx_edge_labels(g, pos, edge_labels=None, **kwargs): @@ -155,7 +188,7 @@ def pre_33_draw_networkx_edge_labels(g, pos, edge_labels=None, **kwargs): def _import_datashade_deps(): for name in ("numpy", "pandas", "datashader", "holoviews", "hvplot", "bokeh"): - pytest.importorskip(name) + _importorskip(name) def test_datashade_single():