Skip to content

Commit d6d99f8

Browse files
eriknwAlex-Markham
authored andcommitted
Add "networkx.plugin_info" entry point and update docstring (networkx#6911)
* Add "networkx.plugin_info" entry point and update docstring We use a new entry point so it can be imported really fast by not needing to import the backend. * Fix? * Update to better dict for info (all func info now under `'functions': {...}`) * Add backends section to online docs for functions implemented by known backends Also, fix a few docstring issues * Update `.circleci/config.yml` to install `nx-cugraph` when building docs * revert change to s_metric docstring
1 parent 17e202f commit d6d99f8

File tree

4 files changed

+153
-12
lines changed

4 files changed

+153
-12
lines changed

.circleci/config.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ jobs:
3838
pip install -r requirements/extra.txt
3939
pip install -r requirements/example.txt
4040
pip install -r requirements/doc.txt
41+
# Install trusted backends, but not their dependencies.
42+
# We only need to use the "networkx.plugin_info" entry-point.
43+
# This is the nightly wheel for nx-cugraph.
44+
pip install nx-cugraph-cu11 --extra-index-url https://rapidsdev:[email protected]/simple --no-deps
4145
pip list
4246
4347
- save_cache:
@@ -49,7 +53,7 @@ jobs:
4953
name: Install
5054
command: |
5155
source venv/bin/activate
52-
pip install -e .
56+
pip install -e . --no-deps
5357
5458
- run:
5559
name: Build docs

.github/workflows/deploy-docs.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ jobs:
3232
pip install -r requirements/example.txt
3333
pip install -U -r requirements/doc.txt
3434
pip install .
35+
# Install trusted backends, but not their dependencies.
36+
# We only need to use the "networkx.plugin_info" entry-point.
37+
# This is the nightly wheel for nx-cugraph.
38+
pip install nx-cugraph-cu11 --extra-index-url https://rapidsdev:[email protected]/simple --no-deps
3539
pip list
3640
3741
# To set up a cross-repository deploy key:

doc/conf.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,48 @@
247247
def setup(app):
248248
app.add_css_file("custom.css")
249249
app.add_js_file("copybutton.js")
250+
251+
252+
# Monkeypatch numpydoc to show "Backends" section
253+
from numpydoc.docscrape import NumpyDocString
254+
255+
orig_setitem = NumpyDocString.__setitem__
256+
257+
258+
def new_setitem(self, key, val):
259+
if key != "Backends":
260+
orig_setitem(self, key, val)
261+
return
262+
# Update how we show backend information in the online docs.
263+
# Start by creating an "admonition" section to make it stand out.
264+
newval = [".. admonition:: Additional backends implement this function", ""]
265+
for line in val:
266+
if line and not line.startswith(" "):
267+
# This line must identify a backend; let's try to add a link
268+
backend, *rest = line.split(" ")
269+
url = networkx.utils.backends.plugin_info.get(backend, {}).get("url")
270+
if url:
271+
line = f"`{backend} <{url}>`_ " + " ".join(rest)
272+
newval.append(f" {line}")
273+
self._parsed_data[key] = newval
274+
275+
276+
NumpyDocString.__setitem__ = new_setitem
277+
278+
from numpydoc.docscrape_sphinx import SphinxDocString
279+
280+
orig_str = SphinxDocString.__str__
281+
282+
283+
def new_str(self, indent=0, func_role="obj"):
284+
rv = orig_str(self, indent=indent, func_role=func_role)
285+
if "Backends" in self:
286+
lines = self._str_section("Backends")
287+
# Remove "Backends" as a section and add a divider instead
288+
lines[0] = "----"
289+
lines = self._str_indent(lines, indent)
290+
rv += "\n".join(lines)
291+
return rv
292+
293+
294+
SphinxDocString.__str__ = new_str

networkx/utils/backends.py

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,14 @@ class WrappedSparse:
9696
__all__ = ["_dispatch"]
9797

9898

99-
def _get_plugins():
99+
def _get_plugins(group, *, load_and_call=False):
100100
if sys.version_info < (3, 10):
101-
items = entry_points()["networkx.plugins"]
101+
eps = entry_points()
102+
if group not in eps:
103+
return {}
104+
items = eps[group]
102105
else:
103-
items = entry_points(group="networkx.plugins")
106+
items = entry_points(group=group)
104107
rv = {}
105108
for ep in items:
106109
if ep.name in rv:
@@ -109,14 +112,24 @@ def _get_plugins():
109112
RuntimeWarning,
110113
stacklevel=2,
111114
)
115+
elif load_and_call:
116+
try:
117+
rv[ep.name] = ep.load()()
118+
except Exception as exc:
119+
warnings.warn(
120+
f"Error encountered when loading info for plugin {ep.name}: {exc}",
121+
RuntimeWarning,
122+
stacklevel=2,
123+
)
112124
else:
113125
rv[ep.name] = ep
114126
# nx-loopback plugin is only available when testing (added in conftest.py)
115-
del rv["nx-loopback"]
127+
rv.pop("nx-loopback", None)
116128
return rv
117129

