Repository: plotly/plotly.py

Issue:
Formatting and hiding hover_data in PX
Let's implement the API from https://github.com/plotly/plotly_express/issues/115 and support `False` as a value so as to support https://github.com/plotly/plotly_express/issues/120.


Discussion:
For the format of `hover_data`, would you prefer 
- accepting a dict, in which case all keys must have a value, eg `hover_data={'species':None, 'petal_length':'%s"}` 
- or keeping a list, and the element of the list can be either column names  / columns / arrays, or tuples, eg `hover_data=[('species', None), 'petal_length', ('petal_width', '%03d')]` etc.
Ah, you mean how should a user be able to specify the formatting for some `hover_data` items but leave some as a default?
yes
Hmmm. And we need some pythonic way of distinguishing between "use the defaults" and "suppress this one"... Is `None` vs `False` clear enough? They're both falsey... `""` has the same problem I think.
yes, this is why I was thinking of keeping a list but allowing tuple elements
Is this a common pattern in Python or... ? It makes sense but I've not seen it before.
hum, not really... let me think this over
I actually think saying "using `""` as a `dict` value will use the default formatting" is OK. That's how  plotly.js will treat it under the hood anyways, as far as I can tell

so `None` and `False` would suppress it and `""` would not do any formatting. And/or we could add a `px.DEFAULT` magic-constant.
Any updates on this? I'm searching for a nice way to suppress certain items and keep redirecting back to this open issue.
This is not yet implemented, no but we'd happily accept a PR :)

Resolving patch:
diff --git a/packages/python/plotly/plotly/express/_core.py b/packages/python/plotly/plotly/express/_core.py
--- a/packages/python/plotly/plotly/express/_core.py
+++ b/packages/python/plotly/plotly/express/_core.py
@@ -290,7 +290,10 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref):
                     go.Histogram2d,
                     go.Histogram2dContour,
                 ]:
+                    hover_is_dict = isinstance(attr_value, dict)
                     for col in attr_value:
+                        if hover_is_dict and not attr_value[col]:
+                            continue
                         try:
                             position = args["custom_data"].index(col)
                         except (ValueError, AttributeError, KeyError):
@@ -387,7 +390,20 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref):
         go.Parcoords,
         go.Parcats,
     ]:
-        hover_lines = [k + "=" + v for k, v in mapping_labels.items()]
+        # Modify mapping_labels according to hover_data keys
+        # if hover_data is a dict
+        mapping_labels_copy = OrderedDict(mapping_labels)
+        if args["hover_data"] and isinstance(args["hover_data"], dict):
+            for k, v in mapping_labels.items():
+                if k in args["hover_data"]:
+                    if args["hover_data"][k][0]:
+                        if isinstance(args["hover_data"][k][0], str):
+                            mapping_labels_copy[k] = v.replace(
+                                "}", "%s}" % args["hover_data"][k][0]
+                            )
+                    else:
+                        _ = mapping_labels_copy.pop(k)
+        hover_lines = [k + "=" + v for k, v in mapping_labels_copy.items()]
         trace_patch["hovertemplate"] = hover_header + "<br>".join(hover_lines)
         trace_patch["hovertemplate"] += "<extra></extra>"
     return trace_patch, fit_results
@@ -869,6 +885,22 @@ def _get_reserved_col_names(args, attrables, array_attrables):
     return reserved_names
 
 
