Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 20 additions & 33 deletions graphblas/io/_networkx.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import numpy as np

from ..dtypes import lookup_dtype
from ._scipy import from_scipy_sparse


def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None):
Expand All @@ -24,6 +23,14 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None):
-------
:class:`~graphblas.Matrix`

Raises
------
networkx.NetworkXError
If the graph has no nodes, or ``nodelist`` is empty, repeats a node, or
names one the graph does not have (as ``nx.to_scipy_sparse_array`` does).
ValueError
If a weight is not a bool, int (at most 64 bits), float or complex scalar.

"""
import networkx as nx

Expand Down Expand Up @@ -81,28 +88,19 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None):
# An explicit dtype= is applied above and never reaches this branch.
values = values.astype(np.int64)
if values.ndim != 1 or values.dtype.kind not in "biufc":
# Defer to scipy so the error matches the previous behavior exactly.
# Two kinds of weight land here: non-numeric attributes (object arrays,
# but also e.g. all-string weights, which infer a <U dtype rather than
# object), and sequence-valued attributes of uniform length, which infer
# a 2-D numeric array that from_coo would otherwise accept as a UDT.
try:
return _from_networkx_via_scipy(G, nodelist, dtype, weight, name)
except (nx.NetworkXError, TypeError) as err:
# Since scipy 1.15 an unsupported dtype is rejected while the coo array
# is built, and that ValueError reaches us unchanged. Older scipy builds
# the array and fails in the coo -> csr conversion instead: scipy raises
# TypeError ("no supported conversion for types"), which networkx 3.4+
# wraps in a NetworkXError that blames the sparse format while
# networkx <= 3.3 lets the TypeError propagate.
# Restate it so every supported stack reports the same error for the
# same graph. The graph and nodelist checks above are the ones
# nx.to_scipy_sparse_array makes, so a NetworkXError or TypeError from
# the fallback can only be that dtype complaint.
raise ValueError(
f"scipy.sparse does not support dtype {values.dtype}; "
"edge weights must be numeric scalars"
) from err
# which ints beyond 64 bits also give, and e.g. all-string weights, which
# infer a <U dtype rather than object), and sequence-valued attributes of
# uniform length, which infer a 2-D numeric array that from_coo would
# otherwise accept as a UDT. nx.to_scipy_sparse_array rejects the same
# weights, but asking it made the error depend on scipy: ValueError since
# 1.15, TypeError or a NetworkXError blaming the sparse format before, and
# ImportError without scipy installed.
got = f"dtype {values.dtype}" if values.ndim == 1 else f"arrays of shape {values.shape[1:]}"
raise ValueError(
"edge weights must be bool, int (at most 64 bits), float or complex scalars; "
f"attribute {weight!r} gives {got}"
)
if values.size == 0:
# An empty graph has no data to infer a dtype from; scipy defaults an
# empty coo array to float64, so match that when dtype is unset.
Expand Down Expand Up @@ -138,17 +136,6 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None):
return Matrix.from_coo(rows, cols, values, nrows=nlen, ncols=nlen, dup_op=dup_op, name=name)


def _from_networkx_via_scipy(G, nodelist, dtype, weight, name):
"""Fallback path: convert through a scipy sparse array.

``dtype`` is already normalized to a numpy type (or None) by the caller.
"""
import networkx as nx

A = nx.to_scipy_sparse_array(G, nodelist=nodelist, dtype=dtype, weight=weight)
return from_scipy_sparse(A, name=name)


# A DiGraph spends about 350 bytes on each node, so a graph with this many nodes would
# need hundreds of terabytes. Refusing up front beats a long climb to out-of-memory,
# and it never refuses a graph that could have been built.
Expand Down
53 changes: 36 additions & 17 deletions graphblas/tests/test_io.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import functools
import re
import sys
from io import BytesIO, StringIO

import numpy as np
Expand Down Expand Up @@ -320,19 +322,41 @@ def test_from_networkx_int32_weights_widen_like_from_coo():
assert gb.Matrix.from_coo([0], [1], [np.int16(2)]).dtype == dtypes.INT16


@pytest.mark.skipif("not nx or not ss")
def _block_scipy(monkeypatch):
# ``import scipy`` (and ``import scipy.sparse``) raise ImportError while a
# sys.modules entry is None; monkeypatch puts the real modules back after.
for modname in ("scipy", "scipy.sparse"):
monkeypatch.setitem(sys.modules, modname, None)


@pytest.mark.skipif("not nx")
@pytest.mark.parametrize(
"graph_cls", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph] if nx else []
)
def test_from_networkx_rejects_array_weights(graph_cls):
# Sequence weights of uniform length infer a 2-D numeric array, which passes
# a dtype-kind test but which from_coo would happily read as a UDT. scipy has
# always rejected these, so the direct path must defer rather than quietly
# widen what from_networkx accepts.
@pytest.mark.parametrize(
("weights", "got"),
[
# Sequences of uniform length infer a 2-D numeric array, which passes a
# dtype-kind test but which from_coo would happily read as a UDT.
([[1, 2], [3, 4]], "arrays of shape (2,)"),
# Strings infer a 1-D <U array, so a shape test alone would let them in.
(["a", "b"], "dtype <U1"),
([2**70, 1], "dtype object"), # beyond 64 bits
([object(), 1], "dtype object"),
],
)
@pytest.mark.parametrize("block_scipy", [False, True])
def test_from_networkx_rejects_weights_a_matrix_cannot_hold(
graph_cls, weights, got, block_scipy, monkeypatch
):
# The same ValueError whether or not scipy is installed, and on any scipy:
# deferring to nx.to_scipy_sparse_array gave ImportError without it.
if block_scipy:
_block_scipy(monkeypatch)
G = graph_cls()
G.add_edge(0, 1, weight=[1, 2])
G.add_edge(1, 0, weight=[3, 4])
with pytest.raises(ValueError, match="must be 1-D"):
G.add_edge(0, 1, weight=weights[0])
G.add_edge(1, 2, weight=weights[1])
with pytest.raises(ValueError, match=re.escape(f"attribute 'weight' gives {got}")):
gb.io.from_networkx(G)


Expand Down Expand Up @@ -377,14 +401,9 @@ def test_from_networkx_rejects_bad_nodelist():

@pytest.mark.skipif("not nx")
def test_from_networkx_multigraph_is_scipy_free(monkeypatch):
# A numeric-weight multigraph must ingest via the direct coo path, not the
# scipy fallback, so networkx ingest no longer requires scipy for this case.
import graphblas.io._networkx as _gnx

def _boom(*args, **kwargs): # pragma: no cover (only runs if the direct path regresses)
raise AssertionError("scipy fallback should not be used for a numeric multigraph")

monkeypatch.setattr(_gnx, "_from_networkx_via_scipy", _boom)
# A numeric-weight multigraph ingests via the direct coo path, so networkx
# ingest does not require scipy for it.
_block_scipy(monkeypatch)
G = nx.MultiDiGraph()
G.add_weighted_edges_from([(0, 1, 2.0), (0, 1, 3.0), (2, 0, 1.0), (1, 1, 4.0), (1, 1, 0.5)])
M = gb.io.from_networkx(G)
Expand Down
Loading