Repository: networkx/networkx

Issue:
Inconsistent implementation of Euler algorithms
According to my research, having an Euler Circuit and/or Euler Path does not require a graph to be connected. Instead, it is enough that all of its vertices **with nonzero degree** belong to a single connected component. Nevertheless, there are some resources which states that a graph can have Euler Path and/or Euler Circuit  iff it is connected.

In the doc_string, it is stated that `has_eulerian_path` method follows the first opinion. 
```
A directed graph has an Eulerian path iff:
- at most one vertex has out_degree - in_degree = 1,
- 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.

An undirected graph has an Eulerian path iff:
- exactly zero or two vertices have odd degree,
- and all of its vertices with nonzero degree belong to a single connected component.
```

However, when we look at the source code, we see that it weirdly requires connectedness for undirected graphs but not for directed ones. Here is the related part:

```
  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)
```
If graph is directed, it removes the isolated nodes. But at the last part, it checks for connectedness both for directed (already removed isolated ones) and undirected graphs.

```
return (
            unbalanced_ins <= 1 and unbalanced_outs <= 1 and nx.is_weakly_connected(G)
        )
else:
    # We know it is not eulerian, so degree of source must be odd.
    if source is not None and G.degree[source] % 2 != 1:
        return False

    # Sum is 2 since we know it is not eulerian (which implies sum is 0)
    return sum(d % 2 == 1 for v, d in G.degree()) == 2 and nx.is_connected(G)
```
As a result, following examples return different results:

