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):