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/graphblas/tests/test_viz.py b/graphblas/tests/test_viz.py new file mode 100644 index 000000000..faeee2670 --- /dev/null +++ b/graphblas/tests/test_viz.py @@ -0,0 +1,345 @@ +"""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, backend, dtypes, viz +from graphblas.core import _supports_udfs as supports_udfs + + +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.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'" + _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) + + +@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") + 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 + # 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: # pragma: no cover (only on failure) + 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)} + + +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) + + +@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 8e2a53228..67da128dc 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 @@ -56,12 +61,53 @@ def draw(m): # pragma: no cover 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}") 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 +134,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 +149,35 @@ 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() + # 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) + 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 +207,15 @@ 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") + # 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: - 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 +254,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 +264,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 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 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*'