diff --git a/python/nx-cugraph/.flake8 b/python/nx-cugraph/.flake8 index c5874e54f7e..cdda8d1080f 100644 --- a/python/nx-cugraph/.flake8 +++ b/python/nx-cugraph/.flake8 @@ -1,9 +1,10 @@ -# Copyright (c) 2023, NVIDIA CORPORATION. +# Copyright (c) 2023-2024, NVIDIA CORPORATION. [flake8] max-line-length = 88 inline-quotes = " extend-ignore = + B020, E203, SIM105, SIM401, diff --git a/python/nx-cugraph/README.md b/python/nx-cugraph/README.md index 8a1824a7a0e..458421e2b6e 100644 --- a/python/nx-cugraph/README.md +++ b/python/nx-cugraph/README.md @@ -267,6 +267,9 @@ Below is the list of algorithms that are currently supported in nx-cugraph. convert_matrix ├─ from_pandas_edgelist └─ from_scipy_sparse_array +relabel + ├─ convert_node_labels_to_integers + └─ relabel_nodes To request nx-cugraph backend support for a NetworkX API that is not listed diff --git a/python/nx-cugraph/_nx_cugraph/__init__.py b/python/nx-cugraph/_nx_cugraph/__init__.py index 2d6017fa219..d18fc53b88c 100644 --- a/python/nx-cugraph/_nx_cugraph/__init__.py +++ b/python/nx-cugraph/_nx_cugraph/__init__.py @@ -69,6 +69,7 @@ "complete_graph", "complete_multipartite_graph", "connected_components", + "convert_node_labels_to_integers", "core_number", "cubical_graph", "cycle_graph", @@ -130,6 +131,7 @@ "path_graph", "petersen_graph", "reciprocity", + "relabel_nodes", "reverse", "sedgewick_maze_graph", "shortest_path", diff --git a/python/nx-cugraph/lint.yaml b/python/nx-cugraph/lint.yaml index 317d5b8d481..ce46360e234 100644 --- a/python/nx-cugraph/lint.yaml +++ b/python/nx-cugraph/lint.yaml @@ -50,7 +50,7 @@ repos: - id: black # - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.1 + rev: v0.5.4 hooks: - id: ruff args: [--fix-only, --show-fixes] # --unsafe-fixes] @@ -58,7 +58,7 @@ repos: rev: 7.1.0 hooks: - id: flake8 - args: ['--per-file-ignores=_nx_cugraph/__init__.py:E501', '--extend-ignore=SIM105'] # Why is this necessary? + args: ['--per-file-ignores=_nx_cugraph/__init__.py:E501', '--extend-ignore=B020,SIM105'] # Why is this necessary? additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==7.1.0 @@ -77,7 +77,7 @@ repos: additional_dependencies: [tomli] files: ^(nx_cugraph|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.1 + rev: v0.5.4 hooks: - id: ruff - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/python/nx-cugraph/nx_cugraph/__init__.py b/python/nx-cugraph/nx_cugraph/__init__.py index 2c54da87898..7d6a2fbe4c6 100644 --- a/python/nx-cugraph/nx_cugraph/__init__.py +++ b/python/nx-cugraph/nx_cugraph/__init__.py @@ -23,6 +23,9 @@ from . import convert_matrix from .convert_matrix import * +from . import relabel +from .relabel import * + from . import generators from .generators import * diff --git a/python/nx-cugraph/nx_cugraph/algorithms/operators/unary.py b/python/nx-cugraph/nx_cugraph/algorithms/operators/unary.py index 08abc9f2872..f53b3458949 100644 --- a/python/nx-cugraph/nx_cugraph/algorithms/operators/unary.py +++ b/python/nx-cugraph/nx_cugraph/algorithms/operators/unary.py @@ -51,5 +51,10 @@ def reverse(G, copy=True): if not G.is_directed(): raise nx.NetworkXError("Cannot reverse an undirected graph.") if isinstance(G, nx.Graph): + if not copy: + raise RuntimeError( + "Using `copy=False` is invalid when using a NetworkX graph " + "as input to `nx_cugraph.reverse`" + ) G = nxcg.from_networkx(G, preserve_all_attrs=True) return G.reverse(copy=copy) diff --git a/python/nx-cugraph/nx_cugraph/algorithms/traversal/breadth_first_search.py b/python/nx-cugraph/nx_cugraph/algorithms/traversal/breadth_first_search.py index f5d5e2a995d..5e4466d7d33 100644 --- a/python/nx-cugraph/nx_cugraph/algorithms/traversal/breadth_first_search.py +++ b/python/nx-cugraph/nx_cugraph/algorithms/traversal/breadth_first_search.py @@ -57,23 +57,41 @@ def _bfs(G, source, *, depth_limit=None, reverse=False): return distances[mask], predecessors[mask], node_ids[mask] -@networkx_algorithm(is_incomplete=True, version_added="24.02", _plc="bfs") -def generic_bfs_edges(G, source, neighbors=None, depth_limit=None, sort_neighbors=None): - """`neighbors` and `sort_neighbors` parameters are not yet supported.""" - if neighbors is not None: - raise NotImplementedError( - "neighbors argument in generic_bfs_edges is not currently supported" - ) - if sort_neighbors is not None: - raise NotImplementedError( - "sort_neighbors argument in generic_bfs_edges is not currently supported" - ) - return bfs_edges(G, source, depth_limit=depth_limit) - - -@generic_bfs_edges._can_run -def _(G, source, neighbors=None, depth_limit=None, sort_neighbors=None): - return neighbors is None and sort_neighbors is None +if nx.__version__[:3] <= "3.3": + + @networkx_algorithm(is_incomplete=True, version_added="24.02", _plc="bfs") + def generic_bfs_edges( + G, source, neighbors=None, depth_limit=None, sort_neighbors=None + ): + """`neighbors` and `sort_neighbors` parameters are not yet supported.""" + if neighbors is not None: + raise NotImplementedError( + "neighbors argument in generic_bfs_edges is not currently supported" + ) + if sort_neighbors is not None: + raise NotImplementedError( + "sort_neighbors argument in generic_bfs_edges is not supported" + ) + return bfs_edges(G, source, depth_limit=depth_limit) + + @generic_bfs_edges._can_run + def _(G, source, neighbors=None, depth_limit=None, sort_neighbors=None): + return neighbors is None and sort_neighbors is None + +else: + + @networkx_algorithm(is_incomplete=True, version_added="24.02", _plc="bfs") + def generic_bfs_edges(G, source, neighbors=None, depth_limit=None): + """`neighbors` parameter is not yet supported.""" + if neighbors is not None: + raise NotImplementedError( + "neighbors argument in generic_bfs_edges is not currently supported" + ) + return bfs_edges(G, source, depth_limit=depth_limit) + + @generic_bfs_edges._can_run + def _(G, source, neighbors=None, depth_limit=None): + return neighbors is None @networkx_algorithm(is_incomplete=True, version_added="24.02", _plc="bfs") diff --git a/python/nx-cugraph/nx_cugraph/convert.py b/python/nx-cugraph/nx_cugraph/convert.py index 9e6c080d6ef..56d16d837d7 100644 --- a/python/nx-cugraph/nx_cugraph/convert.py +++ b/python/nx-cugraph/nx_cugraph/convert.py @@ -411,13 +411,21 @@ def func(it, edge_attr=edge_attr, dtype=dtype): # Node values may be numpy or cupy arrays (useful for str, object, etc). # Someday we'll let the user choose np or cp, and support edge values. node_mask = np.fromiter(iter_mask, bool) - node_value = np.array(vals, dtype) try: - node_value = cp.array(node_value) + node_value = np.array(vals, dtype) except ValueError: - pass + # Handle e.g. list elements + if dtype is None or dtype == object: + node_value = np.fromiter(vals, object) + else: + raise else: - node_mask = cp.array(node_mask) + try: + node_value = cp.array(node_value) + except ValueError: + pass + else: + node_mask = cp.array(node_mask) node_values[node_attr] = node_value node_masks[node_attr] = node_mask # if vals.ndim > 1: ... @@ -431,7 +439,12 @@ def func(it, edge_attr=edge_attr, dtype=dtype): # Node values may be numpy or cupy arrays (useful for str, object, etc). # Someday we'll let the user choose np or cp, and support edge values. if dtype is None: - node_value = np.array(list(iter_values)) + vals = list(iter_values) + try: + node_value = np.array(vals) + except ValueError: + # Handle e.g. list elements + node_value = np.fromiter(vals, object) else: node_value = np.fromiter(iter_values, dtype) try: @@ -477,6 +490,23 @@ def func(it, edge_attr=edge_attr, dtype=dtype): return rv +def _to_tuples(ndim, L): + if ndim > 2: + L = list(map(_to_tuples.__get__(ndim - 1), L)) + return list(map(tuple, L)) + + +def _array_to_tuples(a): + """Like ``a.tolist()``, but nested structures are tuples instead of lists. + + This is only different from ``a.tolist()`` if ``a.ndim > 1``. It is used to + try to return tuples instead of lists for e.g. node values. + """ + if a.ndim > 1: + return _to_tuples(a.ndim, a.tolist()) + return a.tolist() + + def _iter_attr_dicts( values: dict[AttrKey, any_ndarray[EdgeValue | NodeValue]], masks: dict[AttrKey, any_ndarray[bool]], @@ -485,7 +515,7 @@ def _iter_attr_dicts( if full_attrs: full_dicts = ( dict(zip(full_attrs, vals)) - for vals in zip(*(values[attr].tolist() for attr in full_attrs)) + for vals in zip(*(_array_to_tuples(values[attr]) for attr in full_attrs)) ) partial_attrs = list(values.keys() & masks.keys()) if partial_attrs: diff --git a/python/nx-cugraph/nx_cugraph/interface.py b/python/nx-cugraph/nx_cugraph/interface.py index 8569bbf40b9..4007230efa9 100644 --- a/python/nx-cugraph/nx_cugraph/interface.py +++ b/python/nx-cugraph/nx_cugraph/interface.py @@ -68,6 +68,13 @@ def key(testpath): louvain_different = "Louvain may be different due to RNG" no_string_dtype = "string edge values not currently supported" sssp_path_different = "sssp may choose a different valid path" + no_object_dtype_for_edges = ( + "Edges don't support object dtype (lists, strings, etc.)" + ) + tuple_elements_preferred = "elements are tuples instead of lists" + nx_cugraph_in_test_setup = ( + "nx-cugraph Graph is incompatible in test setup in nx versions < 3.3" + ) xfail = { # This is removed while strongly_connected_components() is not @@ -91,6 +98,81 @@ def key(testpath): "test_cycles.py:TestMinimumCycleBasis." "test_gh6787_and_edge_attribute_names" ): sssp_path_different, + key( + "test_graph_hashing.py:test_isomorphic_edge_attr" + ): no_object_dtype_for_edges, + key( + "test_graph_hashing.py:test_isomorphic_edge_attr_and_node_attr" + ): no_object_dtype_for_edges, + key( + "test_graph_hashing.py:test_isomorphic_edge_attr_subgraph_hash" + ): no_object_dtype_for_edges, + key( + "test_graph_hashing.py:" + "test_isomorphic_edge_attr_and_node_attr_subgraph_hash" + ): no_object_dtype_for_edges, + key( + "test_summarization.py:TestSNAPNoEdgeTypes.test_summary_graph" + ): no_object_dtype_for_edges, + key( + "test_summarization.py:TestSNAPUndirected.test_summary_graph" + ): no_object_dtype_for_edges, + key( + "test_summarization.py:TestSNAPDirected.test_summary_graph" + ): no_object_dtype_for_edges, + key("test_gexf.py:TestGEXF.test_relabel"): no_object_dtype_for_edges, + key( + "test_gml.py:TestGraph.test_parse_gml_cytoscape_bug" + ): no_object_dtype_for_edges, + key("test_gml.py:TestGraph.test_parse_gml"): no_object_dtype_for_edges, + key("test_gml.py:TestGraph.test_read_gml"): no_object_dtype_for_edges, + key("test_gml.py:TestGraph.test_data_types"): no_object_dtype_for_edges, + key( + "test_gml.py:TestPropertyLists.test_reading_graph_with_list_property" + ): no_object_dtype_for_edges, + key( + "test_relabel.py:" + "test_relabel_preserve_node_order_partial_mapping_with_copy_false" + ): "Node order is preserved when relabeling with partial mapping", + key( + "test_gml.py:" + "TestPropertyLists.test_reading_graph_with_single_element_list_property" + ): tuple_elements_preferred, + key( + "test_relabel.py:" + "TestRelabel.test_relabel_multidigraph_inout_merge_nodes" + ): no_string_dtype, + key( + "test_relabel.py:TestRelabel.test_relabel_multigraph_merge_inplace" + ): no_string_dtype, + key( + "test_relabel.py:TestRelabel.test_relabel_multidigraph_merge_inplace" + ): no_string_dtype, + key( + "test_relabel.py:TestRelabel.test_relabel_multidigraph_inout_copy" + ): no_string_dtype, + key( + "test_relabel.py:TestRelabel.test_relabel_multigraph_merge_copy" + ): no_string_dtype, + key( + "test_relabel.py:TestRelabel.test_relabel_multidigraph_merge_copy" + ): no_string_dtype, + key( + "test_relabel.py:TestRelabel.test_relabel_multigraph_nonnumeric_key" + ): no_string_dtype, + key("test_contraction.py:test_multigraph_path"): no_object_dtype_for_edges, + key( + "test_contraction.py:test_directed_multigraph_path" + ): no_object_dtype_for_edges, + key( + "test_contraction.py:test_multigraph_blockmodel" + ): no_object_dtype_for_edges, + key( + "test_summarization.py:TestSNAPUndirectedMulti.test_summary_graph" + ): no_string_dtype, + key( + "test_summarization.py:TestSNAPDirectedMulti.test_summary_graph" + ): no_string_dtype, } from packaging.version import parse @@ -118,6 +200,19 @@ def key(testpath): "test_strongly_connected.py:" "TestStronglyConnected.test_connected_raise" ): "test is incompatible with pytest>=8", + # NetworkX 3.3 introduced logic around functions that return graphs + key( + "test_vf2pp_helpers.py:TestGraphTinoutUpdating.test_updating" + ): nx_cugraph_in_test_setup, + key( + "test_vf2pp_helpers.py:TestGraphTinoutUpdating.test_restoring" + ): nx_cugraph_in_test_setup, + key( + "test_vf2pp_helpers.py:TestDiGraphTinoutUpdating.test_updating" + ): nx_cugraph_in_test_setup, + key( + "test_vf2pp_helpers.py:TestDiGraphTinoutUpdating.test_restoring" + ): nx_cugraph_in_test_setup, } ) diff --git a/python/nx-cugraph/nx_cugraph/relabel.py b/python/nx-cugraph/nx_cugraph/relabel.py new file mode 100644 index 00000000000..20d1337a99c --- /dev/null +++ b/python/nx-cugraph/nx_cugraph/relabel.py @@ -0,0 +1,282 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import itertools +from collections import defaultdict + +import cupy as cp +import networkx as nx +import numpy as np + +import nx_cugraph as nxcg + +from .utils import _get_int_dtype, _groupby, index_dtype, networkx_algorithm + +__all__ = [ + "convert_node_labels_to_integers", + "relabel_nodes", +] + + +@networkx_algorithm(version_added="24.08") +def relabel_nodes(G, mapping, copy=True): + if isinstance(G, nx.Graph): + if not copy: + raise RuntimeError( + "Using `copy=False` is invalid when using a NetworkX graph " + "as input to `nx_cugraph.relabel_nodes`" + ) + G = nxcg.from_networkx(G, preserve_all_attrs=True) + it = range(G._N) if G.key_to_id is None else G.id_to_key + if callable(mapping): + previd_to_key = [mapping(node) for node in it] + else: + previd_to_key = [mapping.get(node, node) for node in it] + if not copy: + # Our implementation does not need to raise here, but do so to match networkx. + it = range(G._N) if G.key_to_id is None else G.id_to_key + D = nx.DiGraph([(x, y) for x, y in zip(it, previd_to_key) if x != y]) + if nx.algorithms.dag.has_cycle(D): + raise nx.NetworkXUnfeasible( + "The node label sets are overlapping and no ordering can " + "resolve the mapping. Use copy=True." + ) + key_to_previd = {val: i for i, val in enumerate(previd_to_key)} + newid_to_key = list(key_to_previd) + key_to_newid = dict(zip(newid_to_key, range(len(newid_to_key)))) + + src_indices = G.src_indices + dst_indices = G.dst_indices + edge_values = G.edge_values + edge_masks = G.edge_masks + node_values = G.node_values + node_masks = G.node_masks + if G.is_multigraph(): + edge_indices = G.edge_indices + edge_keys = G.edge_keys + if len(key_to_previd) != G._N: + # Some nodes were combined. + # Node data doesn't get merged, so use the data from the last shared index + int_dtype = _get_int_dtype(G._N - 1) + node_indices = cp.fromiter(key_to_previd.values(), int_dtype) + node_indices_np = node_indices.get() # Node data may be cupy or numpy arrays + node_values = {key: val[node_indices_np] for key, val in node_values.items()} + node_masks = {key: val[node_indices_np] for key, val in node_masks.items()} + + # Renumber, but will have duplicates + translations = cp.fromiter( + (key_to_newid[key] for key in previd_to_key), index_dtype + ) + src_indices_dup = translations[src_indices] + dst_indices_dup = translations[dst_indices] + + if G.is_multigraph(): + # No merging necessary for multigraphs. + if G.is_directed(): + src_indices = src_indices_dup + dst_indices = dst_indices_dup + else: + # New self-edges should have one edge entry, not two + mask = ( + # Not self-edges, no need to deduplicate + (src_indices_dup != dst_indices_dup) + # == : already self-edges; no need to deduplicate + # < : if new self-edges, keep where src < dst + | (src_indices <= dst_indices) + ) + if mask.all(): + src_indices = src_indices_dup + dst_indices = dst_indices_dup + else: + src_indices = src_indices_dup[mask] + dst_indices = dst_indices_dup[mask] + if edge_values: + edge_values = { + key: val[mask] for key, val in edge_values.items() + } + edge_masks = {key: val[mask] for key, val in edge_masks.items()} + if edge_keys is not None: + edge_keys = [ + key for keep, key in zip(mask.tolist(), edge_keys) if keep + ] + if edge_indices is not None: + edge_indices = edge_indices[mask] + # Handling of `edge_keys` and `edge_indices` is pure Python to match nx. + # This may be slower than we'd like; if it's way too slow, should we + # direct users to use the defaults of None? + if edge_keys is not None: + seen = set() + new_edge_keys = [] + for key in zip(src_indices.tolist(), dst_indices.tolist(), edge_keys): + if key in seen: + src, dst, edge_key = key + if not isinstance(edge_key, (int, float)): + edge_key = 0 + for edge_key in itertools.count(edge_key): + if (src, dst, edge_key) not in seen: + seen.add((src, dst, edge_key)) + break + else: + seen.add(key) + edge_key = key[2] + new_edge_keys.append(edge_key) + edge_keys = new_edge_keys + if edge_indices is not None: + # PERF: can we do this using cupy? + seen = set() + new_edge_indices = [] + for key in zip( + src_indices.tolist(), dst_indices.tolist(), edge_indices.tolist() + ): + if key in seen: + src, dst, edge_index = key + for edge_index in itertools.count(edge_index): + if (src, dst, edge_index) not in seen: + seen.add((src, dst, edge_index)) + break + else: + seen.add(key) + edge_index = key[2] + new_edge_indices.append(edge_index) + edge_indices = cp.array(new_edge_indices, index_dtype) + else: + stacked_dup = cp.vstack((src_indices_dup, dst_indices_dup)) + if not edge_values: + # Drop duplicates + stacked = cp.unique(stacked_dup, axis=1) + else: + # Drop duplicates. This relies heavily on `_groupby`. + # It has not been compared to alternative implementations. + # I wonder if there are ways to use assignment using duplicate indices. + (stacked, ind, inv) = cp.unique( + stacked_dup, axis=1, return_index=True, return_inverse=True + ) + if ind.dtype != int_dtype: + ind = ind.astype(int_dtype) + if inv.dtype != int_dtype: + inv = inv.astype(int_dtype) + + # We need to merge edge data + mask = cp.ones(src_indices.size, dtype=bool) + mask[ind] = False + edge_data = [val[mask] for val in edge_values.values()] + edge_data.extend(val[mask] for val in edge_masks.values()) + groups = _groupby(inv[mask], edge_data) + + edge_values = {key: val[ind] for key, val in edge_values.items()} + edge_masks = {key: val[ind] for key, val in edge_masks.items()} + + value_keys = list(edge_values.keys()) + mask_keys = list(edge_masks.keys()) + + values_to_update = defaultdict(list) + masks_to_update = defaultdict(list) + for k, v in groups.items(): + it = iter(v) + vals = dict(zip(value_keys, it)) # zip(strict=False) + masks = dict(zip(mask_keys, it)) # zip(strict=True) + for key, val in vals.items(): + if key in masks: + val = val[masks[key]] + if val.size > 0: + values_to_update[key].append((k, val[-1])) + masks_to_update[key].append((k, True)) + else: + values_to_update[key].append((k, val[-1])) + if key in edge_masks: + masks_to_update[key].append((k, True)) + + int_dtype = _get_int_dtype(src_indices.size - 1) + for k, v in values_to_update.items(): + ii, jj = zip(*v) + edge_val = edge_values[k] + edge_val[cp.array(ii, dtype=int_dtype)] = cp.array( + jj, dtype=edge_val.dtype + ) + for k, v in masks_to_update.items(): + ii, jj = zip(*v) + edge_masks[k][cp.array(ii, dtype=int_dtype)] = cp.array( + jj, dtype=bool + ) + src_indices = stacked[0] + dst_indices = stacked[1] + + if G.is_multigraph(): + # `edge_keys` and `edge_indices` are preserved for free if no nodes were merged + extra_kwargs = {"edge_keys": edge_keys, "edge_indices": edge_indices} + else: + extra_kwargs = {} + rv = G.__class__.from_coo( + len(key_to_previd), + src_indices, + dst_indices, + edge_values=edge_values, + edge_masks=edge_masks, + node_values=node_values, + node_masks=node_masks, + id_to_key=newid_to_key, + key_to_id=key_to_newid, + **extra_kwargs, + ) + rv.graph.update(G.graph) + if not copy: + G._become(rv) + return G + return rv + + +@networkx_algorithm(version_added="24.08") +def convert_node_labels_to_integers( + G, first_label=0, ordering="default", label_attribute=None +): + if ordering not in {"default", "sorted", "increasing degree", "decreasing degree"}: + raise nx.NetworkXError(f"Unknown node ordering: {ordering}") + if isinstance(G, nx.Graph): + G = nxcg.from_networkx(G, preserve_all_attrs=True) + G = G.copy() + if label_attribute is not None: + prev_vals = G.id_to_key + if prev_vals is None: + prev_vals = cp.arange(G._N, dtype=_get_int_dtype(G._N - 1)) + else: + try: + prev_vals = np.array(prev_vals) + except ValueError: + prev_vals = np.fromiter(prev_vals, object) + else: + try: + prev_vals = cp.array(prev_vals) + except ValueError: + pass + G.node_values[label_attribute] = prev_vals + G.node_masks.pop(label_attribute, None) + id_to_key = None + if ordering == "default" or ordering == "sorted" and G.key_to_id is None: + if first_label == 0: + G.key_to_id = None + else: + id_to_key = list(range(first_label, first_label + G._N)) + G.key_to_id = dict(zip(id_to_key, range(G._N))) + elif ordering == "sorted": + key_to_id = G.key_to_id + G.key_to_id = { + i: key_to_id[n] for i, n in enumerate(sorted(key_to_id), first_label) + } + else: + pairs = sorted( + ((d, n) for (n, d) in G._nodearray_to_dict(G._degrees_array()).items()), + reverse=ordering == "decreasing degree", + ) + key_to_id = G.key_to_id + G.key_to_id = {i: key_to_id[n] for i, (d, n) in enumerate(pairs, first_label)} + G._id_to_key = id_to_key + return G diff --git a/python/nx-cugraph/nx_cugraph/tests/test_relabel.py b/python/nx-cugraph/nx_cugraph/tests/test_relabel.py new file mode 100644 index 00000000000..40bf851d376 --- /dev/null +++ b/python/nx-cugraph/nx_cugraph/tests/test_relabel.py @@ -0,0 +1,63 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import networkx as nx +import pytest + +import nx_cugraph as nxcg + +from .testing_utils import assert_graphs_equal + + +@pytest.mark.parametrize( + "create_using", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph] +) +def test_relabel(create_using): + G = nx.complete_graph(3, create_using=create_using) + Hnx = nx.relabel_nodes(G, {2: 1}) + Hcg = nxcg.relabel_nodes(G, {2: 1}) + assert_graphs_equal(Hnx, Hcg) + + G.add_edge(0, 2, a=11) + G.add_edge(1, 2, b=22) + Hnx = nx.relabel_nodes(G, {2: 10, 1: 10}) + Hcg = nxcg.relabel_nodes(G, {2: 10, 1: 10}) + assert_graphs_equal(Hnx, Hcg) + + G = nx.path_graph(3, create_using=create_using) + Hnx = nx.relabel_nodes(G, {2: 0}) + Hcg = nxcg.relabel_nodes(G, {2: 0}) + assert_graphs_equal(Hnx, Hcg) + + +@pytest.mark.parametrize("create_using", [nx.MultiGraph, nx.MultiDiGraph]) +def test_relabel_multigraph(create_using): + G = nx.empty_graph(create_using=create_using) + G.add_edge(0, 1, "x", a=11) + G.add_edge(0, 2, "y", a=10, b=6) + G.add_edge(0, 0, c=7) + G.add_edge(0, 0, "x", a=-1, b=-1, c=-1) + Hnx = nx.relabel_nodes(G, {0: 1, 2: 1}) + Hcg = nxcg.relabel_nodes(G, {0: 1, 2: 1}) + assert_graphs_equal(Hnx, Hcg) + Hnx = nx.relabel_nodes(G, {2: 3, 1: 3, 0: 3}) + Hcg = nxcg.relabel_nodes(G, {2: 3, 1: 3, 0: 3}) + assert_graphs_equal(Hnx, Hcg) + + +def test_relabel_nx_input(): + G = nx.complete_graph(3) + with pytest.raises(RuntimeError, match="Using `copy=False` is invalid"): + nxcg.relabel_nodes(G, {0: 1}, copy=False) + Hnx = nx.relabel_nodes(G, {0: 1}, copy=True) + Hcg = nxcg.relabel_nodes(G, {0: 1}, copy=True) + assert_graphs_equal(Hnx, Hcg) diff --git a/python/nx-cugraph/nx_cugraph/tests/testing_utils.py b/python/nx-cugraph/nx_cugraph/tests/testing_utils.py index 6d4741c9ca6..529a96efd81 100644 --- a/python/nx-cugraph/nx_cugraph/tests/testing_utils.py +++ b/python/nx-cugraph/nx_cugraph/tests/testing_utils.py @@ -18,10 +18,10 @@ def assert_graphs_equal(Gnx, Gcg): assert isinstance(Gnx, nx.Graph) assert isinstance(Gcg, nxcg.Graph) - assert Gnx.number_of_nodes() == Gcg.number_of_nodes() - assert Gnx.number_of_edges() == Gcg.number_of_edges() - assert Gnx.is_directed() == Gcg.is_directed() - assert Gnx.is_multigraph() == Gcg.is_multigraph() + assert (a := Gnx.number_of_nodes()) == (b := Gcg.number_of_nodes()), (a, b) + assert (a := Gnx.number_of_edges()) == (b := Gcg.number_of_edges()), (a, b) + assert (a := Gnx.is_directed()) == (b := Gcg.is_directed()), (a, b) + assert (a := Gnx.is_multigraph()) == (b := Gcg.is_multigraph()), (a, b) G = nxcg.to_networkx(Gcg) rv = nx.utils.graphs_equal(G, Gnx) if not rv: diff --git a/python/nx-cugraph/pyproject.toml b/python/nx-cugraph/pyproject.toml index 07e09201c92..881e5aa4d60 100644 --- a/python/nx-cugraph/pyproject.toml +++ b/python/nx-cugraph/pyproject.toml @@ -181,6 +181,7 @@ ignore = [ # "SIM300", # Yoda conditions are discouraged, use ... instead (Note: we're not this picky) # "SIM401", # Use dict.get ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) # "TRY004", # Prefer `TypeError` exception for invalid type (Note: good advice, but not worth the nuisance) + "B020", # Found for loop that reassigns the iterable it is iterating with each iterable value (too strict) "B904", # Bare `raise` inside exception clause (like TRY200; sometimes okay) "S310", # Audit URL open for permitted schemes (Note: we don't download URLs in normal usage) @@ -207,6 +208,7 @@ ignore = [ "RET502", # Do not implicitly `return None` in function able to return non-`None` value "RET503", # Missing explicit `return` at the end of function able to return non-`None` value "RET504", # Unnecessary variable assignment before `return` statement + "RUF018", # Avoid assignment expressions in `assert` statements "S110", # `try`-`except`-`pass` detected, consider logging the exception (Note: good advice, but we don't log) "S112", # `try`-`except`-`continue` detected, consider logging the exception (Note: good advice, but we don't log) "SIM102", # Use a single `if` statement instead of nested `if` statements (Note: often necessary) @@ -242,6 +244,7 @@ ignore = [ "nx_cugraph/algorithms/**/*py" = ["D205", "D401"] # Allow flexible docstrings for algorithms "nx_cugraph/generators/**/*py" = ["D205", "D401"] # Allow flexible docstrings for generators "nx_cugraph/interface.py" = ["D401"] # Flexible docstrings +"nx_cugraph/convert.py" = ["E721"] # Allow `dtype == object` "scripts/update_readme.py" = ["INP001"] # Not part of a package [tool.ruff.lint.flake8-annotations]