diff options
author | Kelly Boothby <boothby@dwavesys.com> | 2021-06-20 22:42:01 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-06-21 01:42:01 -0400 |
commit | 89a54703fafbb67d5a79b38238f7478ef62a51eb (patch) | |
tree | 7afb22651fc7f90f22f63c29af3bf6496f313cdc | |
parent | 1f96b154a86588a7ef3d9b3637dd66749b1de71f (diff) | |
download | networkx-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>
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 |