```
>>> G = nx.DiGraph([(0, 1), (1, 2), (2, 0)])
>>> G.add_node(3)
>>> nx.draw(G)
>>> nx.has_eulerian_path(G)
True
```
![image](https://user-images.githubusercontent.com/79102788/166156024-a534f467-beb5-42f1-9caa-64d275c6f65f.png)
```
>>> G = nx.Graph([(0, 1), (1, 2), (0, 2)])
>>> G.add_node(3)
>>> nx.draw(G)
>>> nx.has_eulerian_path(G)
False
```
![image](https://user-images.githubusercontent.com/79102788/166155621-738726bd-ef3f-413a-8543-923065724b3c.png)


On the other hand, `is_eulerian` method requires connectedness for both graph types. IMO, there is an incosistency in the source code. If the first approach is to be accepted, it should be implemented both for Euler Paths and Euler Circuits since an Euler Circuit is nothing but an Euler Path that starts and ends at the same vertex. If you think that connectedness must be a condition, it should be implemented for both directed and undirected graph types.

@rossbar @dschult @MridulS 


Discussion:
I looked at the "blame" history of this file (available from a button at the top right of the "view file" screen which is available at the upper right of each file on the PRs diff page).
From the beginning, we have defined eulerian and the eulerian_circuit code to rule out isolated nodes. That is the eulerian circuit must visit all nodes and cross all edges.

In PR #3399 we added functions for `eulerian_path` where the doc_string says the non-isolated nodes must form a connected component, but the code itself only checked for a connected graph -- meaning that the isolated nodes were ruled out.

Then in PR #4246 we made some changes to allow a keyword argument `source` and the code with that change added a line to remove the isolated nodes from G in the directed case only -- probably to make that part of the code agree with the doc_strings.

I agree with your assessment. The code in these functions is inconsistent between directed and undirected and also does not agree with the doc_strings.  It appears that the eulerian_circuit code is consistent, but does not agree with the how the eulerian+path (sometimes) handles isolated nodes.

We need to decide whether we want to rule out isolated nodes from our definitions of eulerian...

Most definitions seem to allow isolated nodes. Our original docs and code ruled out isolated nodes. And most of our functions continue to work with way. The doc_string for eulerian_path does not, but the code does for undirected graphs. The only code that doesn't is for directed graphs.

Why would you want to rule out isolated nodes? It means you don't have to check for and remove isolated nodes for every call to one of these eulerian functions. And since isolated nodes are not really relevant for the concept of eulerian, users can safely remove them before investigating the eulerian nature of the graph. So, if we include a note in the doc_strings alerting the user, it should work fine.  And in our case, backward compatibility for most of our routines calls for handling isolated nodes as *not* eulerian. But this is probably not a highly used scenario -- so the backward compatibility is likely to not be an issue.

Why would you not want to rule out isolated nodes? The concept of eulerian is about edges, not nodes. So it is cleaner from a theoretical perspective to allow isolated nodes so you can ignore whether they are there or not. This argument fails when you realize that you want to rule out graphs that have two components each of which has an eulerian circuit. Then you need the graph to have a single connected component (for all nodes of nonzero degree).  So, in fact you can't ignore nodes completely.

Thoughts? 

After re-reading this with a couple days for perspective, I think we should stick with excluding isolated nodes from our definition of eulerian circuit and eulerian path. This should be well documented in each function, with an example in some main function(s) of how to remove isolated nodes for people who want the other definition.

The desire to allow isolated nodes is driven by a mistaken notion that we can then avoid considering nodes — only edges. That view is mistaken because we need to ensure that the non-isolated nodes form a single component. So you do have to consider the nodes when you exclude them from the single component part of the definition.  I feel it is cleaner to use the definition that disallows isolated nodes, and provide info on how to pre-process a graph if users want to allow isolated nodes.  This choice also provides better backward compatibility than other options to make our code consistent.

So, what needs to be done:
- change the path docstring(s) to make it clear that we don’t consider graphs with isolated nodes to have an eulerian path.
- Provide an example in key functions for how to remove isolated nodes if desired `G.remove_nodes_from(list(nx.isolates(G))`  
- Change the directed code within eulerian_path to no longer remove isolated nodes.
- Add a release note that this behavior has changed (in 1st doc/release/release_dev.rst`)

Will this make the module consistent in its handling of isolated nodes? Am I missing anything?
Thank you, @dschult I am starting to work on the sub-tasks you stated. Meanwhile, I will also continue to check this thread to see if any addition/change is made with respect to what needs to be done.

Resolving patch:
diff --git a/networkx/algorithms/euler.py b/networkx/algorithms/euler.py
--- a/networkx/algorithms/euler.py
+++ b/networkx/algorithms/euler.py
@@ -23,6 +23,11 @@ def is_eulerian(G):
     circuit* is a closed walk that includes each edge of a graph exactly
     once.
 
+    Graphs with isolated vertices (i.e. vertices with zero degree) are not
+    considered to have Eulerian circuits. Therefore, if the graph is not
+    connected (or not strongly connected, for directed graphs), this function
+    returns False.
+
     Parameters
     ----------
     G : NetworkX graph
@@ -37,10 +42,18 @@ def is_eulerian(G):
     >>> nx.is_eulerian(nx.petersen_graph())
     False
 
-    Notes
-    -----
-    If the graph is not connected (or not strongly connected, for
-    directed graphs), this function returns False.
+    If you prefer to allow graphs with isolated vertices to have Eulerian circuits,
+    you can first remove such vertices and then call `is_eulerian` as below example shows.
+
+    >>> G = nx.Graph([(0, 1), (1, 2), (0, 2)])
+    >>> G.add_node(3)
+    >>> nx.is_eulerian(G)
+    False
+
+    >>> G.remove_nodes_from(list(nx.isolates(G)))
+    >>> nx.is_eulerian(G)
+    True
+
 
     """
     if G.is_directed():
@@ -58,6 +71,11 @@ def is_semieulerian(G):
     """Return True iff `G` is semi-Eulerian.
 
     G is semi-Eulerian if it has an Eulerian path but no Eulerian circuit.
+
+    See Also
+    --------
+    has_eulerian_path
+    is_eulerian
     """
     return has_eulerian_path(G) and not is_eulerian(G)
 
@@ -224,8 +242,8 @@ def has_eulerian_path(G, source=None):
         - at most one vertex has out_degree - in_degree = 1,
         - 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.
+        - and all of its vertices belong to a single connected
+          component of the underlying undirected graph.
 
     If `source` is not None, an Eulerian path starting at `source` exists if no
     other node has out_degree - in_degree = 1. This is equivalent to either
@@ -234,13 +252,16 @@ def has_eulerian_path(G, source=None):
 
     An undirected graph has an Eulerian path iff:
         - exactly zero or two vertices have odd degree,
-        - and all of its vertices with nonzero degree belong to a
-        - single connected component.
+        - and all of its vertices belong to a single connected component.
 
     If `source` is not None, an Eulerian path starting at `source` exists if
     either there exists an Eulerian circuit or `source` has an odd degree and the
     conditions above hold.
 
+    Graphs with isolated vertices (i.e. vertices with zero degree) are not considered
+    to have an Eulerian path. Therefore, if the graph is not connected (or not strongly
+    connected, for directed graphs), this function returns False.
+
     Parameters
     ----------
     G : NetworkX Graph
@@ -253,6 +274,20 @@ def has_eulerian_path(G, source=None):
     -------
     Bool : True if G has an Eulerian path.
 
+    Example
+    -------
+    If you prefer to allow graphs with isolated vertices to have Eulerian path,
+    you can first remove such vertices and then call `has_eulerian_path` as below example shows.
+
+    >>> G = nx.Graph([(0, 1), (1, 2), (0, 2)])
+    >>> G.add_node(3)
+    >>> nx.has_eulerian_path(G)
+    False
+
+    >>> G.remove_nodes_from(list(nx.isolates(G)))
+    >>> nx.has_eulerian_path(G)
+    True
+
     See Also
     --------
     is_eulerian
@@ -262,11 +297,6 @@ def has_eulerian_path(G, source=None):
         return True
 
     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
         # Since we know it is not eulerian, outs - ins must be 1 for source


Test patch:
diff --git a/networkx/algorithms/tests/test_euler.py b/networkx/algorithms/tests/test_euler.py
--- a/networkx/algorithms/tests/test_euler.py
+++ b/networkx/algorithms/tests/test_euler.py
@@ -140,15 +140,14 @@ def test_has_eulerian_path_directed_graph(self):
         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
+        # Test directed graphs with isolated node returns False
         G.add_node(3)
-        assert nx.has_eulerian_path(G)
+        assert not nx.has_eulerian_path(G)
 
     @pytest.mark.parametrize("G", (nx.Graph(), nx.DiGraph()))
     def test_has_eulerian_path_not_weakly_connected(self, G):