Repository: bokeh/bokeh

Issue:
[FEATURE] Expose `_known_tools` and `_tool_from_string` to allow reuse
Right now, `_known_tools` from `bokehjs/src/lib/api/plotting.ts` cannot be used outside of BokehJS and `_tool_from_string` is private within `bokeh/plotting/_tools.py`.
That means that if a user wants to add a tool with default parameters on an existing instance of `Figure`, they will have to dig its definition in the Bokeh(JS) sources and copy it over to their codebase.
It would be useful to be able to just call something like `tool_from_string('pan')`.


Discussion:
This is a nice idea. I would just implement this as a static method on `Tool` class.

Resolving patch:
diff --git a/bokeh/models/tools.py b/bokeh/models/tools.py
--- a/bokeh/models/tools.py
+++ b/bokeh/models/tools.py
@@ -37,6 +37,10 @@
 # Imports
 #-----------------------------------------------------------------------------
 
+# Standard library imports
+import difflib
+import typing as tp
+
 # Bokeh imports
 from ..core.enums import (
     Anchor,
@@ -76,6 +80,7 @@
     NO_RANGE_TOOL_RANGES,
 )
 from ..model import Model
+from ..util.string import nice_join
 from .annotations import BoxAnnotation, PolyAnnotation
 from .callbacks import Callback
 from .glyphs import MultiLine, Patches, Rect, XYGlyph
@@ -137,6 +142,25 @@ class Tool(Model):
 
     '''
 
+    _known_aliases: tp.ClassVar[tp.Dict[str, tp.Callable[[], "Tool"]]] = {}
+
+    @classmethod
+    def from_string(cls, name: str) -> "Tool":
+        """ Takes a string and returns a corresponding `Tool` instance. """
+        constructor = cls._known_aliases.get(name)
+        if constructor is not None:
+            return constructor()
+        else:
+            known_names = cls._known_aliases.keys()
+            matches, text = difflib.get_close_matches(name.lower(), known_names), "similar"
+            if not matches:
+                matches, text = known_names, "possible"
+            raise ValueError(f"unexpected tool name '{name}', {text} tools are {nice_join(matches)}")
+
+    @classmethod
+    def register_alias(cls, name: str, constructor: tp.Callable[[], "Tool"]) -> None:
+        cls._known_aliases[name] = constructor
+
 @abstract
 class Action(Tool):
     ''' A base class for tools that are buttons in the toolbar.
@@ -1490,3 +1514,43 @@ def _check_compatible_renderers(self):
 #-----------------------------------------------------------------------------
 # Code
 #-----------------------------------------------------------------------------
