Repository: networkx/networkx

Issue:
Discrepancy in 'strongly_connected_components_recursive' for Detecting SCCs
Hey NetworkX team! 👋
### Description:
While attempting to identify strongly connected components (SCCs) of a graph using the `strongly_connected_components`, `strongly_connected_components_recursive`, and `kosaraju_strongly_connected_components` methods provided by `networkx`, inconsistent SCCs were observed. Notably, the `strongly_connected_components_recursive` method identified node `8` as a separate SCC, while the other two methods included node `8` in a larger SCC.\
For context, I've minimized my original, much larger graph to a smaller version to isolate and highlight the issue. 


### Observed Behavior:
- Using `strongly_connected_components`: [{0, 2, 3, 5, 6, 7, 8, 10}, {1}, {4}, {9}]
- Using `strongly_connected_components_recursive`: [{8}, {0, 2, 3, 5, 6, 7, 10}, {1}, {4}, {9}]
- Using `kosaraju_strongly_connected_components`: [{1}, {0, 2, 3, 5, 6, 7, 8, 10}, {9}, {4}]

### Expected Behavior:
All three methods should produce consistent SCCs. Node `8` should be part of the larger SCC `{0, 2, 3, 5, 6, 7, 8, 10}` based on the provided edges.

### Steps to Reproduce:
1. Create a directed graph with the provided nodes and edges.
2. Compute SCCs using the `strongly_connected_components`, `strongly_connected_components_recursive`, and `kosaraju_strongly_connected_components` methods.

### Environment:
Python version: 3.8.10
NetworkX version: 3.1

