Repository: bokeh/bokeh

Issue:
[Feature Request] In the function ‘from_networkx’, convert NetworkX node/edge attributes to Bokeh node_renderer/edge_renderer data_source.
In the data structure of NetworkX, nodes and edges can have attributes.
However, node/edge attributes are not converted to Bokeh data source in `from_networkx`.

[In]
```Python
import matplotlib.pyplot as plt
import networkx as nx
# Prepare Data
node_datasource_for_nx = [(1, {'tag': 'feature', 'status': 'open', 'status_color': 'cyan', 'pr': 1}),
                          (2, {'tag': 'feature', 'status': 'close', 'status_color': 'gray'}),
                          (3, {'tag': 'bug', 'status': 'open', 'status_color': 'cyan'})]

edge_datasource_for_nx = [(1, 2, {'association_type': 'duplicated'}),
                          (1, 3, {'association_type': 'impact', 'importance': 5})]


G = nx.Graph()
G.add_nodes_from(node_datasource_for_nx)
G.add_edges_from(edge_datasource_for_nx)

print(G.nodes(data=True))
print(G.edges(data=True))

fig = plt.figure()
pos = nx.spring_layout(G)
nx.draw_networkx_nodes(G, pos=pos, node_color=[x[1]['status_color'] for x in node_datasource_for_nx], alpha=0.5)
nx.draw_networkx_labels(G, pos=pos)
nx.draw_networkx_edges(G, pos=pos)
plt.show()
```

