Repository: networkx/networkx

Issue:
Issue with networkx.has_eulerian_path()
This issue adds to #3976 

There appears to be a problem with the `has_eulerian_path` function. It returns the wrong answer on this example:
```
test_graph = nx.DiGraph()
test_graph.add_edges_from([(0, 1), (1,2), (0,2)])
print(nx.has_eulerian_path(test_graph))
print(list(nx.eulerian_path(test_graph)))
```
Output:
```
True
[(0, 0), (0, 1), (1, 2)]
```

This directed graph does not have a Eulerian path, and, adding to #3976, the path returned by `eulerian_path` is not an actual Eulerian path, since it (1) does not include the edge `(0,2)` and (2) includes a self-loop `(0,0)` that is not in the graph.

My hunch is that the problem is in this line: https://github.com/networkx/networkx/blob/3351206a3ce5b3a39bb2fc451e93ef545b96c95b/networkx/algorithms/euler.py#L256

I think changing that `2` to be `semibalanced_ins+semibalanced_outs` may do the trick, since the definition is
>"...every _other_ vertex has equal in\_degree and out\_degree..." 

Going to see about fixing and adding this test. Appreciate any clues, ideas or tips.


Comments:

Patch:
diff --git a/networkx/algorithms/centrality/betweenness.py b/networkx/algorithms/centrality/betweenness.py
index 0829d9b0b..6907f25b1 100644
--- a/networkx/algorithms/centrality/betweenness.py
+++ b/networkx/algorithms/centrality/betweenness.py
@@ -101,20 +101,20 @@ def betweenness_centrality(
     .. [1] Ulrik Brandes:
        A Faster Algorithm for Betweenness Centrality.
        Journal of Mathematical Sociology 25(2):163-177, 2001.
-       http://www.inf.uni-konstanz.de/algo/publications/b-fabc-01.pdf
+       https://doi.org/10.1080/0022250X.2001.9990249
     .. [2] Ulrik Brandes:
        On Variants of Shortest-Path Betweenness
        Centrality and their Generic Computation.
        Social Networks 30(2):136-145, 2008.
-       http://www.inf.uni-konstanz.de/algo/publications/b-vspbc-08.pdf
+       https://doi.org/10.1016/j.socnet.2007.11.001
     .. [3] Ulrik Brandes and Christian Pich:
        Centrality Estimation in Large Networks.
        International Journal of Bifurcation and Chaos 17(7):2303-2318, 2007.
-       http://www.inf.uni-konstanz.de/algo/publications/bp-celn-06.pdf
+       https://dx.doi.org/10.1142/S0218127407018403
     .. [4] Linton C. Freeman:
        A set of measures of centrality based on betweenness.
        Sociometry 40: 35–41, 1977
-       http://moreno.ss.uci.edu/23.pdf
+       https://doi.org/10.2307/3033543
     """
     betweenness = dict.fromkeys(G, 0.0)  # b[v]=0 for v in G
     if k is None:
diff --git a/networkx/algorithms/euler.py b/networkx/algorithms/euler.py
index 6f8283f27..52042580a 100644
--- a/networkx/algorithms/euler.py
+++ b/networkx/algorithms/euler.py
@@ -224,7 +224,7 @@ def has_eulerian_path(G):
         - at most one vertex has in_degree - out_degree = 1,
         - every other vertex has equal in_degree and out_degree,
         - and all of its vertices with nonzero degree belong to a
-        - single connected component of the underlying undirected graph.
+          single connected component of the underlying undirected graph.
 
     An undirected graph has an Eulerian path iff:
         - exactly zero or two vertices have odd degree,
@@ -246,16 +246,28 @@ def has_eulerian_path(G):
     eulerian_path
     """
     if G.is_directed():
+        # Remove isolated nodes (if any) without altering the input graph
+        nodes_remove = [v for v in G if G.in_degree[v] == 0 and G.out_degree[v] == 0]
+        if nodes_remove:
+            G = G.copy()
+            G.remove_nodes_from(nodes_remove)
+
         ins = G.in_degree
         outs = G.out_degree
-        semibalanced_ins = sum(ins(v) - outs(v) == 1 for v in G)
-        semibalanced_outs = sum(outs(v) - ins(v) == 1 for v in G)
+        unbalanced_ins = 0
+        unbalanced_outs = 0
+        for v in G:
+            if ins[v] - outs[v] == 1:
+                unbalanced_ins += 1
+            elif outs[v] - ins[v] == 1:
+                unbalanced_outs += 1
+            elif ins[v] != outs[v]:
+                return False
+
         return (
-            semibalanced_ins <= 1
-            and semibalanced_outs <= 1
-            and sum(G.in_degree(v) != G.out_degree(v) for v in G) <= 2
-            and nx.is_weakly_connected(G)
+            unbalanced_ins <= 1 and unbalanced_outs <= 1 and nx.is_weakly_connected(G)
         )
+
     else:
         return sum(d % 2 == 1 for v, d in G.degree()) in (0, 2) and nx.is_connected(G)
 
diff --git a/networkx/algorithms/minors.py b/networkx/algorithms/minors.py
index 336fe254f..7958bceca 100644
--- a/networkx/algorithms/minors.py
+++ b/networkx/algorithms/minors.py
@@ -445,37 +445,36 @@ def contracted_nodes(G, u, v, self_loops=True, copy=True):
     not be the same as the edge keys for the old edges. This is
     natural because edge keys are unique only within each pair of nodes.
 
+    This function is also available as `identified_nodes`.
+
     Examples
     --------
     Contracting two nonadjacent nodes of the cycle graph on four nodes `C_4`
     yields the path graph (ignoring parallel edges)::
 
-        >>> G = nx.cycle_graph(4)
-        >>> M = nx.contracted_nodes(G, 1, 3)
-        >>> P3 = nx.path_graph(3)
-        >>> nx.is_isomorphic(M, P3)
-        True
-
-        >>> G = nx.MultiGraph(P3)
-        >>> M = nx.contracted_nodes(G, 0, 2)
-        >>> M.edges
-        MultiEdgeView([(0, 1, 0), (0, 1, 1)])
-
-        >>> G = nx.Graph([(1, 2), (2, 2)])
-        >>> H = nx.contracted_nodes(G, 1, 2, self_loops=False)
-        >>> list(H.nodes())
-        [1]
-        >>> list(H.edges())
-        [(1, 1)]
-
-    See also
+    >>> G = nx.cycle_graph(4)
+    >>> M = nx.contracted_nodes(G, 1, 3)
+    >>> P3 = nx.path_graph(3)
+    >>> nx.is_isomorphic(M, P3)
+    True
+
+    >>> G = nx.MultiGraph(P3)
+    >>> M = nx.contracted_nodes(G, 0, 2)
+    >>> M.edges
+    MultiEdgeView([(0, 1, 0), (0, 1, 1)])
+
+    >>> G = nx.Graph([(1, 2), (2, 2)])
+    >>> H = nx.contracted_nodes(G, 1, 2, self_loops=False)
+    >>> list(H.nodes())
+    [1]
+    >>> list(H.edges())
+    [(1, 1)]
+
+    See Also
     --------
     contracted_edge
     quotient_graph
 
-    Notes
-    -----
-    This function is also available as `identified_nodes`.
     """
     # Copying has significant overhead and can be disabled if needed
     if copy:
diff --git a/networkx/classes/function.py b/networkx/classes/function.py
index d17e22f60..700cac158 100644
--- a/networkx/classes/function.py
+++ b/networkx/classes/function.py
@@ -576,23 +576,14 @@ def info(G, n=None):
 
     """
     if n is None:
-        n_nodes = G.number_of_nodes()
-        n_edges = G.number_of_edges()
-        return "".join(
-            [
-                type(G).__name__,
-                f" named '{G.name}'" if G.name else "",
-                f" with {n_nodes} nodes and {n_edges} edges",
-            ]
-        )
-    else:
-        if n not in G:
-            raise nx.NetworkXError(f"node {n} not in graph")
-        info = ""  # append this all to a string
-        info += f"Node {n} has the following properties:\n"
-        info += f"Degree: {G.degree(n)}\n"
-        info += "Neighbors: "
-        info += " ".join(str(nbr) for nbr in G.neighbors(n))
+        return str(G)
+    if n not in G:
+        raise nx.NetworkXError(f"node {n} not in graph")
+    info = ""  # append this all to a string
+    info += f"Node {n} has the following properties:\n"
+    info += f"Degree: {G.degree(n)}\n"
+    info += "Neighbors: "
+    info += " ".join(str(nbr) for nbr in G.neighbors(n))
     return info
 
 
diff --git a/networkx/classes/graph.py b/networkx/classes/graph.py
index ae34047bc..8629ee808 100644
--- a/networkx/classes/graph.py
+++ b/networkx/classes/graph.py
@@ -370,20 +370,31 @@ class Graph:
         self.graph["name"] = s
 
     def __str__(self):
-        """Returns the graph name.
+        """Returns a short summary of the graph.
 
         Returns
         -------
-        name : string
-            The name of the graph.
+        info : string
+            Graph information as provided by `nx.info`
 
         Examples
         --------
         >>> G = nx.Graph(name="foo")
         >>> str(G)
-        'foo'
+        "Graph named 'foo' with 0 nodes and 0 edges"
+
+        >>> G = nx.path_graph(3)
+        >>> str(G)
+        'Graph with 3 nodes and 2 edges'
+
         """
-        return self.name
+        return "".join(
+            [
+                type(self).__name__,
+                f" named '{self.name}'" if self.name else "",
+                f" with {self.number_of_nodes()} nodes and {self.number_of_edges()} edges",
+            ]
+        )
 
     def __iter__(self):
         """Iterate over the nodes. Use: 'for n in G'.


Test patch:
diff --git a/networkx/algorithms/tests/test_euler.py b/networkx/algorithms/tests/test_euler.py
index f136ab0ff..4a0905bc8 100644
--- a/networkx/algorithms/tests/test_euler.py
+++ b/networkx/algorithms/tests/test_euler.py
@@ -134,6 +134,22 @@ class TestHasEulerianPath:
         G = nx.path_graph(6, create_using=nx.DiGraph)
         assert nx.has_eulerian_path(G)
 
+    def test_has_eulerian_path_directed_graph(self):
+        # Test directed graphs and returns False
+        G = nx.DiGraph()
+        G.add_edges_from([(0, 1), (1, 2), (0, 2)])
+        assert not nx.has_eulerian_path(G)
+
+    def test_has_eulerian_path_isolated_node(self):
+        # Test directed graphs without isolated node returns True
+        G = nx.DiGraph()
+        G.add_edges_from([(0, 1), (1, 2), (2, 0)])
+        assert nx.has_eulerian_path(G)
+
+        # Test directed graphs with isolated node returns True
+        G.add_node(3)
+        assert nx.has_eulerian_path(G)
+
 
 class TestFindPathStart:
     def testfind_path_start(self):
diff --git a/networkx/classes/tests/historical_tests.py b/networkx/classes/tests/historical_tests.py
index 8f53c4c53..c19396add 100644
--- a/networkx/classes/tests/historical_tests.py
+++ b/networkx/classes/tests/historical_tests.py
@@ -21,7 +21,6 @@ class HistoricalTests:
 
     def test_name(self):
         G = self.G(name="test")
-        assert str(G) == "test"
         assert G.name == "test"
         H = self.G()
         assert H.name == ""
diff --git a/networkx/classes/tests/test_graph.py b/networkx/classes/tests/test_graph.py
index 03902b092..e90f99510 100644
--- a/networkx/classes/tests/test_graph.py
+++ b/networkx/classes/tests/test_graph.py
@@ -182,9 +182,18 @@ class BaseAttrGraphTester(BaseGraphTester):
         G = self.Graph(name="")
         assert G.name == ""
         G = self.Graph(name="test")
-        assert G.__str__() == "test"
         assert G.name == "test"
 
+    def test_str_unnamed(self):
+        G = self.Graph()
+        G.add_edges_from([(1, 2), (2, 3)])
+        assert str(G) == f"{type(G).__name__} with 3 nodes and 2 edges"
+
+    def test_str_named(self):
+        G = self.Graph(name="foo")
+        G.add_edges_from([(1, 2), (2, 3)])
+        assert str(G) == f"{type(G).__name__} named 'foo' with 3 nodes and 2 edges"
+
     def test_graph_chain(self):
         G = self.Graph([(0, 1), (1, 2)])
         DG = G.to_directed(as_view=True)