```python
import networkx as nx
from matplotlib import pyplot as plt


class Graph:
    def __init__(self):
        self.graph = nx.DiGraph()

    def add_nodes(self, nodes):
        for node in nodes:
            self.graph.add_node(node)

    def add_edges(self, edges):
        for edge in edges:
            self.graph.add_edge(edge[0], edge[1])

    def compute_scc(self):
        """Compute the result using three functions and print them"""
        result1 = list(nx.strongly_connected_components(self.graph))
        result2 = list(nx.strongly_connected_components_recursive(self.graph))
        result3 = list(nx.kosaraju_strongly_connected_components(self.graph))

        print("Using strongly_connected_components:", result1)
        print("Using strongly_connected_components_recursive:", result2)
        print("Using kosaraju_strongly_connected_components:", result3)

if __name__ == "__main__":
    nodes = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    edges = [
        (0, 2), (0, 8), (0, 10), (2, 3), (3, 10),
        (3, 0), (4, 6), (4, 1), (5, 6), (5, 7),
        (6, 2), (6, 5), (7, 10), (8, 7), (9, 8),
        (10, 6)
    ]

    graph_instance = Graph()
    graph_instance.add_nodes(nodes)
    graph_instance.add_edges(edges)
    graph_instance.compute_scc()


Comments:

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index a1e6ef043..f01de2033 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -38,6 +38,10 @@ jobs:
             pip install -r requirements/extra.txt
             pip install -r requirements/example.txt
             pip install -r requirements/doc.txt
+            # Install trusted backends, but not their dependencies.
+            # We only need to use the "networkx.plugin_info" entry-point.
+            # This is the nightly wheel for nx-cugraph.
+            pip install nx-cugraph-cu11 --extra-index-url https://rapidsdev:WeArePyPI@pypi.k8s.rapids.ai/simple --no-deps
             pip list
 
       - save_cache:
@@ -49,7 +53,7 @@ jobs:
           name: Install
           command: |
             source venv/bin/activate
-            pip install -e .
+            pip install -e . --no-deps
 
       - run:
           name: Build docs
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index edd211f6f..92862bce9 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -32,6 +32,10 @@ jobs:
           pip install -r requirements/example.txt
           pip install -U -r requirements/doc.txt
           pip install .
+          # Install trusted backends, but not their dependencies.
+          # We only need to use the "networkx.plugin_info" entry-point.
+          # This is the nightly wheel for nx-cugraph.
+          pip install nx-cugraph-cu11 --extra-index-url https://rapidsdev:WeArePyPI@pypi.k8s.rapids.ai/simple --no-deps
           pip list
 
       # To set up a cross-repository deploy key:
diff --git a/doc/conf.py b/doc/conf.py
index b32f8d7a7..4161a4ee0 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -247,3 +247,48 @@ numpydoc_show_class_members = False
 def setup(app):
     app.add_css_file("custom.css")
     app.add_js_file("copybutton.js")
+
+
+# Monkeypatch numpydoc to show "Backends" section
+from numpydoc.docscrape import NumpyDocString
+
+orig_setitem = NumpyDocString.__setitem__
+
+
+def new_setitem(self, key, val):
+    if key != "Backends":
+        orig_setitem(self, key, val)
+        return
+    # Update how we show backend information in the online docs.
+    # Start by creating an "admonition" section to make it stand out.
+    newval = [".. admonition:: Additional backends implement this function", ""]
+    for line in val:
+        if line and not line.startswith(" "):
+            # This line must identify a backend; let's try to add a link
+            backend, *rest = line.split(" ")
+            url = networkx.utils.backends.plugin_info.get(backend, {}).get("url")
+            if url:
+                line = f"`{backend} <{url}>`_ " + " ".join(rest)
+        newval.append(f"   {line}")
+    self._parsed_data[key] = newval
+
+
+NumpyDocString.__setitem__ = new_setitem
+
+from numpydoc.docscrape_sphinx import SphinxDocString
+
+orig_str = SphinxDocString.__str__
+
+
+def new_str(self, indent=0, func_role="obj"):
+    rv = orig_str(self, indent=indent, func_role=func_role)
+    if "Backends" in self:
+        lines = self._str_section("Backends")
+        # Remove "Backends" as a section and add a divider instead
+        lines[0] = "----"
+        lines = self._str_indent(lines, indent)
+        rv += "\n".join(lines)
+    return rv
+
+
+SphinxDocString.__str__ = new_str
diff --git a/doc/developer/deprecations.rst b/doc/developer/deprecations.rst
index 266fb4055..e20426196 100644
--- a/doc/developer/deprecations.rst
+++ b/doc/developer/deprecations.rst
@@ -61,3 +61,5 @@ Version 3.4
 * Remove ``normalized`` kwarg from ``algorithms.s_metric``
 * Remove renamed function ``join()`` in ``algorithms/tree/operations.py`` and
   in ``doc/reference/algorithms/trees.rst``
+* Remove ``strongly_connected_components_recursive`` from
+  ``algorithms/components/strongly_connected.py``
diff --git a/doc/release/release_dev.rst b/doc/release/release_dev.rst
index 519ffe407..152516a7b 100644
--- a/doc/release/release_dev.rst
+++ b/doc/release/release_dev.rst
@@ -68,6 +68,9 @@ Deprecations
 - [`#6841 <https://github.com/networkx/pull/6841>`_]
   Deprecate the ``normalized`` keyword of the ``s_metric`` function. Ignore
   value of ``normalized`` for future compatibility.
+- [`#6957 <https://github.com/networkx/pull/6957>`_]
+  Deprecate ``strongly_connected_components_recursive`` function in favor of
+  the non-recursive implmentation ``strongly_connected_components``.
 
 Merged PRs
 ----------
diff --git a/networkx/algorithms/components/strongly_connected.py b/networkx/algorithms/components/strongly_connected.py
index 3486d4034..5bf5b9947 100644
--- a/networkx/algorithms/components/strongly_connected.py
+++ b/networkx/algorithms/components/strongly_connected.py
@@ -178,6 +178,11 @@ def kosaraju_strongly_connected_components(G, source=None):
 def strongly_connected_components_recursive(G):
     """Generate nodes in strongly connected components of graph.
 
+    .. deprecated:: 3.2
+
+       This function is deprecated and will be removed in a future version of
+       NetworkX. Use `strongly_connected_components` instead.
+
     Recursive version of algorithm.
 
     Parameters
@@ -236,35 +241,18 @@ def strongly_connected_components_recursive(G):
        Information Processing Letters 49(1): 9-14, (1994)..
 
     """