+def _isinstance_listlike(x):
+    """Returns True if x is an iterable which can be transformed into a pandas Series,
+    False for the other types of possible values of a `hover_data` dict.
+    A tuple of length 2 is a special case corresponding to a (format, data) tuple.
+    """
+    if (
+        isinstance(x, str)
+        or (isinstance(x, tuple) and len(x) == 2)
+        or isinstance(x, bool)
+        or x is None
+    ):
+        return False
+    else:
+        return True
+
+
 def build_dataframe(args, attrables, array_attrables):
     """
     Constructs a dataframe and modifies `args` in-place.
@@ -890,7 +922,7 @@ def build_dataframe(args, attrables, array_attrables):
     for field in args:
         if field in array_attrables and args[field] is not None:
             args[field] = (
-                dict(args[field])
+                OrderedDict(args[field])
                 if isinstance(args[field], dict)
                 else list(args[field])
             )
@@ -919,6 +951,19 @@ def build_dataframe(args, attrables, array_attrables):
         else:
             df_output[df_input.columns] = df_input[df_input.columns]
 
+    # hover_data is a dict
+    hover_data_is_dict = (
+        "hover_data" in args
+        and args["hover_data"]
+        and isinstance(args["hover_data"], dict)
+    )
+    # If dict, convert all values of hover_data to tuples to simplify processing
+    if hover_data_is_dict:
+        for k in args["hover_data"]:
+            if _isinstance_listlike(args["hover_data"][k]):
+                args["hover_data"][k] = (True, args["hover_data"][k])
+            if not isinstance(args["hover_data"][k], tuple):
+                args["hover_data"][k] = (args["hover_data"][k], None)
     # Loop over possible arguments
     for field_name in attrables:
         # Massaging variables
@@ -954,6 +999,16 @@ def build_dataframe(args, attrables, array_attrables):
             if isinstance(argument, str) or isinstance(
                 argument, int
             ):  # just a column name given as str or int
+
+                if (
+                    field_name == "hover_data"
+                    and hover_data_is_dict
+                    and args["hover_data"][str(argument)][1] is not None
+                ):
+                    col_name = str(argument)
+                    df_output[col_name] = args["hover_data"][col_name][1]
+                    continue
+
                 if not df_provided:
                     raise ValueError(
                         "String or int arguments are only possible when a "
@@ -1029,6 +1084,8 @@ def build_dataframe(args, attrables, array_attrables):
             # Finally, update argument with column name now that column exists
             if field_name not in array_attrables:
                 args[field_name] = str(col_name)
+            elif isinstance(args[field_name], dict):
+                pass
             else:
                 args[field_name][i] = str(col_name)
 
diff --git a/packages/python/plotly/plotly/express/_doc.py b/packages/python/plotly/plotly/express/_doc.py
--- a/packages/python/plotly/plotly/express/_doc.py
+++ b/packages/python/plotly/plotly/express/_doc.py
@@ -180,8 +180,15 @@
         "Values from this column or array_like appear in bold in the hover tooltip.",
     ],
     hover_data=[
-        colref_list_type,
-        colref_list_desc,
+        "list of str or int, or Series or array-like, or dict",
+        "Either a list of names of columns in `data_frame`, or pandas Series,",
+        "or array_like objects",
+        "or a dict with column names as keys, with values True (for default formatting)",
+        "False (in order to remove this column from hover information),",
+        "or a formatting string, for example ':.3f' or '|%a'",
+        "or list-like data to appear in the hover tooltip",
+        "or tuples with a bool or formatting string as first element,",
+        "and list-like data to appear in hover as second element",
         "Values from these columns appear as extra data in the hover tooltip.",
     ],
     custom_data=[


Test patch:
diff --git a/packages/python/plotly/plotly/tests/test_core/test_px/test_px_hover.py b/packages/python/plotly/plotly/tests/test_core/test_px/test_px_hover.py
new file mode 100644
--- /dev/null
+++ b/packages/python/plotly/plotly/tests/test_core/test_px/test_px_hover.py
@@ -0,0 +1,97 @@
+import plotly.express as px
+import numpy as np
+import pandas as pd
+import pytest
+import plotly.graph_objects as go
+from collections import OrderedDict  # an OrderedDict is needed for Python 2
+
+
+def test_skip_hover():
+    df = px.data.iris()
+    fig = px.scatter(
+        df,
+        x="petal_length",
+        y="petal_width",
+        size="species_id",
+        hover_data={"petal_length": None, "petal_width": None},
+    )
+    assert fig.data[0].hovertemplate == "species_id=%{marker.size}<extra></extra>"
+
+
+def test_composite_hover():
+    df = px.data.tips()
+    hover_dict = OrderedDict(
+        {"day": False, "time": False, "sex": True, "total_bill": ":.1f"}
+    )
+    fig = px.scatter(
+        df,
+        x="tip",
+        y="total_bill",
+        color="day",
+        facet_row="time",
+        hover_data=hover_dict,
+    )
+    for el in ["tip", "total_bill", "sex"]:
+        assert el in fig.data[0].hovertemplate
+    for el in ["day", "time"]:
+        assert el not in fig.data[0].hovertemplate
+    assert ":.1f" in fig.data[0].hovertemplate
+
+
+def test_newdatain_hover_data():
+    hover_dicts = [
+        {"comment": ["a", "b", "c"]},
+        {"comment": (1.234, 45.3455, 5666.234)},
+        {"comment": [1.234, 45.3455, 5666.234]},
+        {"comment": np.array([1.234, 45.3455, 5666.234])},
+        {"comment": pd.Series([1.234, 45.3455, 5666.234])},
+    ]
+    for hover_dict in hover_dicts:
+        fig = px.scatter(x=[1, 2, 3], y=[3, 4, 5], hover_data=hover_dict)
+        assert (
+            fig.data[0].hovertemplate
+            == "x=%{x}<br>y=%{y}<br>comment=%{customdata[0]}<extra></extra>"
+        )
+    fig = px.scatter(
+        x=[1, 2, 3], y=[3, 4, 5], hover_data={"comment": (True, ["a", "b", "c"])}
+    )
+    assert (
+        fig.data[0].hovertemplate
+        == "x=%{x}<br>y=%{y}<br>comment=%{customdata[0]}<extra></extra>"
+    )
+    hover_dicts = [
+        {"comment": (":.1f", (1.234, 45.3455, 5666.234))},
+        {"comment": (":.1f", [1.234, 45.3455, 5666.234])},
+        {"comment": (":.1f", np.array([1.234, 45.3455, 5666.234]))},
+        {"comment": (":.1f", pd.Series([1.234, 45.3455, 5666.234]))},
+    ]
+    for hover_dict in hover_dicts:
+        fig = px.scatter(x=[1, 2, 3], y=[3, 4, 5], hover_data=hover_dict,)
+        assert (
+            fig.data[0].hovertemplate
+            == "x=%{x}<br>y=%{y}<br>comment=%{customdata[0]:.1f}<extra></extra>"
+        )
+
+
+def test_fail_wrong_column():
+    with pytest.raises(ValueError):
+        fig = px.scatter(
+            {"a": [1, 2], "b": [3, 4], "c": [2, 1]},
+            x="a",
+            y="b",
+            hover_data={"d": True},
+        )
+    with pytest.raises(ValueError):
+        fig = px.scatter(
+            {"a": [1, 2], "b": [3, 4], "c": [2, 1]},
+            x="a",
+            y="b",
+            hover_data={"d": ":.1f"},
+        )
+    with pytest.raises(ValueError):
+        fig = px.scatter(
+            {"a": [1, 2], "b": [3, 4], "c": [2, 1]},
+            x="a",
+            y="b",
+            hover_data={"d": (True, [3, 4, 5])},
+        )