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]