From 9fe3ac413e308e9b086c8983808479c2220fb841 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 12 Oct 2026 12:01:53 -0500 Subject: [PATCH] Reject a user-defined dtype in from_networkx from_networkx passed dtype= to np.array over the sequence of weights. For a UDT, numpy read that sequence as the fields or elements of one value of the type, so the result depended on how many weights there were: FP64[2] with two edges, or INT64[3] with three, came back as an FP64 or INT64 matrix without a word, a record type raised "could not assign tuple of length 3 to structure with 2 fields", and other counts raised a numpy IndexError or a broadcast error. The scipy route this path replaced had the same fault (FP64[2] with one undirected edge gave FP64 there). A weight is one number, so a UDT has nothing to hold it as. Raise a ValueError naming the type before any conversion, and document dtype= as a built-in bool, int, float or complex type. The structural path no longer needs its own guard against non-numeric dtypes. The test covers a record type and two array types, with and without weight, for one to three edges; on the parent 4 of its 18 cases built a matrix, 4 raised IndexError and 10 raised ValueError with an unrelated message. Built-in dtypes are unaffected: 124 conversions compared with the parent are identical. --- graphblas/io/_networkx.py | 20 +++++++++++++++----- graphblas/tests/test_io.py | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index f3cc6f4b7..e1082e864 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -12,8 +12,9 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): Graph to convert nodelist : list, optional List of nodes in the nx.Graph. If not provided, all nodes will be used. - dtype : - Data type + dtype : optional + A built-in data type (bool, int, float or complex). If not provided, it + is inferred from the weights. weight : str, default="weight" Weight attribute name : str, optional @@ -29,13 +30,22 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): 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. + If a weight is not a bool, int (at most 64 bits), float or complex scalar, + or ``dtype`` is a user-defined type. """ import networkx as nx if dtype is not None: - dtype = lookup_dtype(dtype).np_type + dtype = lookup_dtype(dtype) + if dtype._is_udt: + # numpy reads the weights as the fields or elements of one value of + # the type, which built a matrix of the type's base dtype for some edge + # counts and raised unrelated errors for others (through scipy too). + raise ValueError( + f"dtype must be a built-in bool, int, float or complex type, not the UDT {dtype}" + ) + dtype = dtype.np_type # The node selection below mirrors nx.to_scipy_sparse_array so that empty # graphs, nodelist subsets, missing nodes, and duplicate nodes raise the @@ -68,7 +78,7 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): G = G.subgraph(nodelist) index = dict(zip(nodelist, range(nlen), strict=True)) - if weight is None and not G.is_multigraph() and (dtype is None or dtype.kind in "biufc"): + if weight is None and not G.is_multigraph(): # Every entry is 1, so read only the coordinates: row by row in nodelist # order, straight from the adjacency, which for an undirected graph already # holds both directions and each self-loop once. No tuple and no value per diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 7ef360521..2cf57ff67 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -292,6 +292,24 @@ def test_from_networkx_structure_matches_the_general_path(graph_cls, dtype): gb.io.from_networkx(G, nodelist=["a", "missing"], weight=None) +@pytest.mark.skipif("not nx") +@pytest.mark.parametrize("weight", [None, "weight"]) +@pytest.mark.parametrize("nedges", [1, 2, 3]) +@pytest.mark.parametrize("dtype", ["record", "FP64[2]", "INT64[3]"]) +def test_from_networkx_rejects_udt_dtype(weight, nedges, dtype): + # numpy read the weights as the fields or elements of one UDT value, so a type + # whose element count matched the number of weights came back as its base + # dtype (FP64[2] with two edges gave FP64), and other counts raised unrelated + # numpy errors. + if dtype == "record": + dtype = dtypes.register_anonymous( + np.dtype([("from_networkx_x", np.int64), ("from_networkx_y", np.float64)]) + ) + G = nx.path_graph(nedges + 1) + with pytest.raises(ValueError, match="not the UDT"): + gb.io.from_networkx(G, weight=weight, dtype=dtype) + + @pytest.mark.skipif("not nx") @pytest.mark.parametrize("wt", [np.inf, -np.inf, 1e308]) def test_from_networkx_undirected_selfloop_keeps_its_weight(wt):