Repository: networkx/networkx

Issue:
`relabel_nodes` does not work for callables with `__getitem__`
<!-- If you have a general question about NetworkX, please use the discussions tab to create a new discussion -->

<!--- Provide a general summary of the issue in the Title above -->

`relabel_nodes` accepts mappings in the form of functions. It differentiates function from mapping by testing`not hasattr(mapping, "__getitem__")`. This test is ambiguous for callables that do possess '__getitem__`, e.g. `str`.

Would it be better to test `isinstance(mapping, typing.Mapping)` and document accordingly?

### Current Behavior
<!--- Tell us what happens instead of the expected behavior -->
```python
import networkx as nx

G = nx.DiGraph()
G.add_nodes_from([1, 2])

# works
# nx.relabel_nodes(G, lambda x: str(x))

# error
nx.relabel_nodes(G, str)
```

```
Traceback (most recent call last):
  File "...\networkx_2_8_4_relabel_callable.py", line 10, in <module>
    nx.relabel_nodes(G, str)
  File "...\lib\site-packages\networkx\relabel.py", line 121, in relabel_nodes
    return _relabel_copy(G, m)
  File "...\lib\site-packages\networkx\relabel.py", line 193, in _relabel_copy
    H.add_nodes_from(mapping.get(n, n) for n in G)
  File "...\lib\site-packages\networkx\classes\digraph.py", line 519, in add_nodes_from
    for n in nodes_for_adding:
  File "...\lib\site-packages\networkx\relabel.py", line 193, in <genexpr>
    H.add_nodes_from(mapping.get(n, n) for n in G)
AttributeError: type object 'str' has no attribute 'get'
```

### Expected Behavior
<!--- Tell us what should happen -->
`relabel_nodes` works for callables.

### Steps to Reproduce
<!--- Provide a minimal example that reproduces the bug -->
See current behavior.

### Environment
<!--- Please provide details about your local environment -->
Python version: 3.9
NetworkX version: 2.8.5


### Additional context
<!--- Add any other context about the problem here, screenshots, etc. -->



Discussion:
This is a good point.  We want to identify if something has attribute `__getitem__` (which could be a dict/list/etc) or if they have attribute `__call__` (any function, class, many other objects). But what do we choose to do when they can do both?  In terms of abc classes they are a Callable Sequence or Mapping.  Our code prioritizes mappings specified via lookup over callables. So if something can do both, we use it for lookup rather than calling.  Your suggestion would prioritize calling over lookup.  I'm not sure I can argue for which is better. lookup-first preserves backward compatibility.

But your example shows that it is even more complicated than the story above because classes have method-attributes even though they are not bound methods. In your example `str.__getitem__` exists, but is a class method requiring an instance before lookup is possible. It is not bound to an instance. This suggests to me that our check for `__getitem__` could be improved. The idea is to check for an attribute `__getitem__` first, but to also make sure it is a bound method. Bound methods are indicated by the `__self__` attribute.

So we could change the check for `__getitem__` to a double-hasattr check:
```python
# check for bound method __getitem__
if not (hasattr(mapping, "__getitem__") and hasattr(mapping.__getitem__, "__self__"))
```

Resolving patch:
diff --git a/networkx/relabel.py b/networkx/relabel.py
--- a/networkx/relabel.py
+++ b/networkx/relabel.py
@@ -114,9 +114,13 @@ def relabel_nodes(G, mapping, copy=True):
     --------
     convert_node_labels_to_integers
     """
-    # you can pass a function f(old_label)->new_label
+    # you can pass a function f(old_label) -> new_label
+    # or a class e.g. str(old_label) -> new_label
     # but we'll just make a dictionary here regardless
-    if not hasattr(mapping, "__getitem__"):
+    # To allow classes, we check if __getitem__ is a bound method using __self__
+    if not (
+        hasattr(mapping, "__getitem__") and hasattr(mapping.__getitem__, "__self__")
+    ):
         m = {n: mapping(n) for n in G}
     else:
         m = mapping


Test patch:
diff --git a/networkx/tests/test_relabel.py b/networkx/tests/test_relabel.py
--- a/networkx/tests/test_relabel.py
+++ b/networkx/tests/test_relabel.py
@@ -106,6 +106,12 @@ def mapping(n):
         H = nx.relabel_nodes(G, mapping)
         assert nodes_equal(H.nodes(), [65, 66, 67, 68])
 
+    def test_relabel_nodes_classes(self):
+        G = nx.empty_graph()
+        G.add_edges_from([(0, 1), (0, 2), (1, 2), (2, 3)])
+        H = nx.relabel_nodes(G, str)
+        assert nodes_equal(H.nodes, ["0", "1", "2", "3"])
+
     def test_relabel_nodes_graph(self):
         G = nx.Graph([("A", "B"), ("A", "C"), ("B", "C"), ("C", "D")])
         mapping = {"A": "aardvark", "B": "bear", "C": "cat", "D": "dog"}