From bdce4ef14e51ff20613793ab15d683a2d1c09cdd Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 6 Jul 2026 05:57:15 -0500 Subject: [PATCH 01/13] 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 aecb590a76c6f46d8e67175786752caac29caa27 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 6 Jul 2026 05:58:16 -0500 Subject: [PATCH 02/13] 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 08b650d102454183a779ab17608c451ee80b49a9 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 6 Jul 2026 17:52:14 -0500 Subject: [PATCH 03/13] 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 1cbbb01ba29a717cb4250aeed00b7e09f9f38713 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 6 Jul 2026 17:52:15 -0500 Subject: [PATCH 04/13] 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 e6372b708..b599bf27d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,9 +219,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 2fff6a7598f55b300272b5ab0d628793b5e5d918 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 4 Aug 2026 16:57:27 -0700 Subject: [PATCH 05/13] 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(): From 47a47949fd162e8fa260b320b9cf9e7904aa5ee5 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 27 Oct 2026 16:46:12 -0500 Subject: [PATCH 06/13] Install the datashade stack in the notebooks CI jobs graphblas/tests/test_viz.py's datashade tests skip unless datashader, holoviews, hvplot and bokeh are installed, and no CI job installed them, so they never ran and viz.datashade never counted for coverage. The notebooks jobs already add matplotlib for the rest of viz, so add these four there too. datashader depends on numba, so they are installed only where numba is. The notebooks jobs never skip numba at random; this keeps a pypy draw, or a draw the version picker leaves without a compatible numba, solvable. Every distinct notebooks draw the picker makes (673 combinations of Python 3.11-3.15 with its numpy, pandas and numba pins, from 20000 draws per Python) solves with the four added on linux-64, osx-arm64 and win-64, picking the newest datashader (0.19.1), holoviews (1.23.2) and hvplot (0.12.2) every time. test_viz.py passes with them on the newest stack (Python 3.13, numpy 2.5, pandas 3.1, numba 0.68) and on the oldest draw (Python 3.11, numpy 1.24, pandas 1.5, numba 0.57). scripts/check_versions.sh lists them with the other dependencies CI tests. --- .github/workflows/test_and_build.yml | 9 +++++++-- scripts/check_versions.sh | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index ecd82e2ab..b98adfa3d 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -215,9 +215,10 @@ jobs: echo "NUMBA_THREADING_LAYER=workqueue" >> "$GITHUB_ENV" fi - # Decide whether to install numba and sparse. + # Decide whether to install numba and the packages that depend on it. # Random skip: pypy always, 20% on Windows, 40% on other OSes (unless notebooks). awkward=awkward${akver} + datashade="" if [[ ${{ contains(steps.pyver.outputs.selected, 'pypy') }} == true || ( ${{ matrix.slowtask != 'notebooks'}} == true && ( ( ${{ matrix.os == 'windows-latest' }} == true && $(python -c 'import random ; print(random.random() < .2)') == True ) || @@ -246,6 +247,10 @@ jobs: sparsever=NA else numba=numba${numbaver} + # graphblas.viz.datashade's dependencies, which the notebooks job installs so its + # tests run and count for coverage. datashader depends on numba, so they come + # only where numba does. + datashade="datashader holoviews hvplot bokeh" if [[ $sparsever == "NA" ]] ; then sparse="" else @@ -280,7 +285,7 @@ jobs: pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ networkx${nxver} ${numba} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ - ${{ matrix.slowtask == 'notebooks' && '"matplotlib${mplver}" nbconvert jupyter "ipython>=7" drawsvg' || '' }} \ + ${{ matrix.slowtask == 'notebooks' && '"matplotlib${mplver}" nbconvert jupyter "ipython>=7" drawsvg ${datashade}' || '' }} \ ${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \ ${graphblas} ${blas:+"$blas"} \ ${interpreter} \ diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index e99cb7ca6..a9bcb6d0f 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -25,5 +25,11 @@ conda search 'pyyaml[channel=conda-forge]>=6.0' # matplotlib is installed unpinned for the notebooks job; ci_pick_versions.py caps it # below 3.11 when the numpy pick predates matplotlib's numpy >=1.25 runtime floor. conda search 'matplotlib-base[channel=conda-forge]>=3.11' +# The notebooks job also installs these, unpinned, wherever it installs numba, which +# datashader depends on. They are what graphblas.viz.datashade needs. +conda search 'datashader[channel=conda-forge]>=0.19' +conda search 'holoviews[channel=conda-forge]>=1.23' +conda search 'hvplot[channel=conda-forge]>=0.12' +conda search 'bokeh[channel=conda-forge]>=3.10' conda search 'python-suitesparse-graphblas[channel=conda-forge]>=10.5.2' # conda search 'python[channel=conda-forge]>=3.11 *pypy*' From 78b606d04cc645cbfed1008536cdb5d048d8834b Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Thu, 29 Oct 2026 04:02:59 -0500 Subject: [PATCH 07/13] Exclude a test stand-in's failure-only raise from coverage The pre-3.3 networkx stand-in raises only if draw() passes connectionstyle to it, which is the regression the test guards against. --- graphblas/tests/test_viz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py index 232d01ce3..bcb5ee976 100644 --- a/graphblas/tests/test_viz.py +++ b/graphblas/tests/test_viz.py @@ -172,7 +172,7 @@ def test_draw_without_networkx_curved_label_support(monkeypatch): real = nx.draw_networkx_edge_labels def pre_33_draw_networkx_edge_labels(g, pos, edge_labels=None, **kwargs): - if "connectionstyle" in kwargs: + if "connectionstyle" in kwargs: # pragma: no cover (only on failure) raise TypeError( "draw_networkx_edge_labels() got an unexpected keyword argument " "'connectionstyle'" From d07d8f5c300edc76ad9c7b7fd303cdecbaea7bc2 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 7 Nov 2026 20:47:39 -0600 Subject: [PATCH 08/13] Datashade a boolean Matrix as 0 and 1 viz.datashade on a BOOL Matrix returned its plot, which then came out blank: datashader aggregates only numbers, and holoviews logged "input must be numeric" from the lazy render instead of raising it. Hand datashader the values as uint8, so a boolean Matrix plots with any aggregator; "sum", for example, counts the true values in each pixel. --- graphblas/tests/test_viz.py | 20 ++++++++++++++++++++ graphblas/viz.py | 3 +++ 2 files changed, 23 insertions(+) diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py index bcb5ee976..140ad492e 100644 --- a/graphblas/tests/test_viz.py +++ b/graphblas/tests/test_viz.py @@ -256,3 +256,23 @@ def test_datashade_positions_match_spy(): 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)} + + +def test_datashade_bool(): + # datashader aggregates only numbers. A boolean Matrix used to plot blank, with + # holoviews logging "input must be numeric" when it rendered; now its values + # plot as 0 and 1. + _import_datashade_deps() + import numpy as np + + M = Matrix.from_coo([0, 2], [3, 1], [True, False], nrows=3, ncols=4) + nan = np.nan + for agg, expected in [ + ("sum", [[nan, nan, nan, 1], [nan, nan, nan, nan], [nan, 0, nan, nan]]), + ("count", [[0, 0, 0, 1], [0, 0, 0, 0], [0, 1, 0, 0]]), + ]: + # Indexing the lazy DynamicMap computes the image, as rendering would + image = viz.datashade(M, agg=agg)[()] + values = image.data[image.vdims[0].name] + assert values.dims == ("row", "col") + np.testing.assert_array_equal(values.values, expected) diff --git a/graphblas/viz.py b/graphblas/viz.py index 8e685b374..cc88dd8d9 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -160,6 +160,9 @@ def _matrix_to_dataframe(M): cols = cols.astype(np.float64) else: cols = cols.astype(np.int64) + if vals.dtype == np.bool_: + # datashader aggregates only numbers, so a boolean Matrix plots as 0 and 1 + vals = vals.astype(np.uint8) return pd.DataFrame({"row": rows, "col": cols, "val": vals}) From 0caaacdc60c436a274015064057fa1cb3960fbb6 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 7 Nov 2026 20:48:18 -0600 Subject: [PATCH 09/13] Raise from viz.datashade for a Matrix it cannot plot datashade returns a lazy holoviews plot. For a complex or UDT Matrix, or one with no rows or no columns, the error came only when it rendered (a numba TypingError, "NumPy datatype ... not supported", or ZeroDivisionError), and holoviews logged it as a warning and showed a blank plot. Check the dtype and shape when datashade is called instead: TypeError for complex and UDT values, ValueError for a zero dimension. An empty Matrix with rows and columns still plots. --- graphblas/tests/test_viz.py | 34 +++++++++++++++++++++++++++++++++- graphblas/viz.py | 6 ++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py index 140ad492e..7547d447d 100644 --- a/graphblas/tests/test_viz.py +++ b/graphblas/tests/test_viz.py @@ -15,7 +15,7 @@ import pytest -from graphblas import Matrix, Vector, viz +from graphblas import Matrix, Vector, dtypes, viz def _importorskip(modname): @@ -276,3 +276,35 @@ def test_datashade_bool(): values = image.data[image.vdims[0].name] assert values.dims == ("row", "col") np.testing.assert_array_equal(values.values, expected) + + +@pytest.mark.skipif("not dtypes._supports_complex") +def test_datashade_rejects_complex(): + # Rendering used to fail later with a numba TypingError that holoviews only + # logged, leaving a blank plot. + _import_datashade_deps() + M = Matrix.from_coo([0, 2], [3, 1], [1j, 2], nrows=3, ncols=4) + with pytest.raises(TypeError, match="booleans or real numbers, not FC64"): + viz.datashade(M) + + +def test_datashade_rejects_udt(): + # Rendering used to fail later with "NumPy datatype ... not supported" + _import_datashade_deps() + import numpy as np + + udt = dtypes.register_anonymous(np.dtype([("viz_ds_a", np.int32), ("viz_ds_b", np.float64)])) + M = Matrix.from_coo([0, 2], [3, 1], [(1, 2.0), (3, 4.0)], nrows=3, ncols=4, dtype=udt) + with pytest.raises(TypeError, match="booleans or real numbers"): + viz.datashade(M) + with pytest.raises(TypeError, match="booleans or real numbers"): + viz.datashade(M, agg=["count", "sum"]) + + +@pytest.mark.parametrize(("nrows", "ncols"), [(0, 0), (3, 0), (0, 4)]) +def test_datashade_rejects_no_rows_or_columns(nrows, ncols): + # Rendering used to fail later with ZeroDivisionError + _import_datashade_deps() + M = Matrix(float, nrows, ncols) + with pytest.raises(ValueError, match="with rows and columns"): + viz.datashade(M) diff --git a/graphblas/viz.py b/graphblas/viz.py index cc88dd8d9..43317ddfe 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -208,6 +208,12 @@ def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kw """ bk, hv, _hp, _ds = _get_imports(["bk", "hv", "hp", "ds"], "datashade") + # The plot renders lazily, and holoviews only logs an error raised then and + # shows a blank plot, so refuse up front what datashader cannot draw. + if M.dtype._is_udt or M.dtype.np_type.kind == "c": + raise TypeError(f"Can only datashade a Matrix of booleans or real numbers, not {M.dtype}") + if M.nrows == 0 or M.ncols == 0: + raise ValueError(f"Can only datashade a Matrix with rows and columns, not shape {M.shape}") if "df" not in kwargs: df = _matrix_to_dataframe(M) else: From c520edfc593e8a9f28a023daa69955699ad0bd43 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 7 Nov 2026 20:48:45 -0600 Subject: [PATCH 10/13] Raise a clear TypeError from viz.draw for a complex or UDT Matrix draw lays the graph out with the Matrix values as edge weights, and networkx reads those as floats. A complex Matrix failed deep inside networkx with "float() argument must be a string or a real number, not 'complex'", and a UDT one with "shape mismatch: value array of shape (2,2) could not be broadcast". Refuse both before building the graph. A boolean Matrix still draws, its weights read as 0 and 1. --- graphblas/tests/test_viz.py | 20 ++++++++++++++++++++ graphblas/viz.py | 3 +++ 2 files changed, 23 insertions(+) diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py index 7547d447d..03f576855 100644 --- a/graphblas/tests/test_viz.py +++ b/graphblas/tests/test_viz.py @@ -129,6 +129,26 @@ def test_draw_rejects_non_matrix(): viz.draw(v) +@pytest.mark.skipif("not dtypes._supports_complex") +def test_draw_rejects_complex(): + # networkx's layout used to raise "float() argument must be ... not 'complex'" + _importorskip("networkx") + M = Matrix.from_coo([0, 1], [1, 0], [1j, 2], nrows=2, ncols=2) + with pytest.raises(TypeError, match="booleans or real numbers, not FC64"): + viz.draw(M) + + +def test_draw_rejects_udt(): + # networkx's layout used to raise "shape mismatch: value array of shape (2,2) ..." + _importorskip("networkx") + import numpy as np + + udt = dtypes.register_anonymous(np.dtype([("viz_draw_a", np.int32), ("viz_draw_b", np.int8)])) + M = Matrix.from_coo([0, 1], [1, 0], [(1, 2), (3, 4)], nrows=2, ncols=2, dtype=udt) + with pytest.raises(TypeError, match="booleans or real numbers"): + viz.draw(M) + + @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 diff --git a/graphblas/viz.py b/graphblas/viz.py index 43317ddfe..cc7e6e99c 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -61,6 +61,9 @@ def draw(m): typ = _output_type(m) if typ is not _Matrix and typ is not _TransposedMatrix: raise TypeError(f"Can only draw a Matrix, not {type(m)}") + if m.dtype._is_udt or m.dtype.np_type.kind == "c": + # The layout reads the values as edge weights, which networkx needs as floats + raise TypeError(f"Can only draw a Matrix of booleans or real numbers, not {m.dtype}") g = to_networkx(m) pos = nx.spring_layout(g) From 5005be3ed59bc3e6af47424caba57b773c07f4d8 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 7 Nov 2026 20:49:38 -0600 Subject: [PATCH 11/13] Refuse a non-square Matrix in viz.draw, as its docstring says draw's docstring asks for a square adjacency Matrix, but a rectangular one drew without complaint: to_networkx makes a node of each row and of each column with an entry, so a 2x5 Matrix with entries in columns 0 and 4 drew nodes 0, 1 and 4 and left out 2 and 3. Raise ValueError instead. --- graphblas/tests/test_viz.py | 10 ++++++++++ graphblas/viz.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py index 03f576855..fa230bc49 100644 --- a/graphblas/tests/test_viz.py +++ b/graphblas/tests/test_viz.py @@ -129,6 +129,16 @@ def test_draw_rejects_non_matrix(): viz.draw(v) +@pytest.mark.parametrize(("nrows", "ncols"), [(2, 5), (5, 2)]) +def test_draw_rejects_non_square(nrows, ncols): + # A rectangular Matrix used to draw as a graph of its rows plus whichever + # columns had entries. + _importorskip("networkx") + M = Matrix.from_coo([0, 1], [1, 0], [1.0, 2.0], nrows=nrows, ncols=ncols) + with pytest.raises(ValueError, match=rf"square Matrix, not shape \({nrows}, {ncols}\)"): + viz.draw(M) + + @pytest.mark.skipif("not dtypes._supports_complex") def test_draw_rejects_complex(): # networkx's layout used to raise "float() argument must be ... not 'complex'" diff --git a/graphblas/viz.py b/graphblas/viz.py index cc7e6e99c..462467b7d 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -61,6 +61,9 @@ def draw(m): typ = _output_type(m) if typ is not _Matrix and typ is not _TransposedMatrix: raise TypeError(f"Can only draw a Matrix, not {type(m)}") + if m.nrows != m.ncols: + # Row i and column i are both node i, so only a square Matrix is an adjacency matrix + raise ValueError(f"Can only draw a square Matrix, not shape {m.shape}") if m.dtype._is_udt or m.dtype.np_type.kind == "c": # The layout reads the values as edge weights, which networkx needs as floats raise TypeError(f"Can only draw a Matrix of booleans or real numbers, not {m.dtype}") From 813fb5542283e4e3a514202eba3095cf6294313e Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 7 Nov 2026 20:50:04 -0600 Subject: [PATCH 12/13] Drop the unreachable float64 index fallback from viz datashade _matrix_to_dataframe cast the uint64 indices to float64 when a dimension and an index exceeded the int64 max, and to int64 otherwise. GraphBLAS dimensions stop at 2**60 (GrB_INDEX_MAX + 1; a larger nrows raises InvalidValue, checked on SuiteSparse 8.2 and 10.5), so the float64 branches could never run, and their rows.max() would have raised on a Matrix with no entries. Always cast to int64. --- graphblas/viz.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/graphblas/viz.py b/graphblas/viz.py index 462467b7d..67da128dc 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -157,15 +157,9 @@ def _matrix_to_dataframe(M): """ 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) + # GraphBLAS dimensions are at most 2**60, so int64 holds every uint64 index + rows = rows.astype(np.int64) + cols = cols.astype(np.int64) if vals.dtype == np.bool_: # datashader aggregates only numbers, so a boolean Matrix plots as 0 and 1 vals = vals.astype(np.uint8) From 0a0a65e65f2ad00397bc9e972eb2920293bdde92 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 7 Nov 2026 22:04:26 -0600 Subject: [PATCH 13/13] Skip the UDT draw test where vanilla cannot build a UDT Matrix without Numba test_draw_rejects_udt builds its Matrix with from_coo, whose build needs binary.any on the UDT. The suitesparse-vanilla backend has that only through Numba (main too), so without Numba the test failed in its setup with 'any does not work with ...', before viz.draw was called. --- graphblas/tests/test_viz.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py index fa230bc49..faeee2670 100644 --- a/graphblas/tests/test_viz.py +++ b/graphblas/tests/test_viz.py @@ -15,7 +15,8 @@ import pytest -from graphblas import Matrix, Vector, dtypes, viz +from graphblas import Matrix, Vector, backend, dtypes, viz +from graphblas.core import _supports_udfs as supports_udfs def _importorskip(modname): @@ -148,6 +149,10 @@ def test_draw_rejects_complex(): viz.draw(M) +@pytest.mark.skipif( + not supports_udfs and backend == "suitesparse-vanilla", + reason="building a UDT Matrix needs binary.any on it, which vanilla has only through Numba", +) def test_draw_rejects_udt(): # networkx's layout used to raise "shape mismatch: value array of shape (2,2) ..." _importorskip("networkx")