diff --git a/lib/galaxy/tools/lazy_toolbox.py b/lib/galaxy/tools/lazy_toolbox.py index 68a8a4a40d5..31b1d235a8e 100644 --- a/lib/galaxy/tools/lazy_toolbox.py +++ b/lib/galaxy/tools/lazy_toolbox.py @@ -1349,15 +1349,27 @@ class LazyToolBox(ToolBox): """ if self._store is None: raise RuntimeError(f"LazyTool materialise needs a tool source store (id={entry.id!r})") - stored = self._store.get(entry.source_hash) + stored = self._stored_source_for_entry(entry) if stored is None: raise RuntimeError( - f"LazyTool materialise: source missing from store (id={entry.id!r}, hash={entry.source_hash!r})" + "LazyTool materialise: indexed source missing from store " + f"(id={entry.id!r}, path={entry.source_path!r}, hash={entry.source_hash!r})" ) tool = self._create_tool_from_stored_source(stored, entry=entry) self._register_loaded_tool(tool) return tool + def _stored_source_for_entry(self, entry: ToolIndexEntry) -> StoredToolSource | None: + """Resolve path-specific source metadata without ambiguous hash lookup.""" + if self._store is None: + return None + if entry.source_path is None: + return self._store.get(entry.source_hash) + stored = self._store.get_by_source_path(entry.source_path) + if stored is None or stored.hash != entry.source_hash: + return None + return stored + def _load_tool_on_demand(self, tool_id: str, tool_version: str | None = None) -> Optional["Tool"]: """ Load a tool from the store on-demand. @@ -1391,9 +1403,14 @@ class LazyToolBox(ToolBox): return None # Load source from store - stored = self._store.get(entry.source_hash) + stored = self._stored_source_for_entry(entry) if not stored: - log.warning(f"Tool source not found for {tool_id} (hash: {entry.source_hash})") + log.warning( + "Indexed tool source not found for %s (path: %s, hash: %s)", + tool_id, + entry.source_path, + entry.source_hash, + ) return None # Create Tool object @@ -1770,8 +1787,7 @@ class LazyToolBox(ToolBox): """ result = super().remove_tool_by_id(tool_id, remove_from_panel=remove_from_panel) if self._tool_index is not None: - self._tool_index.entries.pop(tool_id, None) - self._tool_index.entries_by_version.pop(tool_id, None) + self._tool_index.remove_entry(tool_id) # In-place membership change on the same ToolIndex object: the # identity-keyed sibling-versions cache would otherwise keep # serving the removed version to lineage lookups. @@ -1983,7 +1999,9 @@ class LazyToolBox(ToolBox): req_tuple = (req.get("name"), req.get("version"), req.get("type")) requirements.add(req_tuple) return [ - {"name": r[0], "version": r[1], "type": r[2]} for r in requirements if r[0] # Filter out empty names + {"name": r[0], "version": r[1], "type": r[2]} + for r in requirements + if r[0] # Filter out empty names ] return [] diff --git a/test/unit/app/tools/test_lazy_tool.py b/test/unit/app/tools/test_lazy_tool.py index 6c786dad64e..e0fbce9cb9d 100644 --- a/test/unit/app/tools/test_lazy_tool.py +++ b/test/unit/app/tools/test_lazy_tool.py @@ -333,6 +333,37 @@ def test_resolve_index_entry_returns_none_when_nothing_matches(): assert box._resolve_index_entry(None, None) is None +def test_stored_source_for_entry_uses_source_path_for_identical_content(): + box = _seam_box() + entry = _entry(source_path="/tools/b/upload.xml", source_hash="same_hash") + expected = StoredToolSource( + hash="same_hash", + tool_source_class="XmlToolSource", + raw_source="", + tool_id="upload1", + tool_dir="/tools/b", + source_path=entry.source_path, + ) + box._store.get_by_source_path.return_value = expected + + assert box._stored_source_for_entry(entry) is expected + box._store.get.assert_not_called() + + +def test_stored_source_for_entry_rejects_stale_path_row(): + box = _seam_box() + entry = _entry(source_path="/tools/b/upload.xml", source_hash="current_hash") + box._store.get_by_source_path.return_value = StoredToolSource( + hash="stale_hash", + tool_source_class="XmlToolSource", + raw_source="", + source_path=entry.source_path, + ) + + assert box._stored_source_for_entry(entry) is None + box._store.get.assert_not_called() + + def _reconcile_box(index_hashes, store_hashes): box = _seam_box() box._store.read_only = False