Repository: networkx/networkx

Issue:
Add more useful ```__str__``` function for Graph objects
Currently the ```__str___``` function prints the ```G.name``` property which is ```G.graph["name"]```.
That is pretty rarely useful in my experience (often a blank string).

There is useful information in nx.info(G) and much less of it than there once was. Perhaps it would be reasonable to look at making something like ```nx.info(G)``` show for ```__str__```.  Are there reasons to avoid doing this? What implications does it have for ```__repr__```?  Issue #3956 requested a useful ```__repr__``` and so is related to this.


Discussion:
It would be wise to include the name of the graph in case it exists. Some additional information such as the number of nodes and edges as well as the type of graph can also be included in a format such as `DiGraph(name:"asdf",nodes:3,edges:2)`
> Are there reasons to avoid doing this?

Not a reason to avoid it, but something to consider: `nx.info` includes the number_of_edges and the average degree calculation, which *might* take a long time for some graphs? e.g. 

```python
>>> G = nx.path_graph(int(1e7))
>>> %timeit nx.info(G)
4.4 s ± 47.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
```

N.B. @dschult I think you raised this concern before - I'm simply adding here for visibility!
I'd like to get into this, but I have a question: is there any particular reason that `G.number_of_edges` (or really, `G.size`) is calculated on demand and not stored as an attribute on `Graph` and maintained whenever edges are added/removed to facilitate O(1) lookups?

 I think @parth-verma's suggestion above is perfect, but `__str__` should run in constant time.
> is there any particular reason that G.number_of_edges (or really, G.size) is calculated on demand and not stored as an attribute on Graph and maintained whenever edges are added/removed to facilitate O(1) lookups?

I think the advantage of the property is that the class/user does not have to explicitly manage state. If the e.g. `number_of_edges` were an attribute instead of a property of the graph, then all methods for adding/removing edges would have to update the value explicitly on modification which in principle 1) makes the modification itself slightly more expensive (a minor concern) and 2) makes it very easy to introduce subtle bugs (compare this situation to ref counting for example).

Resolving patch:
diff --git a/networkx/classes/function.py b/networkx/classes/function.py
--- 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
--- a/networkx/classes/graph.py
+++ b/networkx/classes/graph.py
@@ -370,20 +370,31 @@ def name(self, s):
         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/classes/tests/historical_tests.py b/networkx/classes/tests/historical_tests.py
--- a/networkx/classes/tests/historical_tests.py
+++ b/networkx/classes/tests/historical_tests.py
@@ -21,7 +21,6 @@ def setup_class(cls):
 
     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
--- a/networkx/classes/tests/test_graph.py
+++ b/networkx/classes/tests/test_graph.py
@@ -182,9 +182,18 @@ def test_name(self):
         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)