+
+Tool.register_alias("pan", lambda: PanTool(dimensions="both"))
+Tool.register_alias("xpan", lambda: PanTool(dimensions="width"))
+Tool.register_alias("ypan", lambda: PanTool(dimensions="height"))
+Tool.register_alias("xwheel_pan", lambda: WheelPanTool(dimension="width"))
+Tool.register_alias("ywheel_pan", lambda: WheelPanTool(dimension="height"))
+Tool.register_alias("wheel_zoom", lambda: WheelZoomTool(dimensions="both"))
+Tool.register_alias("xwheel_zoom", lambda: WheelZoomTool(dimensions="width"))
+Tool.register_alias("ywheel_zoom", lambda: WheelZoomTool(dimensions="height"))
+Tool.register_alias("zoom_in", lambda: ZoomInTool(dimensions="both"))
+Tool.register_alias("xzoom_in", lambda: ZoomInTool(dimensions="width"))
+Tool.register_alias("yzoom_in", lambda: ZoomInTool(dimensions="height"))
+Tool.register_alias("zoom_out", lambda: ZoomOutTool(dimensions="both"))
+Tool.register_alias("xzoom_out", lambda: ZoomOutTool(dimensions="width"))
+Tool.register_alias("yzoom_out", lambda: ZoomOutTool(dimensions="height"))
+Tool.register_alias("click", lambda: TapTool(behavior="inspect"))
+Tool.register_alias("tap", lambda: TapTool())
+Tool.register_alias("crosshair", lambda: CrosshairTool())
+Tool.register_alias("box_select", lambda: BoxSelectTool())
+Tool.register_alias("xbox_select", lambda: BoxSelectTool(dimensions="width"))
+Tool.register_alias("ybox_select", lambda: BoxSelectTool(dimensions="height"))
+Tool.register_alias("poly_select", lambda: PolySelectTool())
+Tool.register_alias("lasso_select", lambda: LassoSelectTool())
+Tool.register_alias("box_zoom", lambda: BoxZoomTool(dimensions="both"))
+Tool.register_alias("xbox_zoom", lambda: BoxZoomTool(dimensions="width"))
+Tool.register_alias("ybox_zoom", lambda: BoxZoomTool(dimensions="height"))
+Tool.register_alias("save", lambda: SaveTool())
+Tool.register_alias("undo", lambda: UndoTool())
+Tool.register_alias("redo", lambda: RedoTool())
+Tool.register_alias("reset", lambda: ResetTool())
+Tool.register_alias("help", lambda: HelpTool())
+Tool.register_alias("box_edit", lambda: BoxEditTool())
+Tool.register_alias("point_draw", lambda: PointDrawTool())
+Tool.register_alias("poly_draw", lambda: PolyDrawTool())
+Tool.register_alias("poly_edit", lambda: PolyEditTool())
+Tool.register_alias("hover", lambda: HoverTool(tooltips=[
+    ("index", "$index"),
+    ("data (x, y)", "($x, $y)"),
+    ("screen (x, y)", "($sx, $sy)"),
+]))
diff --git a/bokeh/plotting/_tools.py b/bokeh/plotting/_tools.py
--- a/bokeh/plotting/_tools.py
+++ b/bokeh/plotting/_tools.py
@@ -16,37 +16,13 @@
 #-----------------------------------------------------------------------------
 
 # Standard library imports
-import difflib
 import itertools
 import re
 import warnings
+from typing import Dict, List, Optional, Sequence, Tuple, Union
 
 # Bokeh imports
-from ..models import (
-    BoxEditTool,
-    BoxSelectTool,
-    BoxZoomTool,
-    CrosshairTool,
-    HelpTool,
-    HoverTool,
-    LassoSelectTool,
-    PanTool,
-    PointDrawTool,
-    PolyDrawTool,
-    PolyEditTool,
-    PolySelectTool,
-    RedoTool,
-    ResetTool,
-    SaveTool,
-    TapTool,
-    Tool,
-    UndoTool,
-    WheelPanTool,
-    WheelZoomTool,
-    ZoomInTool,
-    ZoomOutTool,
-)
-from ..util.string import nice_join
+from ..models import HoverTool, Plot, Tool
 
 #-----------------------------------------------------------------------------
 # Globals and constants
@@ -57,49 +33,6 @@
     'process_tools_arg',
 )
 