+    import warnings
+
+    warnings.warn(
+        (
+            "\n\nstrongly_connected_components_recursive is deprecated and will be\n"
+            "removed in the future. Use strongly_connected_components instead."
+        ),
+        category=DeprecationWarning,
+        stacklevel=2,
+    )
 
-    def visit(v, cnt):
-        root[v] = cnt
-        visited[v] = cnt
-        cnt += 1
-        stack.append(v)
-        for w in G[v]:
-            if w not in visited:
-                yield from visit(w, cnt)
-            if w not in component:
-                root[v] = min(root[v], root[w])
-        if root[v] == visited[v]:
-            component[v] = root[v]
-            tmpc = {v}  # hold nodes in this component
-            while stack[-1] != v:
-                w = stack.pop()
-                component[w] = root[v]
-                tmpc.add(w)
-            stack.remove(v)
-            yield tmpc
-
-    visited = {}
-    component = {}
-    root = {}
-    cnt = 0
-    stack = []
-    for source in G:
-        if source not in visited:
-            yield from visit(source, cnt)
+    yield from strongly_connected_components(G)
 
 
 @not_implemented_for("undirected")
diff --git a/networkx/utils/backends.py b/networkx/utils/backends.py
index ae7b030a7..0ce1a9e00 100644
--- a/networkx/utils/backends.py
+++ b/networkx/utils/backends.py
@@ -96,11 +96,14 @@ from ..exception import NetworkXNotImplemented
 __all__ = ["_dispatch"]
 
 
-def _get_plugins():
+def _get_plugins(group, *, load_and_call=False):
     if sys.version_info < (3, 10):
-        items = entry_points()["networkx.plugins"]
+        eps = entry_points()
+        if group not in eps:
+            return {}
+        items = eps[group]
     else:
-        items = entry_points(group="networkx.plugins")
+        items = entry_points(group=group)
     rv = {}
     for ep in items:
         if ep.name in rv:
@@ -109,14 +112,24 @@ def _get_plugins():
                 RuntimeWarning,
                 stacklevel=2,
             )
+        elif load_and_call:
+            try:
+                rv[ep.name] = ep.load()()
+            except Exception as exc:
+                warnings.warn(
+                    f"Error encountered when loading info for plugin {ep.name}: {exc}",
+                    RuntimeWarning,
+                    stacklevel=2,
+                )
         else:
             rv[ep.name] = ep
     # nx-loopback plugin is only available when testing (added in conftest.py)
-    del rv["nx-loopback"]
+    rv.pop("nx-loopback", None)
     return rv
 
 
-plugins = _get_plugins()
+plugins = _get_plugins("networkx.plugins")
+plugin_info = _get_plugins("networkx.plugin_info", load_and_call=True)
 _registered_algorithms = {}
 
 
@@ -234,7 +247,7 @@ class _dispatch:
         # standard function-wrapping stuff
         # __annotations__ not used
         self.__name__ = func.__name__
-        self.__doc__ = func.__doc__
+        # self.__doc__ = func.__doc__  # __doc__ handled as cached property
         self.__defaults__ = func.__defaults__
         # We "magically" add `backend=` keyword argument to allow backend to be specified
         if func.__kwdefaults__:
@@ -246,6 +259,10 @@ class _dispatch:
         self.__dict__.update(func.__dict__)
         self.__wrapped__ = func
 
+        # Supplement docstring with backend info; compute and cache when needed
+        self._orig_doc = func.__doc__
+        self._cached_doc = None
+
         self.orig_func = func
         self.name = name
         self.edge_attrs = edge_attrs
