Repository: networkx/networkx

Issue:
`node_link` functions `attrs` kwarg
`node_link_graph` and `node_link_data` have a similar API to the `cytoscape` functions, where there is a `attrs` kwarg which expects a dictionary containing specific keys that is then unpacked internally.

This API is a bit un-Pythonic and was removed in favor of keyword arguments - see the related discussion in #4199. It's worth checking if the `node_link` functions wouldn't benefit from a similar treatment.


Discussion:
Yes, IMO these functions should be changed to have 5 keyword arguments instead of a single dict containing the five values.
Thinking out loud...

Will need to:

- add key words to signature, while retaining `attrs`
- add a deprecation warning in the,
-- doc string
-- functions. Code will trigger the warning when `attrs is not None`
- Fix  the functions to use the keyword values, instead of `attrs`, when `attrs` is None
-  add tests that  use keywords instead of `attrs`. These tests will work after `attrs` is dropped.


Resolving patch:
diff --git a/networkx/conftest.py b/networkx/conftest.py
--- a/networkx/conftest.py
+++ b/networkx/conftest.py
@@ -142,6 +142,11 @@ def set_warnings():
     warnings.filterwarnings(
         "ignore", category=DeprecationWarning, message="nx.nx_pydot"
     )
+    warnings.filterwarnings(
+        "ignore",
+        category=DeprecationWarning,
+        message="signature change for node_link functions",
+    )
 
 
 @pytest.fixture(autouse=True)
diff --git a/networkx/readwrite/json_graph/node_link.py b/networkx/readwrite/json_graph/node_link.py
--- a/networkx/readwrite/json_graph/node_link.py
+++ b/networkx/readwrite/json_graph/node_link.py
@@ -25,7 +25,9 @@ def _to_tuple(x):
     return tuple(map(_to_tuple, x))
 
 
-def node_link_data(G, attrs=None):
+def node_link_data(
+    G, attrs=None, source="source", target="target", name="id", key="key", link="links"
+):
     """Returns data in node-link format that is suitable for JSON serialization
     and use in Javascript documents.
 
@@ -45,6 +47,27 @@ def node_link_data(G, attrs=None):
         If some user-defined graph data use these attribute names as data keys,
         they may be silently dropped.
 
+        .. deprecated:: 2.8.6
+
+           The `attrs` keyword argument will be replaced with `source`, `target`, `name`,
+           `key` and `link`. in networkx 3.1
+
+           If the `attrs` keyword and the new keywords are both used in a single function call (not recommended)
+           the `attrs` keyword argument will take precedence.
+
+           The values of the keywords must be unique.
+
+    source : string
+        A string that provides the 'source' attribute name for storing NetworkX-internal graph data.
+    target : string
+        A string that provides the 'target' attribute name for storing NetworkX-internal graph data.
+    name : string
+        A string that provides the 'name' attribute name for storing NetworkX-internal graph data.
+    key : string
+        A string that provides the 'key' attribute name for storing NetworkX-internal graph data.
+    link : string
+        A string that provides the 'link' attribute name for storing NetworkX-internal graph data.
+
     Returns
     -------
     data : dict
@@ -53,25 +76,35 @@ def node_link_data(G, attrs=None):
     Raises
     ------
     NetworkXError
-        If values in attrs are not unique.
+        If the values of 'source', 'target' and 'key' are not unique.
 
     Examples
     --------
-    >>> from networkx.readwrite import json_graph
     >>> G = nx.Graph([("A", "B")])
-    >>> data1 = json_graph.node_link_data(G)
-    >>> H = nx.gn_graph(2)
-    >>> data2 = json_graph.node_link_data(
-    ...     H, {"link": "edges", "source": "from", "target": "to"}
-    ... )
+    >>> data1 = nx.node_link_data(G)
+    >>> data1
+    {'directed': False, 'multigraph': False, 'graph': {}, 'nodes': [{'id': 'A'}, {'id': 'B'}], 'links': [{'source': 'A', 'target': 'B'}]}
 
-    To serialize with json
+    To serialize with JSON
 
     >>> import json
     >>> s1 = json.dumps(data1)
-    >>> s2 = json.dumps(
-    ...     data2, default={"link": "edges", "source": "from", "target": "to"}
-    ... )
+    >>> s1
+    '{"directed": false, "multigraph": false, "graph": {}, "nodes": [{"id": "A"}, {"id": "B"}], "links": [{"source": "A", "target": "B"}]}'
+
+    A graph can also be serialized by passing `node_link_data` as an encoder function. The two methods are equivalent.
+
+    >>> s1 = json.dumps(G, default=nx.node_link_data)
+    >>> s1
+    '{"directed": false, "multigraph": false, "graph": {}, "nodes": [{"id": "A"}, {"id": "B"}], "links": [{"source": "A", "target": "B"}]}'
+
+    The attribute names for storing NetworkX-internal graph data can
+    be specified as keyword options.
+
+    >>> H = nx.gn_graph(2)
+    >>> data2 = nx.node_link_data(H, link="edges", source="from", target="to")
+    >>> data2
+    {'directed': True, 'multigraph': False, 'graph': {}, 'nodes': [{'id': 0}, {'id': 1}], 'edges': [{'from': 1, 'to': 0}]}
 
     Notes
     -----
@@ -80,22 +113,43 @@ def node_link_data(G, attrs=None):
 
     Attribute 'key' is only used for multigraphs.
 
+    To use `node_link_data` in conjunction with `node_link_graph`,
+    the keyword names for the attributes must match.
+
+
     See Also
     --------
     node_link_graph, adjacency_data, tree_data
     """