-TOOLS_MAP = {
-    "pan": lambda: PanTool(dimensions='both'),
-    "xpan": lambda: PanTool(dimensions='width'),
-    "ypan": lambda: PanTool(dimensions='height'),
-    "xwheel_pan": lambda: WheelPanTool(dimension="width"),
-    "ywheel_pan": lambda: WheelPanTool(dimension="height"),
-    "wheel_zoom": lambda: WheelZoomTool(dimensions='both'),
-    "xwheel_zoom": lambda: WheelZoomTool(dimensions='width'),
-    "ywheel_zoom": lambda: WheelZoomTool(dimensions='height'),
-    "zoom_in": lambda: ZoomInTool(dimensions='both'),
-    "xzoom_in": lambda: ZoomInTool(dimensions='width'),
-    "yzoom_in": lambda: ZoomInTool(dimensions='height'),
-    "zoom_out": lambda: ZoomOutTool(dimensions='both'),
-    "xzoom_out": lambda: ZoomOutTool(dimensions='width'),
-    "yzoom_out": lambda: ZoomOutTool(dimensions='height'),
-    "click": lambda: TapTool(behavior="inspect"),
-    "tap": lambda: TapTool(),
-    "crosshair": lambda: CrosshairTool(),
-    "box_select": lambda: BoxSelectTool(),
-    "xbox_select": lambda: BoxSelectTool(dimensions='width'),
-    "ybox_select": lambda: BoxSelectTool(dimensions='height'),
-    "poly_select": lambda: PolySelectTool(),
-    "lasso_select": lambda: LassoSelectTool(),
-    "box_zoom": lambda: BoxZoomTool(dimensions='both'),
-    "xbox_zoom": lambda: BoxZoomTool(dimensions='width'),
-    "ybox_zoom": lambda: BoxZoomTool(dimensions='height'),
-    "hover": lambda: HoverTool(tooltips=[
-        ("index", "$index"),
-        ("data (x, y)", "($x, $y)"),
-        ("screen (x, y)", "($sx, $sy)"),
-    ]),
-    "save": lambda: SaveTool(),
-    "previewsave": "save",
-    "undo": lambda: UndoTool(),
-    "redo": lambda: RedoTool(),
-    "reset": lambda: ResetTool(),
-    "help": lambda: HelpTool(),
-    "box_edit": lambda: BoxEditTool(),
-    "point_draw": lambda: PointDrawTool(),
-    "poly_draw": lambda: PolyDrawTool(),
-    "poly_edit": lambda: PolyEditTool()
-}
-
 #-----------------------------------------------------------------------------
 # General API
 #-----------------------------------------------------------------------------