@@ -306,6 +323,16 @@ class _dispatch:
         # Compute and cache the signature on-demand
         self._sig = None
 
+        # Load and cache backends on-demand
+        self._backends = {}
+
+        # Which backends implement this function?
+        self.backends = {
+            backend
+            for backend, info in plugin_info.items()
+            if "functions" in info and name in info["functions"]
+        }
+
         if name in _registered_algorithms:
             raise KeyError(
                 f"Algorithm already exists in dispatch registry: {name}"
@@ -313,6 +340,18 @@ class _dispatch:
         _registered_algorithms[name] = self
         return self
 
+    @property
+    def __doc__(self):
+        if (rv := self._cached_doc) is not None:
+            return rv
+        rv = self._cached_doc = self._make_doc()
+        return rv
+
+    @__doc__.setter
+    def __doc__(self, val):
+        self._orig_doc = val
+        self._cached_doc = None
+
     @property
     def __signature__(self):
         if self._sig is None:
@@ -462,7 +501,7 @@ class _dispatch:
                     f"{self.name}() has networkx and {plugin_name} graphs, but NetworkX is not "
                     f"configured to automatically convert graphs from networkx to {plugin_name}."
                 )
-            backend = plugins[plugin_name].load()
+            backend = self._load_backend(plugin_name)
             if hasattr(backend, self.name):
                 if "networkx" in plugin_names:
                     # We need to convert networkx graphs to backend graphs
@@ -494,9 +533,15 @@ class _dispatch:
         # Default: run with networkx on networkx inputs
         return self.orig_func(*args, **kwargs)
 
+    def _load_backend(self, plugin_name):
+        if plugin_name in self._backends:
+            return self._backends[plugin_name]
+        rv = self._backends[plugin_name] = plugins[plugin_name].load()
+        return rv
+
     def _can_backend_run(self, plugin_name, /, *args, **kwargs):
         """Can the specified backend run this algorithms with these arguments?"""
-        backend = plugins[plugin_name].load()
+        backend = self._load_backend(plugin_name)
         return hasattr(backend, self.name) and (
             not hasattr(backend, "can_run") or backend.can_run(self.name, args, kwargs)
         )
@@ -645,7 +690,7 @@ class _dispatch:
 
         # It should be safe to assume that we either have networkx graphs or backend graphs.
         # Future work: allow conversions between backends.
-        backend = plugins[plugin_name].load()
+        backend = self._load_backend(plugin_name)
         for gname in self.graphs:
             if gname in self.list_graphs:
                 bound.arguments[gname] = [
@@ -704,7 +749,7 @@ class _dispatch:
 
     def _convert_and_call(self, plugin_name, args, kwargs, *, fallback_to_nx=False):
         """Call this dispatchable function with a backend, converting graphs if necessary."""
-        backend = plugins[plugin_name].load()
+        backend = self._load_backend(plugin_name)
         if not self._can_backend_run(plugin_name, *args, **kwargs):
             if fallback_to_nx:
                 return self.orig_func(*args, **kwargs)
@@ -729,7 +774,7 @@ class _dispatch:
         self, plugin_name, args, kwargs, *, fallback_to_nx=False
     ):
         """Call this dispatchable function with a backend; for use with testing."""
-        backend = plugins[plugin_name].load()
+        backend = self._load_backend(plugin_name)
         if not self._can_backend_run(plugin_name, *args, **kwargs):
             if fallback_to_nx:
                 return self.orig_func(*args, **kwargs)
@@ -807,6 +852,49 @@ class _dispatch:
 
         return backend.convert_to_nx(result, name=self.name)
 
