diff --git a/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py
new file mode 100644
index 000000000..232d01ce3
--- /dev/null
+++ b/graphblas/tests/test_viz.py
@@ -0,0 +1,258 @@
+"""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 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 = _importorskip("matplotlib")
+mpl.use("Agg")
+plt = _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():
+ _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.
+ _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,
+ # including the auto-markersize path (which once raised NameError here).
+ _importorskip("scipy.sparse")
+ A = square_matrix()
+ fig = mpl.figure.Figure()
+ axes = fig.subplots()
+ 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.
+ _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
+ # plt.show(); on Agg that show() emits the non-interactive UserWarning,
+ # which we ignore here.
+ _importorskip("networkx")
+ _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():
+ _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)
+
+
+@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.
+ 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]
+
+ 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 = _importorskip("networkx")
+ _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"):
+ _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
+
+
+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 8e2a53228..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()
@@ -88,12 +128,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:
@@ -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,
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