+    # ------ TODO: Remove between the lines after signature change is complete ----- #
+    if attrs is not None:
+        import warnings
+
+        msg = (
+            "\n\nThe `attrs` keyword argument of node_link_data is deprecated\n"
+            "and will be removed in networkx 3.1. It is replaced with explicit\n"
+            "keyword arguments: `source`, `target`, `name`, `key` and `link`.\n"
+            "To make this warning go away, and ensure usage is forward\n"
+            "compatible, replace `attrs` with the keywords. "
+            "For example:\n\n"
+            "   >>> node_link_data(G, attrs={'target': 'foo', 'name': 'bar'})\n\n"
+            "should instead be written as\n\n"
+            "   >>> node_link_data(G, target='foo', name='bar')\n\n"
+            "in networkx 3.1.\n"
+            "The default values of the keywords will not change.\n"
+        )
+        warnings.warn(msg, DeprecationWarning, stacklevel=2)
+
+        source = attrs.get("source", "source")
+        target = attrs.get("target", "target")
+        name = attrs.get("name", "name")
+        key = attrs.get("key", "key")
+        link = attrs.get("link", "links")
+    # -------------------------------------------------- #
     multigraph = G.is_multigraph()
-    # Allow 'attrs' to keep default values.
-    if attrs is None:
-        attrs = _attrs
-    else:
-        attrs.update({k: v for (k, v) in _attrs.items() if k not in attrs})
-    name = attrs["name"]
-    source = attrs["source"]
-    target = attrs["target"]
-    links = attrs["link"]
+
     # Allow 'key' to be omitted from attrs if the graph is not a multigraph.
-    key = None if not multigraph else attrs["key"]
+    key = None if not multigraph else key
     if len({source, target, key}) < 3:
         raise nx.NetworkXError("Attribute names are not unique.")
     data = {
@@ -105,20 +159,31 @@ def node_link_data(G, attrs=None):
         "nodes": [dict(chain(G.nodes[n].items(), [(name, n)])) for n in G],
     }
     if multigraph:
