Repository: dask/dask

Issue:
to_graphviz overwriting argument and weird behavior
The function `to_graphviz ` overwrites the argument `function_attributes` and has weird label behavior: Some node is relabeled and colored red, but not the intended one.

Unfortunately I could not generate a minimal complete verifiable example, but I still want to share my output.

Input (wrong behavior):
```python
from dask.dot import to_graphviz
# func generated by other function
print(func)
dsk = df.__dask_graph__()
to_graphviz(
    dsk,
    function_attributes=func,
)
print(func)
```

Output: 

```python
{('astype-c6c98680075866d1ac62c371040d601f', 1): {'color': 'red'}, ('read-parquet-2f14274e079f28949ad46046620cf4a1', 1): {'color': 'red'}}
{('astype-c6c98680075866d1ac62c371040d601f', 1): {'color': 'red', 'label': 'read-parquet', 'shape': 'circle'}, ('read-parquet-2f14274e079f28949ad46046620cf4a1', 1): {'color': 'red', 'label': 'read-parquet', 'shape': 'circle'}}
```

Input (correct behavior):
```python
from dask.dot import to_graphviz
func = {('astype-c6c98680075866d1ac62c371040d601f', 1): {'color': 'red'}, ('read-parquet-2f14274e079f28949ad46046620cf4a1', 1): {'color': 'red'}}
print(func)
dsk = df.__dask_graph__()
to_graphviz(
    dsk,
    function_attributes=func,
)
print(func)
```

Output:
```python
{('astype-c6c98680075866d1ac62c371040d601f', 1): {'color': 'red'}, ('read-parquet-2f14274e079f28949ad46046620cf4a1', 1): {'color': 'red'}}
{('astype-c6c98680075866d1ac62c371040d601f', 1): {'color': 'red', 'label': 'astype', 'shape': 'circle'}, ('read-parquet-2f14274e079f28949ad46046620cf4a1', 1): {'color': 'red', 'label': 'read-parquet', 'shape': 'circle'}}
```

As you can see `func` is being overwritten, which is not nice, but also the wrong label is assigned when not directly assigning func.

Both `func` arguments are equal when comparing with `==`.

`to_graphviz` can be found [here](https://github.com/dask/dask/blob/master/dask/dot.py#L128).

I want to propose, that the argument gets copied before being modified.

Also it would be great, if someone has an idea why the labeling is different depending on how the `func` argument is created. There is probably a bug somewhere :D

Im using dask 2.12.0 on Anaconda for Windows.

Thank you!


Discussion:
Thanks for the report. If you are able to provide a minimal example we'd appreciate it. It'll make writing a test for this behavior easier.

It looks like we do mutate function_attributes in https://github.com/dask/dask/blob/fb3203a403ad4706ada753fb471237a49fc27e58/dask/dot.py#L161-L163. We should likely copy `function_attributes` if it's provided by the user (unless we're actually relying on it being mutated elsewhere, but we probably aren't).

> but also the wrong label is assigned when not directly assigning func.

I'm not sure why that would be without looking more closely.
Thank you for your fast response! I couldnt replicate it inside a minimal example, but only production code. It may be that something is wrong in the environment, but I tested a lot and I think the output above is pretty clear that something is wrong inside `to_graphviz`, but I might be wrong.
fyi: I think I found the bug. `attrs.setdefault` also changes `function_attributes`. So in further iterations all labels are set to the first one.

https://github.com/dask/dask/blob/7b4f90f0a708cbb97ebb498c78f7931d82ec99db/dask/dot.py#L163-L165
Yep found it and will hand in merge request :) I don't think you can test it though, because I still can't replicate it in a minimum environment. If you have an idea, that would be great

Resolving patch:
diff --git a/dask/dot.py b/dask/dot.py
--- a/dask/dot.py
+++ b/dask/dot.py
@@ -130,7 +130,7 @@ def to_graphviz(
     data_attributes=None,
     function_attributes=None,
     rankdir="BT",
-    graph_attr={},
+    graph_attr=None,
     node_attr=None,
     edge_attr=None,
     collapse_outputs=False,
@@ -141,6 +141,8 @@ def to_graphviz(
         data_attributes = {}
     if function_attributes is None:
         function_attributes = {}
+    if graph_attr is None:
+        graph_attr = {}
 
     graph_attr = graph_attr or {}
     graph_attr["rankdir"] = rankdir
@@ -158,7 +160,7 @@ def to_graphviz(
             func_name = name((k, "function")) if not collapse_outputs else k_name
             if collapse_outputs or func_name not in seen:
                 seen.add(func_name)
-                attrs = function_attributes.get(k, {})
+                attrs = function_attributes.get(k, {}).copy()
                 attrs.setdefault("label", key_split(k))
                 attrs.setdefault("shape", "circle")
                 g.node(func_name, **attrs)
@@ -171,7 +173,7 @@ def to_graphviz(
                 dep_name = name(dep)
                 if dep_name not in seen:
                     seen.add(dep_name)
-                    attrs = data_attributes.get(dep, {})
+                    attrs = data_attributes.get(dep, {}).copy()
                     attrs.setdefault("label", box_label(dep, verbose))
                     attrs.setdefault("shape", "box")
                     g.node(dep_name, **attrs)
@@ -187,7 +189,7 @@ def to_graphviz(
 
         if (not collapse_outputs or k_name in connected) and k_name not in seen:
             seen.add(k_name)
-            attrs = data_attributes.get(k, {})
+            attrs = data_attributes.get(k, {}).copy()
             attrs.setdefault("label", box_label(k, verbose))
             attrs.setdefault("shape", "box")
             g.node(k_name, **attrs)


Test patch:
diff --git a/dask/tests/test_dot.py b/dask/tests/test_dot.py
--- a/dask/tests/test_dot.py
+++ b/dask/tests/test_dot.py
@@ -3,6 +3,7 @@
 import re
 from operator import add, neg
 import sys
+import copy
 import pytest
 
 
@@ -271,3 +272,21 @@ def f(x, y=True):
     label = task_label(x.dask[x.key])
     assert "f" in label
     assert "apply" not in label
+
+
+def test_immutable_attributes():
+    def inc(x):
+        return x + 1
+
+    dsk = {"a": (inc, 1), "b": (inc, 2), "c": (add, "a", "b")}
+    attrs_func = {"a": {}}
+    attrs_data = {"b": {}}
+    attrs_func_test = copy.deepcopy(attrs_func)
+    attrs_data_test = copy.deepcopy(attrs_data)
+
+    to_graphviz(
+        dsk, function_attributes=attrs_func, data_attributes=attrs_data,
+    )
+
+    assert attrs_func_test == attrs_func
+    assert attrs_data_test == attrs_data