Hoist lazy-branch local imports to module level

The only local import left is lazy_toolbox's send_control_task, which
is a genuine cycle: galaxy.queue_worker imports LazyToolBox at module
level. Everything else - app boot, queue_worker, tool_panel_manager,
services/tools, search, populator, benchmarks, uses_shed and the test
modules - now imports at the top; the claimed app/tools circularity
never existed (galaxy.app already imports galaxy.tools).

Claude-Session: https://claude.ai/code/session_018L7ZmCv2ubKA3JNeSL8Pkr
This commit is contained in:
mvdbeek
2026-07-28 17:27:28 +02:00
parent 0b0eb9d3b5
commit 26249d3da6
10 changed files with 36 additions and 88 deletions
+9 -16
View File
@@ -176,10 +176,15 @@ from galaxy.tools.data import ToolDataTableManager
from galaxy.tools.data_manager.manager import DataManagers
from galaxy.tools.error_reports import ErrorReports
from galaxy.tools.evaluation import ToolTemplatingException
from galaxy.tools.lazy_toolbox import LazyToolBox
from galaxy.tools.search import (
LazyToolboxSearch,
ToolBoxSearch,
)
from galaxy.tools.source_store import (
build_tool_source_store,
ToolSourceStore,
)
from galaxy.tools.special_tools import load_lib_tools
from galaxy.tours import (
build_tours_registry,
@@ -425,12 +430,6 @@ class MinimalGalaxyApplication(BasicSharedApp, HaltableContainer, SentryClientMi
from ``build_tool_source_store`` — we let it propagate so the
operator sees the failure at startup.
"""
# Local import: avoids a circular import between galaxy.app and galaxy.tools.
from galaxy.tools.source_store import (
build_tool_source_store,
ToolSourceStore,
)
self.tool_source_store: ToolSourceStore | None = None
if not self.config.use_lazy_toolbox:
return
@@ -459,14 +458,11 @@ class MinimalGalaxyApplication(BasicSharedApp, HaltableContainer, SentryClientMi
def _shutdown_lazy_toolbox(self) -> None:
toolbox = getattr(self, "_toolbox", None)
# Lazy import: only relevant when the lazy path is in use.
try:
from galaxy.tools.lazy_toolbox import LazyToolBox
if isinstance(toolbox, LazyToolBox):
if isinstance(toolbox, LazyToolBox):
try:
toolbox.close()
except Exception as e:
log.debug(f"_shutdown_lazy_toolbox: {e}")
except Exception as e:
log.debug(f"_shutdown_lazy_toolbox: {e}")
def _use_lazy_toolbox(self) -> bool:
"""Determine whether to use LazyToolBox instead of regular ToolBox.
@@ -482,9 +478,6 @@ class MinimalGalaxyApplication(BasicSharedApp, HaltableContainer, SentryClientMi
def _create_lazy_toolbox(self) -> "tools.ToolBox":
"""Create a LazyToolBox instance."""
# Lazy import: avoids circular import between galaxy.app and galaxy.tools.
from galaxy.tools.lazy_toolbox import LazyToolBox
cache_size = self.config.lazy_toolbox_cache_size
log.info(f"Using LazyToolBox with cache_size={cache_size}")
+1 -5
View File
@@ -37,6 +37,7 @@ from galaxy.managers.sse import (
from galaxy.model import User
from galaxy.tools import ToolBox
from galaxy.tools.data_manager.manager import DataManagers
from galaxy.tools.lazy_toolbox import LazyToolBox
from galaxy.tools.special_tools import load_lib_tools
logging.getLogger("kombu").setLevel(logging.WARNING)
@@ -290,9 +291,6 @@ def _get_new_toolbox(app: "UniverseApplication", save_integrated_tool_panel: boo
new_toolbox: ToolBox
if getattr(app.config, "use_lazy_toolbox", False) and getattr(app, "tool_source_store", None) is not None:
# Lazy import: avoids circular import between galaxy.queue_worker and galaxy.tools.
from galaxy.tools.lazy_toolbox import LazyToolBox
new_toolbox = LazyToolBox(
config_filenames=tool_configs,
tool_root_dir=app.config.tool_path,
@@ -399,8 +397,6 @@ def reload_tool_source_cache(app, **kwargs):
This is typically triggered by an external process (like populate_store.py --watch)
when tool files change on disk.
"""
from galaxy.tools.lazy_toolbox import LazyToolBox
log.debug("Executing tool source cache reload on '%s'", app.config.server_name)
# Invalidate the lazy toolbox cache if the active toolbox is a LazyToolBox.
@@ -11,6 +11,8 @@ from galaxy.tool_shed.galaxy_install.client import InstallationTarget
from galaxy.tool_shed.util.basic_util import strip_path
from galaxy.tool_shed.util.repository_util import get_repository_owner
from galaxy.tool_shed.util.shed_util_common import get_tool_panel_config_tool_path_install_dir
from galaxy.tool_util.toolbox.base import resolve_tool_path
from galaxy.tools.source_store.populator import populate_for_paths
from galaxy.util import (
Element,
parse_xml_string,
@@ -31,14 +33,9 @@ def _collect_new_tool_paths(elem_list, tool_path: str, shed_tool_conf: str) -> d
— either top-level ``<tool>`` elements or ``<section>`` elements with
nested ``<tool>`` children. Paths must match what
``galaxy.tools.source_store.discover.discover_tools`` yields for the
rewritten conf byte-for-byte (the partial populate filters on the string):
a relative ``tool_path`` resolves against the conf file's directory, not
the process cwd, so route through the same ``resolve_tool_path``.
rewritten conf byte-for-byte (the partial populate filters on the string),
so route through the same ``resolve_tool_path`` discover uses.
"""
# Local import: keeps galaxy.tools.source_store out of the eager
# tool-shed install path's module graph.
from galaxy.tools.source_store.discover import resolve_tool_path
resolved_base = resolve_tool_path(tool_path, shed_tool_conf)
path_guids: dict[str, str | None] = {}
@@ -178,8 +175,6 @@ class ToolPanelManager:
load_elem_list, tool_path, shed_tool_conf_dict["config_filename"]
)
if new_path_guids:
from galaxy.tools.source_store.populator import populate_for_paths
populate_for_paths(
self.app.config,
paths=list(new_path_guids),
+2
View File
@@ -1323,6 +1323,8 @@ class LazyToolBox(ToolBox):
# broadcasts an invalidation; removals must broadcast too or
# peer web workers keep serving the uninstalled tool until an
# unrelated populate happens to run.
# Local import: genuine circularity — galaxy.queue_worker
# imports LazyToolBox at module level.
from galaxy.queue_worker import send_control_task
try:
+8 -10
View File
@@ -57,6 +57,14 @@ from whoosh.scoring import (
from whoosh.writing import AsyncWriter
from galaxy.config import GalaxyAppConfiguration
from galaxy.tools.source_store.populator import (
DEFAULT_STORE_NAME,
whoosh_dir_for_store,
)
from galaxy.tools.source_store.search import (
ToolSearchTuning,
ToolWhooshIndex,
)
from galaxy.util import (
ExecutionTimer,
unicodify,
@@ -156,16 +164,6 @@ class LazyToolboxSearch(ToolBoxSearch):
self.index_count += 1
def search(self, q: str, panel_view: str, config: GalaxyAppConfiguration) -> list[str]:
# Lazy import: avoids pulling populator's deps into module load.
from galaxy.tools.source_store.populator import (
DEFAULT_STORE_NAME,
whoosh_dir_for_store,
)
from galaxy.tools.source_store.search import (
ToolSearchTuning,
ToolWhooshIndex,
)
if not config.tool_search_index_dir:
# No index dir means whoosh search is off entirely.
return []
+1 -12
View File
@@ -12,6 +12,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from galaxy.tool_util.parser.factory import TOOL_SOURCE_FACTORIES
from .index import (
ToolIndex,
ToolIndexEntry,
@@ -162,12 +163,6 @@ class ToolSourceBenchmarks:
print("No tools found for XML parsing benchmark")
return None
try:
from galaxy.tool_util.parser.factory import TOOL_SOURCE_FACTORIES
except ImportError:
print("Could not import TOOL_SOURCE_FACTORIES")
return None
# Use a representative tool
path, content = tools[0]
factory = TOOL_SOURCE_FACTORIES.get("XmlToolSource")
@@ -188,12 +183,6 @@ class ToolSourceBenchmarks:
print("No tools found for deserialization benchmark")
return None
try:
from galaxy.tool_util.parser.factory import TOOL_SOURCE_FACTORIES
except ImportError:
print("Could not import TOOL_SOURCE_FACTORIES")
return None
path, content = tools[0]
# Simulate DB storage format
+1 -6
View File
@@ -8,7 +8,6 @@ from typing import (
cast,
get_args,
Optional,
TYPE_CHECKING,
)
from uuid import UUID
@@ -62,14 +61,12 @@ from galaxy.tool_util_models.parameters import (
)
from galaxy.tools import Tool
from galaxy.tools._types import InputFormatT
from galaxy.tools.lazy_toolbox import LazyToolBox
from galaxy.tools.search import ToolBoxSearch
from galaxy.util.path import safe_contains
from galaxy.webapps.galaxy.services._fetch_util import validate_and_normalize_targets
from galaxy.webapps.galaxy.services.base import ServiceBase
if TYPE_CHECKING:
from galaxy.tools.lazy_toolbox import LazyToolBox
log = logging.getLogger(__name__)
ToolRunPayload = dict[str, Any]
@@ -563,8 +560,6 @@ class ToolsService(ServiceBase):
def _get_lazy_toolbox(self, trans: ProvidesUserContext) -> Optional["LazyToolBox"]:
"""Return the active toolbox if it's a LazyToolBox, else None."""
from galaxy.tools.lazy_toolbox import LazyToolBox
toolbox = trans.app.toolbox
return toolbox if isinstance(toolbox, LazyToolBox) else None
+1 -2
View File
@@ -10,6 +10,7 @@ from unittest import SkipTest
log = logging.getLogger(__name__)
from galaxy.app import UniverseApplication
from galaxy.tools.source_store.populator import reconcile_index
from galaxy.util.tool_shed.tool_shed_registry import DEFAULT_TOOL_SHED_URL
from galaxy.util.unittest_utils import is_site_up
from galaxy_test.base.populators import DEFAULT_TIMEOUT
@@ -90,8 +91,6 @@ class UsesShed(UsesShedApi):
# would otherwise survive the ``reload_toolbox`` below: ``LazyToolBox``
# only re-runs the populator when discovery turns up a *new* path.
try:
from galaxy.tools.source_store.populator import reconcile_index
reconcile_index(self._app.config, rebuild_whoosh=True)
except Exception as e:
log.warning("reset_shed_tools: reconcile_index raised (continuing): %s", e)
+5 -17
View File
@@ -12,6 +12,11 @@ import tempfile
import pytest
from galaxy.queue_worker import reload_toolbox
from galaxy.tools.lazy_toolbox import LazyToolBox
from galaxy.tools.source_store import ToolIndex
from galaxy.tools.source_store.composite import CompositeToolSourceStore
from galaxy.tools.source_store.sqlalchemy import SqlAlchemyToolSourceStore
from galaxy_test.base.populators import DatasetPopulator
from galaxy_test.driver import integration_util
@@ -70,8 +75,6 @@ class TestSqliteToolSourceStorage(BaseToolSourceStorageIntegrationTestCase):
self._test_api_tools_show()
def test_default_store_is_sqlalchemy_backend(self):
from galaxy.tools.source_store.sqlalchemy import SqlAlchemyToolSourceStore
assert isinstance(self._app.tool_source_store, SqlAlchemyToolSourceStore)
@@ -93,8 +96,6 @@ class TestCompositeToolSourceStorage(BaseToolSourceStorageIntegrationTestCase):
cls._tmpdir = tempfile.mkdtemp(prefix="composite_tss_")
cls._sqlite_path = os.path.join(cls._tmpdir, "sources.sqlite")
from galaxy.tools.source_store.sqlalchemy import SqlAlchemyToolSourceStore
SqlAlchemyToolSourceStore(url=f"sqlite:///{cls._sqlite_path}").count()
cls._conf_path = os.path.join(cls._tmpdir, "extra_tool_conf.xml")
@@ -120,8 +121,6 @@ class TestCompositeToolSourceStorage(BaseToolSourceStorageIntegrationTestCase):
# is enabled. Verifying the live app's store directly is more
# robust than relying on /api/tools, which depends on whether the
# store was populated in advance.
from galaxy.tools.source_store.composite import CompositeToolSourceStore
assert isinstance(self._app.tool_source_store, CompositeToolSourceStore)
def test_api_tools_list_populated_via_bootstrap(self):
@@ -165,8 +164,6 @@ class TestLazyToolBoxReload(BaseToolSourceStorageIntegrationTestCase):
config["use_lazy_toolbox"] = True
def test_base_tools_survive_toolbox_reload(self):
from galaxy.queue_worker import reload_toolbox
self._test_api_tools_show("cat1")
assert self._app.toolbox.get_tool("upload1") is not None
reload_toolbox(self._app)
@@ -180,9 +177,6 @@ class TestLazyToolBoxReload(BaseToolSourceStorageIntegrationTestCase):
# with its own, much smaller view, and the peer broadcast invalidates
# our store cache. A subsequent reload must serve THIS instance's
# tools from its inline repopulate, not the foreign index.
from galaxy.tools.source_store import ToolIndex
from galaxy.tools.source_store.sqlalchemy import SqlAlchemyToolSourceStore
store = self._app.tool_source_store
assert store is not None
connection = self._app.config.tool_source_database_connection
@@ -195,8 +189,6 @@ class TestLazyToolBoxReload(BaseToolSourceStorageIntegrationTestCase):
assert upload_stored
store.delete(upload_stored[0].hash)
from galaxy.queue_worker import reload_toolbox
old_toolbox = self._app.toolbox
reload_toolbox(self._app)
assert self._app.toolbox is not old_toolbox
@@ -211,8 +203,6 @@ class TestLazyToolBoxReload(BaseToolSourceStorageIntegrationTestCase):
for source_hash in list(store.list_all()):
store.delete(source_hash)
store.invalidate_index_cache()
from galaxy.queue_worker import reload_toolbox
reload_toolbox(self._app)
assert self._app.toolbox.get_tool("upload1") is not None
self._test_api_tools_show("cat1")
@@ -390,8 +380,6 @@ class TestLazyToolBoxApi(BaseToolSourceStorageIntegrationTestCase):
# batch surface instead. LAZY_TOOL_STRICT covers off-surface reads;
# this also covers the ones strict can't (a filter that parses, an
# _MATERIALIZE_OK attr read in a loop).
from galaxy.tools.lazy_toolbox import LazyToolBox
toolbox = self._app.toolbox
# This class self-enables lazy mode; assert it (and narrow the type so
# mypy accepts the lazy-only _lazy_materialize_count counter).
+4 -11
View File
@@ -8,6 +8,10 @@ from unittest.mock import MagicMock
import pytest
import galaxy.queue_worker as queue_worker_mod
import galaxy.tools.lazy_toolbox as mod
from galaxy.tool_util.toolbox.lineages.factory import LazyLineageMap
from galaxy.tool_util.toolbox.panel import ToolPanelElements
from galaxy.tools.lazy_toolbox import (
LazyTool,
LazyToolBox,
@@ -230,8 +234,6 @@ def test_strict_getattr_raises_with_clear_message(monkeypatch):
# Strict mode is opt-in via LAZY_TOOL_STRICT=1; permissive (materialise
# on unknown attr with WARN) is the default. Flip the module-level flag
# for this test so the strict path fires.
import galaxy.tools.lazy_toolbox as mod
monkeypatch.setattr(mod, "_LAZY_TOOL_PERMISSIVE", False)
t = _stub()
with pytest.raises(NotImplementedError) as ei:
@@ -255,8 +257,6 @@ def test_materialize_ok_set_forwards_to_real_tool(caplog):
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:
@@ -337,8 +337,6 @@ def test_create_tool_populates_adhoc_for_existing_file(tmp_path, monkeypatch):
# Shed installs load cloned tools during metadata generation, before
# any conf is persisted — a miss for an on-disk file populates that
# path instead of raising.
import galaxy.tools.lazy_toolbox as mod
guid = "toolshed.example.com/repos/owner/repo/cloned/1.0"
tool_file = tmp_path / "cloned.xml"
tool_file.write_text("<tool id='cloned' version='1.0'/>")
@@ -451,9 +449,6 @@ def _registry_box():
removal bookkeeping paths run against genuine data structures."""
import threading
from galaxy.tool_util.toolbox.lineages.factory import LazyLineageMap
from galaxy.tool_util.toolbox.panel import ToolPanelElements
box = _seam_box()
box._tools_by_id = {}
box._tool_versions_by_id = {}
@@ -503,8 +498,6 @@ def test_invalidate_index_cache_keeps_unindexed_tools():
def test_remove_tool_by_id_broadcasts_reload_to_peers(monkeypatch):
import galaxy.queue_worker as queue_worker_mod
box = _registry_box()
box._tool_index = ToolIndex()
entry = _entry(id="doomed")