118130

119-
plugins = _get_plugins()
131+
plugins = _get_plugins("networkx.plugins")
132+
plugin_info = _get_plugins("networkx.plugin_info", load_and_call=True)
120133
_registered_algorithms = {}
121134

122135

@@ -234,7 +247,7 @@ def __new__(
234247
# standard function-wrapping stuff
235248
# __annotations__ not used
236249
self.__name__ = func.__name__
237-
self.__doc__ = func.__doc__
250+
# self.__doc__ = func.__doc__ # __doc__ handled as cached property
238251
self.__defaults__ = func.__defaults__
239252
# We "magically" add `backend=` keyword argument to allow backend to be specified
240253
if func.__kwdefaults__:
@@ -246,6 +259,10 @@ def __new__(
246259
self.__dict__.update(func.__dict__)
247260
self.__wrapped__ = func
248261

262+
# Supplement docstring with backend info; compute and cache when needed
263+
self._orig_doc = func.__doc__
264+
self._cached_doc = None
265+
249266
self.orig_func = func
250267
self.name = name
251268
self.edge_attrs = edge_attrs
@@ -306,13 +323,35 @@ def __new__(
306323
# Compute and cache the signature on-demand
307324
self._sig = None
308325

326+
# Load and cache backends on-demand
327+
self._backends = {}
328+
329+
# Which backends implement this function?
330+
self.backends = {
331+
backend
332+
for backend, info in plugin_info.items()
333+
if "functions" in info and name in info["functions"]
334+
}
335+
309336
if name in _registered_algorithms:
310337
raise KeyError(
311338
f"Algorithm already exists in dispatch registry: {name}"
312339
) from None
313340
_registered_algorithms[name] = self
314341
return self
315342

343+
@property
344+
def __doc__(self):
345+
if (rv := self._cached_doc) is not None:
346+
return rv
347+
rv = self._cached_doc = self._make_doc()
348+
return rv
349+
350+
@__doc__.setter
351+
def __doc__(self, val):
352+
self._orig_doc = val
353+
self._cached_doc = None
354+
316355
@property
317356
def __signature__(self):
318357
if self._sig is None:
@@ -462,7 +501,7 @@ def __call__(self, /, *args, backend=None, **kwargs):
462501
f"{self.name}() has networkx and {plugin_name} graphs, but NetworkX is not "
463502
f"configured to automatically convert graphs from networkx to {plugin_name}."
464503
)
465-
backend = plugins[plugin_name].load()
504+
backend = self._load_backend(plugin_name)
466505
if hasattr(backend, self.name):
467506
if "networkx" in plugin_names:
468507
# We need to convert networkx graphs to backend graphs
@@ -494,9 +533,15 @@ def __call__(self, /, *args, backend=None, **kwargs):
494533
# Default: run with networkx on networkx inputs
495534
return self.orig_func(*args, **kwargs)
496535

536+
def _load_backend(self, plugin_name):
537+
if plugin_name in self._backends:
538+
return self._backends[plugin_name]
539+
rv = self._backends[plugin_name] = plugins[plugin_name].load()
540+
return rv
541+
497542
def _can_backend_run(self, plugin_name, /, *args, **kwargs):
498543
"""Can the specified backend run this algorithms with these arguments?"""
499-
backend = plugins[plugin_name].load()
544+
backend = self._load_backend(plugin_name)
500545
return hasattr(backend, self.name) and (
501546
not hasattr(backend, "can_run") or backend.can_run(self.name, args, kwargs)
502547
)
@@ -645,7 +690,7 @@ def _convert_arguments(self, plugin_name, args, kwargs):
645690

646691
# It should be safe to assume that we either have networkx graphs or backend graphs.
647692
# Future work: allow conversions between backends.
648-
backend = plugins[plugin_name].load()
693+
backend = self._load_backend(plugin_name)
649694
for gname in self.graphs:
650695
if gname in self.list_graphs:
651696
bound.arguments[gname] = [
@@ -704,7 +749,7 @@ def _convert_arguments(self, plugin_name, args, kwargs):
704749

705750
def _convert_and_call(self, plugin_name, args, kwargs, *, fallback_to_nx=False):
706751
"""Call this dispatchable function with a backend, converting graphs if necessary."""
707-
backend = plugins[plugin_name].load()
752+
backend = self._load_backend(plugin_name)
708753
if not self._can_backend_run(plugin_name, *args, **kwargs):
709754
if fallback_to_nx:
710755
return self.orig_func(*args, **kwargs)
@@ -729,7 +774,7 @@ def _convert_and_call_for_tests(
729774
self, plugin_name, args, kwargs, *, fallback_to_nx=False
730775
):
731776
"""Call this dispatchable function with a backend; for use with testing."""
732-
backend = plugins[plugin_name].load()
777+
backend = self._load_backend(plugin_name)
733778
if not self._can_backend_run(plugin_name, *args, **kwargs):
734779
if fallback_to_nx:
735780
return self.orig_func(*args, **kwargs)
@@ -807,6 +852,49 @@ def _convert_and_call_for_tests(
807852

808853
return backend.convert_to_nx(result, name=self.name)
809854

855+
def _make_doc(self):
856+
if not self.backends:
857+
return self._orig_doc
858+
lines = [
859+
"Backends",
860+
"--------",
861+
]
862+
for backend in sorted(self.backends):
863+
info = plugin_info[backend]
864+
if "short_summary" in info:
865+
lines.append(f"{backend} : {info['short_summary']}")
866+
else:
867+
lines.append(backend)
868+
if "functions" not in info or self.name not in info["functions"]:
869+
lines.append("")
870+
continue
871+
872+
func_info = info["functions"][self.name]
873+
if "extra_docstring" in func_info:
874+
lines.extend(
875+
f" {line}" if line else line
876+
for line in func_info["extra_docstring"].split("\n")
877+
)
878+
add_gap = True
879+
else:
880+
add_gap = False
881+
if "extra_parameters" in func_info:
882+
if add_gap:
883+
lines.append("")
884+
lines.append(" Extra parameters:")
885+
extra_parameters = func_info["extra_parameters"]
886+
for param in sorted(extra_parameters):
887+
lines.append(f" {param}")
888+
if desc := extra_parameters[param]:
889+
lines.append(f" {desc}")
890+
lines.append("")
891+
else:
892+
lines.append("")
893+
894+
lines.pop() # Remove last empty line
895+
to_add = "\n ".join(lines)
896+
return f"{self._orig_doc.rstrip()}\n\n {to_add}"
897+
810898
def __reduce__(self):
811899
"""Allow this object to be serialized with pickle.
812900

0 commit comments

Comments
 (0)