summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKelly Boothby <boothby@dwavesys.com>2021-06-20 22:42:01 -0700
committerGitHub <noreply@github.com>2021-06-21 01:42:01 -0400
commit89a54703fafbb67d5a79b38238f7478ef62a51eb (patch)
tree7afb22651fc7f90f22f63c29af3bf6496f313cdc
parent1f96b154a86588a7ef3d9b3637dd66749b1de71f (diff)
downloadnetworkx-89a54703fafbb67d5a79b38238f7478ef62a51eb.tar.gz
Remove decorator dependency (#4739)
* added argmap decorator * removed most dependency on decorator * removed last reference to decorator? * Made the compilation of argmap-decorated functions lazy to reduce import time. * black * reworked try_finally to make cleanup cleaner * first pass at documentation; general cleanup * incorporated dschult's comments * rest formatted docstrings * added unit tests and fixed a few bugs that cropped up * Apply suggestions from code review Co-authored-by: Ross Barnowski <rossbar@berkeley.edu> Co-authored-by: Dan Schult <dschult@colgate.edu> * Exapnd docstrings for decorators.py * * refactored try_finally into a keyword-only argument * more tweaks to documentation re: @stefanv's comments * additional unit test for signature-clobbering decorators * spellcheck my txt and expand new test to help me grok it * rehash docstrings for sphinx * rewrite docs to provide some examples where argmap used without @argmap * doc tweak * last touches * documentation clarifications * run black * doc review * remove decorator module from github workflows and INSTALL.rst * add text to release_dev to describe highlights and improvements here Co-authored-by: Ross Barnowski <rossbar@berkeley.edu> Co-authored-by: Dan Schult <dschult@colgate.edu>
-rw-r--r--.github/workflows/test.yml2
-rw-r--r--INSTALL.rst2
-rw-r--r--doc/reference/utils.rst1
-rw-r--r--doc/release/release_dev.rst3
-rw-r--r--networkx/algorithms/community/quality.py16
-rw-r--r--networkx/algorithms/components/tests/test_attracting.py3
-rw-r--r--networkx/algorithms/components/tests/test_biconnected.py9
-rw-r--r--networkx/algorithms/components/tests/test_connected.py3
-rw-r--r--networkx/algorithms/components/tests/test_strongly_connected.py13
-rw-r--r--networkx/algorithms/components/tests/test_weakly_connected.py6
-rw-r--r--networkx/algorithms/connectivity/tests/test_edge_kcomponents.py6
-rw-r--r--networkx/algorithms/smallworld.py6
-rw-r--r--networkx/algorithms/tests/test_clique.py2
-rw-r--r--networkx/algorithms/tests/test_lowest_common_ancestors.py18
-rw-r--r--networkx/algorithms/tests/test_smallworld.py6
-rw-r--r--networkx/algorithms/tree/decomposition.py2
-rw-r--r--networkx/utils/decorators.py1228
-rw-r--r--networkx/utils/tests/test_decorators.py213
-rw-r--r--requirements/default.txt1
19 files changed, 1258 insertions, 282 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index bf998628..8020105d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -49,7 +49,7 @@ jobs:
run: |
pip install --upgrade pip wheel setuptools
pip install -r requirements/test.txt
- pip install decorator . --no-deps
+ pip install . --no-deps
pip list
- name: Test NetworkX
diff --git a/INSTALL.rst b/INSTALL.rst
index b9d85e40..7df658f5 100644
--- a/INSTALL.rst
+++ b/INSTALL.rst
@@ -35,7 +35,7 @@ install into your user directory using the ``--user`` flag::
If you do not want to install our dependencies (e.g., ``numpy``, ``scipy``, etc.),
you can use::
- $ pip install decorator networkx --no-deps
+ $ pip install networkx --no-deps
This may be helpful if you are using PyPy or you are working on a project that
only needs a limited subset of our functionality and you want to limit the
diff --git a/doc/reference/utils.rst b/doc/reference/utils.rst
index 0be25aac..81fd80e6 100644
--- a/doc/reference/utils.rst
+++ b/doc/reference/utils.rst
@@ -58,6 +58,7 @@ Decorators
nodes_or_number
preserve_random_state
random_state
+ argmap
Cuthill-Mckee Ordering
----------------------
diff --git a/doc/release/release_dev.rst b/doc/release/release_dev.rst
index d11c0da3..d6a83ca6 100644
--- a/doc/release/release_dev.rst
+++ b/doc/release/release_dev.rst
@@ -21,6 +21,7 @@ X contributors. Highlights include:
- Dropped support for Python 3.6.
- NumPy, SciPy, Matplotlib, and pandas are now default requirements.
+- NetworkX no longer depends on the library "decorator".
- Improved example gallery
- Removed code for supporting Jython/IronPython
- The ``__str__`` method for graph objects is more informative and concise.
@@ -121,6 +122,8 @@ Improvements
``modularity_max`` now supports edge weights.
- [`#4727 <https://github.com/networkx/networkx/pull/4727>`_]
Improved performance of ``scale_free_graph``.
+- [`#4739 <https://github.com/networkx/networkx/pull/4739>`_]
+ Added `argmap` function to replace the decorator library dependence
- [`#4757 <https://github.com/networkx/networkx/pull/4757>`_]
Adds ``topological_generations`` function for DAG stratification.
- [`#4768 <https://github.com/networkx/networkx/pull/4768>`_]
diff --git a/networkx/algorithms/community/quality.py b/networkx/algorithms/community/quality.py
index 470db9ee..ba972277 100644
--- a/networkx/algorithms/community/quality.py
+++ b/networkx/algorithms/community/quality.py
@@ -9,6 +9,7 @@ from itertools import product, combinations
import networkx as nx
from networkx import NetworkXError
from networkx.utils import not_implemented_for
+from networkx.utils.decorators import argmap
from networkx.algorithms.community.community_utils import is_partition
__all__ = ["coverage", "modularity", "performance", "partition_quality"]
@@ -22,7 +23,7 @@ class NotAPartition(NetworkXError):
super().__init__(msg)
-def require_partition(func):
+def _require_partition(G, partition):
"""Decorator to check that a valid partition is input to a function
Raises :exc:`networkx.NetworkXError` if the partition is not valid.
@@ -51,17 +52,12 @@ def require_partition(func):
networkx.exception.NetworkXError: `partition` is not a valid partition of the nodes of G
"""
+ if is_partition(G, partition):
+ return G, partition
+ raise nx.NetworkXError("`partition` is not a valid partition of the nodes of G")
- @wraps(func)
- def new_func(*args, **kw):
- # Here we assume that the first two arguments are (G, partition).
- if not is_partition(*args[:2]):
- raise nx.NetworkXError(
- "`partition` is not a valid partition of" " the nodes of G"
- )
- return func(*args, **kw)
- return new_func
+require_partition = argmap(_require_partition, (0, 1))
def intra_community_edges(G, partition):
diff --git a/networkx/algorithms/components/tests/test_attracting.py b/networkx/algorithms/components/tests/test_attracting.py
index aee49e05..99a37a67 100644
--- a/networkx/algorithms/components/tests/test_attracting.py
+++ b/networkx/algorithms/components/tests/test_attracting.py
@@ -63,6 +63,7 @@ class TestAttractingComponents:
def test_connected_raise(self):
G = nx.Graph()
- pytest.raises(NetworkXNotImplemented, nx.attracting_components, G)
+ with pytest.raises(NetworkXNotImplemented):
+ next(nx.attracting_components(G))
pytest.raises(NetworkXNotImplemented, nx.number_attracting_components, G)
pytest.raises(NetworkXNotImplemented, nx.is_attracting_component, G)
diff --git a/networkx/algorithms/components/tests/test_biconnected.py b/networkx/algorithms/components/tests/test_biconnected.py
index 3b2f1e90..80faea0e 100644
--- a/networkx/algorithms/components/tests/test_biconnected.py
+++ b/networkx/algorithms/components/tests/test_biconnected.py
@@ -238,7 +238,10 @@ def test_null_graph():
def test_connected_raise():
DG = nx.DiGraph()
- pytest.raises(NetworkXNotImplemented, nx.biconnected_components, DG)
- pytest.raises(NetworkXNotImplemented, nx.biconnected_component_edges, DG)
- pytest.raises(NetworkXNotImplemented, nx.articulation_points, DG)
+ with pytest.raises(NetworkXNotImplemented):
+ next(nx.biconnected_components(DG))
+ with pytest.raises(NetworkXNotImplemented):
+ next(nx.biconnected_component_edges(DG))
+ with pytest.raises(NetworkXNotImplemented):
+ next(nx.articulation_points(DG))
pytest.raises(NetworkXNotImplemented, nx.is_biconnected, DG)
diff --git a/networkx/algorithms/components/tests/test_connected.py b/networkx/algorithms/components/tests/test_connected.py
index ebe30ac6..f079da47 100644
--- a/networkx/algorithms/components/tests/test_connected.py
+++ b/networkx/algorithms/components/tests/test_connected.py
@@ -96,7 +96,8 @@ class TestConnected:
assert not nx.is_connected(G)
def test_connected_raise(self):
- pytest.raises(NetworkXNotImplemented, nx.connected_components, self.DG)
+ with pytest.raises(NetworkXNotImplemented):
+ next(nx.connected_components(self.DG))
pytest.raises(NetworkXNotImplemented, nx.number_connected_components, self.DG)
pytest.raises(NetworkXNotImplemented, nx.node_connected_component, self.DG, 1)
pytest.raises(NetworkXNotImplemented, nx.is_connected, self.DG)
diff --git a/networkx/algorithms/components/tests/test_strongly_connected.py b/networkx/algorithms/components/tests/test_strongly_connected.py
index d23ce098..10b63f95 100644
--- a/networkx/algorithms/components/tests/test_strongly_connected.py
+++ b/networkx/algorithms/components/tests/test_strongly_connected.py
@@ -175,13 +175,12 @@ class TestStronglyConnected:
def test_connected_raise(self):
G = nx.Graph()
- pytest.raises(NetworkXNotImplemented, nx.strongly_connected_components, G)
- pytest.raises(
- NetworkXNotImplemented, nx.kosaraju_strongly_connected_components, G
- )
- pytest.raises(
- NetworkXNotImplemented, nx.strongly_connected_components_recursive, G
- )
+ with pytest.raises(NetworkXNotImplemented):
+ next(nx.strongly_connected_components(G))
+ with pytest.raises(NetworkXNotImplemented):
+ next(nx.kosaraju_strongly_connected_components(G))
+ with pytest.raises(NetworkXNotImplemented):
+ 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()
diff --git a/networkx/algorithms/components/tests/test_weakly_connected.py b/networkx/algorithms/components/tests/test_weakly_connected.py
index 9964236e..e973010e 100644
--- a/networkx/algorithms/components/tests/test_weakly_connected.py
+++ b/networkx/algorithms/components/tests/test_weakly_connected.py
@@ -69,11 +69,13 @@ class TestWeaklyConnected:
G = nx.DiGraph()
assert list(nx.weakly_connected_components(G)) == []
assert nx.number_weakly_connected_components(G) == 0
- pytest.raises(nx.NetworkXPointlessConcept, nx.is_weakly_connected, G)
+ with pytest.raises(nx.NetworkXPointlessConcept):
+ next(nx.is_weakly_connected(G))
def test_connected_raise(self):
G = nx.Graph()
- pytest.raises(NetworkXNotImplemented, nx.weakly_connected_components, G)
+ with pytest.raises(NetworkXNotImplemented):
+ next(nx.weakly_connected_components(G))
pytest.raises(NetworkXNotImplemented, nx.number_weakly_connected_components, G)
pytest.raises(NetworkXNotImplemented, nx.is_weakly_connected, G)
diff --git a/networkx/algorithms/connectivity/tests/test_edge_kcomponents.py b/networkx/algorithms/connectivity/tests/test_edge_kcomponents.py
index 08aa3232..436eebca 100644
--- a/networkx/algorithms/connectivity/tests/test_edge_kcomponents.py
+++ b/networkx/algorithms/connectivity/tests/test_edge_kcomponents.py
@@ -162,8 +162,10 @@ def test_not_implemented():
pytest.raises(nx.NetworkXNotImplemented, EdgeComponentAuxGraph.construct, G)
pytest.raises(nx.NetworkXNotImplemented, nx.k_edge_components, G, k=2)
pytest.raises(nx.NetworkXNotImplemented, nx.k_edge_subgraphs, G, k=2)
- pytest.raises(nx.NetworkXNotImplemented, bridge_components, G)
- pytest.raises(nx.NetworkXNotImplemented, bridge_components, nx.DiGraph())
+ with pytest.raises(nx.NetworkXNotImplemented):
+ next(bridge_components(G))
+ with pytest.raises(nx.NetworkXNotImplemented):
+ next(bridge_components(nx.DiGraph()))
def test_general_k_edge_subgraph_quick_return():
diff --git a/networkx/algorithms/smallworld.py b/networkx/algorithms/smallworld.py
index eb2b6401..7344166a 100644
--- a/networkx/algorithms/smallworld.py
+++ b/networkx/algorithms/smallworld.py
@@ -58,9 +58,6 @@ def random_reference(G, niter=1, connectivity=True, seed=None):
"Specificity and stability in topology of protein networks."
Science 296.5569 (2002): 910-913.
"""
- if G.is_directed():
- msg = "random_reference() not defined for directed graphs."
- raise nx.NetworkXError(msg)
if len(G) < 4:
raise nx.NetworkXError("Graph has less than four nodes.")
@@ -162,9 +159,6 @@ def lattice_reference(G, niter=1, D=None, connectivity=True, seed=None):
local_conn = nx.connectivity.local_edge_connectivity
- if G.is_directed():
- msg = "lattice_reference() not defined for directed graphs."
- raise nx.NetworkXError(msg)
if len(G) < 4:
raise nx.NetworkXError("Graph has less than four nodes.")
# Instead of choosing uniformly at random from a generated edge list,
diff --git a/networkx/algorithms/tests/test_clique.py b/networkx/algorithms/tests/test_clique.py
index ae230ee8..e54b6540 100644
--- a/networkx/algorithms/tests/test_clique.py
+++ b/networkx/algorithms/tests/test_clique.py
@@ -195,7 +195,7 @@ class TestCliques:
def test_directed(self):
with pytest.raises(nx.NetworkXNotImplemented):
- cliques = nx.find_cliques(nx.DiGraph())
+ next(nx.find_cliques(nx.DiGraph()))
class TestEnumerateAllCliques:
diff --git a/networkx/algorithms/tests/test_lowest_common_ancestors.py b/networkx/algorithms/tests/test_lowest_common_ancestors.py
index 4724f383..2b51c387 100644
--- a/networkx/algorithms/tests/test_lowest_common_ancestors.py
+++ b/networkx/algorithms/tests/test_lowest_common_ancestors.py
@@ -135,16 +135,22 @@ class TestTreeLCA:
def test_not_implemented_for(self):
NNI = nx.NetworkXNotImplemented
G = nx.Graph([(0, 1)])
- pytest.raises(NNI, tree_all_pairs_lca, G)
- pytest.raises(NNI, all_pairs_lca, G)
+ with pytest.raises(NNI):
+ next(tree_all_pairs_lca(G))
+ with pytest.raises(NNI):
+ next(all_pairs_lca(G))
pytest.raises(NNI, nx.lowest_common_ancestor, G, 0, 1)
G = nx.MultiGraph([(0, 1)])
- pytest.raises(NNI, tree_all_pairs_lca, G)
- pytest.raises(NNI, all_pairs_lca, G)
+ with pytest.raises(NNI):
+ next(tree_all_pairs_lca(G))
+ with pytest.raises(NNI):
+ next(all_pairs_lca(G))
pytest.raises(NNI, nx.lowest_common_ancestor, G, 0, 1)
G = nx.MultiDiGraph([(0, 1)])
- pytest.raises(NNI, tree_all_pairs_lca, G)
- pytest.raises(NNI, all_pairs_lca, G)
+ with pytest.raises(NNI):
+ next(tree_all_pairs_lca(G))
+ with pytest.raises(NNI):
+ next(all_pairs_lca(G))
pytest.raises(NNI, nx.lowest_common_ancestor, G, 0, 1)
def test_tree_all_pairs_lowest_common_ancestor13(self):
diff --git a/networkx/algorithms/tests/test_smallworld.py b/networkx/algorithms/tests/test_smallworld.py
index 6291f7d0..b6219133 100644
--- a/networkx/algorithms/tests/test_smallworld.py
+++ b/networkx/algorithms/tests/test_smallworld.py
@@ -18,8 +18,10 @@ def test_random_reference():
Cr = nx.average_clustering(Gr)
assert C > Cr
- pytest.raises(nx.NetworkXError, random_reference, nx.Graph())
- pytest.raises(nx.NetworkXNotImplemented, random_reference, nx.DiGraph())
+ with pytest.raises(nx.NetworkXError):
+ next(random_reference(nx.Graph()))
+ with pytest.raises(nx.NetworkXNotImplemented):
+ next(random_reference(nx.DiGraph()))
H = nx.Graph(((0, 1), (2, 3)))
Hl = random_reference(H, niter=1, seed=rng)
diff --git a/networkx/algorithms/tree/decomposition.py b/networkx/algorithms/tree/decomposition.py
index b7953079..c4f99800 100644
--- a/networkx/algorithms/tree/decomposition.py
+++ b/networkx/algorithms/tree/decomposition.py
@@ -8,7 +8,7 @@ from itertools import combinations
__all__ = ["junction_tree"]
-@not_implemented_for("multigraph", "MultiDiGraph")
+@not_implemented_for("multigraph")
def junction_tree(G):
r"""Returns a junction tree of a given graph.
diff --git a/networkx/utils/decorators.py b/networkx/utils/decorators.py
index 8ab0a4d2..1802055a 100644
--- a/networkx/utils/decorators.py
+++ b/networkx/utils/decorators.py
@@ -5,9 +5,12 @@ from pathlib import Path
import warnings
import networkx as nx
-from decorator import decorator
from networkx.utils import create_random_state, create_py_random_state
+import inspect, itertools, collections
+
+import re, gzip, bz2
+
__all__ = [
"not_implemented_for",
"open_file",
@@ -16,6 +19,7 @@ __all__ = [
"random_state",
"np_random_state",
"py_random_state",
+ "argmap",
]
@@ -25,7 +29,7 @@ def not_implemented_for(*graph_types):
Parameters
----------
graph_types : container of strings
- Entries must be one of 'directed','undirected', 'multigraph', 'graph'.
+ Entries must be one of "directed", "undirected", "multigraph", or "graph".
Returns
-------
@@ -46,59 +50,53 @@ def not_implemented_for(*graph_types):
--------
Decorate functions like this::
- @not_implemented_for('directed')
+ @not_implemented_for("directed")
def sp_function(G):
pass
- @not_implemented_for('directed','multigraph')
+ # rule out MultiDiGraph
+ @not_implemented_for("directed","multigraph")
def sp_np_function(G):
pass
- """
-
- @decorator
- def _not_implemented_for(not_implement_for_func, *args, **kwargs):
- graph = args[0]
- terms = {
- "directed": graph.is_directed(),
- "undirected": not graph.is_directed(),
- "multigraph": graph.is_multigraph(),
- "graph": not graph.is_multigraph(),
- }
- match = True
- try:
- for t in graph_types:
- match = match and terms[t]
- except KeyError as e:
- raise KeyError(
- "use one or more of " "directed, undirected, multigraph, graph"
- ) from e
- if match:
- msg = f"not implemented for {' '.join(graph_types)} type"
- raise nx.NetworkXNotImplemented(msg)
- else:
- return not_implement_for_func(*args, **kwargs)
-
- return _not_implemented_for
-
-def _open_gz(path, mode):
- import gzip
+ # rule out all except DiGraph
+ @not_implemented_for("undirected")
+ @not_implemented_for("multigraph")
+ def sp_np_function(G):
+ pass
+ """
+ if ("directed" in graph_types) and ("undirected" in graph_types):
+ raise ValueError("Function not implemented on directed AND undirected graphs?")
+ if ("multigraph" in graph_types) and ("graph" in graph_types):
+ raise ValueError("Function not implemented on graph AND multigraphs?")
+ if not set(graph_types) < {"directed", "undirected", "multigraph", "graph"}:
+ raise KeyError(
+ "use one or more of directed, undirected, multigraph, graph. "
+ f"You used {graph_types}"
+ )
- return gzip.open(path, mode=mode)
+ # 3-way logic: True if "directed" input, False if "undirected" input, else None
+ dval = ("directed" in graph_types) or not ("undirected" in graph_types) and None
+ mval = ("multigraph" in graph_types) or not ("graph" in graph_types) and None
+ errmsg = f"not implemented for {' '.join(graph_types)} type"
+ def _not_implemented_for(g):
+ if (mval is None or mval == g.is_multigraph()) and (
+ dval is None or dval == g.is_directed()
+ ):
+ raise nx.NetworkXNotImplemented(errmsg)
-def _open_bz2(path, mode):
- import bz2
+ return g
- return bz2.BZ2File(path, mode=mode)
+ return argmap(_not_implemented_for, 0)
# To handle new extensions, define a function accepting a `path` and `mode`.
# Then add the extension to _dispatch_dict.
_dispatch_dict = defaultdict(lambda: open)
-_dispatch_dict[".gz"] = _open_gz
-_dispatch_dict[".bz2"] = _open_bz2
-_dispatch_dict[".gzip"] = _open_gz
+_dispatch_dict[".gz"] = gzip.open
+_dispatch_dict[".bz2"] = bz2.BZ2File
+_dispatch_dict[".gzip"] = gzip.open
def open_file(path_arg, mode="r"):
@@ -106,10 +104,9 @@ def open_file(path_arg, mode="r"):
Parameters
----------
- path_arg : int
- Location of the path argument in args. Even if the argument is a
- named positional argument (with a default value), you must specify its
- index as a positional argument.
+ path_arg : string or int
+ Name or index of the argument that is a path.
+
mode : str
String for opening mode.
@@ -122,140 +119,91 @@ def open_file(path_arg, mode="r"):
--------
Decorate functions like this::
- @open_file(0,'r')
+ @open_file(0,"r")
def read_function(pathname):
pass
- @open_file(1,'w')
- def write_function(G,pathname):
+ @open_file(1,"w")
+ def write_function(G, pathname):
pass
- @open_file(1,'w')
- def write_function(G, pathname='graph.dot')
+ @open_file(1,"w")
+ def write_function(G, pathname="graph.dot")
pass
- @open_file('path', 'w+')
+ @open_file("pathname","w")
+ def write_function(G, pathname="graph.dot")
+ pass
+
+ @open_file("path", "w+")
def another_function(arg, **kwargs):
- path = kwargs['path']
+ path = kwargs["path"]
pass
+
+ Notes
+ -----
+ Note that this decorator solves the problem when a path argument is
+ specified as a string, but it does not handle the situation when the
+ function wants to accept a default of None (and then handle it).
+
+ Here is an example of how to handle this case:
+
+ @open_file("path")
+ def some_function(arg1, arg2, path=None):
+ if path is None:
+ fobj = tempfile.NamedTemporaryFile(delete=False)
+ else:
+ # `path` could have been a string or file object or something
+ # similar. In any event, the decorator has given us a file object
+ # and it will close it for us, if it should.
+ fobj = path
+
+ try:
+ fobj.write("blah")
+ finally:
+ if path is None:
+ fobj.close()
+
+ Normally, we'd want to use "with" to ensure that fobj gets closed.
+ However, the decorator will make `path` a file object for us,
+ and using "with" would undesirably close that file object.
+ Instead, we use a try block, as shown above.
+ When we exit the function, fobj will be closed, if it should be, by the decorator.
"""
- # Note that this decorator solves the problem when a path argument is
- # specified as a string, but it does not handle the situation when the
- # function wants to accept a default of None (and then handle it).
- # Here is an example:
- #
- # @open_file('path')
- # def some_function(arg1, arg2, path=None):
- # if path is None:
- # fobj = tempfile.NamedTemporaryFile(delete=False)
- # close_fobj = True
- # else:
- # # `path` could have been a string or file object or something
- # # similar. In any event, the decorator has given us a file object
- # # and it will close it for us, if it should.
- # fobj = path
- # close_fobj = False
- #
- # try:
- # fobj.write('blah')
- # finally:
- # if close_fobj:
- # fobj.close()
- #
- # Normally, we'd want to use "with" to ensure that fobj gets closed.
- # However, recall that the decorator will make `path` a file object for
- # us, and using "with" would undesirably close that file object. Instead,
- # you use a try block, as shown above. When we exit the function, fobj will
- # be closed, if it should be, by the decorator.
-
- @decorator
- def _open_file(func_to_be_decorated, *args, **kwargs):
-
- # Note that since we have used @decorator, *args, and **kwargs have
- # already been resolved to match the function signature of func. This
- # means default values have been propagated. For example, the function
- # func(x, y, a=1, b=2, **kwargs) if called as func(0,1,b=5,c=10) would
- # have args=(0,1,1,5) and kwargs={'c':10}.
-
- # First we parse the arguments of the decorator. The path_arg could
- # be an positional argument or a keyword argument. Even if it is
- try:
- # path_arg is a required positional argument
- # This works precisely because we are using @decorator
- path = args[path_arg]
- except TypeError:
- # path_arg is a keyword argument. It is "required" in the sense
- # that it must exist, according to the decorator specification,
- # It can exist in `kwargs` by a developer specified default value
- # or it could have been explicitly set by the user.
- try:
- path = kwargs[path_arg]
- except KeyError as e:
- # Could not find the keyword. Thus, no default was specified
- # in the function signature and the user did not provide it.
- msg = f"Missing required keyword argument: {path_arg}"
- raise nx.NetworkXError(msg) from e
- else:
- is_kwarg = True
- except IndexError as e:
- # A "required" argument was missing. This can only happen if
- # the decorator of the function was incorrectly specified.
- # So this probably is not a user error, but a developer error.
- msg = "path_arg of open_file decorator is incorrect"
- raise nx.NetworkXError(msg) from e
- else:
- is_kwarg = False
+ def _open_file(path):
# Now we have the path_arg. There are two types of input to consider:
# 1) string representing a path that should be opened
# 2) an already opened file object
if isinstance(path, str):
ext = splitext(path)[1]
- fobj = _dispatch_dict[ext](path, mode=mode)
- close_fobj = True
- elif hasattr(path, "read"):
- # path is already a file-like object
- fobj = path
- close_fobj = False
elif isinstance(path, Path):
# path is a pathlib reference to a filename
- fobj = _dispatch_dict[path.suffix](str(path), mode=mode)
- close_fobj = True
+ ext = path.suffix
+ path = str(path)
else:
- # could be None, in which case the algorithm will deal with it
- fobj = path
- close_fobj = False
-
- # Insert file object into args or kwargs.
- if is_kwarg:
- new_args = args
- kwargs[path_arg] = fobj
- else:
- # args is a tuple, so we must convert to list before modifying it.
- new_args = list(args)
- new_args[path_arg] = fobj
+ # could be None, or a file handle, in which case the algorithm will deal with it
+ return path, lambda: None
- # Finally, we call the original function, making sure to close the fobj
- try:
- result = func_to_be_decorated(*new_args, **kwargs)
- finally:
- if close_fobj:
- fobj.close()
+ fobj = _dispatch_dict[ext](path, mode=mode)
+ return fobj, lambda: fobj.close()
- return result
-
- return _open_file
+ return argmap(_open_file, path_arg, try_finally=True)
def nodes_or_number(which_args):
"""Decorator to allow number of nodes or container of nodes.
+ With this decorator, the specified argument can be either a number or a container
+ of nodes. If it is a number, the nodes used are `range(n)`.
+ This allows `nx.complete_graph(50)` in place of `nx.complete_graph(list(range(50)))`.
+ And it also allows `nx.complete_graph(any_list_of_nodes)`.
+
Parameters
----------
- which_args : int or sequence of ints
- Location of the node arguments in args. Even if the argument is a
- named positional argument (with a default value), you must specify its
- index as a positional argument.
+ which_args : string or int or sequence of strings or ints
+ If string, the name of the argument to be treated.
+ If int, the index of the argument to be treated.
If more than one node argument is allowed, can be a list of locations.
Returns
@@ -267,48 +215,53 @@ def nodes_or_number(which_args):
--------
Decorate functions like this::
+ @nodes_or_number("nodes")
+ def empty_graph(nodes):
+ # nodes is converted to a list of nodes
+
@nodes_or_number(0)
def empty_graph(nodes):
- pass
+ # nodes is converted to a list of nodes
- @nodes_or_number([0,1])
+ @nodes_or_number(["m1", "m2"])
def grid_2d_graph(m1, m2, periodic=False):
- pass
+ # m1 and m2 are each converted to a list of nodes
+
+ @nodes_or_number([0, 1])
+ def grid_2d_graph(m1, m2, periodic=False):
+ # m1 and m2 are each converted to a list of nodes
@nodes_or_number(1)
def full_rary_tree(r, n)
- # r is a number. n can be a number of a list of nodes
- pass
+ # presumably r is a number. It is not handled by this decorator.
+ # n is converted to a list of nodes
"""
- @decorator
- def _nodes_or_number(func_to_be_decorated, *args, **kw):
- # form tuple of arg positions to be converted.
+ def _nodes_or_number(n):
try:
- iter_wa = iter(which_args)
+ nodes = list(range(n))
except TypeError:
- iter_wa = (which_args,)
- # change each argument in turn
- new_args = list(args)
- for i in iter_wa:
- n = args[i]
- try:
- nodes = list(range(n))
- except TypeError:
- nodes = tuple(n)
- else:
- if n < 0:
- msg = "Negative number of nodes not valid: {n}"
- raise nx.NetworkXError(msg)
- new_args[i] = (n, nodes)
- return func_to_be_decorated(*new_args, **kw)
+ nodes = tuple(n)
+ else:
+ if n < 0:
+ msg = "Negative number of nodes not valid: {n}"
+ raise nx.NetworkXError(msg)
+ return (n, nodes)
+
+ try:
+ iter_wa = iter(which_args)
+ except TypeError:
+ iter_wa = (which_args,)
- return _nodes_or_number
+ return argmap(_nodes_or_number, *iter_wa)
def preserve_random_state(func):
"""Decorator to preserve the numpy.random state during a function.
+ .. deprecated:: 2.6
+ This is deprecated and will be removed in NetworkX v3.0.
+
Parameters
----------
func : function
@@ -357,19 +310,21 @@ def preserve_random_state(func):
return func
-def random_state(random_state_index):
- """Decorator to generate a numpy.random.RandomState instance.
+def random_state(random_state_argument):
+ """Decorator to generate a `numpy.random.RandomState` instance.
- Argument position `random_state_index` is processed by create_random_state.
- The result is a numpy.random.RandomState instance.
+ The decorator processes the argument indicated by `random_state_argument`
+ using :func:`nx.utils.create_random_state`.
+ The argument value can be a seed (integer), or a `numpy.random.RandomState`
+ instance or (`None` or `numpy.random`). The latter options use the glocal
+ random number generator used by `numpy.random`.
+ The result is a `numpy.random.RandomState` instance.
Parameters
----------
- random_state_index : int
- Location of the random_state argument in args that is to be used to
- generate the numpy.random.RandomState instance. Even if the argument is
- a named positional argument (with a default value), you must specify
- its index as a positional argument.
+ random_state_argument : string or int
+ The name or index of the argument to be converted
+ to a `numpy.random.RandomState` instance.
Returns
-------
@@ -380,9 +335,13 @@ def random_state(random_state_index):
--------
Decorate functions like this::
+ @np_random_state("seed")
+ def random_float(seed=None):
+ return seed.rand()
+
@np_random_state(0)
- def random_float(random_state=None):
- return random_state.rand()
+ def random_float(rng=None):
+ return rng.rand()
@np_random_state(1)
def random_array(dims, random_state=1):
@@ -392,84 +351,883 @@ def random_state(random_state_index):
--------
py_random_state
"""
-
- @decorator
- def _random_state(func, *args, **kwargs):
- # Parse the decorator arguments.
- try:
- random_state_arg = args[random_state_index]
- except TypeError as e:
- raise nx.NetworkXError("random_state_index must be an integer") from e
- except IndexError as e:
- raise nx.NetworkXError("random_state_index is incorrect") from e
-
- # Create a numpy.random.RandomState instance
- random_state = create_random_state(random_state_arg)
-
- # args is a tuple, so we must convert to list before modifying it.
- new_args = list(args)
- new_args[random_state_index] = random_state
- return func(*new_args, **kwargs)
-
- return _random_state
+ return argmap(create_random_state, random_state_argument)
np_random_state = random_state
-def py_random_state(random_state_index):
+def py_random_state(random_state_argument):
"""Decorator to generate a random.Random instance (or equiv).
- Argument position `random_state_index` processed by create_py_random_state.
- The result is either a random.Random instance, or numpy.random.RandomState
- instance with additional attributes to mimic basic methods of Random.
+ The decorator processes the argument indicated by `random_state_argument`
+ using :func:`nx.utils.create_py_random_state`.
+ The argument value can be a seed (integer), or a random number generator::
+
+ If int, return a random.Random instance set with seed=int.
+ If random.Random instance, return it.
+ If None or the `random` package, return the global random number
+ generator used by `random`.
+ If np.random package, return the global numpy random number
+ generator wrapped in a PythonRandomInterface class.
+ If np.random.RandomState instance, return it wrapped in
+ PythonRandomInterface
+ If a PythonRandomInterface instance, return it
Parameters
----------
- random_state_index : int
- Location of the random_state argument in args that is to be used to
- generate the numpy.random.RandomState instance. Even if the argument is
- a named positional argument (with a default value), you must specify
- its index as a positional argument.
+ random_state_argument : string or int
+ The name of the argument or the index of the argument in args that is
+ to be converted to the random.Random instance or numpy.random.RandomState
+ instance that mimics basic methods of random.Random.
Returns
-------
_random_state : function
- Function whose random_state keyword argument is a RandomState instance.
+ Function whose random_state_argument is converted to a Random instance.
Examples
--------
Decorate functions like this::
- @py_random_state(0)
+ @py_random_state("random_state")
def random_float(random_state=None):
return random_state.rand()
+ @py_random_state(0)
+ def random_float(rng=None):
+ return rng.rand()
+
@py_random_state(1)
- def random_array(dims, random_state=1):
- return random_state.rand(*dims)
+ def random_array(dims, seed=12345):
+ return seed.rand(*dims)
See Also
--------
np_random_state
"""
- @decorator
- def _random_state(func, *args, **kwargs):
- # Parse the decorator arguments.
- try:
- random_state_arg = args[random_state_index]
- except TypeError as e:
- raise nx.NetworkXError("random_state_index must be an integer") from e
- except IndexError as e:
- raise nx.NetworkXError("random_state_index is incorrect") from e
+ return argmap(create_py_random_state, random_state_argument)
+
+
+class argmap:
+ """A decorator to apply a map to arguments before calling the function
+
+ This class provides a decorator that maps (transforms) arguments of the function
+ before the function is called. Thus for example, we have similar code
+ in many functions to determine whether an argument is the number of nodes
+ to be created, or a list of nodes to be handled. The decorator provides
+ the code to accept either -- transforming the indicated argument into a
+ list of nodes before the actual function is called.
+
+ This decorator class allows us to process single or multiple arguments.
+ The arguments to be processed can be specified by string, naming the argument,
+ or by index, specifying the item in the args list.
+
+ Parameters
+ ----------
+ func : callable
+ The function to apply to arguments
+
+ *args : iterable of (int, str or tuple)
+ A list of parameters, specified either as strings (their names), ints
+ (numerical indices) or tuples, which may contain ints, strings, and
+ (recursively) tuples. Each indicates which parameters the decorator
+ should map. Tuples indicate that the map function takes (and returns)
+ multiple parameters in the same order and nested structure as indicated
+ here.
+
+ try_finally : bool (default: False)
+ When True, wrap the function call in a try-finally block with code
+ for the finally block created by `func`. This is used when the map
+ function constructs an object (like a file handle) that requires
+ post-processing (like closing).
+
+ Examples
+ --------
+ Most of these examples use `@argmap(...)` to apply the decorator to
+ the function defined on the next line.
+ In the NetworkX codebase however, `argmap` is used within a function to
+ construct a decorator. That is, the decorator defines a mapping function
+ and then uses `argmap` to build and return a decorated function.
+ A simple example is a decorator that specifies which currency to report money.
+ The decorator (named `convert_to`) would be used like::
+
+ @convert_to("US_Dollars", "income")
+ def show_me_the_money(name, income):
+ print(f"{name} : {income}")
+
+ And the code to create the decorator might be::
+
+ def convert_to(currency, which_arg):
+ def _convert(amount):
+ if amount.currency != currency:
+ amount = amount.to_currency(currency)
+ return amount
+ return argmap(_convert, which_arg)
+
+ Despite this common idiom for argmap, most of the following examples
+ use the `@argmap(...)` idiom to save space.
+
+ Here's an example use of argmap to sum the elements of two of the functions
+ arguments. The decorated function::
+
+ @argmap(sum, "xlist", "zlist")
+ def foo(xlist, y, zlist):
+ return xlist - y + zlist
+
+ is syntactic sugar for::
+
+ def foo(xlist, y, zlist):
+ x = sum(xlist)
+ z = sum(zlist)
+ return x - y + z
+
+ and is equivalent to (using argument indexes)::
- # Create a numpy.random.RandomState instance
- random_state = create_py_random_state(random_state_arg)
+ @argmap(sum, "xlist", 2)
+ def foo(xlist, y, zlist):
+ return xlist - y + zlist
- # args is a tuple, so we must convert to list before modifying it.
- new_args = list(args)
- new_args[random_state_index] = random_state
- return func(*new_args, **kwargs)
+ or::
- return _random_state
+ @argmap(sum, "zlist", 0)
+ def foo(xlist, y, zlist):
+ return xlist - y + zlist
+
+ Transforming functions can be applied to multiple arguments, such as::
+
+ def swap(x, y):
+ return y, x
+
+ # the 2-tuple tells argmap that the map `swap` has 2 inputs/outputs.
+ @argmap(swap, ("a", "b")):
+ def foo(a, b, c):
+ return a / b * c
+
+ is equivalent to::
+
+ def foo(a, b, c):
+ a, b = swap(a, b)
+ return a / b * c
+
+ More generally, the applied arguments can be nested tuples of strings or ints.
+ The syntax `@argmap(some_func, ("a", ("b", "c")))` would expect `some_func` to
+ accept 2 inputs with the second expected to be a 2-tuple. It should then return
+ 2 outputs with the second a 2-tuple. The returns values would replace input "a"
+ "b" and "c" respectively. Similarly for `@argmap(some_func, (0, ("b", 2)))`.
+
+ Also, note that an index larger than the number of named parameters is allowed
+ for variadic functions. For example::
+
+ def double(a):
+ return 2 * a
+
+ @argmap(double, 3)
+ def overflow(a, *args):
+ return a, args
+
+ print(overflow(1, 2, 3, 4, 5, 6)) # output is 1, (2, 3, 8, 5, 6)
+
+ **Try Finally**
+
+ Additionally, this `argmap` class can be used to create a decorator that
+ initiates a try...finally block. The decorator must be written to return
+ both the transformed argument and a closing function.
+ This feature was included to enable the `open_file` decorator which might
+ need to close the file or not depending on whether it had to open that file.
+ This feature uses the keyword-only `try_finally` argument to `@argmap`.
+
+ For example this map opens a file and then makes sure it is closed::
+
+ def open_file(fn):
+ f = open(fn)
+ return f, lambda: f.close()
+
+ The decorator applies that to the function `foo`::
+
+ @argmap(open_file, "file", try_finally=True)
+ def foo(file):
+ print(file.read())
+
+ is syntactic sugar for::
+
+ def foo(file):
+ file, close_file = open_file(file)
+ try:
+ print(file.read())
+ finally:
+ close_file()
+
+ and is equivalent to (using indexes)::
+
+ @argmap(open_file, 0, try_finally=True)
+ def foo(file):
+ print(file.read())
+
+ Here's an example of the try_finally feature used to create a decorator::
+
+ def my_closing_decorator(which_arg):
+ def _opener(path):
+ if path is None:
+ path = open(path)
+ fclose = path.close
+ else:
+ # assume `path` handles the closing
+ fclose = lambda: None
+ return path, fclose
+ return argmap(_opener, which_arg, try_finally=True)
+
+ which can then be used as::
+
+ @my_closing_decorator("file")
+ def fancy_reader(file=None):
+ # this code doesn't need to worry about closing the file
+ print(file.read())
+
+ Notes
+ -----
+ An object of this class is callable and intended to be used when
+ defining a decorator. Generally, a decorator takes a function as input
+ and constructs a function as output. Specifically, an `argmap` object
+ returns the input function decorated/wrapped so that specified arguments
+ are mapped (transformed) to new values before the decorated function is called.
+
+ As an overview, the argmap object returns a new function with all the
+ dunder values of the original function (like `__doc__`, `__name__`, etc).
+ Code for this decorated function is built based on the original function's
+ signature. It starts by mapping the input arguments to potentially new
+ values. Then it calls the decorated function with these new values in place
+ of the indicated arguments that have been mapped. The return value of the
+ original function is then returned. This new function is the function that
+ is actually called by the user.
+
+ Three additional features are provided.
+ 1) The code is lazily compiled. That is, the new function is returned
+ as an object without the code compiled, but with all information
+ needed so it can be compiled upon it's first invocation. This saves
+ time on import at the cost of additional time on the first call of
+ the function. Subsequent calls are then just as fast as normal.
+
+ 2) If the "try_finally" keyword-only argument is True, a try block
+ follows each mapped argument, matched on the other side of the wrapped
+ call, by a finally block closing that mapping. We expect func to return
+ a 2-tuple: the mapped value and a function to be called in the finally
+ clause. This feature was included so the `open_file` decorator could
+ provide a file handle to the decorated function and close the file handle
+ after the function call. It even keeps track of whether to close the file
+ handle or not based on whether it had to open the file or the input was
+ already open. So, the decorated function does not need to include any
+ code to open or close files.
+
+ 3) The maps applied can process multiple arguments. For example,
+ you could swap two arguments using a mapping, or transform
+ them to their sum and their difference. This was included to allow
+ a decorator in the `quality.py` module that checks that an input
+ `partition` is a valid partition of the nodes of the input graph `G`.
+ In this example, the map has inputs `(G, partition)`. After checking
+ for a valid partition, the map either raises an exception or leaves
+ the inputs unchanged. Thus many functions that make this check can
+ use the decorator rather than copy the checking code into each function.
+ More complicated nested argument structures are described below.
+
+ The remaining notes describe the code structure and methods for this
+ class in broad terms to aid in understanding how to use it.
+
+ Instantiating an `argmap` object simply stores the mapping function and
+ the input identifiers of which arguments to map. The resulting decorator
+ is ready to use this map to decorate any function. Calling that object
+ (`argmap.__call__`, but usually done via `@my_decorator`) a lazily
+ compiled thin wrapper of the decorated function is constructed,
+ wrapped with the necessary function dunder attributes like `__doc__`
+ and `__name__`. That thinly wrapped function is returned as the
+ decorated function. When that decorated function is called, the thin
+ wrapper of code calls `argmap._lazy_compile` which compiles the decorated
+ function (using `argmap.compile`) and replaces the code of the thin
+ wrapper with the newly compiled code. This saves the compilation step
+ every import of networkx, at the cost of compiling upon the first call
+ to the decorated function.
+
+ When the decorated function is compiled, the code is recursively assembled
+ using the `argmap.assemble` method. The recursive nature is needed in
+ case of nested decorators. The result of the assembly is a number of
+ useful objects.
+
+ sig : the function signature of the original decorated function as
+ constructed by :func:`argmap.signature`. This is constructed
+ using `inspect.signature` but enhanced with attribute
+ strings `sig_def` and `sig_call`, and other information
+ specific to mapping arguments of this function.
+ This information is used to construct a string of code defining
+ the new decorated function.
+
+ wrapped_name : a unique internally used name constructed by argmap
+ for the decorated function.
+
+ functions : a dict of the functions used inside the code of this
+ decorated function, to be used as `globals` in `exec`.
+ This dict is recursively updated to allow for nested decorating.
+
+ mapblock : code (as a list of strings) to map the incoming argument
+ values to their mapped values.
+
+ finallys : code (as a list of strings) to provide the possibly nested
+ set of finally clauses if needed.
+
+ mutable_args : a bool indicating whether the `sig.args` tuple should be
+ converted to a list so mutation can occur.
+
+ After this recursive assembly process, the `argmap.compile` method
+ constructs code (as strings) to convert the tuple `sig.args` to a list
+ if needed. It joins the defining code with appropriate indents and
+ compiles the result. Finally, this code is evaluated and the original
+ wrapper's implementation is replaced with the compiled version (see
+ `argmap._lazy_compile` for more details).
+
+ Other `argmap` methods include `_name` and `_count` which allow internally
+ generated names to be unique within a python session.
+ The methods `_flatten` and `_indent` process the nested lists of strings
+ into properly indented python code ready to be compiled.
+
+ More complicated nested tuples of arguments also allowed though
+ usually not used. For the simple 2 argument case, the argmap
+ input ("a", "b") implies the mapping function will take 2 arguments
+ and return a 2-tuple of mapped values. A more complicated example
+ with argmap input `("a", ("b", "c"))` requires the mapping function
+ take 2 inputs, with the second being a 2-tuple. It then must output
+ the 3 mapped values in the same nested structure `(newa, (newb, newc))`.
+ This level of generality is not often needed, but was convenient
+ to implement when handling the multiple arguments.
+
+ See Also
+ --------
+ not_implemented_for
+ open_file
+ nodes_or_number
+ random_state
+ py_random_state
+ networkx.community.quality.require_partition
+ require_partition
+
+ """
+
+ def __init__(self, func, *args, try_finally=False):
+ self._func = func
+ self._args = args
+ self._finally = try_finally
+
+ @staticmethod
+ def _lazy_compile(func):
+ """Compile the source of a wrapped function
+
+ Assemble and compile the decorated function, and intrusively replace its
+ code with the compiled version's. The thinly wrapped function becomes
+ the decorated function.
+
+ Parameters
+ ----------
+ func : callable
+ A function returned by argmap.__call__ which is in the process
+ of being called for the first time.
+
+ Returns
+ -------
+ func : callable
+ The same function, with a new __code__ object.
+
+ Notes
+ -----
+ It was observed in NetworkX issue #4732 [1] that the import time of
+ NetworkX was significantly bloated by the use of decorators: over half
+ of the import time was being spent decorating functions. This was
+ somewhat improved by a change made to the `decorator` library, at the
+ cost of a relatively heavy-weight call to `inspect.Signature.bind`
+ for each call to the decorated function.
+
+ The workaround we arrived at is to do minimal work at the time of
+ decoration. When the decorated function is called for the first time,
+ we compile a function with the same function signature as the wrapped
+ function. The resulting decorated function is faster than one made by
+ the `decorator` library, so that the overhead of the first call is
+ 'paid off' after a small number of calls.
+
+ References
+ ----------
+
+ [1] https://github.com/networkx/networkx/issues/4732
+
+ """
+ real_func = func.__argmap__.compile(func.__wrapped__)
+ func.__code__ = real_func.__code__
+ func.__globals__.update(real_func.__globals__)
+ func.__dict__.update(real_func.__dict__)
+ return func
+
+ def __call__(self, f):
+ """Construct a lazily decorated wrapper of f.
+
+ The decorated function will be compiled when it is called for the first time,
+ and it will replace its own __code__ object so subsequent calls are fast.
+
+ Parameters
+ ----------
+ f : callable
+ A function to be decorated.
+
+ Returns
+ -------
+ func : callable
+ The decorated function.
+
+ See Also
+ --------
+ argmap._lazy_compile
+ """
+
+ if inspect.isgeneratorfunction(f):
+
+ def func(*args, __wrapper=None, **kwargs):
+ yield from argmap._lazy_compile(__wrapper)(*args, **kwargs)
+
+ else:
+
+ def func(*args, __wrapper=None, **kwargs):
+ return argmap._lazy_compile(__wrapper)(*args, **kwargs)
+
+ # standard function-wrapping stuff
+ func.__name__ = f.__name__
+ func.__doc__ = f.__doc__
+ func.__defaults__ = f.__defaults__
+ func.__kwdefaults__.update(f.__kwdefaults__ or {})
+ func.__module__ = f.__module__
+ func.__qualname__ = f.__qualname__
+ func.__dict__.update(f.__dict__)
+ func.__wrapped__ = f
+
+ # now that we've wrapped f, we may have picked up some __dict__ or
+ # __kwdefaults__ items that were set by a previous argmap. Thus, we set
+ # these values after those update() calls.
+
+ # If we attempt to access func from within itself, that happens through
+ # a closure -- which trips an error when we replace func.__code__. The
+ # standard workaround for functions which can't see themselves is to use
+ # a Y-combinator, as we do here.
+ func.__kwdefaults__["_argmap__wrapper"] = func
+
+ # this self-reference is here because functools.wraps preserves
+ # everything in __dict__, and we don't want to mistake a non-argmap
+ # wrapper for an argmap wrapper
+ func.__self__ = func
+
+ # this is used to variously call self.assemble and self.compile
+ func.__argmap__ = self
+
+ return func
+
+ __count = 0
+
+ @classmethod
+ def _count(cls):
+ """Maintain a globally-unique identifier for function names and "file" names
+
+ Note that this counter is a class method reporting a class variable
+ so the count is unique within a Python session. It could differ from
+ session to session for a specific decorator depending on the order
+ that the decorators are created. But that doesn't disrupt `argmap`.
+
+ This is used in two places: to construct unique variable names
+ in the `_name` method and to construct unique fictitious filenames
+ in the `_compile` method.
+
+ Returns
+ -------
+ count : int
+ An integer unique to this Python session (simply counts from zero)
+ """
+ cls.__count += 1
+ return cls.__count
+
+ _bad_chars = re.compile("[^a-zA-Z0-9_]")
+
+ @classmethod
+ def _name(cls, f):
+ """Mangle the name of a function to be unique but somewhat human-readable
+
+ The names are unique within a Python session and set using `_count`.
+
+ Parameters
+ ----------
+ f : str or object
+
+ Returns
+ -------
+ name : str
+ The mangled version of `f.__name__` (if `f.__name__` exists) or `f`
+
+ """
+ f = f.__name__ if hasattr(f, "__name__") else f
+ fname = re.sub(cls._bad_chars, "_", f)
+ return f"argmap_{fname}_{cls._count()}"
+
+ def compile(self, f):
+ """Compile the decorated function.
+
+ Called once for a given decorated function -- collects the code from all
+ argmap decorators in the stack, and compiles the decorated function.
+
+ Much of the work done here uses the `assemble` method to allow recursive
+ treatment of multiple argmap decorators on a single decorated function.
+ That flattens the argmap decorators, collects the source code to construct
+ a single decorated function, then compiles/executes/returns that function.
+
+ The source code for the decorated function is stored as an attribute
+ `_code` on the function object itself.
+
+ Note that Python's `compile` function requires a filename, but this
+ code is constructed without a file, so a fictitious filename is used
+ to describe where the function comes from. The name is something like:
+ "argmap compilation 4".
+
+ Parameters
+ ----------
+ f : callable
+ The function to be decorated
+
+ Returns
+ -------
+ func : callable
+ The decorated file
+
+ """
+ sig, wrapped_name, functions, mapblock, finallys, mutable_args = self.assemble(
+ f
+ )
+
+ call = f"{sig.call_sig.format(wrapped_name)}#"
+ mut_args = f"{sig.args} = list({sig.args})" if mutable_args else ""
+ body = argmap._indent(sig.def_sig, mut_args, mapblock, call, finallys)
+ code = "\n".join(body)
+
+ locl = {}
+ globl = dict(functions.values())
+ filename = f"{self.__class__} compilation {self._count()}"
+ compiled = compile(code, filename, "exec")
+ exec(compiled, globl, locl)
+ func = locl[sig.name]
+ func._code = code
+ return func
+
+ def assemble(self, f):
+ """Collects components of the source for the decorated function wrapping f.
+
+ If `f` has multiple argmap decorators, we recursively assemble the stack of
+ decorators into a single flattened function.
+
+ This method is part of the `compile` method's process yet separated
+ from that method to allow recursive processing. The outputs are
+ strings, dictionaries and lists that collect needed info to
+ flatten any nested argmap-decoration.
+
+ Parameters
+ ----------
+ f : callable
+ The function to be decorated. If f is argmapped, we assemble it.
+
+ Returns
+ -------
+ sig : argmap.Signature
+ The function signature as an `argmap.Signature` object.
+ wrapped_name : str
+ The mangled name used to represent the wrapped function in the code
+ being assembled.
+ functions : dict
+ A dictionary mapping id(g) -> (mangled_name(g), g) for functions g
+ referred to in the code being assembled. These need to be present
+ in the ``globals`` scope of ``exec`` when defining the decorated
+ function.
+ mapblock : list of lists and/or strings
+ Code that implements mapping of parameters including any try blocks
+ if needed. This code will precede the decorated function call.
+ finallys : list of lists and/or strings
+ Code that implements the finally blocks to post-process the
+ arguments (usually close any files if needed) after the
+ decorated function is called.
+ mutable_args : bool
+ True if the decorator needs to modify positional arguments
+ via their indices. The compile method then turns the argument
+ tuple into a list so that the arguments can be modified.
+ """
+
+ # first, we check if f is already argmapped -- if that's the case,
+ # build up the function recursively.
+ # > mapblock is generally a list of function calls of the sort
+ # arg = func(arg)
+ # in addition to some try-blocks if needed.
+ # > finallys is a recursive list of finally blocks of the sort
+ # finally:
+ # close_func_1()
+ # finally:
+ # close_func_2()
+ # > functions is a dict of functions used in the scope of our decorated
+ # function. It will be used to construct globals used in compilation.
+ # We make functions[id(f)] = name_of_f, f to ensure that a given
+ # function is stored and named exactly once even if called by
+ # nested decorators.
+ if hasattr(f, "__argmap__") and f.__self__ is f:
+ (
+ sig,
+ wrapped_name,
+ functions,
+ mapblock,
+ finallys,
+ mutable_args,
+ ) = f.__argmap__.assemble(f.__wrapped__)
+ functions = dict(functions) # shallow-copy just in case
+ else:
+ sig = self.signature(f)
+ wrapped_name = self._name(f)
+ mapblock, finallys = [], []
+ functions = {id(f): (wrapped_name, f)}
+ mutable_args = False
+
+ if id(self._func) in functions:
+ fname, _ = functions[id(self._func)]
+ else:
+ fname, _ = functions[id(self._func)] = self._name(self._func), self._func
+
+ # this is a bit complicated -- we can call functions with a variety of
+ # nested arguments, so long as their input and output are tuples with
+ # the same nested structure. e.g. ("a", "b") maps arguments a and b.
+ # A more complicated nesting like (0, (3, 4)) maps arguments 0, 3, 4
+ # expecting the mapping to output new values in the same nested shape.
+ # The ability to argmap multiple arguments was necessary for
+ # the decorator `nx.algorithms.community.quality.require_partition`, and
+ # while we're not taking full advantage of the ability to handle
+ # multiply-nested tuples, it was convenient to implement this in
+ # generality because the recursive call to `get_name` is necessary in
+ # any case.
+ applied = set()
+
+ def get_name(arg, first=True):
+ nonlocal mutable_args
+ if isinstance(arg, tuple):
+ name = ", ".join(get_name(x, False) for x in arg)
+ return name if first else f"({name})"
+ if arg in applied:
+ raise nx.NetworkXError(f"argument {name} is specified multiple times")
+ applied.add(arg)
+ if arg in sig.names:
+ return sig.names[arg]
+ elif isinstance(arg, str):
+ if sig.kwargs is None:
+ raise nx.NetworkXError(
+ f"name {arg} is not a named parameter and this function doesn't have kwargs"
+ )
+ return f"{sig.kwargs}[{arg!r}]"
+ else:
+ if sig.args is None:
+ raise nx.NetworkXError(
+ f"index {arg} not a parameter index and this function doesn't have args"
+ )
+ mutable_args = True
+ return f"{sig.args}[{arg - sig.n_positional}]"
+
+ if self._finally:
+ # here's where we handle try_finally decorators. Such a decorator
+ # returns a mapped argument and a function to be called in a
+ # finally block. This feature was required by the open_file
+ # decorator. The below generates the code
+ #
+ # name, final = func(name) #<--append to mapblock
+ # try: #<--append to mapblock
+ # ... more argmapping and try blocks
+ # return WRAPPED_FUNCTION(...)
+ # ... more finally blocks
+ # finally: #<--prepend to finallys
+ # final() #<--prepend to finallys
+ #
+ for a in self._args:
+ name = get_name(a)
+ final = self._name(name)
+ mapblock.append(f"{name}, {final} = {fname}({name})")
+ mapblock.append("try:")
+ finallys = ["finally:", f"{final}()#", "#", finallys]
+ else:
+ mapblock.extend(
+ f"{name} = {fname}({name})" for name in map(get_name, self._args)
+ )
+
+ return sig, wrapped_name, functions, mapblock, finallys, mutable_args
+
+ @classmethod
+ def signature(cls, f):
+ """Construct a Signature object describing `f`
+
+ Compute a Signature so that we can write a function wrapping f with
+ the same signature and call-type.
+
+ Parameters
+ ----------
+ f : callable
+ A function to be decorated
+
+ Returns
+ -------
+ sig : argmap.Signature
+ The Signature of f
+
+ The Signature is a namedtuple with names:
+
+ name : a unique version of the name of the decorated function
+ signature : the inspect.signature of the decorated function
+ def_sig : a string used as code to define the new function
+ call_sig : a string used as code to call the decorated function
+ names : a dict keyed by argument name and index to the argument's name
+ n_positional : the number of positional arguments in the signature
+ args : the name of the VAR_POSITIONAL argument if any, i.e. *theseargs
+ kwargs : the name of the VAR_KEYWORDS argument if any, i.e. **kwargs
+
+ These named attributes of the signature are used in `assemble` and `compile`
+ to construct a string of source code for the decorated function.
+
+ """
+ sig = inspect.signature(f, follow_wrapped=False)
+ def_sig = []
+ call_sig = []
+ names = {}
+
+ kind = None
+ args = None
+ kwargs = None
+ npos = 0
+ for i, param in enumerate(sig.parameters.values()):
+ # parameters can be position-only, keyword-or-position, keyword-only
+ # in any combination, but only in the order as above. we do edge
+ # detection to add the appropriate punctuation
+ prev = kind
+ kind = param.kind
+ if prev == param.POSITIONAL_ONLY != kind:
+ # the last token was position-only, but this one isn't
+ def_sig.append("/")
+ if prev != param.KEYWORD_ONLY == kind != param.VAR_POSITIONAL:
+ # param is the first keyword-only arg and isn't starred
+ def_sig.append("*")
+
+ # star arguments as appropriate
+ if kind == param.VAR_POSITIONAL:
+ name = "*" + param.name
+ args = param.name
+ count = 0
+ elif kind == param.VAR_KEYWORD:
+ name = "**" + param.name
+ kwargs = param.name
+ count = 0
+ else:
+ names[i] = names[param.name] = param.name
+ name = param.name
+ count = 1
+
+ # assign to keyword-only args in the function call
+ if kind == param.KEYWORD_ONLY:
+ call_sig.append(f"{name} = {name}")
+ else:
+ npos += count
+ call_sig.append(name)
+
+ def_sig.append(name)
+
+ fname = cls._name(f)
+ def_sig = f'def {fname}({", ".join(def_sig)}):'
+
+ if inspect.isgeneratorfunction(f):
+ _return = "yield from"
+ else:
+ _return = "return"
+
+ call_sig = f"{_return} {{}}({', '.join(call_sig)})"
+
+ return cls.Signature(fname, sig, def_sig, call_sig, names, npos, args, kwargs)
+
+ Signature = collections.namedtuple(
+ "Signature",
+ [
+ "name",
+ "signature",
+ "def_sig",
+ "call_sig",
+ "names",
+ "n_positional",
+ "args",
+ "kwargs",
+ ],
+ )
+
+ @staticmethod
+ def _flatten(nestlist, visited):
+ """flattens a recursive list of lists that doesn't have cyclic references
+
+ Parameters
+ ----------
+ nestlist : iterable
+ A recursive list of objects to be flattened into a single iterable
+
+ visited : set
+ A set of object ids which have been walked -- initialize with an
+ empty set
+
+ Yields
+ ------
+ Non-list objects contained in nestlist
+
+ """
+ for thing in nestlist:
+ if isinstance(thing, list):
+ if id(thing) in visited:
+ raise ValueError("A cycle was found in nestlist. Be a tree.")
+ else:
+ visited.add(id(thing))
+ yield from argmap._flatten(thing, visited)
+ else:
+ yield thing
+
+ _tabs = " " * 64
+
+ @staticmethod
+ def _indent(*lines):
+ """Indent list of code lines to make executable Python code
+
+ Indents a tree-recursive list of strings, following the rule that one
+ space is added to the tab after a line that ends in a colon, and one is
+ removed after a line that ends in an hashmark.
+
+ Parameters
+ ----------
+ *lines : lists and/or strings
+ A recursive list of strings to be assembled into properly indented
+ code.
+
+ Returns
+ -------
+ code : str
+
+ Examples
+ --------
+
+ argmap._indent(*["try:", "try:", "pass#", "finally:", "pass#", "#",
+ "finally:", "pass#"])
+
+ renders to
+
+ '''try:
+ try:
+ pass#
+ finally:
+ pass#
+ #
+ finally:
+ pass#'''
+ """
+ depth = 0
+ for line in argmap._flatten(lines, set()):
+ yield f"{argmap._tabs[:depth]}{line}"
+ depth += (line[-1:] == ":") - (line[-1:] == "#")
diff --git a/networkx/utils/tests/test_decorators.py b/networkx/utils/tests/test_decorators.py
index c14fa191..64fc3e28 100644
--- a/networkx/utils/tests/test_decorators.py
+++ b/networkx/utils/tests/test_decorators.py
@@ -12,16 +12,74 @@ from networkx.utils.decorators import (
py_random_state,
np_random_state,
random_state,
+ argmap,
)
from networkx.utils.misc import PythonRandomInterface
def test_not_implemented_decorator():
@not_implemented_for("directed")
- def test1(G):
+ def test_d(G):
pass
- test1(nx.Graph())
+ test_d(nx.Graph())
+ with pytest.raises(nx.NetworkXNotImplemented):
+ test_d(nx.DiGraph())
+
+ @not_implemented_for("undirected")
+ def test_u(G):
+ pass
+
+ test_u(nx.DiGraph())
+ with pytest.raises(nx.NetworkXNotImplemented):
+ test_u(nx.Graph())
+
+ @not_implemented_for("multigraph")
+ def test_m(G):
+ pass
+
+ test_m(nx.Graph())
+ with pytest.raises(nx.NetworkXNotImplemented):
+ test_m(nx.MultiGraph())
+
+ @not_implemented_for("graph")
+ def test_g(G):
+ pass
+
+ test_g(nx.MultiGraph())
+ with pytest.raises(nx.NetworkXNotImplemented):
+ test_g(nx.Graph())
+
+ # not MultiDiGraph (multiple arguments => AND)
+ @not_implemented_for("directed", "multigraph")
+ def test_not_md(G):
+ pass
+
+ test_not_md(nx.Graph())
+ test_not_md(nx.DiGraph())
+ test_not_md(nx.MultiGraph())
+ with pytest.raises(nx.NetworkXNotImplemented):
+ test_not_md(nx.MultiDiGraph())
+
+ # Graph only (multiple decorators => OR)
+ @not_implemented_for("directed")
+ @not_implemented_for("multigraph")
+ def test_graph_only(G):
+ pass
+
+ test_graph_only(nx.Graph())
+ with pytest.raises(nx.NetworkXNotImplemented):
+ test_graph_only(nx.DiGraph())
+ with pytest.raises(nx.NetworkXNotImplemented):
+ test_graph_only(nx.MultiGraph())
+ with pytest.raises(nx.NetworkXNotImplemented):
+ test_graph_only(nx.MultiDiGraph())
+
+ with pytest.raises(ValueError):
+ not_implemented_for("directed", "undirected")
+
+ with pytest.raises(ValueError):
+ not_implemented_for("multigraph", "graph")
def test_not_implemented_decorator_key():
@@ -298,3 +356,154 @@ def test_py_random_state_invalid_arg_index():
pass
rstate = make_random_state(1)
+
+
+class TestArgmap:
+ class ArgmapError(RuntimeError):
+ pass
+
+ def test_trivial_function(self):
+ def do_not_call(x):
+ raise ArgmapError("do not call this function")
+
+ @argmap(do_not_call)
+ def trivial_argmap():
+ return 1
+
+ assert trivial_argmap() == 1
+
+ def test_trivial_iterator(self):
+ def do_not_call(x):
+ raise ArgmapError("do not call this function")
+
+ @argmap(do_not_call)
+ def trivial_argmap():
+ yield from (1, 2, 3)
+
+ assert tuple(trivial_argmap()) == (1, 2, 3)
+
+ def test_contextmanager(self):
+ container = []
+
+ def contextmanager(x):
+ nonlocal container
+ return x, lambda: container.append(x)
+
+ @argmap(contextmanager, 0, 1, 2, try_finally=True)
+ def foo(x, y, z):
+ return x, y, z
+
+ x, y, z = foo("a", "b", "c")
+
+ # context exits are called in reverse
+ assert container == ["c", "b", "a"]
+
+ def test_contextmanager_iterator(self):
+ container = []
+
+ def contextmanager(x):
+ nonlocal container
+ return x, lambda: container.append(x)
+
+ @argmap(contextmanager, 0, 1, 2, try_finally=True)
+ def foo(x, y, z):
+ yield from (x, y, z)
+
+ q = foo("a", "b", "c")
+ assert next(q) == "a"
+ assert container == []
+ assert next(q) == "b"
+ assert container == []
+ assert next(q) == "c"
+ assert container == []
+ with pytest.raises(StopIteration):
+ next(q)
+
+ # context exits are called in reverse
+ assert container == ["c", "b", "a"]
+
+ def test_actual_vararg(self):
+ @argmap(lambda x: -x, 4)
+ def foo(x, y, *args):
+ return (x, y) + tuple(args)
+
+ assert foo(1, 2, 3, 4, 5, 6) == (1, 2, 3, 4, -5, 6)
+
+ def test_signature_destroying_intermediate_decorator(self):
+ def add_one_to_first_bad_decorator(f):
+ """Bad because it doesn't wrap the f signature (clobbers it)"""
+
+ def decorated(a, *args, **kwargs):
+ return f(a + 1, *args, **kwargs)
+
+ return decorated
+
+ add_two_to_second = argmap(lambda b: b + 2, 1)
+
+ @add_two_to_second
+ @add_one_to_first_bad_decorator
+ def add_one_and_two(a, b):
+ return a, b
+
+ assert add_one_and_two(5, 5) == (6, 7)
+
+ def test_actual_kwarg(self):
+ @argmap(lambda x: -x, "arg")
+ def foo(*, arg):
+ return arg
+
+ assert foo(arg=3) == -3
+
+ def test_nested_tuple(self):
+ def xform(x, y):
+ u, v = y
+ return x + u + v, (x + u, x + v)
+
+ # we're testing args and kwargs here, too
+ @argmap(xform, (0, ("t", 2)))
+ def foo(a, *args, **kwargs):
+ return a, args, kwargs
+
+ a, args, kwargs = foo(1, 2, 3, t=4)
+
+ assert a == 1 + 4 + 3
+ assert args == (2, 1 + 3)
+ assert kwargs == {"t": 1 + 4}
+
+ def test_flatten(self):
+ assert tuple(argmap._flatten([[[[[], []], [], []], [], [], []]], set())) == ()
+
+ rlist = ["a", ["b", "c"], [["d"], "e"], "f"]
+ assert "".join(argmap._flatten(rlist, set())) == "abcdef"
+
+ def test_indent(self):
+ code = "\n".join(
+ argmap._indent(
+ *[
+ "try:",
+ "try:",
+ "pass#",
+ "finally:",
+ "pass#",
+ "#",
+ "finally:",
+ "pass#",
+ ]
+ )
+ )
+ assert (
+ code
+ == """try:
+ try:
+ pass#
+ finally:
+ pass#
+ #
+finally:
+ pass#"""
+ )
+
+ def nop(self):
+ print(foo.__argmap__.assemble(foo.__wrapped__))
+ argmap._lazy_compile(foo)
+ print(foo._code)
diff --git a/requirements/default.txt b/requirements/default.txt
index c404b5e8..691b34e0 100644
--- a/requirements/default.txt
+++ b/requirements/default.txt
@@ -1,4 +1,3 @@
-decorator>=5.0.7,<6
numpy>=1.19
scipy>=1.5,!=1.6.1
matplotlib>=3.3