[Out]
![prepare_data](https://user-images.githubusercontent.com/31801148/44341378-b4e02800-a4c2-11e8-97ae-a47388cca97a.png)

[In]
```Python
from bokeh.models.graphs import from_networkx
graph_renderer = from_networkx(G, nx.spring_layout)

graph_renderer.node_renderer.data_source
```

[Out]
![node_datasource_no_attribute](https://user-images.githubusercontent.com/31801148/44341742-c544d280-a4c3-11e8-9871-54edd155b480.png)

[In]
```Python
graph_renderer.edge_renderer.data_source
```

[Out]
![edge_datasource_no_attribute](https://user-images.githubusercontent.com/31801148/44341772-dd1c5680-a4c3-11e8-9b7a-e116b3c7eaf4.png)

I currently solve this in the following way.

```Python
from bokeh.models.sources import ColumnDataSource, CDSView
# Convert: NetworkX node attributes -> ColumnDataSource
node_dict = dict()
node_dict['index'] = list(G.nodes())

node_attr_keys = [attr_key for node in list(G.nodes(data=True)) for attr_key in node[1].keys() ]
node_attr_keys = list(set(node_attr_keys))

for attr_key in node_attr_keys:
    node_dict[attr_key] = [node_attr[attr_key] if attr_key in node_attr.keys() else None 
                           for node_key, node_attr in list(G.nodes(data=True))]
node_source = ColumnDataSource(node_dict)

# Set node attribute to Bokeh DataSource
graph_renderer.node_renderer.data_source = node_source
graph_renderer.node_renderer.view = CDSView(source=node_source)

# Convert: NetworkX edge attributes -> ColumnDataSource
edge_dict = dict()
edge_dict['start'] = [x[0] for x in G.edges(data=True)]
edge_dict['end'] = [x[1] for x in G.edges(data=True)]

edge_attr_keys = [attr_key for edge in list(G.edges(data=True)) for attr_key in edge[2].keys() ]
edge_attr_keys = list(set(edge_attr_keys))

for attr_key in edge_attr_keys:
    edge_dict[attr_key] = [edge_attr[attr_key] if attr_key in edge_attr.keys() else None 
                           for _, _, edge_attr in list(G.edges(data=True))]
edge_source = ColumnDataSource(edge_dict)
    
# Set edge attribute to Bokeh DataSource
graph_renderer.edge_renderer.data_source = edge_source
graph_renderer.edge_renderer.view = CDSView(source=edge_source)

graph_renderer.node_renderer.data_source
```

[Out]
![node_datasource_with_attributes](https://user-images.githubusercontent.com/31801148/44344128-d8a76c00-a4ca-11e8-97bd-5634531d138d.png)

[In]
```Python
graph_renderer.edge_renderer.data_source
```

[Out]
![edge_datasource_with_attributes](https://user-images.githubusercontent.com/31801148/44344118-cf1e0400-a4ca-11e8-9db3-bdbb486f0357.png)

However, it is painful to write the above code every time.
I think that it is general case to specify Bokeh's hover information and draw style by using node/edge attributes  (for example, specifying node colors with node attributes).
It is useful to be able to use the node/edge  attributes of NetworkX also in Bokeh.

[In]
```Python
from bokeh.io import show, output_notebook
from bokeh.models import Plot, Range1d, MultiLine, Circle, HoverTool

plot = Plot(plot_width=300, plot_height=300,
            x_range=Range1d(-1.1, 1.1), y_range=Range1d(-1.1, 1.1))

# !!! Specify colors with node attributes !!!
graph_renderer.node_renderer.glyph = Circle(size=15, 
                                            fill_color='status_color') 
graph_renderer.edge_renderer.glyph = MultiLine(line_color="#CCCCCC", 
                                               line_alpha=0.8, 
                                               line_width=1)

# !!! Hover the node attributes !!! 
node_hover = HoverTool(tooltips=[('Issue', '@index'),
                                 ('Tag', '@tag'),
                                 ('Status','@status')],)
plot.add_tools(node_hover)
plot.renderers.append(graph_renderer)

output_notebook()
show(plot)
```

[Out]
![node_hover](https://user-images.githubusercontent.com/31801148/44348001-4a37e800-a4d4-11e8-9fc4-8d51edb2e7c5.png)

# Feature request
## Expected result
In `from_networkx`, convert the attributes of nodes/edges as follows.   
1. NetworkX: [Graph.nodes](https://networkx.github.io/documentation/stable/reference/classes/generated/networkx.Graph.nodes.html) data -> Bokeh: [GraphRenderer](https://bokeh.pydata.org/en/stable/docs/reference/models/renderers.html).node_renderer.data_source.data
2. NetworkX: [Graph.edges](https://networkx.github.io/documentation/latest/reference/classes/generated/networkx.Graph.edges.html) data -> Bokeh: [GraphRenderer](https://bokeh.pydata.org/en/latest/docs/reference/models/renderers.html).edge_renderer.data_source.data

I think that this function can be realized by modifying the code as follows.

**[before]**   
https://github.com/bokeh/bokeh/blob/master/bokeh/models/graphs.py#L35-L77

**[after]**
```Python
def from_networkx(graph, layout_function, **kwargs):
    '''
        ...
    '''

    # inline import to prevent circular imports
    from bokeh.models.renderers import GraphRenderer
    from bokeh.models.graphs import StaticLayoutProvider

    # Handles nx 1.x vs 2.x data structure change
    # Convert node attributes
    node_dict = dict()
    node_dict['index'] = list(graph.nodes())
        
    node_attr_keys = [attr_key for node in list(graph.nodes(data=True)) for attr_key in node[1].keys()]
    node_attr_keys = list(set(node_attr_keys))


    for attr_key in node_attr_keys:
        node_dict[attr_key] = [node_attr[attr_key] if attr_key in node_attr.keys() else None 
                               for node_key, node_attr in list(graph.nodes(data=True))]
        
    # Convert edge attributes
    edge_dict = dict()
    edge_dict['start'] = [x[0] for x in graph.edges(data=True)]
    edge_dict['end'] = [x[1] for x in graph.edges(data=True)]

    edge_attr_keys = [attr_key for edge in list(graph.edges(data=True)) for attr_key in edge[2].keys()]
    edge_attr_keys = list(set(edge_attr_keys))

    for attr_key in edge_attr_keys:
        edge_dict[attr_key] = [edge_attr[attr_key] if attr_key in edge_attr.keys() else None 
                                   for _, _, edge_attr in list(graph.edges(data=True))]

    node_source = ColumnDataSource(data=node_dict)
    edge_source = ColumnDataSource(data=edge_dict)

    graph_renderer = GraphRenderer()
    graph_renderer.node_renderer.data_source.data = node_source.data
    graph_renderer.edge_renderer.data_source.data = edge_source.data

    graph_layout = layout_function(graph, **kwargs)
    graph_renderer.layout_provider = StaticLayoutProvider(graph_layout=graph_layout)

    return graph_renderer
```

**Note:**   
The above code is still incomplete. I think that it is necessary to consider how to handle it in the following case. (raising exceptions, etc.)
1. The case that nodes have a attribute named 'index'
2. The case that edges have a attribute named 'start' or 'end'


Discussion:
@komo-fr are you interested in working up a PR for this since you have already explored a potential solution? 

cc @canavandl any comments?
@bryevdv Thank you for comments. Yes, I would like to send a pull request if this specification is accepted.
@komo-fr great, I'd like to see if @canavandl can offer any quick thoughts, since he is the most familiar with this area of the codebase. If not it will be a little while before I can take a deeper look, but I look forward to collaborating on it with you.
@bryevdv
Thanks! 
I’m trying to send a PR to bokeh for the first time. Therefore, I don’t know well how to discuss the following problem written at the end of the first comment. (In the following cases, I confirmed that it was transformed into an unintended figure. If you request, I will post the code and results.)

> The above code is still incomplete. I think that it is necessary to consider how to handle it in the following case. (raising exceptions, etc.)
> 1. The case that nodes have a attribute named 'index'
> 2. The case that edges have a attribute named 'start' or 'end'

I am glad if you guide me about the next steps I should take.
Which of the following should I choose?

(1) First, send a PR with the contents written in the first comment, then discuss the handling of special attributes later.
(2) After discussing handling of special attributes, send a PR.
(3) Send a PR including provisional implementation corresponding to special attributes. (Raise an exception. Discuss on code base).
(4) Wait a little more reply from @bryevdv or @canavandl.
(5) Others 

It is (1) that I can respond the most quickly.
I can prepare the provisional implementation of (3), but I'm worried about whether it will be too big for the first PR.

Resolving patch:
diff --git a/bokeh/models/graphs.py b/bokeh/models/graphs.py
--- a/bokeh/models/graphs.py
+++ b/bokeh/models/graphs.py
@@ -48,6 +48,10 @@ def from_networkx(graph, layout_function, **kwargs):
         .. warning::
             Only two dimensional layouts are currently supported.
 
+        .. warning::
+            Node attributes labeled 'index' and edge attributes labeled 'start' or 'end' are ignored.
+            If you want to convert these attributes, please re-label them to other names.
+
         '''
 
         # inline import to prevent circular imports
@@ -55,17 +59,45 @@ def from_networkx(graph, layout_function, **kwargs):
         from ..models.graphs import StaticLayoutProvider
 
         # Handles nx 1.x vs 2.x data structure change
-        nodes = list(graph.nodes())
-        edges = list(graph.edges())
-
-        edges_start = [edge[0] for edge in edges]
-        edges_end = [edge[1] for edge in edges]
-
-        node_source = ColumnDataSource(data=dict(index=nodes))
-        edge_source = ColumnDataSource(data=dict(
-            start=edges_start,
-            end=edges_end
-        ))
+        # Convert node attributes
+        node_dict = dict()
+        node_attr_keys = [attr_key for node in list(graph.nodes(data=True))
+                          for attr_key in node[1].keys()]
+        node_attr_keys = list(set(node_attr_keys))
+
+        for attr_key in node_attr_keys:
+            node_dict[attr_key] = [node_attr[attr_key] if attr_key in node_attr.keys() else None
+                                   for _, node_attr
+                                   in graph.nodes(data=True)]
+
+        if 'index' in node_attr_keys:
+            from warnings import warn
+            warn("Converting node attributes labeled 'index' are skipped. "
+                 "If you want to convert these attributes, please re-label with other names.")
+
+        node_dict['index'] = list(graph.nodes())
+
+        # Convert edge attributes
+        edge_dict = dict()
+        edge_attr_keys = [attr_key for edge in graph.edges(data=True)
+                          for attr_key in edge[2].keys()]
+        edge_attr_keys = list(set(edge_attr_keys))
+
+        for attr_key in edge_attr_keys:
+            edge_dict[attr_key] = [edge_attr[attr_key] if attr_key in edge_attr.keys() else None
+                                   for _, _, edge_attr
+                                   in graph.edges(data=True)]
+
+        if 'start' in edge_attr_keys or 'end' in edge_attr_keys:
+            from warnings import warn
+            warn("Converting edge attributes labeled 'start' or 'end' are skipped. "
+                 "If you want to convert these attributes, please re-label them with other names.")
+
+        edge_dict['start'] = [x[0] for x in graph.edges()]
+        edge_dict['end'] = [x[1] for x in graph.edges()]
+
+        node_source = ColumnDataSource(data=node_dict)
+        edge_source = ColumnDataSource(data=edge_dict)
 
         graph_renderer = GraphRenderer()
         graph_renderer.node_renderer.data_source.data = node_source.data


Test patch:
diff --git a/bokeh/models/tests/test_graphs.py b/bokeh/models/tests/test_graphs.py
--- a/bokeh/models/tests/test_graphs.py
+++ b/bokeh/models/tests/test_graphs.py
@@ -1,6 +1,7 @@
 import networkx as nx
 import numpy as np
 from numpy.testing import assert_allclose
+import pytest
 
 from bokeh.models.graphs import StaticLayoutProvider, from_networkx
 
@@ -33,3 +34,53 @@ def test_from_networkx_method_with_kwargs():
     gl = renderer.layout_provider.graph_layout
     assert set(gl.keys()) == set([0,1,2,3])
     assert_allclose(gl[0], np.array([2., 0.]), atol=1e-7)
+
+def test_from_networkx_with_attributes():
+    G = nx.Graph()
+    G.add_nodes_from([(0, {"attr_1": "a", "attr_2": 10}),
+                      (1, {"attr_1": "b"}),
+                      (2, {"attr_1": "c", "attr_2": 30})])
+    G.add_edges_from([(0, 1, {"attr_1": "A"}),
+                      (0, 2, {"attr_1": "B", "attr_2": 10})])
+
+    renderer = from_networkx(G, nx.circular_layout)
+
+    assert renderer.node_renderer.data_source.data["index"] == [0, 1, 2]
+    assert renderer.node_renderer.data_source.data["attr_1"] == ["a", "b", "c"]
+    assert renderer.node_renderer.data_source.data["attr_2"] == [10, None, 30]
+
+    assert renderer.edge_renderer.data_source.data["start"] == [0, 0]
+    assert renderer.edge_renderer.data_source.data["end"] == [1, 2]
+    assert renderer.edge_renderer.data_source.data["attr_1"] == ["A", "B"]
+    assert renderer.edge_renderer.data_source.data["attr_2"] == [None, 10]
+
+def test_from_networkx_with_bad_attributes():
+    G = nx.Graph()
+    G.add_nodes_from([(0, {"index": "a", "attr_1": 10}),
+                      (1, {"index": "b", "attr_1": 20})])
+    G.add_edges_from([[0, 1]])
+
+    with pytest.warns(UserWarning):
+        renderer = from_networkx(G, nx.circular_layout)
+        assert renderer.node_renderer.data_source.data["index"] == [0, 1]
+        assert renderer.node_renderer.data_source.data["attr_1"] == [10, 20]
+
+    G = nx.Graph()
+    G.add_nodes_from([0, 1])
+    G.add_edges_from([(0, 1, {"start": "A", "attr_1": 10})])
+
+    with pytest.warns(UserWarning):
+        renderer = from_networkx(G, nx.circular_layout)
+        assert renderer.edge_renderer.data_source.data["start"] == [0]
+        assert renderer.edge_renderer.data_source.data["end"] == [1]
+        assert renderer.edge_renderer.data_source.data["attr_1"] == [10]
+
+    G = nx.Graph()
+    G.add_nodes_from([0, 1])
+    G.add_edges_from([(0, 1, {"end": "A", "attr_1": 10})])
+
+    with pytest.warns(UserWarning):
+        renderer = from_networkx(G, nx.circular_layout)
+        assert renderer.edge_renderer.data_source.data["start"] == [0]
+        assert renderer.edge_renderer.data_source.data["end"] == [1]
+        assert renderer.edge_renderer.data_source.data["attr_1"] == [10]