@@ -153,7 +86,8 @@ def process_active_tools(toolbar, tool_map, active_drag, active_inspect, active_
     else:
         raise ValueError("Got unknown %r for 'active_tap', which was not a string supplied in 'tools' argument" % active_tap)
 
-def process_tools_arg(plot, tools, tooltips=None):
+def process_tools_arg(plot: Plot, tools: Union[str, Sequence[Union[Tool, str]]],
+        tooltips: Optional[Union[str, Tuple[str, str]]] = None) -> Tuple[List[Tool], Dict[str, Tool]]:
     """ Adds tools to the plot object
 
     Args:
@@ -173,7 +107,7 @@ def process_tools_arg(plot, tools, tooltips=None):
     temp_tool_str = ""
     repeated_tools = []
 
-    if isinstance(tools, (list, tuple)):
+    if not isinstance(tools, str):
         for tool in tools:
             if isinstance(tool, Tool):
                 tool_objs.append(tool)
@@ -188,12 +122,11 @@ def process_tools_arg(plot, tools, tooltips=None):
         if tool == "":
             continue
 
-        tool_obj = _tool_from_string(tool)
+        tool_obj = Tool.from_string(tool)
         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)):
+    for typename, group in itertools.groupby(sorted(tool.__class__.__name__ for tool in tool_objs)):
         if len(list(group)) > 1:
             repeated_tools.append(typename)
 
@@ -214,26 +147,6 @@ def process_tools_arg(plot, tools, tooltips=None):
 # Private API
 #-----------------------------------------------------------------------------
 
-
-def _tool_from_string(name):
-    """ Takes a string and returns a corresponding `Tool` instance. """
-    known_tools = sorted(TOOLS_MAP.keys())
-
-    if name in known_tools:
-        tool_fn = TOOLS_MAP[name]
-
-        if isinstance(tool_fn, str):
-            tool_fn = TOOLS_MAP[tool_fn]
-
-        return tool_fn()
-    else:
-        matches, text = difflib.get_close_matches(name.lower(), known_tools), "similar"
-
-        if not matches:
-            matches, text = known_tools, "possible"
-
-        raise ValueError("unexpected tool name '%s', %s tools are %s" % (name, text, nice_join(matches)))
-
 #-----------------------------------------------------------------------------
 # Code
 #-----------------------------------------------------------------------------


Test patch:
diff --git a/tests/unit/bokeh/models/test_tools.py b/tests/unit/bokeh/models/test_tools.py
--- a/tests/unit/bokeh/models/test_tools.py
+++ b/tests/unit/bokeh/models/test_tools.py
@@ -23,7 +23,8 @@
 from bokeh.models import LayoutDOM
 
 # Module under test
-from bokeh.models.tools import Toolbar, ToolbarBox # isort:skip
+from bokeh.models.tools import Tool, Toolbar, ToolbarBox # isort:skip
+import bokeh.models.tools as t # isort:skip
 
 #-----------------------------------------------------------------------------
 # Setup
@@ -33,6 +34,44 @@
 # General API
 #-----------------------------------------------------------------------------
 
+def test_Tool_from_string() -> None:
+    assert isinstance(Tool.from_string("pan"), t.PanTool)
+    assert isinstance(Tool.from_string("xpan"), t.PanTool)
+    assert isinstance(Tool.from_string("ypan"), t.PanTool)
+    assert isinstance(Tool.from_string("xwheel_pan"), t.WheelPanTool)
+    assert isinstance(Tool.from_string("ywheel_pan"), t.WheelPanTool)
+    assert isinstance(Tool.from_string("wheel_zoom"), t.WheelZoomTool)
+    assert isinstance(Tool.from_string("xwheel_zoom"), t.WheelZoomTool)
+    assert isinstance(Tool.from_string("ywheel_zoom"), t.WheelZoomTool)
+    assert isinstance(Tool.from_string("zoom_in"), t.ZoomInTool)
+    assert isinstance(Tool.from_string("xzoom_in"), t.ZoomInTool)
+    assert isinstance(Tool.from_string("yzoom_in"), t.ZoomInTool)
+    assert isinstance(Tool.from_string("zoom_out"), t.ZoomOutTool)
+    assert isinstance(Tool.from_string("xzoom_out"), t.ZoomOutTool)
+    assert isinstance(Tool.from_string("yzoom_out"), t.ZoomOutTool)
+    assert isinstance(Tool.from_string("click"), t.TapTool)
+    assert isinstance(Tool.from_string("tap"), t.TapTool)
+    assert isinstance(Tool.from_string("crosshair"), t.CrosshairTool)
+    assert isinstance(Tool.from_string("box_select"), t.BoxSelectTool)
+    assert isinstance(Tool.from_string("xbox_select"), t.BoxSelectTool)
+    assert isinstance(Tool.from_string("ybox_select"), t.BoxSelectTool)
+    assert isinstance(Tool.from_string("poly_select"), t.PolySelectTool)
+    assert isinstance(Tool.from_string("lasso_select"), t.LassoSelectTool)
+    assert isinstance(Tool.from_string("box_zoom"), t.BoxZoomTool)
+    assert isinstance(Tool.from_string("xbox_zoom"), t.BoxZoomTool)
+    assert isinstance(Tool.from_string("ybox_zoom"), t.BoxZoomTool)
+    assert isinstance(Tool.from_string("save"), t.SaveTool)
+    assert isinstance(Tool.from_string("undo"), t.UndoTool)
+    assert isinstance(Tool.from_string("redo"), t.RedoTool)
+    assert isinstance(Tool.from_string("reset"), t.ResetTool)
+    assert isinstance(Tool.from_string("help"), t.HelpTool)
+    assert isinstance(Tool.from_string("box_edit"), t.BoxEditTool)
+    assert isinstance(Tool.from_string("point_draw"), t.PointDrawTool)
+    assert isinstance(Tool.from_string("poly_draw"), t.PolyDrawTool)
+    assert isinstance(Tool.from_string("poly_edit"), t.PolyEditTool)
+    assert isinstance(Tool.from_string("hover"), t.HoverTool)
+
+
 def test_Toolbar() -> None:
     tb = Toolbar()
     assert tb.active_drag == 'auto'
diff --git a/tests/unit/bokeh/test_model.py b/tests/unit/bokeh/test_model.py
--- a/tests/unit/bokeh/test_model.py
+++ b/tests/unit/bokeh/test_model.py
@@ -174,7 +174,7 @@ def test_all_builtin_models_default_constructible() -> None:
     for name, cls in Model.model_class_reverse_map.items():
         try:
             cls()
-        except:
+        except Exception:
             bad.append(name)
         assert bad == []