Repository: bokeh/bokeh

Issue:
[BUG] `gridplot` `merge_tools` removes distinct tools it thinks are repeated, e.g., `xpan` and `ypan`
### Software versions

**bokeh version**: 1.4.0
**python version**: 3.7.3 (macOS), 3.8.0 (Ubuntu)
**OS**: macOS 10.15.3 and Ubuntu 19.10
**browser**: (tested only on macOS) Firefox 73.0.1, Chrome 80.0.3987.132

### Expected and Observed Behavior

**Expected behavior**: Merged toolbar contains both vertical and horizontal pan tools.
**Observed behavior**: Only one of the pan tools is present on the merged plot.

Note: With `merge_tools=False`, each plot correctly contains two pan tools, but there are multiple toolbars, which is not desired.
Note: Same problem when using e.g., `xwheel_zoom` and `ywheel_zoom`

### Minimal code to reproduce

```python
import pandas as pd
import numpy as np

from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
from bokeh.layouts import gridplot


t = np.linspace(0, 10, 1000)
data = pd.DataFrame(data={
    'time': t,
    'x': 12*np.cos(2*3.14/0.5*t),
    'y': 12*np.cos(2*3.14/2*t),
})
source = ColumnDataSource(data)

tools = 'xpan,ypan'
plt_x = figure(tools=tools, height=150)
plt_x.line(x='time', y='x', source=source)
plt_y = figure(tools=tools, height=150)
plt_y.line(x='time', y='y', source=source)
gplt = gridplot([[plt_x], [plt_y]], toolbar_location='right', merge_tools=True)

show(gplt)
```

### Python and Browser output

(no error, no stack trace)

Python output:

```
/Users/***/.virtualenvs/py37/lib/python3.7/site-packages/bokeh/plotting/helpers.py:689: UserWarning: PanTool are being repeated
  warnings.warn("%s are being repeated" % ",".join(repeated_tools))
```

Browser console (Firefox):

```
Request to access cookie or storage on “https://cdn.pydata.org/bokeh/release/bokeh-1.4.0.min.js” was blocked because we are blocking all third-party storage access requests and content blocking is enabled. bkmin.html
[bokeh] setting log level to: 'info' bokeh-1.4.0.min.js:317:2257
[bokeh] document idle at 210 ms bokeh-1.4.0.min.js:242:1545
Source map error: Error: request failed with status 403
Resource URL: https://cdn.pydata.org/bokeh/release/bokeh-1.4.0.min.js
Source Map URL: bokeh.min.js.map 2
```

### Screenshots

Merging toolbars (incorrect behavior):