-        data[links] = [
+        data[link] = [
             dict(chain(d.items(), [(source, u), (target, v), (key, k)]))
             for u, v, k, d in G.edges(keys=True, data=True)
         ]
     else:
-        data[links] = [
+        data[link] = [
             dict(chain(d.items(), [(source, u), (target, v)]))
             for u, v, d in G.edges(data=True)
         ]
     return data
 
 
-def node_link_graph(data, directed=False, multigraph=True, attrs=None):
+def node_link_graph(
+    data,
+    directed=False,
+    multigraph=True,
+    attrs=None,
+    source="source",
+    target="target",
+    name="id",
+    key="key",
+    link="links",
+):
     """Returns graph from node-link data format.
+    Useful for de-serialization from JSON.
 
     Parameters
     ----------
@@ -139,6 +204,27 @@ def node_link_graph(data, directed=False, multigraph=True, attrs=None):
             dict(source='source', target='target', name='id',
                 key='key', link='links')
 
+        .. deprecated:: 2.8.6
+
+           The `attrs` keyword argument will be replaced with the individual keywords: `source`, `target`, `name`,
+           `key` and `link`. in networkx 3.1.
+
+           If the `attrs` keyword and the new keywords are both used in a single function call (not recommended)
+           the `attrs` keyword argument will take precedence.
+
+           The values of the keywords must be unique.
+
+    source : string
+        A string that provides the 'source' attribute name for storing NetworkX-internal graph data.
+    target : string
+        A string that provides the 'target' attribute name for storing NetworkX-internal graph data.
+    name : string
+        A string that provides the 'name' attribute name for storing NetworkX-internal graph data.
+    key : string
+        A string that provides the 'key' attribute name for storing NetworkX-internal graph data.
+    link : string
+        A string that provides the 'link' attribute name for storing NetworkX-internal graph data.
+
     Returns
     -------
     G : NetworkX graph
@@ -146,24 +232,65 @@ def node_link_graph(data, directed=False, multigraph=True, attrs=None):
 
     Examples
     --------
-    >>> from networkx.readwrite import json_graph
-    >>> G = nx.Graph([("A", "B")])
-    >>> data = json_graph.node_link_data(G)
-    >>> H = json_graph.node_link_graph(data)
+
+    Create data in node-link format by converting a graph.
+
+    >>> G = nx.Graph([('A', 'B')])
+    >>> data = nx.node_link_data(G)
+    >>> data
+    {'directed': False, 'multigraph': False, 'graph': {}, 'nodes': [{'id': 'A'}, {'id': 'B'}], 'links': [{'source': 'A', 'target': 'B'}]}
+
+    Revert data in node-link format to a graph.
+
+    >>> H = nx.node_link_graph(data)
+    >>> print(H.edges)
+    [('A', 'B')]
+
+    To serialize and deserialize a graph with JSON,
+
+    >>> import json
+    >>> d = json.dumps(node_link_data(G))
+    >>> H = node_link_graph(json.loads(d))
+    >>> print(G.edges, H.edges)
+    [('A', 'B')] [('A', 'B')]
+
 
     Notes
     -----
     Attribute 'key' is only used for multigraphs.
 
+    To use `node_link_data` in conjunction with `node_link_graph`,
+    the keyword names for the attributes must match.
+
     See Also
     --------
     node_link_data, adjacency_data, tree_data
     """
-    # Allow 'attrs' to keep default values.
-    if attrs is None:
-        attrs = _attrs
-    else:
-        attrs.update({k: v for k, v in _attrs.items() if k not in attrs})
+    # ------ TODO: Remove between the lines after signature change is complete ----- #
+    if attrs is not None:
+        import warnings
+
+        msg = (
+            "\n\nThe `attrs` keyword argument of node_link_graph is deprecated\n"
+            "and will be removed in networkx 3.1. It is replaced with explicit\n"
+            "keyword arguments: `source`, `target`, `name`, `key` and `link`.\n"
+            "To make this warning go away, and ensure usage is forward\n"
+            "compatible, replace `attrs` with the keywords. "
+            "For example:\n\n"
+            "   >>> node_link_graph(data, attrs={'target': 'foo', 'name': 'bar'})\n\n"
+            "should instead be written as\n\n"
+            "   >>> node_link_graph(data, target='foo', name='bar')\n\n"
+            "in networkx 3.1.\n"
+            "The default values of the keywords will not change.\n"
+        )
+        warnings.warn(msg, DeprecationWarning, stacklevel=2)
+
+        source = attrs.get("source", "source")
+        target = attrs.get("target", "target")
+        name = attrs.get("name", "name")
+        key = attrs.get("key", "key")
+        link = attrs.get("link", "links")
+    # -------------------------------------------------- #
     multigraph = data.get("multigraph", multigraph)
     directed = data.get("directed", directed)
     if multigraph:
@@ -172,19 +299,16 @@ def node_link_graph(data, directed=False, multigraph=True, attrs=None):
         graph = nx.Graph()
     if directed:
         graph = graph.to_directed()
-    name = attrs["name"]
-    source = attrs["source"]
-    target = attrs["target"]
-    links = attrs["link"]
+
     # Allow 'key' to be omitted from attrs if the graph is not a multigraph.
-    key = None if not multigraph else attrs["key"]
+    key = None if not multigraph else key
     graph.graph = data.get("graph", {})
     c = count()
     for d in data["nodes"]:
         node = _to_tuple(d.get(name, next(c)))
         nodedata = {str(k): v for k, v in d.items() if k != name}
         graph.add_node(node, **nodedata)
-    for d in data[links]:
+    for d in data[link]:
         src = tuple(d[source]) if isinstance(d[source], list) else d[source]
         tgt = tuple(d[target]) if isinstance(d[target], list) else d[target]
         if not multigraph:


Test patch:
diff --git a/networkx/readwrite/json_graph/tests/test_node_link.py b/networkx/readwrite/json_graph/tests/test_node_link.py
--- a/networkx/readwrite/json_graph/tests/test_node_link.py
+++ b/networkx/readwrite/json_graph/tests/test_node_link.py
@@ -6,7 +6,73 @@
 from networkx.readwrite.json_graph import node_link_data, node_link_graph
 
 
+# TODO: To be removed when signature change complete
+def test_attrs_deprecation(recwarn):
+    G = nx.path_graph(3)
+
+    # No warnings when `attrs` kwarg not used
+    data = node_link_data(G)
+    H = node_link_graph(data)
+    assert len(recwarn) == 0
+
+    # Future warning raised with `attrs` kwarg
+    attrs = dict(source="source", target="target", name="id", key="key", link="links")
+    data = node_link_data(G, attrs=attrs)
+    assert len(recwarn) == 1
+
+    recwarn.clear()
+    H = node_link_graph(data, attrs=attrs)
+    assert len(recwarn) == 1
+
+
 class TestNodeLink:
+
+    # TODO: To be removed when signature change complete
+    def test_custom_attrs_dep(self):
+        G = nx.path_graph(4)
+        G.add_node(1, color="red")
+        G.add_edge(1, 2, width=7)
+        G.graph[1] = "one"
+        G.graph["foo"] = "bar"
+
+        attrs = dict(
+            source="c_source",
+            target="c_target",
+            name="c_id",
+            key="c_key",
+            link="c_links",
+        )
+
+        H = node_link_graph(
+            node_link_data(G, attrs=attrs), multigraph=False, attrs=attrs
+        )
+        assert nx.is_isomorphic(G, H)
+        assert H.graph["foo"] == "bar"
+        assert H.nodes[1]["color"] == "red"
+        assert H[1][2]["width"] == 7
+
+        # provide only a partial dictionary of keywords.
+        # This is similar to an example in the doc string
+        attrs = dict(
+            link="c_links",
+            source="c_source",
+            target="c_target",
+        )
+        H = node_link_graph(
+            node_link_data(G, attrs=attrs), multigraph=False, attrs=attrs
+        )
+        assert nx.is_isomorphic(G, H)
+        assert H.graph["foo"] == "bar"
+        assert H.nodes[1]["color"] == "red"
+        assert H[1][2]["width"] == 7
+
+    # TODO: To be removed when signature change complete
+    def test_exception_dep(self):
+        with pytest.raises(nx.NetworkXError):
+            G = nx.MultiDiGraph()
+            attrs = dict(name="node", source="node", target="node", key="node")
+            node_link_data(G, attrs)
+
     def test_graph(self):
         G = nx.path_graph(4)
         H = node_link_graph(node_link_data(G))
@@ -68,7 +134,7 @@ def test_exception(self):
         with pytest.raises(nx.NetworkXError):
             G = nx.MultiDiGraph()
             attrs = dict(name="node", source="node", target="node", key="node")
-            node_link_data(G, attrs)
+            node_link_data(G, **attrs)
 
     def test_string_ids(self):
         q = "qualité"
@@ -97,9 +163,7 @@ def test_custom_attrs(self):
             link="c_links",
         )
 
-        H = node_link_graph(
-            node_link_data(G, attrs=attrs), multigraph=False, attrs=attrs
-        )
+        H = node_link_graph(node_link_data(G, **attrs), multigraph=False, **attrs)
         assert nx.is_isomorphic(G, H)
         assert H.graph["foo"] == "bar"
         assert H.nodes[1]["color"] == "red"