diff --git a/lib/galaxy/app/__init__.py b/lib/galaxy/app/__init__.py index d969a1d5368..a1f8b1cfca0 100644 --- a/lib/galaxy/app/__init__.py +++ b/lib/galaxy/app/__init__.py @@ -413,8 +413,10 @@ class MinimalGalaxyApplication(BasicSharedApp, HaltableContainer, SentryClientMi index_help = getattr(self.config, "index_tool_help", True) if self._use_lazy_toolbox(): # Populator owns the whoosh index in lazy mode; the toolbox search - # singleton is a thin reader. See ``LazyToolboxSearch``. - search_singleton: ToolBoxSearch = LazyToolboxSearch(self.config) + # singleton is a thin reader. The toolbox is threaded in so + # ``search`` can scope hits to the requested panel view. See + # ``LazyToolboxSearch``. + search_singleton: ToolBoxSearch = LazyToolboxSearch(self.config, self.toolbox) else: search_singleton = ToolBoxSearch( self.toolbox, index_dir=self.config.tool_search_index_dir, index_help=index_help diff --git a/lib/galaxy/tool_util/toolbox/base.py b/lib/galaxy/tool_util/toolbox/base.py index f54a0e6339a..0878c1fa82a 100644 --- a/lib/galaxy/tool_util/toolbox/base.py +++ b/lib/galaxy/tool_util/toolbox/base.py @@ -215,6 +215,19 @@ def resolve_tool_path(tool_path: str | None, config_filename: str, default_tool_ return string.Template(tool_path).safe_substitute(tool_path_vars) +def _collect_panel_tool_ids(panel_items: "ToolPanelElements", ids: set[str]) -> None: + """Gather tool ids from a rendered panel, recursing into sections. + + Reads ids off the ``tool_`` panel keys rather than the tool objects, + so a ``LazyToolBox`` stub is never materialised just to answer membership. + """ + for key, item_type, item in panel_items.panel_items_iter(): + if item_type == panel_item_types.TOOL: + ids.add(key[len("tool_") :]) + elif item_type == panel_item_types.SECTION: + _collect_panel_tool_ids(item.panel_items(), ids) + + class AbstractToolBox(ManagesIntegratedToolPanelMixin): """ Abstract container for managing a ToolPanel - containing tools and @@ -479,6 +492,20 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin): panel_view_rendered = self._tool_panel_view_rendered[panel_view_id] return panel_view_rendered.has_item_recursive(tool) + def panel_view_tool_ids(self, panel_view_id: str) -> set[str]: + """Ids of the tools placed in the rendered panel view ``panel_view_id``. + + The by-id counterpart of :meth:`panel_has_tool`: it reads membership + straight off the panel keys (``tool_``) without touching the tool + objects, so the lazy search filter can scope hits to a view without + materialising anything. Raises ``KeyError`` for an unknown view, + matching :meth:`ToolBoxSearch.search`'s contract. + """ + panel_view_rendered = self._tool_panel_view_rendered[panel_view_id] + ids: set[str] = set() + _collect_panel_tool_ids(panel_view_rendered, ids) + return ids + def load_dynamic_tool(self, dynamic_tool: "DynamicTool") -> Union["Tool", None]: if not dynamic_tool.active or not dynamic_tool.public: return None diff --git a/lib/galaxy/tools/search/__init__.py b/lib/galaxy/tools/search/__init__.py index 3d1f5e3f817..390608e7423 100644 --- a/lib/galaxy/tools/search/__init__.py +++ b/lib/galaxy/tools/search/__init__.py @@ -31,6 +31,7 @@ import re import shutil from typing import ( Any, + Optional, TYPE_CHECKING, ) @@ -135,8 +136,13 @@ class LazyToolboxSearch(ToolBoxSearch): The populator (``galaxy.tools.source_store.populator``) builds and owns one whoosh index per store; this class is a thin reader that opens them - on each query and merges hits by score. Per-panel-view fan-out is - collapsed: ``search`` ignores ``panel_view``. + on each query and merges hits by score. + + Per-view scoping mirrors the eager :class:`ToolBoxSearch`: the merged hits + are filtered down to the tools the requested panel view holds, and an + unknown ``panel_view`` raises ``KeyError``. Membership is read off the + toolbox's rendered panel by id (:meth:`AbstractToolBox.panel_view_tool_ids`), + so no tool is materialised just to filter. ``build_index`` is a no-op (the populator's job). ``index_count`` is still incremented so :func:`galaxy.queue_worker.rebuild_toolbox_search_index` @@ -144,12 +150,13 @@ class LazyToolboxSearch(ToolBoxSearch): toolbox reload count" without re-building anything. """ - def __init__(self, config: GalaxyAppConfiguration) -> None: + def __init__(self, config: GalaxyAppConfiguration, toolbox: Optional["ToolBox"] = None) -> None: # Skip ToolBoxSearch.__init__ — it walks ``toolbox.panel_views()`` and # builds a ToolPanelViewSearch per view. Under lazy mode the - # populator owns the whoosh indexes; per-view filtering is a - # follow-up if needed. + # populator owns the whoosh indexes; view scoping is a post-filter on + # the merged hits instead of a per-view index. self.config = config + self._toolbox = toolbox self.panel_searches: dict[str, ToolPanelViewSearch] = {} self.index_count = -1 @@ -159,6 +166,13 @@ class LazyToolboxSearch(ToolBoxSearch): self.index_count += 1 def search(self, q: str, panel_view: str, config: GalaxyAppConfiguration) -> list[str]: + # Resolve view membership first so an unknown view raises ``KeyError`` + # even when whoosh search is disabled — parity with eager + # ``ToolBoxSearch.search``. ``None`` (no toolbox wired, e.g. in unit + # tests) skips scoping and returns the raw merged hits. + member_ids: set[str] | None = None + if self._toolbox is not None: + member_ids = self._toolbox.panel_view_tool_ids(panel_view) if not config.tool_search_index_dir: # No index dir means whoosh search is off entirely. return [] @@ -166,7 +180,8 @@ class LazyToolboxSearch(ToolBoxSearch): # the default's would make every named-store tool invisible to # ``/api/tools?q=``. Search each configured store's index and merge # by score. Over-searching a catalog store no conf references is - # harmless: ``resolve_search_hit`` drops ids not in this toolbox. + # harmless: the panel-view filter (and ``resolve_search_hit`` + # downstream) drops ids not placed in this toolbox. store_names = [DEFAULT_STORE_NAME, *sorted(config.tool_source_stores or {})] tuning = ToolSearchTuning.from_config(config) scored: dict[str, float] = {} @@ -182,7 +197,10 @@ class LazyToolboxSearch(ToolBoxSearch): # BM25 scores from different indexes aren't strictly comparable # (per-corpus statistics), but interleaving by score beats # concatenation; ties keep first-seen order (default store first). - return [tool_id for tool_id, _score in sorted(scored.items(), key=lambda kv: -kv[1])] + ordered = [tool_id for tool_id, _score in sorted(scored.items(), key=lambda kv: -kv[1])] + if member_ids is None: + return ordered + return [tool_id for tool_id in ordered if tool_id in member_ids] class ToolPanelViewSearch: diff --git a/test/unit/app/tools/source_store/test_multi_store_search.py b/test/unit/app/tools/source_store/test_multi_store_search.py index 3c625dc5a14..8f8a973fc52 100644 --- a/test/unit/app/tools/source_store/test_multi_store_search.py +++ b/test/unit/app/tools/source_store/test_multi_store_search.py @@ -1,6 +1,8 @@ from types import SimpleNamespace from typing import cast +import pytest + from galaxy.config import GalaxyAppConfiguration from galaxy.tools.search import LazyToolboxSearch from galaxy.tools.source_store.index import ( @@ -68,3 +70,52 @@ def test_search_merges_hits_across_store_indexes(tmp_path, monkeypatch): def test_search_without_index_dir_returns_empty(): config = cast(GalaxyAppConfiguration, SimpleNamespace(tool_search_index_dir=None, tool_source_stores={})) assert LazyToolboxSearch(config).search("mapper", panel_view="default", config=config) == [] + + +class _FakeToolbox: + def __init__(self, views): + self._views = views + + def panel_view_tool_ids(self, panel_view_id): + return self._views[panel_view_id] + + +def _single_store_config(index_root): + return cast( + GalaxyAppConfiguration, + SimpleNamespace(tool_search_index_dir=index_root, tool_source_stores={}), + ) + + +def test_search_unknown_panel_view_raises_key_error(tmp_path, monkeypatch): + monkeypatch.setattr(ToolSearchTuning, "from_config", classmethod(lambda cls, config: _TUNING)) + index_root = str(tmp_path) + _build_store_index( + index_root, DEFAULT_STORE_NAME, [ToolIndexEntry(id="local_mapper", name="Sequence mapper", version="1.0")] + ) + config = _single_store_config(index_root) + search = LazyToolboxSearch(config, _FakeToolbox({"default": {"local_mapper"}})) # type: ignore[arg-type] + with pytest.raises(KeyError): + search.search("mapper", panel_view="does_not_exist", config=config) + + +def test_search_scopes_hits_to_requested_panel_view(tmp_path, monkeypatch): + monkeypatch.setattr(ToolSearchTuning, "from_config", classmethod(lambda cls, config: _TUNING)) + index_root = str(tmp_path) + _build_store_index( + index_root, + DEFAULT_STORE_NAME, + [ + ToolIndexEntry(id="local_mapper", name="Sequence mapper", version="1.0"), + ToolIndexEntry(id="other_mapper", name="Sequence mapper deluxe", version="1.0"), + ], + ) + config = _single_store_config(index_root) + search = LazyToolboxSearch( + config, + _FakeToolbox({"default": {"local_mapper", "other_mapper"}, "restricted": {"local_mapper"}}), # type: ignore[arg-type] + ) + assert set(search.search("mapper", panel_view="default", config=config)) == {"local_mapper", "other_mapper"} + # The restricted view holds only one of the two matching tools; the + # out-of-view hit must be dropped. + assert search.search("mapper", panel_view="restricted", config=config) == ["local_mapper"]