mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-29 17:42:12 +08:00
LazyTool stub + create_tool seam (dormant; activation in follow-up)
Introduces the seam the conceptual-simplification plan (``.claude/plans/witty-drifting-clock.md``) hangs the LazyToolBox refactor on: * ``LazyTool`` — strict-by-default proxy over ``ToolIndexEntry`` that forwards the narrow attribute surface the eager pipeline actually reads (audited at plan time: ~28 attrs + 3 methods, zero ``isinstance(_, Tool)`` checks anywhere). Mutations (``tool.hidden = True``, shed metadata, ``tool.labels = ...``) land in ``_overrides`` and survive any later materialise. ``to_dict(link_details=False)`` stays on the entry — only ``link_details=True`` (and the ``_MATERIALIZE_OK`` allow-list) parse XML. Unknown attributes raise ``NotImplementedError`` with a clear message; ``LAZY_TOOL_PERMISSIVE=1`` downgrades to warn + materialise for debugging. * ``LazyToolBox.create_tool`` — returns a ``LazyTool`` when the index already has the source; otherwise falls through to the eager ``ToolBox.create_tool`` and persists the freshly-parsed tool via ``_persist_tool_source`` so the next boot is a hit. Paired with ``load_tool_from_cache``/``add_tool_to_cache`` no-ops so the disk ``ToolCache`` doesn't shadow the seam. * ``_resolve_index_entry`` — guid → ``store.get_by_source_path`` → short-id fallback. ``_materialize_for_lazy_tool`` routes the stub→Tool promotion through the existing ``_create_tool_from_stored_source`` + ``_register_loaded_tool`` so ``_tools_by_id`` / ``_tool_versions_by_id`` / ``_tools_by_old_id`` / lineage all agree with the eager bookkeeping. The seam is **dormant** in this commit: ``LazyToolBox.__init__`` still runs its hand-written ``_init_lazy_toolbox`` mirror, so ``create_tool`` is never invoked on the boot path. A follow-up commit will flip ``__init__`` to ``super().__init__()`` and delete the duplicate panel-parsing complex (~1500 lines per Phase 2 of the plan); this commit is purely additive so the activation can be reviewed in isolation. 21 unit tests cover the forwarded surface, mutator overrides round-tripping through materialise, the ``to_dict`` fast/slow paths, strict / permissive ``__getattr__`` behavior, and the ``create_tool`` index-hit paths (guid + source_path).
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from galaxy.tool_source_store import StoredToolSource
|
||||
from galaxy.tool_source_store.index import (
|
||||
ToolIndex,
|
||||
ToolIndexEntry,
|
||||
)
|
||||
from galaxy.tools.lazy_toolbox import (
|
||||
LazyTool,
|
||||
LazyToolBox,
|
||||
)
|
||||
|
||||
|
||||
def _entry(**overrides):
|
||||
base: dict[str, Any] = dict(
|
||||
id="bowtie2",
|
||||
version="2.5.0",
|
||||
name="Bowtie2",
|
||||
description="Fast aligner",
|
||||
hidden=False,
|
||||
require_login=False,
|
||||
tool_type="default",
|
||||
tags=[],
|
||||
labels=["align"],
|
||||
edam_operations=["operation_0292"],
|
||||
edam_topics=["topic_0102"],
|
||||
source_hash="abc",
|
||||
)
|
||||
base.update(overrides)
|
||||
return ToolIndexEntry(**base)
|
||||
|
||||
|
||||
def _stub(entry=None, materialize=None, is_admin=None):
|
||||
if materialize is None:
|
||||
|
||||
def materialize(_e): # noqa: E306
|
||||
raise AssertionError(f"unexpected materialise for {_e.id!r}")
|
||||
|
||||
return LazyTool(
|
||||
entry or _entry(),
|
||||
materialize_callback=materialize,
|
||||
is_admin_user=is_admin or (lambda u: False),
|
||||
)
|
||||
|
||||
|
||||
def test_forwarded_reads_off_entry():
|
||||
e = _entry()
|
||||
t = _stub(e)
|
||||
assert t.id == e.id
|
||||
assert t.version == e.version
|
||||
assert t.name == e.name
|
||||
assert t.description == e.description
|
||||
assert t.hidden is False
|
||||
assert t.require_login is False
|
||||
assert t.tool_type == "default"
|
||||
assert t.labels == ["align"]
|
||||
assert t.edam_operations == ["operation_0292"]
|
||||
assert t.edam_topics == ["topic_0102"]
|
||||
|
||||
|
||||
def test_overrides_shadow_entry_and_survive_materialise():
|
||||
materialised = []
|
||||
|
||||
class _Real:
|
||||
hidden = False
|
||||
labels = ()
|
||||
tool_shed = None
|
||||
|
||||
def materialise(_e):
|
||||
materialised.append(_e.id)
|
||||
return _Real()
|
||||
|
||||
t = LazyTool(_entry(), materialize_callback=materialise, is_admin_user=lambda u: False)
|
||||
t.hidden = True
|
||||
t.labels = ["a", "b"]
|
||||
t.tool_shed = "toolshed.example.com"
|
||||
assert t.hidden is True
|
||||
assert t.labels == ["a", "b"]
|
||||
|
||||
real = t._materialize()
|
||||
assert materialised == [_entry().id]
|
||||
assert real.hidden is True
|
||||
assert real.labels == ["a", "b"]
|
||||
assert real.tool_shed == "toolshed.example.com"
|
||||
|
||||
|
||||
def test_shed_metadata_derives_guid_and_installed_changeset_revision():
|
||||
e = _entry(
|
||||
id="toolshed.example.com/repos/owner/repo/tool/1.0",
|
||||
changeset_revision="abc123",
|
||||
tool_shed="toolshed.example.com",
|
||||
repository_name="repo",
|
||||
repository_owner="owner",
|
||||
)
|
||||
t = _stub(e)
|
||||
assert t.guid == "toolshed.example.com/repos/owner/repo/tool/1.0"
|
||||
assert t.installed_changeset_revision == "abc123"
|
||||
assert t.tool_shed == "toolshed.example.com"
|
||||
|
||||
|
||||
def test_old_id_short_circuits_for_shed_ids():
|
||||
e = _entry(id="toolshed.example.com/repos/owner/repo/cat_tool/1.0")
|
||||
assert _stub(e).old_id == "cat_tool"
|
||||
assert _stub(_entry(id="local_tool")).old_id == "local_tool"
|
||||
|
||||
|
||||
def test_to_dict_fast_path_does_not_materialise():
|
||||
t = _stub()
|
||||
d = t.to_dict(trans=None, link_details=False)
|
||||
assert d["id"] == "bowtie2"
|
||||
assert d["model_class"] == "Tool"
|
||||
assert d["link"] == "/api/tools/bowtie2"
|
||||
|
||||
|
||||
def test_to_dict_link_details_materialises_exactly_once():
|
||||
calls = []
|
||||
|
||||
class _Real:
|
||||
def to_dict(self, trans, link_details, tool_help, **kw):
|
||||
calls.append("real-to_dict")
|
||||
return {"id": "real"}
|
||||
|
||||
def mat(_e):
|
||||
calls.append("mat")
|
||||
return _Real()
|
||||
|
||||
t = LazyTool(_entry(), materialize_callback=mat, is_admin_user=lambda u: False)
|
||||
assert t.to_dict(trans=None, link_details=True) == {"id": "real"}
|
||||
assert t.to_dict(trans=None, link_details=True) == {"id": "real"}
|
||||
assert calls == ["mat", "real-to_dict", "real-to_dict"]
|
||||
|
||||
|
||||
def test_allow_user_access_uses_index_data_without_materialise():
|
||||
t = _stub(_entry(require_login=True))
|
||||
assert t.allow_user_access(user=None) is False
|
||||
|
||||
class _U:
|
||||
id = 1
|
||||
|
||||
assert t.allow_user_access(user=_U()) is True
|
||||
|
||||
|
||||
def test_allow_user_access_blocks_non_admin_for_data_manager():
|
||||
e = _entry(tool_type="data_manager", require_login=False)
|
||||
t = _stub(e, is_admin=lambda u: False)
|
||||
|
||||
class _U:
|
||||
id = 5
|
||||
|
||||
assert t.allow_user_access(user=_U(), attempting_access=False) is False
|
||||
|
||||
|
||||
def test_allow_user_access_allows_admin_for_data_manager():
|
||||
e = _entry(tool_type="data_manager", require_login=False)
|
||||
t = _stub(e, is_admin=lambda u: True)
|
||||
|
||||
class _U:
|
||||
id = 5
|
||||
|
||||
assert t.allow_user_access(user=_U()) is True
|
||||
|
||||
|
||||
def test_strict_getattr_raises_with_clear_message():
|
||||
t = _stub()
|
||||
with pytest.raises(NotImplementedError) as ei:
|
||||
_ = t.tool_action
|
||||
assert "tool_action" in str(ei.value)
|
||||
assert "bowtie2" in str(ei.value)
|
||||
|
||||
|
||||
def test_underscore_attrs_surface_as_attribute_error():
|
||||
t = _stub()
|
||||
with pytest.raises(AttributeError):
|
||||
_ = t.__some_dunder_thing__
|
||||
|
||||
|
||||
def test_materialize_ok_set_forwards_to_real_tool(caplog):
|
||||
class _Real:
|
||||
to_archive = "archive-payload"
|
||||
|
||||
t = LazyTool(_entry(), materialize_callback=lambda _e: _Real(), is_admin_user=lambda u: False)
|
||||
assert t.to_archive == "archive-payload"
|
||||
|
||||
|
||||
def test_permissive_flag_warns_and_materialises(monkeypatch, caplog):
|
||||
import galaxy.tools.lazy_toolbox as mod
|
||||
|
||||
monkeypatch.setattr(mod, "_LAZY_TOOL_PERMISSIVE", True)
|
||||
|
||||
class _Real:
|
||||
weird_attr = "warm"
|
||||
|
||||
t = _stub(materialize=lambda _e: _Real())
|
||||
caplog.set_level(logging.WARNING, logger="galaxy.tools.lazy_toolbox")
|
||||
assert t.weird_attr == "warm"
|
||||
assert any("LAZY_TOOL_PERMISSIVE" in rec.getMessage() for rec in caplog.records)
|
||||
|
||||
|
||||
def test_lineage_slot_settable_and_readable():
|
||||
t = _stub()
|
||||
assert t.lineage is None
|
||||
t._lineage = object()
|
||||
assert t.lineage is t._lineage
|
||||
|
||||
|
||||
def test_macro_paths_returns_empty_class_default():
|
||||
t = _stub()
|
||||
assert tuple(t._macro_paths) == ()
|
||||
with pytest.raises(AttributeError):
|
||||
t._macro_paths = ["x"]
|
||||
|
||||
|
||||
def test_config_file_reflects_entry_source_path():
|
||||
e = _entry()
|
||||
e.source_path = "/galaxy/tools/foo.xml"
|
||||
assert _stub(e).config_file == "/galaxy/tools/foo.xml"
|
||||
assert _stub(_entry()).config_file is None
|
||||
|
||||
|
||||
# --- LazyToolBox.create_tool seam ---
|
||||
|
||||
|
||||
def _seam_box():
|
||||
box = LazyToolBox.__new__(LazyToolBox)
|
||||
box._tool_index = ToolIndex()
|
||||
box._store = MagicMock()
|
||||
box._store.get_by_source_path.return_value = None
|
||||
box.app = MagicMock()
|
||||
box.app.config.is_admin_user = lambda u: False
|
||||
return box
|
||||
|
||||
|
||||
def test_create_tool_returns_lazytool_on_guid_hit():
|
||||
box = _seam_box()
|
||||
box._tool_index.entries["bowtie2"] = _entry()
|
||||
tool = box.create_tool(config_file=None, guid="bowtie2")
|
||||
assert isinstance(tool, LazyTool)
|
||||
assert tool.id == "bowtie2"
|
||||
|
||||
|
||||
def test_create_tool_returns_lazytool_on_source_path_hit():
|
||||
box = _seam_box()
|
||||
box._tool_index.entries["bowtie2"] = _entry()
|
||||
box._store.get_by_source_path.return_value = StoredToolSource(
|
||||
hash="abc",
|
||||
tool_source_class="XmlToolSource",
|
||||
raw_source="<tool/>",
|
||||
tool_id="bowtie2",
|
||||
tool_dir=None,
|
||||
source_path="/tools/bowtie2.xml",
|
||||
stored_at=datetime.utcnow(),
|
||||
)
|
||||
tool = box.create_tool(config_file="/tools/bowtie2.xml")
|
||||
assert isinstance(tool, LazyTool)
|
||||
assert tool.id == "bowtie2"
|
||||
|
||||
|
||||
def test_resolve_index_entry_returns_none_when_nothing_matches():
|
||||
box = _seam_box()
|
||||
assert box._resolve_index_entry(None, None) is None
|
||||
|
||||
|
||||
def test_load_tool_from_cache_returns_none():
|
||||
box = _seam_box()
|
||||
assert box.load_tool_from_cache("any/path.xml") is None
|
||||
|
||||
|
||||
def test_add_tool_to_cache_is_noop():
|
||||
box = _seam_box()
|
||||
assert box.add_tool_to_cache(object(), "any/path.xml") is None
|
||||
Reference in New Issue
Block a user