lazy search: scope hits to the requested panel view

This commit is contained in:
mvdbeek
2026-07-28 17:27:47 +02:00
parent bf421f1c59
commit 0bf881915e
4 changed files with 107 additions and 9 deletions
+4 -2
View File
@@ -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
+27
View File
@@ -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_<id>`` 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_<id>``) 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
+25 -7
View File
@@ -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:
@@ -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"]