+    def _make_doc(self):
+        if not self.backends:
+            return self._orig_doc
+        lines = [
+            "Backends",
+            "--------",
+        ]
+        for backend in sorted(self.backends):
+            info = plugin_info[backend]
+            if "short_summary" in info:
+                lines.append(f"{backend} : {info['short_summary']}")
+            else:
+                lines.append(backend)
+            if "functions" not in info or self.name not in info["functions"]:
+                lines.append("")
+                continue
+
+            func_info = info["functions"][self.name]
+            if "extra_docstring" in func_info:
+                lines.extend(
+                    f"  {line}" if line else line
+                    for line in func_info["extra_docstring"].split("\n")
+                )
+                add_gap = True
+            else:
+                add_gap = False
+            if "extra_parameters" in func_info:
+                if add_gap:
+                    lines.append("")
+                lines.append("  Extra parameters:")
+                extra_parameters = func_info["extra_parameters"]
+                for param in sorted(extra_parameters):
+                    lines.append(f"    {param}")
+                    if desc := extra_parameters[param]:
+                        lines.append(f"      {desc}")
+                    lines.append("")
+            else:
+                lines.append("")
+
+        lines.pop()  # Remove last empty line
+        to_add = "\n    ".join(lines)
+        return f"{self._orig_doc.rstrip()}\n\n    {to_add}"
+
     def __reduce__(self):
         """Allow this object to be serialized with pickle.
 


Test patch:
diff --git a/networkx/algorithms/components/tests/test_strongly_connected.py b/networkx/algorithms/components/tests/test_strongly_connected.py
index ab4e89395..f1c773026 100644
--- a/networkx/algorithms/components/tests/test_strongly_connected.py
+++ b/networkx/algorithms/components/tests/test_strongly_connected.py
@@ -63,7 +63,8 @@ class TestStronglyConnected:
     def test_tarjan_recursive(self):
         scc = nx.strongly_connected_components_recursive
         for G, C in self.gc:
-            assert {frozenset(g) for g in scc(G)} == C
+            with pytest.deprecated_call():
+                assert {frozenset(g) for g in scc(G)} == C
 
     def test_kosaraju(self):
         scc = nx.kosaraju_strongly_connected_components
@@ -168,7 +169,8 @@ class TestStronglyConnected:
         G = nx.DiGraph()
         assert list(nx.strongly_connected_components(G)) == []
         assert list(nx.kosaraju_strongly_connected_components(G)) == []
-        assert list(nx.strongly_connected_components_recursive(G)) == []
+        with pytest.deprecated_call():
+            assert list(nx.strongly_connected_components_recursive(G)) == []
         assert len(nx.condensation(G)) == 0
         pytest.raises(
             nx.NetworkXPointlessConcept, nx.is_strongly_connected, nx.DiGraph()
@@ -181,7 +183,8 @@ class TestStronglyConnected:
         with pytest.raises(NetworkXNotImplemented):
             next(nx.kosaraju_strongly_connected_components(G))
         with pytest.raises(NetworkXNotImplemented):
-            next(nx.strongly_connected_components_recursive(G))
+            with pytest.deprecated_call():
+                next(nx.strongly_connected_components_recursive(G))
         pytest.raises(NetworkXNotImplemented, nx.is_strongly_connected, G)
         pytest.raises(
             nx.NetworkXPointlessConcept, nx.is_strongly_connected, nx.DiGraph()
@@ -191,7 +194,6 @@ class TestStronglyConnected:
     strong_cc_methods = (
         nx.strongly_connected_components,
         nx.kosaraju_strongly_connected_components,
-        nx.strongly_connected_components_recursive,
     )
 
     @pytest.mark.parametrize("get_components", strong_cc_methods)
diff --git a/networkx/conftest.py b/networkx/conftest.py
index 0cad73f21..1702bdce5 100644
--- a/networkx/conftest.py
+++ b/networkx/conftest.py
@@ -117,6 +117,11 @@ def set_warnings():
     warnings.filterwarnings(
         "ignore", category=DeprecationWarning, message="function `join` is deprecated"
     )
+    warnings.filterwarnings(
+        "ignore",
+        category=DeprecationWarning,
+        message="\n\nstrongly_connected_components_recursive",
+    )
 
 
 @pytest.fixture(autouse=True)