![image](https://user-images.githubusercontent.com/1521221/76053152-4181f800-5f3a-11ea-9763-342561772386.png)


Not merging toolbars (correct behavior, but multiple toolbars):

![image](https://user-images.githubusercontent.com/1521221/76053038-e6e89c00-5f39-11ea-837b-83beec1057af.png)


Discussion:
I'd like to work on this issue

Resolving patch:
diff --git a/bokeh/core/has_props.py b/bokeh/core/has_props.py
--- a/bokeh/core/has_props.py
+++ b/bokeh/core/has_props.py
@@ -27,6 +27,7 @@
 
 # Standard library imports
 import difflib
+from typing import Any, Dict
 from warnings import warn
 
 # Bokeh imports
@@ -486,7 +487,7 @@ def dataspecs_with_props(cls):
         '''
         return accumulate_dict_from_superclasses(cls, "__dataspecs__")
 
-    def properties_with_values(self, include_defaults=True):
+    def properties_with_values(self, include_defaults: bool = True) -> Dict[str, Any]:
         ''' Collect a dict mapping property names to their values.
 
         This method *always* traverses the class hierarchy and includes
diff --git a/bokeh/plotting/_tools.py b/bokeh/plotting/_tools.py
--- a/bokeh/plotting/_tools.py
+++ b/bokeh/plotting/_tools.py
@@ -19,10 +19,24 @@
 import itertools
 import re
 import warnings
-from typing import Dict, List, Optional, Sequence, Tuple, Union
+from typing import (
+    Any,
+    Dict,
+    Iterator,
+    List,
+    NamedTuple,
+    Optional,
+    Sequence,
+    Tuple,
+    Union,
+)
+
+# External imports
+from typing_extensions import Literal
 
 # Bokeh imports
-from ..models import HoverTool, Plot, Tool
+from ..models import HoverTool, Plot, Tool, Toolbar
+from ..models.tools import Drag, Inspection, Scroll, Tap
 
 #-----------------------------------------------------------------------------
 # Globals and constants
@@ -41,12 +55,20 @@
 # Dev API
 #-----------------------------------------------------------------------------
 
-def process_active_tools(toolbar, tool_map, active_drag, active_inspect, active_scroll, active_tap):
+# TODO: str should be literal union of e.g. pan | xpan | ypan
+Auto = Literal["auto"]
+ActiveDrag = Union[Drag, Auto, str, None]
+ActiveInspect = Union[List[Inspection], Inspection, Auto, str, None]
+ActiveScroll = Union[Scroll, Auto, str, None]
+ActiveTap = Union[Tap, Auto, str, None]
+
+def process_active_tools(toolbar: Toolbar, tool_map: Dict[str, Tool],
+        active_drag: ActiveDrag, active_inspect: ActiveInspect, active_scroll: ActiveScroll, active_tap: ActiveTap) -> None:
     """ Adds tools to the plot object
 
     Args:
         toolbar (Toolbar): instance of a Toolbar object
-        tools_map (dict[str]|Tool): tool_map from _process_tools_arg
+        tools_map (dict[str]): tool_map from _process_tools_arg
         active_drag (str or Tool): the tool to set active for drag
         active_inspect (str or Tool): the tool to set active for inspect
         active_scroll (str or Tool): the tool to set active for scroll
@@ -65,9 +87,10 @@ def process_active_tools(toolbar, tool_map, active_drag, active_inspect, active_
     else:
         raise ValueError("Got unknown %r for 'active_drag', which was not a string supplied in 'tools' argument" % active_drag)
 
-    if active_inspect in ['auto', None] or isinstance(active_inspect, Tool) or all(isinstance(t, Tool) for t in active_inspect):
+    if active_inspect in ["auto", None] or isinstance(active_inspect, Tool) or \
+            (isinstance(active_inspect, list) and all(isinstance(t, Tool) for t in active_inspect)):
         toolbar.active_inspect = active_inspect
-    elif active_inspect in tool_map:
+    elif isinstance(active_inspect, str) and active_inspect in tool_map:
         toolbar.active_inspect = tool_map[active_inspect]
     else:
         raise ValueError("Got unknown %r for 'active_inspect', which was not a string supplied in 'tools' argument" % active_scroll)
@@ -102,12 +125,32 @@ def process_tools_arg(plot: Plot, tools: Union[str, Sequence[Union[Tool, str]]],
     Returns:
         list of Tools objects added to plot, map of supplied string names to tools
     """
+    tool_objs, tool_map = _resolve_tools(tools)
+
+    repeated_tools = [ str(obj) for obj in _collect_repeated_tools(tool_objs) ]
+    if repeated_tools:
+        warnings.warn("%s are being repeated" % ",".join(repeated_tools))
+
+    if tooltips is not None:
+        for tool_obj in tool_objs:
+            if isinstance(tool_obj, HoverTool):
+                tool_obj.tooltips = tooltips
+                break
+        else:
+            tool_objs.append(HoverTool(tooltips=tooltips)) # type: ignore
+
+    return tool_objs, tool_map
+
+#-----------------------------------------------------------------------------
+# Private API
+#-----------------------------------------------------------------------------
+
+def _resolve_tools(tools: Union[str, Sequence[Union[Tool, str]]]) -> Tuple[List[Tool], Dict[str, Tool]]:
     tool_objs = []
     tool_map = {}
-    temp_tool_str = ""
-    repeated_tools = []
 
     if not isinstance(tools, str):
+        temp_tool_str = ""
         for tool in tools:
             if isinstance(tool, Tool):
                 tool_objs.append(tool)
@@ -126,26 +169,22 @@ def process_tools_arg(plot: Plot, tools: Union[str, Sequence[Union[Tool, str]]],
         tool_objs.append(tool_obj)
         tool_map[tool] = tool_obj
 
-    for typename, group in itertools.groupby(sorted(tool.__class__.__name__ for tool in tool_objs)):
-        if len(list(group)) > 1:
-            repeated_tools.append(typename)
+    return tool_objs, tool_map
 
-    if repeated_tools:
-        warnings.warn("%s are being repeated" % ",".join(repeated_tools))
+def _collect_repeated_tools(tool_objs: List[Tool]) -> Iterator[Tool]:
+    class Item(NamedTuple):
+        obj: Tool
+        properties: Dict[str, Any]
 
-    if tooltips is not None:
-        for tool_obj in tool_objs:
-            if isinstance(tool_obj, HoverTool):
-                tool_obj.tooltips = tooltips
-                break
-        else:
-            tool_objs.append(HoverTool(tooltips=tooltips))
-
-    return tool_objs, tool_map
+    key = lambda obj: obj.__class__.__name__
 
-#-----------------------------------------------------------------------------
-# Private API
-#-----------------------------------------------------------------------------
+    for _, group in itertools.groupby(sorted(tool_objs, key=key), key=key):
+        rest = [ Item(obj, obj.properties_with_values()) for obj in group ]
+        while len(rest) > 1:
+            head, *rest = rest
+            for item in rest:
+                if item.properties == head.properties:
+                    yield item.obj
 
 #-----------------------------------------------------------------------------
 # Code


Test patch:
diff --git a/tests/unit/bokeh/plotting/test__tools.py b/tests/unit/bokeh/plotting/test__tools.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/plotting/test__tools.py
@@ -0,0 +1,65 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2020, Anaconda, Inc., and Bokeh Contributors.
+# All rights reserved.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Module under test
+import bokeh.plotting._tools as _tools # isort:skip
+
+#-----------------------------------------------------------------------------
+# Setup
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# General API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Dev API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Private API
+#-----------------------------------------------------------------------------
+
+def test__collect_repeated_tools() -> None:
+    def count_repeated(tools: str) -> int:
+        objs, _ = _tools._resolve_tools(tools)
+        return len(list(_tools._collect_repeated_tools(objs)))
+
+    assert count_repeated("pan,xpan,ypan") == 0
+    assert count_repeated("xwheel_pan,ywheel_pan") == 0
+    assert count_repeated("wheel_zoom,xwheel_zoom,ywheel_zoom") == 0
+    assert count_repeated("zoom_in,xzoom_in,yzoom_in") == 0
+    assert count_repeated("zoom_out,xzoom_out,yzoom_out") == 0
+    assert count_repeated("click,tap") == 0
+    assert count_repeated("crosshair") == 0
+    assert count_repeated("box_select,xbox_select,ybox_select") == 0
+    assert count_repeated("poly_select,lasso_select") == 0
+    assert count_repeated("box_zoom,xbox_zoom,ybox_zoom") == 0
+    assert count_repeated("hover,save,undo,redo,reset,help") == 0
+
+    assert count_repeated("pan,xpan,xpan") == 1
+
+    assert count_repeated("pan,xpan,ypan,xpan") == 1
+    assert count_repeated("pan,xpan,ypan,click,xpan") == 1
+    assert count_repeated("pan,xpan,ypan,click,xpan,click") == 2
+
+    assert count_repeated("pan,xpan,ypan,xpan,ypan") == 2
+    assert count_repeated("pan,xpan,ypan,click,xpan,ypan") == 2
+    assert count_repeated("pan,xpan,ypan,click,xpan,ypan,click") == 3
+
+#-----------------------------------------------------------------------------
+# Code
+#-----------------------------------------------------------------------------