Repository: networkx/networkx

Issue:
Missing edge label attribute in GEXFWriter
According to the [GEXF specification ](https://gephi.org/gexf/1.2draft/gexf-12draft-primer.pdf):

> Each edge can have a optional XML-attribute label, which is a string.

The [specification 1.2draft](https://github.com/gephi/gexf/blob/81ba4e7ccdc25631f836fc5caa4ed64ba5300379/specs/1.2draft/data.xsd#L73) defines this attribute as:
`<xs:attribute name="label" type="xs:token"/>`

The GEXF writer doesn't recognize this attribute correctly.
Instead it creates a new data attribute like:
```
		<attributes class="edge" mode="static">
			<attribute id="0" title="label" type="string"/>
		</attributes>
``` 

Discussion:
jonaschn: @dschult What do you think?
dschult: This looks like a good approach to me. Can you add a test to make sure we don't mess it up with future changes? Perhaps the example you show above?  Thanks!

Patch:
diff --git a/networkx/classes/multigraph.py b/networkx/classes/multigraph.py
index d3685b14c..fd939f559 100644
--- a/networkx/classes/multigraph.py
+++ b/networkx/classes/multigraph.py
@@ -520,10 +520,10 @@ class MultiGraph(Graph):
             ddd.update(attr)
             try:
                 ddd.update(dd)
-            except:
+            except (TypeError, ValueError):
                 if ne != 3:
                     raise
-                key = dd
+                key = dd  # ne == 3 with 3rd value not dict, must be a key
             key = self.add_edge(u, v, key)
             self[u][v][key].update(ddd)
             keylist.append(key)
diff --git a/networkx/readwrite/gexf.py b/networkx/readwrite/gexf.py
index 3f5d5ed1c..b69c4ae72 100644
--- a/networkx/readwrite/gexf.py
+++ b/networkx/readwrite/gexf.py
@@ -400,6 +400,11 @@ class GEXFWriter(GEXF):
         edges_element = Element("edges")
         for u, v, key, edge_data in edge_key_data(G):
             kw = {"id": str(key)}
+            try:
+                edge_label = edge_data.pop("label")
+                kw["label"] = str(edge_label)
+            except KeyError:
+                pass
             try:
                 edge_weight = edge_data.pop("weight")
                 kw["weight"] = str(edge_weight)
diff --git a/networkx/readwrite/graphml.py b/networkx/readwrite/graphml.py
index df4982c44..47b8d46d3 100644
--- a/networkx/readwrite/graphml.py
+++ b/networkx/readwrite/graphml.py
@@ -196,7 +196,7 @@ def generate_graphml(G, encoding="utf-8", prettyprint=True, named_key_ids=False)
 
 
 @open_file(0, mode="rb")
-def read_graphml(path, node_type=str, edge_key_type=int):
+def read_graphml(path, node_type=str, edge_key_type=int, force_multigraph=False):
     """Read graph in GraphML format from path.
 
     Parameters
@@ -209,14 +209,19 @@ def read_graphml(path, node_type=str, edge_key_type=int):
        Convert node ids to this type
 
     edge_key_type: Python type (default: int)
-       Convert graphml edge ids to this type as key of multi-edges
+       Convert graphml edge ids to this type. Multigraphs use id as edge key.
+       Non-multigraphs add to edge attribute dict with name "id".
 
+    force_multigraph : bool (default: False)
+       If True, return a multigraph with edge keys. If False (the default)
+       return a multigraph when multiedges are in the graph.
 
     Returns
     -------
     graph: NetworkX graph
-        If no parallel edges are found a Graph or DiGraph is returned.
-        Otherwise a MultiGraph or MultiDiGraph is returned.
+        If parallel edges are present or `force_multigraph == True```then
+        a MultiGraph or MultiDiGraph is returned. Otherwise a Graph/DiGraph.
+        The returned graph is directed if the file indicates it should be.
 
     Notes
     -----
@@ -248,7 +253,7 @@ def read_graphml(path, node_type=str, edge_key_type=int):
     the file to "file.graphml.gz".
 
     """
-    reader = GraphMLReader(node_type=node_type, edge_key_type=edge_key_type)
+    reader = GraphMLReader(node_type, edge_key_type, force_multigraph)
     # need to check for multiple graphs
     glist = list(reader(path=path))
     if len(glist) == 0:
@@ -263,7 +268,9 @@ def read_graphml(path, node_type=str, edge_key_type=int):
     return glist[0]
 
 
-def parse_graphml(graphml_string, node_type=str):
+def parse_graphml(
+    graphml_string, node_type=str, edge_key_type=int, force_multigraph=False
+):
     """Read graph in GraphML format from string.
 
     Parameters
@@ -275,6 +282,15 @@ def parse_graphml(graphml_string, node_type=str):
     node_type: Python type (default: str)
        Convert node ids to this type
 
+    edge_key_type: Python type (default: int)
+       Convert graphml edge ids to this type. Multigraphs use id as edge key.
+       Non-multigraphs add to edge attribute dict with name "id".
+
+    force_multigraph : bool (default: False)
+       If True, return a multigraph with edge keys. If False (the default)
+       return a multigraph when multiedges are in the graph.
+
+
     Returns
     -------
     graph: NetworkX graph
@@ -312,7 +328,7 @@ def parse_graphml(graphml_string, node_type=str):
     will be provided.
 
     """
-    reader = GraphMLReader(node_type=node_type)
+    reader = GraphMLReader(node_type, edge_key_type, force_multigraph)
     # need to check for multiple graphs
     glist = list(reader(string=graphml_string))
     if len(glist) == 0:
@@ -743,11 +759,11 @@ else:
 class GraphMLReader(GraphML):
     """Read a GraphML document.  Produces NetworkX graph objects."""
 
-    def __init__(self, node_type=str, edge_key_type=int):
+    def __init__(self, node_type=str, edge_key_type=int, force_multigraph=False):
         self.node_type = node_type
         self.edge_key_type = edge_key_type
-        self.multigraph = False  # assume multigraph and test for multiedges
-        self.edge_ids = {}  # dict mapping (u,v) tuples to id edge attributes
+        self.multigraph = force_multigraph  # If False, test for multiedges
+        self.edge_ids = {}  # dict mapping (u,v) tuples to edge id attributes
 
     def __call__(self, path=None, string=None):
         if path is not None:
@@ -793,14 +809,13 @@ class GraphMLReader(GraphML):
         data = self.decode_data_elements(graphml_keys, graph_xml)
         G.graph.update(data)
 
-        # switch to Graph or DiGraph if no parallel edges were found.
-        if not self.multigraph:
-            if G.is_directed():
-                G = nx.DiGraph(G)
-            else:
-                G = nx.Graph(G)
-            nx.set_edge_attributes(G, values=self.edge_ids, name="id")
+        # switch to Graph or DiGraph if no parallel edges were found
+        if self.multigraph:
+            return G
 
+        G = nx.DiGraph(G) if G.is_directed() else nx.Graph(G)
+        # add explicit edge "id" from file as attribute in NX graph.
+        nx.set_edge_attributes(G, values=self.edge_ids, name="id")
         return G
 
     def add_node(self, G, node_xml, graphml_keys, defaults):
@@ -859,6 +874,7 @@ class GraphMLReader(GraphML):
             self.multigraph = True
 
         # Use add_edges_from to avoid error with add_edge when `'key' in data`
+        # Note there is only one edge here...
         G.add_edges_from([(source, target, edge_id, data)])
 
     def decode_data_elements(self, graphml_keys, obj_xml):
diff --git a/requirements/doc.txt b/requirements/doc.txt
index cfe9e90d6..54a77b1f2 100644
--- a/requirements/doc.txt
+++ b/requirements/doc.txt
@@ -1,4 +1,4 @@
-sphinx==3.1.2
+sphinx==3.2.1
 sphinx_rtd_theme==0.5.0
 sphinx-gallery==0.7.0
 pillow>=7.2.0


Test patch:
diff --git a/networkx/readwrite/tests/test_gexf.py b/networkx/readwrite/tests/test_gexf.py
index 4a2663218..9b43794b1 100644
--- a/networkx/readwrite/tests/test_gexf.py
+++ b/networkx/readwrite/tests/test_gexf.py
@@ -78,7 +78,7 @@ org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.gexf.net/\
       </node>
     </nodes>
     <edges>
-      <edge id="0" source="0" target="1"/>
+      <edge id="0" source="0" target="1" label="foo"/>
       <edge id="1" source="0" target="2"/>
       <edge id="2" source="1" target="0"/>
       <edge id="3" source="2" target="1"/>
@@ -105,7 +105,7 @@ org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.gexf.net/\
             indegree=1,
             frog=True,
         )
-        cls.attribute_graph.add_edge("0", "1", id="0")
+        cls.attribute_graph.add_edge("0", "1", id="0", label="foo")
         cls.attribute_graph.add_edge("0", "2", id="1")
         cls.attribute_graph.add_edge("1", "0", id="2")
         cls.attribute_graph.add_edge("2", "1", id="3")
@@ -573,7 +573,8 @@ gexf.net/1.2draft http://www.gexf.net/1.2draft/gexf.xsd" version="1.2">
         G = nx.MultiGraph()
         G.add_node(0, label="1", color="green")
         G.add_node(1, label="2", color="green")
-        G.add_edge(0, 1, id="0", weight=3, type="undirected", start=0, end=1)
+        G.add_edge(0, 1, id="0", wight=3, type="undirected", start=0, end=1)
+        G.add_edge(0, 1, id="1", label="foo", start=0, end=1)
         G.add_edge(0, 1)
         fh = io.BytesIO()
         nx.write_gexf(G, fh)
diff --git a/networkx/readwrite/tests/test_graphml.py b/networkx/readwrite/tests/test_graphml.py
index f59f2bd53..39ea907d1 100644
--- a/networkx/readwrite/tests/test_graphml.py
+++ b/networkx/readwrite/tests/test_graphml.py
@@ -44,6 +44,7 @@ class BaseGraphML:
         cls.simple_directed_graph = nx.DiGraph()
         cls.simple_directed_graph.add_node("n10")
         cls.simple_directed_graph.add_edge("n0", "n2", id="foo")
+        cls.simple_directed_graph.add_edge("n0", "n2")
         cls.simple_directed_graph.add_edges_from(
             [
                 ("n1", "n2"),
@@ -207,6 +208,75 @@ class BaseGraphML:
         fh = io.BytesIO(cls.simple_undirected_data.encode("UTF-8"))
         cls.simple_undirected_fh = fh
 
+        cls.undirected_multigraph_data = """<?xml version="1.0" encoding="UTF-8"?>
+<graphml xmlns="http://graphml.graphdrawing.org/xmlns"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns
+         http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">
+  <graph id="G">
+    <node id="n0"/>
+    <node id="n1"/>
+    <node id="n2"/>
+    <node id="n10"/>
+    <edge id="e0" source="n0" target="n2"/>
+    <edge id="e1" source="n1" target="n2"/>
+    <edge id="e2" source="n2" target="n1"/>
+  </graph>
+</graphml>"""
+        cls.undirected_multigraph = nx.MultiGraph()
+        cls.undirected_multigraph.add_node("n10")
+        cls.undirected_multigraph.add_edge("n0", "n2", id="e0")
+        cls.undirected_multigraph.add_edge("n1", "n2", id="e1")
+        cls.undirected_multigraph.add_edge("n2", "n1", id="e2")
+        fh = io.BytesIO(cls.undirected_multigraph_data.encode("UTF-8"))
+        cls.undirected_multigraph_fh = fh
+
+        cls.undirected_multigraph_no_multiedge_data = """<?xml version="1.0" encoding="UTF-8"?>
+<graphml xmlns="http://graphml.graphdrawing.org/xmlns"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns
+         http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">
+  <graph id="G">
+    <node id="n0"/>
+    <node id="n1"/>
+    <node id="n2"/>
+    <node id="n10"/>
+    <edge id="e0" source="n0" target="n2"/>
+    <edge id="e1" source="n1" target="n2"/>
+    <edge id="e2" source="n2" target="n3"/>
+  </graph>
+</graphml>"""
+        cls.undirected_multigraph_no_multiedge = nx.MultiGraph()
+        cls.undirected_multigraph_no_multiedge.add_node("n10")
+        cls.undirected_multigraph_no_multiedge.add_edge("n0", "n2", id="e0")
+        cls.undirected_multigraph_no_multiedge.add_edge("n1", "n2", id="e1")
+        cls.undirected_multigraph_no_multiedge.add_edge("n2", "n3", id="e2")
+        fh = io.BytesIO(cls.undirected_multigraph_no_multiedge_data.encode("UTF-8"))
+        cls.undirected_multigraph_no_multiedge_fh = fh
+
+        cls.multigraph_only_ids_for_multiedges_data = """<?xml version="1.0" encoding="UTF-8"?>
+<graphml xmlns="http://graphml.graphdrawing.org/xmlns"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns
+         http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">
+  <graph id="G">
+    <node id="n0"/>
+    <node id="n1"/>
+    <node id="n2"/>
+    <node id="n10"/>
+    <edge source="n0" target="n2"/>
+    <edge id="e1" source="n1" target="n2"/>
+    <edge id="e2" source="n2" target="n1"/>
+  </graph>
+</graphml>"""
+        cls.multigraph_only_ids_for_multiedges = nx.MultiGraph()
+        cls.multigraph_only_ids_for_multiedges.add_node("n10")
+        cls.multigraph_only_ids_for_multiedges.add_edge("n0", "n2")
+        cls.multigraph_only_ids_for_multiedges.add_edge("n1", "n2", id="e1")
+        cls.multigraph_only_ids_for_multiedges.add_edge("n2", "n1", id="e2")
+        fh = io.BytesIO(cls.multigraph_only_ids_for_multiedges_data.encode("UTF-8"))
+        cls.multigraph_only_ids_for_multiedges_fh = fh
+
 
 class TestReadGraphML(BaseGraphML):
     def test_read_simple_directed_graphml(self):
@@ -217,10 +287,10 @@ class TestReadGraphML(BaseGraphML):
         assert sorted(G.edges(data=True)) == sorted(H.edges(data=True))
         self.simple_directed_fh.seek(0)
 
-        I = nx.parse_graphml(self.simple_directed_data)
-        assert sorted(G.nodes()) == sorted(I.nodes())
-        assert sorted(G.edges()) == sorted(I.edges())
-        assert sorted(G.edges(data=True)) == sorted(I.edges(data=True))
+        PG = nx.parse_graphml(self.simple_directed_data)
+        assert sorted(G.nodes()) == sorted(PG.nodes())
+        assert sorted(G.edges()) == sorted(PG.edges())
+        assert sorted(G.edges(data=True)) == sorted(PG.edges(data=True))
 
     def test_read_simple_undirected_graphml(self):
         G = self.simple_undirected_graph
@@ -229,9 +299,42 @@ class TestReadGraphML(BaseGraphML):
         assert_edges_equal(G.edges(), H.edges())
         self.simple_undirected_fh.seek(0)
 
-        I = nx.parse_graphml(self.simple_undirected_data)
-        assert_nodes_equal(G.nodes(), I.nodes())
-        assert_edges_equal(G.edges(), I.edges())
+        PG = nx.parse_graphml(self.simple_undirected_data)
+        assert_nodes_equal(G.nodes(), PG.nodes())
+        assert_edges_equal(G.edges(), PG.edges())
+
+    def test_read_undirected_multigraph_graphml(self):
+        G = self.undirected_multigraph
+        H = nx.read_graphml(self.undirected_multigraph_fh)
+        assert_nodes_equal(G.nodes(), H.nodes())
+        assert_edges_equal(G.edges(), H.edges())
+        self.undirected_multigraph_fh.seek(0)
+
+        PG = nx.parse_graphml(self.undirected_multigraph_data)
+        assert_nodes_equal(G.nodes(), PG.nodes())
+        assert_edges_equal(G.edges(), PG.edges())
+
+    def test_read_undirected_multigraph_no_multiedge_graphml(self):
+        G = self.undirected_multigraph_no_multiedge
+        H = nx.read_graphml(self.undirected_multigraph_no_multiedge_fh)
+        assert_nodes_equal(G.nodes(), H.nodes())
+        assert_edges_equal(G.edges(), H.edges())
+        self.undirected_multigraph_no_multiedge_fh.seek(0)
+
+        PG = nx.parse_graphml(self.undirected_multigraph_no_multiedge_data)
+        assert_nodes_equal(G.nodes(), PG.nodes())
+        assert_edges_equal(G.edges(), PG.edges())
+
+    def test_read_undirected_multigraph_only_ids_for_multiedges_graphml(self):
+        G = self.multigraph_only_ids_for_multiedges
+        H = nx.read_graphml(self.multigraph_only_ids_for_multiedges_fh)
+        assert_nodes_equal(G.nodes(), H.nodes())
+        assert_edges_equal(G.edges(), H.edges())
+        self.multigraph_only_ids_for_multiedges_fh.seek(0)
+
+        PG = nx.parse_graphml(self.multigraph_only_ids_for_multiedges_data)
+        assert_nodes_equal(G.nodes(), PG.nodes())
+        assert_edges_equal(G.edges(), PG.edges())
 
     def test_read_attribute_graphml(self):
         G = self.attribute_graph
@@ -243,10 +346,10 @@ class TestReadGraphML(BaseGraphML):
             assert a == b
         self.attribute_fh.seek(0)
 
-        I = nx.parse_graphml(self.attribute_data)
-        assert sorted(G.nodes(True)) == sorted(I.nodes(data=True))
+        PG = nx.parse_graphml(self.attribute_data)
+        assert sorted(G.nodes(True)) == sorted(PG.nodes(data=True))
         ge = sorted(G.edges(data=True))
-        he = sorted(I.edges(data=True))
+        he = sorted(PG.edges(data=True))
         for a, b in zip(ge, he):
             assert a == b
 
@@ -396,6 +499,22 @@ class TestReadGraphML(BaseGraphML):
         assert_edges_equal(G.edges(data=True, keys=True), H.edges(data=True, keys=True))
         assert G._adj == H._adj
 
+        Gadj = {
+            str(node): {
+                str(nbr): {str(ekey): dd for ekey, dd in key_dict.items()}
+                for nbr, key_dict in nbr_dict.items()
+            }
+            for node, nbr_dict in G._adj.items()
+        }
+        fh.seek(0)
+        HH = nx.read_graphml(fh, node_type=str, edge_key_type=str)
+        assert Gadj == HH._adj
+
+        fh.seek(0)
+        string_fh = fh.read()
+        HH = nx.parse_graphml(string_fh, node_type=str, edge_key_type=str)
+        assert Gadj == HH._adj
+
     def test_yfiles_extension(self):
         data = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 <graphml xmlns="http://graphml.graphdrawing.org/xmlns"
@@ -465,12 +584,24 @@ class TestReadGraphML(BaseGraphML):
 </graphml>
 """
         fh = io.BytesIO(data.encode("UTF-8"))
+        G = nx.read_graphml(fh, force_multigraph=True)
+        assert list(G.edges()) == [("n0", "n1")]
+        assert G.has_edge("n0", "n1", key="e0")
+        assert G.nodes["n0"]["label"] == "1"
+        assert G.nodes["n1"]["label"] == "2"
+        fh.seek(0)
         G = nx.read_graphml(fh)
         assert list(G.edges()) == [("n0", "n1")]
         assert G["n0"]["n1"]["id"] == "e0"
         assert G.nodes["n0"]["label"] == "1"
         assert G.nodes["n1"]["label"] == "2"
 
+        H = nx.parse_graphml(data, force_multigraph=True)
+        assert list(H.edges()) == [("n0", "n1")]
+        assert H.has_edge("n0", "n1", key="e0")
+        assert H.nodes["n0"]["label"] == "1"
+        assert H.nodes["n1"]["label"] == "2"
+
         H = nx.parse_graphml(data)
         assert list(H.edges()) == [("n0", "n1")]
         assert H["n0"]["n1"]["id"] == "e0"
@@ -1010,6 +1141,17 @@ class TestWriteGraphML(BaseGraphML):
         assert not H.edges["n0", "n1", 0]["special"]
         assert H.edges["n0", "n1", 1]["special"] == 0
 
+    def test_numpy_float(self):
+        np = pytest.importorskip("numpy")
+        wt = np.float(3.4)
+        G = nx.Graph([(1, 2, {"weight": wt})])
+        fd, fname = tempfile.mkstemp()
+        self.writer(G, fname)
+        H = nx.read_graphml(fname, node_type=int)
+        assert G._adj == H._adj
+        os.close(fd)
+        os.unlink(fname)
+
     def test_multigraph_to_graph(self):
         # test converting multigraph to graph if no parallel edges found
         G = nx.MultiGraph()
@@ -1018,17 +1160,19 @@ class TestWriteGraphML(BaseGraphML):
         self.writer(G, fname)
         H = nx.read_graphml(fname)
         assert not H.is_multigraph()
+        H = nx.read_graphml(fname, force_multigraph=True)
+        assert H.is_multigraph()
         os.close(fd)
         os.unlink(fname)
 
-    def test_numpy_float(self):
-        np = pytest.importorskip("numpy")
-        wt = np.float(3.4)
-        G = nx.Graph([(1, 2, {"weight": wt})])
+        # add a multiedge
+        G.add_edge("a", "b", "e-id")
         fd, fname = tempfile.mkstemp()
         self.writer(G, fname)
-        H = nx.read_graphml(fname, node_type=int)
-        assert G._adj == H._adj
+        H = nx.read_graphml(fname)
+        assert H.is_multigraph()
+        H = nx.read_graphml(fname, force_multigraph=True)
+        assert H.is_multigraph()
         os.close(fd)
         os.unlink(fname)