mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-21 05:45:37 +08:00
lazy search: scope hits to the requested panel view
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user