Merge pull request #23067 from mvdbeek/store-infra

Add pluggable tool source store infrastructure
This commit is contained in:
Marius van den Beek
2026-07-15 10:31:32 +02:00
committed by GitHub
45 changed files with 6466 additions and 67 deletions
+39
View File
@@ -420,6 +420,45 @@
:Type: str
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``tool_source_database_connection``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:Description:
SQLAlchemy connection string for the tool source store, a
rebuildable cache of pre-parsed tool sources kept outside Galaxy's
main database. Multi-host deployments should point every Galaxy
process at the same URI, such as a SQLite file on a shared
filesystem.
Sample default ``sqlite:///<data_dir>/tool_sources.sqlite``.
Populate the store with: python
scripts/tool_source/populate_store.py
For details see
https://docs.galaxyproject.org/en/master/admin/tool_source_storage.html
:Default: ``None``
:Type: str
~~~~~~~~~~~~~~~~~~~~~~
``tool_source_stores``
~~~~~~~~~~~~~~~~~~~~~~
:Description:
Optional named tool source stores referenced from individual
tool_conf files via a top-level ``store="<name>"`` attribute (XML)
or ``store: <name>`` key (YAML). When any tool_conf opts in, the
process composes its named store with the default
(``tool_source_database_connection``) store at runtime, with reads
tried in declared order and writes always landing on the default.
Each entry takes a SQLAlchemy ``url`` and an optional ``read_only:
true`` flag. For SQLite connection-level read-only, use a SQLite
URI with ``mode=ro&uri=true``.
For details see
https://docs.galaxyproject.org/en/master/admin/tool_source_storage.html
:Default: ``None``
:Type: map
~~~~~~~~~~~~~~~~~~~~~~~
``tool_dependency_dir``
~~~~~~~~~~~~~~~~~~~~~~~
+1
View File
@@ -22,6 +22,7 @@ Galaxy Deployment & Administration
ai_agents
enable_headers_in_fetch_requests
tool_panel
tool_source_storage
data_tables
mq
dependency_resolvers
+274
View File
@@ -0,0 +1,274 @@
Tool Source Storage
===================
Overview
--------
By default, Galaxy parses every tool at startup and keeps all of them in
memory. For installations with many tools this:
- Slows down Galaxy startup significantly
- Consumes large amounts of memory in every Galaxy process
Tool source storage addresses this by doing the parsing work once, ahead of
time:
1. Tool sources are pre-parsed (with macros expanded) and stored in a
configurable database backend
2. A lightweight index over the stored tools supports fast tool listings and
search without touching tool files
A toolbox that consumes this store to load tools on demand is planned as
follow-up work; this document covers the store, the populator, and the index
that it will build on.
Configuration
-------------
Tool source storage is configured in ``galaxy.yml``. The following options are available:
Default Store
^^^^^^^^^^^^^
.. code-block:: yaml
galaxy:
# SQLAlchemy URI for storing tool sources.
tool_source_database_connection: sqlite:////srv/galaxy/tool_sources.sqlite
The store lives in a standalone database - a SQLite file under
``<data_dir>/tool_sources.sqlite`` by default - separate from Galaxy's main
database. It is a rebuildable cache: it can be deleted at any time and
recreated by re-running the population script.
Multi-host deployments must point every Galaxy process (web workers *and*
job handlers) at the same store — typically a SQLite file on a shared
filesystem:
.. code-block:: yaml
galaxy:
tool_source_database_connection: sqlite:////shared/galaxy/tool_sources.sqlite
Any other SQLAlchemy-supported database (e.g. PostgreSQL) works as well.
Per-conf Store Routing (CVMFS Recipe)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Individual ``tool_conf`` files can opt into a *named* tool source store,
distinct from the global default. The typical use case is shipping a
read-only SQLite bundle on CVMFS alongside a tool_conf, so worker
processes can resolve every tool in that conf with local-cached lookups
instead of one network round-trip per JSON file.
Declare the named stores under the top-level ``tool_source_stores`` key in
``galaxy.yml``. Each entry takes a SQLAlchemy ``url`` and optional
``read_only`` flag. SQLite is the typical choice for CVMFS bundles (single
self-contained file), but any SQLAlchemy-supported database works:
.. code-block:: yaml
galaxy:
tool_source_database_connection: sqlite:////srv/galaxy/tool_sources.sqlite
tool_source_stores:
cvmfs_main:
url: sqlite:///file:/cvmfs/example.org/tools/sources.sqlite?mode=ro&uri=true
read_only: true
site_shared:
url: sqlite:///file:/shared/galaxy/tool_sources.sqlite?mode=ro&uri=true
read_only: true
Then point the tool_conf at it via the root element's ``store`` attribute
(XML) or top-level key (YAML):
.. code-block:: xml
<?xml version="1.0"?>
<toolbox store="cvmfs_main">
<section id="cvmfs_tools" name="CVMFS Tools">
<tool file="bwa/bwa.xml"/>
...
</section>
</toolbox>
At startup, Galaxy inspects every ``tool_conf`` for the attribute, builds
the referenced stores, and wraps them with the writable default in a
composite store. Reads are tried in declared order (first hit wins) and
writes always go to the default store. If no tool_conf opts in, the
default store is used directly with zero overhead.
**Building the bundle**
Build the SQLite file from a writable host before shipping it:
.. code-block:: console
$ python scripts/tool_source/populate_store.py -c galaxy.yml --target cvmfs_main
Use ``--target`` to restrict population to a single named store; without
it, ``populate_store.py`` populates **every writable store** referenced
from a tool_conf in the same run.
Once the bundle is in place on CVMFS (or any read-only mount), restart
Galaxy. The ``read_only: true`` flag prevents Galaxy from writing through that
store. For SQLite connection-level read-only, use ``mode=ro&uri=true`` in the
SQLite URI as shown above.
Populating the Tool Source Store
--------------------------------
After configuring tool source storage, you need to populate it with your tools.
Use the ``populate_store.py`` script:
Basic Usage
^^^^^^^^^^^
.. code-block:: console
$ python scripts/tool_source/populate_store.py --config /path/to/galaxy.yml
Deployments that install Galaxy from packages get the same command as the
``galaxy-populate-tool-source-store`` console script (shipped with the
``galaxy-app`` package), so no Galaxy source checkout is needed:
.. code-block:: console
$ galaxy-populate-tool-source-store --config /path/to/galaxy.yml
This will:
1. Discover tools from your tool configs (uses the same logic as Galaxy startup)
2. Parse each tool (with macro expansion) and compute a content hash
3. Store the tool sources in the configured backend (skipping unchanged tools)
Note: ``--config`` is required; the script does not assume a default path.
Command Line Options
^^^^^^^^^^^^^^^^^^^^
.. code-block:: console
$ python scripts/tool_source/populate_store.py --help
Options:
--config, -c PATH Galaxy configuration file (required)
--dry-run Show what would be stored without storing
--incremental Only store new/changed tools (default)
--full Force re-store of all tools
--tool-id PATTERN Only process tools whose ID contains PATTERN
--parallel, -j N Number of parallel workers (default: 4)
--rebuild-index Rebuild the tool index after population
--target NAME Restrict to a single named store from
tool_source_stores (or '__default__'). Without
this, every writable store is populated.
--verbose, -v Verbose output
--watch, -w Watch tool directories and send reload notifications
--watch-polling Use polling observer (for NFS/CVMFS/network FS)
Examples
^^^^^^^^
**Initial population:**
.. code-block:: console
$ python scripts/tool_source/populate_store.py -c /path/to/galaxy.yml
**Force re-store everything (e.g., after a parser change):**
.. code-block:: console
$ python scripts/tool_source/populate_store.py -c galaxy.yml --full
**Process only a subset of tools:**
.. code-block:: console
$ python scripts/tool_source/populate_store.py -c galaxy.yml --tool-id samtools
Automation with Cron
^^^^^^^^^^^^^^^^^^^^
For installations where tools are frequently updated, you can run the population
script on a schedule:
.. code-block:: cron
# Update tool source store every hour (incremental is the default)
0 * * * * /path/to/galaxy/.venv/bin/python /path/to/galaxy/scripts/tool_source/populate_store.py -c /path/to/galaxy.yml >> /var/log/galaxy/tool_source_update.log 2>&1
Watch Mode (Live Updates)
^^^^^^^^^^^^^^^^^^^^^^^^^
As an alternative to cron, you can run the population script in watch mode to
keep the store continuously up to date. This uses ``watchdog`` to monitor tool
directories for changes and automatically updates the store, then sends a
notification via Kombu to trigger cache reloads in all Galaxy processes.
.. code-block:: console
$ python scripts/tool_source/populate_store.py --config galaxy.yml --watch
Watch mode options:
- ``--watch, -w`` - Enable watch mode
- ``--watch-polling`` - Use polling observer (required for network filesystems like NFS/CVMFS)
Example with polling for network filesystem:
.. code-block:: console
$ python scripts/tool_source/populate_store.py -c galaxy.yml --watch --watch-polling
**Requirements:**
- The ``watchdog`` library must be installed: ``pip install watchdog``
- Galaxy must have ``amqp_internal_connection`` configured for Kombu notifications
- All Galaxy processes must be connected to the same AMQP broker
When a tool XML file changes, watch mode will:
1. Detect the file change (with debouncing to handle rapid edits)
2. Re-parse the tool and update the store
3. Send a ``reload_tool_source_cache`` control message via Kombu
4. All Galaxy processes will invalidate their local caches
This is useful for:
- Installations using shared storage where tools may be updated externally
- CI/CD pipelines that deploy tool updates
- Development environments where tools are being actively edited
Troubleshooting
---------------
Tools not appearing in the index
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1. Re-run the population script with verbose output:
.. code-block:: console
$ python scripts/tool_source/populate_store.py -c galaxy.yml -v
2. Check for parsing errors in the Galaxy log
Populating an existing installation
-----------------------------------
To set up tool source storage on an existing Galaxy installation:
1. Add the configuration to ``galaxy.yml``:
.. code-block:: yaml
galaxy:
tool_source_database_connection: sqlite:////srv/galaxy/tool_sources.sqlite
2. Run the population script:
.. code-block:: console
$ python scripts/tool_source/populate_store.py -c /path/to/galaxy.yml
3. Restart Galaxy
+1
View File
@@ -20,6 +20,7 @@ A multi-hour long video playlist covering these slides can be found at
data_managers
data_source
data_types
tool_source_storage
faq
writing_tests
debugging_tests
+215
View File
@@ -0,0 +1,215 @@
Tool Source Storage Architecture
================================
This document describes the architecture of the tool source storage subsystem:
the store backends, the populator, and the index they build. For operator-facing
setup and configuration, see :doc:`/admin/tool_source_storage`.
A toolbox that consumes this store to load tools on demand is planned as
follow-up work; the pieces documented here are the storage layer it will build on.
Goals
-----
The traditional ``ToolBox`` parses every tool XML at startup, builds full
``Tool`` objects, and keeps them all in memory. With thousands of tools that
scales poorly: slow boot, large per-process RSS, and expensive worker reloads.
The tool source storage subsystem moves that parsing work out of the request
path:
- A separate process (``populate_store.py``) parses tools once and persists
the canonical, macro-expanded source plus a lightweight metadata index.
- The store and index are laid out so a consumer can load only the index at
startup and materialize ``Tool`` objects on demand, instead of parsing the
full tree in-process. That consumer is the planned follow-up toolbox.
Module Layout
-------------
::
lib/galaxy/tools/source_store/
__init__.py Public re-exports
interface.py ToolSourceStore ABC and StoredToolSource
factory.py Store construction from Galaxy configuration
sqlalchemy.py SqlAlchemyToolSourceStore (any SQLAlchemy URL)
composite.py CompositeToolSourceStore (per-conf routing, merged index)
index.py ToolIndex, ToolIndexEntry (the lightweight metadata)
search.py ToolWhooshIndex (Whoosh search index built from a ToolIndex)
discover.py discover_tools() — conf walk without booting a ToolBox
populator.py Population + watch logic (parse, store, index, broadcast)
freshness.py Optional external freshness probes
watcher.py Filesystem watch support
scripts/tool_source/populate_store.py Thin CLI wrapper over populator.main
The same ``populator.main`` is registered as the
``galaxy-populate-tool-source-store`` console script in the ``galaxy-app``
package metadata (``packages/app/pyproject.toml``).
Data Model
----------
Two persistence concepts:
**StoredToolSource** — the canonical macro-expanded XML/YAML for a tool,
keyed by SHA-256 of the expanded content. Multiple versions of the same
``tool_id`` coexist as separate hashes. The store keeps its own schema in a
standalone database (a SQLite file by default, any SQLAlchemy URL for shared
deployments) — deliberately outside Galaxy's database: the store is a
rebuildable cache and does not participate in Galaxy's migrations or session
lifecycle.
**ToolIndex** — a Pydantic model containing one default ``ToolIndexEntry`` per tool
plus its versioned and panel-placement projections,
holding everything a store consumer needs (id, name, description, panel section,
labels, EDAM, requirements, container info, test counts, hidden/disabled,
shed metadata). The index is serialized and gzip-compressed as a blob.
The schema is auto-created on first open; ``tool_index`` holds a single
row per index version.
Backend Abstraction
-------------------
``ToolSourceStore`` (in ``tools/source_store/interface.py``) is an ABC defining:
- ``store/get/exists/delete/list_all/get_by_tool_id/count`` — per-tool source
operations, all keyed by content hash.
- ``store_index/load_index/update_index_entry`` — index operations.
- ``get_stats()`` — backend-specific stats (count, size, backend name).
``build_tool_source_store(config)`` is the only entry point used
by Galaxy. It builds the default store from
``config.tool_source_database_connection`` and uses the same SQLAlchemy-backed
store implementation for all configured URIs. ``ConfigurationError`` is raised
for missing required settings and is allowed to propagate up so
misconfiguration fails fast at startup.
The ABC defines a ``read_only: bool`` class attribute (default ``False``).
``ReadOnlyStoreError`` is raised by mutating methods of stores that opted
in. The populator, watch reload, and composite all consult this flag to
route around read-only members rather than crashing.
Per-conf composition
^^^^^^^^^^^^^^^^^^^^
If any tool_conf carries a top-level ``store="..."`` attribute (XML root)
or ``store: ...`` key (YAML), ``build_tool_source_store`` instantiates
the referenced named stores from ``config.tool_source_stores`` and wraps
them with the writable default in a :class:`CompositeToolSourceStore`.
The composite implements the same ``ToolSourceStore`` interface, so store
consumers stay completely unaware of the multi-store layout:
- **Reads** iterate ``[per-conf members..., default]`` in order; first
hit wins. ``count`` and ``list_all`` dedupe across members.
- **Writes** always land on the designated default. The default may not
itself be ``read_only``; that's enforced at construction.
- ``load_index()`` calls each member's ``load_index()`` and folds the
entries into a single :class:`ToolIndex`. Earlier members shadow later
ones on tool-id collisions; ``by_section`` is unioned; ``built_at``
takes the most recent value.
- ``invalidate_index_cache()`` fans out so a single Kombu reload hits
every member.
- ``store_to(name, ...)`` lets the populator address a specific member by
name without going through composite write routing.
When no tool_conf opts in, ``build_tool_source_store`` returns the
default store directly — the composite path is zero-cost for the common
case.
The ``sqlalchemy`` backend (``sqlalchemy.py``) was added to make this
useful for CVMFS: a single self-contained ``.sqlite`` file, opened with
its own SQLAlchemy ``MetaData`` (independent of ``galaxy.model``) so the
file is portable, and openable with a SQLite URI such as
``sqlite:///file:/cvmfs/example.org/tools/sources.sqlite?mode=ro&uri=true``
for read-only mounts. Despite the name, the backend is not sqlite-specific -
pass any SQLAlchemy URL (Postgres, MySQL, ...). Auto schema creation runs on
first open; on remote backends operators may prefer to manage migrations
explicitly.
Per-conf populator routing
^^^^^^^^^^^^^^^^^^^^^^^^^^
``scripts/tool_source/populate_store.py`` is per-conf aware. It reads
``parse_store_name()`` for each tool_conf, builds every named store plus
the default, and routes each ``DiscoveredTool.path`` to the store its
conf points at. By default it populates *every* writable store in one
run; ``--target NAME`` restricts to a single store and raises
``ReadOnlyStoreError`` if that store is read-only. Tools whose target is
read-only in default mode are silently skipped (the bundle is treated as
authoritative for those entries).
Discovery
---------
``galaxy.tools.source_store.discover.discover_tools`` walks tool config files
and yields ``DiscoveredTool`` records without booting a full ``ToolBox``. It is
used by:
- the populator to find tools to parse and store.
- watch mode to know which directories to monitor.
- callers that compare on-disk confs against the indexed tool set.
Pulling discovery out of ``ToolBox`` was deliberate: the populator must run
*without* a full app (or even a running Galaxy), and the watch mode must run in
a long-lived loop with no Galaxy process at all.
Population Script
-----------------
``scripts/tool_source/populate_store.py`` is a thin CLI wrapper over
``galaxy.tools.source_store.populator.main``. It loads only the Galaxy
config and calls ``build_tool_source_store(config)``. Converter discovery builds
the datatypes registry, but the standalone process does not initialize the Galaxy
model. Tools are parsed in a
``ThreadPoolExecutor`` (``--parallel``, default 4 workers); each tool is
matched to its source path and carried forward when its raw file hash is unchanged
(``--incremental``, the default). Once the JSON index is committed the
populator rebuilds the Whoosh search index (``search.py``) so ranked tool
search stays in sync with the stored sources.
Watch mode (``--watch``) uses ``watchdog`` to monitor every directory yielded
by ``discover_tools``. File events are debounced (default 2 s), the changed
files are re-parsed, the store is updated, and a single
``reload_tool_source_cache`` Kombu control task is published on the Galaxy
exchange. ``--watch-polling`` switches to ``PollingObserver`` for
NFS/CVMFS/network filesystems where inotify is unreliable.
The broadcast is the populator's half of the contract: it publishes
``reload_tool_source_cache`` so peer processes can drop their stale index
view. The control-task handler that consumes the message lands with the
follow-up toolbox.
Design Notes
------------
**Why a separate index instead of always querying the store?** A consumer
needs O(N) access to N entries; doing that against the backing store on every
request is a latency hit. Keeping the index in-process and only paying for
invalidation on reload is the better tradeoff.
**Why an out-of-process populator?** Parsing tools and computing macro
expansions is expensive and shouldn't block worker startup. Keeping the
populator separate also lets it run on a single host while many web workers
share the resulting store.
**Why hash-keyed storage?** Content-addressed storage gives us cheap
deduplication across versions and shed installations, and idempotent
incremental updates: re-running the populator over an unchanged tree is
effectively a no-op.
Testing
-------
- Store unit tests: ``test/unit/app/tools/source_store/`` exercises each backend
through the ``ToolSourceStore`` interface (``test_stores.py``,
``test_sqlite_store.py``, ``test_composite_store.py``,
``test_index_versions.py``).
- Populator/discovery unit tests: ``test/unit/scripts/tool_source/``
(``test_populate_store.py``, ``test_discover.py``,
``test_build_index_entry.py``, ``test_whoosh_dir.py``). These use fakes
(not mocks) of ``ToolSourceStore`` so behavior is verified against the real
interface.
+1 -10
View File
@@ -376,16 +376,7 @@ class MinimalGalaxyApplication(BasicSharedApp, HaltableContainer, SentryClientMi
log.warning("Waiting for toolbox reload timed out after 60 seconds")
def _configure_tool_config_files(self):
if self.config.shed_tool_config_file not in self.config.tool_configs:
self.config.tool_configs.append(self.config.shed_tool_config_file)
# The value of migrated_tools_config is the file reserved for containing only those tools that have been
# eliminated from the distribution and moved to the tool shed. If migration checking is disabled, only add it if
# it exists (since this may be an existing deployment where migrations were previously run).
if (
os.path.exists(self.config.migrated_tools_config)
and self.config.migrated_tools_config not in self.config.tool_configs
):
self.config.tool_configs.append(self.config.migrated_tools_config)
self.config.tool_configs = self.config.all_tool_config_files()
def _configure_toolbox(self):
self.citations_manager = self._register_singleton(CitationsManager, CitationsManager(self))
@@ -290,6 +290,8 @@ class MockAppConfig(GalaxyDataTestConfig, CommonConfigurationMixin):
self.track_jobs_in_database = False
self.amqp_internal_connection = None
self.tool_configs = []
self.tool_source_database_connection = f"sqlite:///{os.path.join(self.data_dir, 'tool_sources.sqlite')}"
self.tool_source_stores = None
self.manage_dependency_relationships = False
self.enable_tool_shed_check = False
self.monitor_thread_join_timeout = 1
+21
View File
@@ -850,6 +850,8 @@ class GalaxyAppConfiguration(GalaxyAppConfigurationAttributes, BaseAppConfigurat
if not self.database_connection: # Provide default if not supplied by user
db_path = self._in_data_dir("universe.sqlite")
self.database_connection = f"sqlite:///{db_path}?isolation_level=IMMEDIATE"
if not self.tool_source_database_connection:
self.tool_source_database_connection = f"sqlite:///{self._in_data_dir('tool_sources.sqlite')}"
self.database_engine_options = get_database_engine_options(kwargs)
self.database_encoding = kwargs.get("database_encoding") # Create new databases with this encoding
self.thread_local_log = None
@@ -1357,6 +1359,7 @@ class GalaxyAppConfiguration(GalaxyAppConfigurationAttributes, BaseAppConfigurat
try_parsing(self.database_connection, "database_connection")
try_parsing(self.install_database_connection, "install_database_connection")
try_parsing(self.tool_source_database_connection, "tool_source_database_connection")
if self.interactivetoolsproxy_map is not None:
try_parsing(self.interactivetoolsproxy_map, "interactivetoolsproxy_map")
try_parsing(self.amqp_internal_connection, "amqp_internal_connection")
@@ -1428,6 +1431,24 @@ class GalaxyAppConfiguration(GalaxyAppConfigurationAttributes, BaseAppConfigurat
def ensure_tempdir(self):
self._ensure_directory(self.new_file_path)
def all_tool_config_files(self) -> list[str]:
"""Every tool config the toolbox loads: ``tool_config_file`` plus the
shed tool conf and, when present on disk, the migrated tools conf.
"""
configs = list(self.tool_configs or [])
if self.shed_tool_config_file and self.shed_tool_config_file not in configs:
configs.append(self.shed_tool_config_file)
# migrated_tools_config is reserved for tools eliminated from the
# distribution; only load it when it exists (an existing deployment
# where migrations were previously run).
if (
self.migrated_tools_config
and os.path.exists(self.migrated_tools_config)
and self.migrated_tools_config not in configs
):
configs.append(self.migrated_tools_config)
return configs
def check(self):
# Check that required directories exist; attempt to create otherwise
paths_to_check = [
@@ -37,6 +37,8 @@ class GalaxyAppConfigurationAttributes:
migrated_tools_config: str
integrated_tool_panel_config: str
tool_path: str
tool_source_database_connection: str | None
tool_source_stores: Any
tool_dependency_dir: str | None
dependency_resolvers_config_file: str
conda_prefix: str | None
@@ -588,6 +588,31 @@ galaxy:
# an attribute in the <toolbox> tag.
#tool_path: tools
# SQLAlchemy connection string for the tool source store, a
# rebuildable cache of pre-parsed tool sources kept outside Galaxy's
# main database. Multi-host deployments should point every Galaxy
# process at the same URI, such as a SQLite file on a shared
# filesystem.
# Sample default ``sqlite:///<data_dir>/tool_sources.sqlite``.
# Populate the store with: python
# scripts/tool_source/populate_store.py
# For details see
# https://docs.galaxyproject.org/en/master/admin/tool_source_storage.html
#tool_source_database_connection: null
# Optional named tool source stores referenced from individual
# tool_conf files via a top-level ``store="<name>"`` attribute (XML)
# or ``store: <name>`` key (YAML). When any tool_conf opts in, the
# process composes its named store with the default
# (``tool_source_database_connection``) store at runtime, with reads
# tried in declared order and writes always landing on the default.
# Each entry takes a SQLAlchemy ``url`` and an optional ``read_only:
# true`` flag. For SQLite connection-level read-only, use a SQLite URI
# with ``mode=ro&uri=true``.
# For details see
# https://docs.galaxyproject.org/en/master/admin/tool_source_storage.html
#tool_source_stores: null
# Various dependency resolver configuration parameters will have
# defaults set relative to this path, such as the default conda
# prefix, default Galaxy packages path, legacy tool shed dependencies
@@ -322,6 +322,41 @@ mapping:
Other tool config files must include the tool_path as an attribute in the
<toolbox> tag.
tool_source_database_connection:
type: str
required: false
desc: |
SQLAlchemy connection string for the tool source store, a
rebuildable cache of pre-parsed tool sources kept outside Galaxy's
main database. Multi-host deployments should point every Galaxy
process at the same URI, such as a SQLite file on a shared
filesystem.
Sample default ``sqlite:///<data_dir>/tool_sources.sqlite``.
Populate the store with: python scripts/tool_source/populate_store.py
For details see
https://docs.galaxyproject.org/en/master/admin/tool_source_storage.html
tool_source_stores:
type: map
required: false
desc: |
Optional named tool source stores referenced from individual
tool_conf files via a top-level ``store="<name>"`` attribute (XML)
or ``store: <name>`` key (YAML). When any tool_conf opts in, the
process composes its named store with the default
(``tool_source_database_connection``) store at runtime, with reads
tried in declared order and writes always landing on the default.
Each entry takes a SQLAlchemy ``url`` and an optional
``read_only: true`` flag. For SQLite connection-level read-only,
use a SQLite URI with ``mode=ro&uri=true``.
For details see
https://docs.galaxyproject.org/en/master/admin/tool_source_storage.html
tool_dependency_dir:
type: str
default: dependencies
+23 -17
View File
@@ -7,9 +7,6 @@ All message queues used by Galaxy
import datetime
import logging
import socket
from typing import (
TYPE_CHECKING,
)
from kombu import (
Connection,
@@ -20,12 +17,11 @@ from sqlalchemy import (
or_,
select,
)
from sqlalchemy.orm import Session
from galaxy.model import WorkerProcess
from galaxy.util import now
if TYPE_CHECKING:
from galaxy.web_stack import ApplicationStack
from galaxy.web_stack import ApplicationStack
log = logging.getLogger(__name__)
@@ -41,7 +37,26 @@ WEBAPP_APP_TYPE = "webapp"
SSE_MONITOR_APP_TYPE = "sse_monitor"
def all_control_queues_for_declare(application_stack: "ApplicationStack", webapp_only: bool = False) -> list[Queue]:
def control_queues_for_session(session: Session, webapp_only: bool = False) -> list[Queue]:
"""Build the per-process control-queue declare list from a model session.
Split out of :func:`all_control_queues_for_declare` so callers that have a
bare session but no ``ApplicationStack`` — notably the standalone
tool-source populator CLI — can build the same routing table.
"""
stmt = select(WorkerProcess).where(
WorkerProcess.update_time > now() - datetime.timedelta(seconds=DEFAULT_ACTIVE_PROCESS_WINDOW_SECONDS)
)
if webapp_only:
stmt = stmt.where(WorkerProcess.app_type == WEBAPP_APP_TYPE)
else:
# ``!=`` alone would drop NULL app_type rows (job handlers); keep them.
stmt = stmt.where(or_(WorkerProcess.app_type != SSE_MONITOR_APP_TYPE, WorkerProcess.app_type.is_(None)))
processes = session.scalars(stmt).all()
return [Queue(f"control.{p.server_name}@{p.hostname}", galaxy_exchange, routing_key="control.*") for p in processes]
def all_control_queues_for_declare(application_stack: ApplicationStack, webapp_only: bool = False) -> list[Queue]:
"""
For in-memory routing (used by sqlalchemy-based transports), we need to be able to
build the entire routing table in producers.
@@ -65,20 +80,11 @@ def all_control_queues_for_declare(application_stack: "ApplicationStack", webapp
"""
app = application_stack.app
try:
stmt = select(WorkerProcess).where(
WorkerProcess.update_time > now() - datetime.timedelta(seconds=DEFAULT_ACTIVE_PROCESS_WINDOW_SECONDS)
)
if webapp_only:
stmt = stmt.where(WorkerProcess.app_type == WEBAPP_APP_TYPE)
else:
# ``!=`` alone would drop NULL app_type rows (job handlers); keep them.
stmt = stmt.where(or_(WorkerProcess.app_type != SSE_MONITOR_APP_TYPE, WorkerProcess.app_type.is_(None)))
with app.model.new_session() as session:
processes = session.scalars(stmt).all()
return control_queues_for_session(session, webapp_only=webapp_only)
except Exception:
log.debug("Failed to look up active processes for control-queue declare", exc_info=True)
return []
return [Queue(f"control.{p.server_name}@{p.hostname}", galaxy_exchange, routing_key="control.*") for p in processes]
def control_queues_from_config(config):
+64 -39
View File
@@ -5,6 +5,7 @@ import os
import string
import time
from collections import namedtuple
from collections.abc import Iterator
from errno import ENOENT
from typing import (
Any,
@@ -28,6 +29,7 @@ from galaxy.exceptions import (
RequestParameterInvalidException,
)
from galaxy.util import (
Element,
etree,
ExecutionTimer,
listify,
@@ -36,6 +38,7 @@ from galaxy.util import (
unicodify,
)
from galaxy.util.bunch import Bunch
from galaxy.util.path import StrPath
from .filters import FilterFactory
from .integrated_panel import ManagesIntegratedToolPanelMixin
from .lineages import LineageMap
@@ -75,8 +78,6 @@ if TYPE_CHECKING:
from galaxy.model.tool_shed_install import ToolShedRepository
from galaxy.tools import Tool
from galaxy.tools.cache import ToolCache
from galaxy.util import Element
from galaxy.util.path import StrPath
log = logging.getLogger(__name__)
@@ -161,6 +162,47 @@ class ToolLoadConfigurationConflict(Exception):
pass
def walk_tool_directories(directory: StrPath, recursive: bool) -> Iterator[tuple[str, list[str]]]:
"""Yield ``(directory, files)`` for ``directory`` and, when ``recursive``,
each subdirectory - skipping hidden/private (``.``/``_`` prefixed) entries.
``files`` are candidate tool file paths in sorted order; filtering them
(e.g. via ``looks_like_a_tool``) is the caller's responsibility.
"""
files = []
subdirs = []
for name in sorted(os.listdir(directory)):
if name.startswith((".", "_")):
# Very unlikely that we want to load tools from a hidden or private folder
continue
child = os.path.join(str(directory), name)
if os.path.isdir(child):
subdirs.append(child)
else:
files.append(child)
yield str(directory), files
if recursive:
for subdir in subdirs:
yield from walk_tool_directories(subdir, recursive)
def resolve_tool_path(tool_path: str | None, config_filename: str, default_tool_path: "StrPath | None" = None) -> str:
"""Resolve a tool conf's ``tool_path`` attribute to the directory its tool
files are relative to.
Expands the ``${tool_conf_dir}`` template; falls back to
``default_tool_path`` (the toolbox's ``tool_root_dir``, i.e.
``config.tool_path``) when the conf doesn't set one.
"""
if not tool_path:
# Default to backward compatible config setting.
return str(default_tool_path) if default_tool_path else ""
# Allow use of ${tool_conf_dir} in toolbox config files.
tool_conf_dir = os.path.dirname(config_filename)
tool_path_vars = {"tool_conf_dir": tool_conf_dir}
return string.Template(tool_path).safe_substitute(tool_path_vars)
class AbstractToolBox(ManagesIntegratedToolPanelMixin):
"""
Abstract container for managing a ToolPanel - containing tools and
@@ -281,7 +323,7 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
config_value = getattr(config, "default_panel_view", None)
return config_value or self.__default_panel_view
def create_tool(self, config_file: "StrPath", **kwds) -> "Tool":
def create_tool(self, config_file: StrPath, **kwds: Any) -> "Tool":
raise NotImplementedError()
def create_dynamic_tool(self, dynamic_tool: "DynamicTool") -> "Tool":
@@ -375,7 +417,7 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
config_elems = []
tool_conf_type = "shed tool" if parsing_shed_tool_conf else "tool"
log.debug("Tool path for %s configuration %s is %s", tool_conf_type, config_filename, tool_path)
tool_path = self.__resolve_tool_path(tool_path, config_filename)
tool_path = resolve_tool_path(tool_path, config_filename, self._tool_root_dir)
# Only load the panel_dict under certain conditions.
load_panel_dict = not self._integrated_tool_panel_config_has_contents
for item in tool_conf_source.parse_items():
@@ -550,17 +592,6 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
tool_id = tool.id
return self._tool_panel.get_section_for_tool_id(tool_id)
def __resolve_tool_path(self, tool_path, config_filename):
if not tool_path:
# Default to backward compatible config setting.
tool_path = self._tool_root_dir
else:
# Allow use of __tool_conf_dir__ in toolbox config files.
tool_conf_dir = os.path.dirname(config_filename)
tool_path_vars = {"tool_conf_dir": tool_conf_dir}
tool_path = string.Template(tool_path).safe_substitute(tool_path_vars)
return tool_path
def add_tool_to_tool_panel_view(self, tool, view_panel_component):
self.__add_tool_to_tool_panel(tool, view_panel_component)
@@ -995,7 +1026,7 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
log.exception("Error reading tool from path: %s", path)
def get_tool_repository_from_xml_item(
self, elem: "Element", path: str
self, elem: Element, path: str
) -> Union[ToolConfRepository, "ToolShedRepository"]:
tool_shed_el = elem.find("tool_shed")
assert tool_shed_el is not None
@@ -1154,14 +1185,14 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
def __watch_directory(
self,
directory: "StrPath",
elems,
integrated_elems,
directory: StrPath,
elems: ToolPanelElements,
integrated_elems: ToolPanelElements,
load_panel_dict: bool,
recursive: bool,
force_watch: bool = False,
) -> None:
def quick_load(tool_file: "StrPath", async_load: bool = True) -> str | None:
def quick_load(tool_file: StrPath, async_load: bool = True) -> str | None:
if not self._looks_like_a_tool(str(tool_file)):
return None
try:
@@ -1186,30 +1217,24 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
log.exception("Failed to load potential tool %s.", tool_file)
return None
tool_loaded = False
if not os.path.isdir(directory):
log.error("Failed to read tool directory %s.", directory)
return
for name in os.listdir(directory):
if name.startswith((".", "_")):
# Very unlikely that we want to load tools from a hidden or private folder
continue
child_path = os.path.join(directory, name)
if os.path.isdir(child_path) and recursive:
self.__watch_directory(child_path, elems, integrated_elems, load_panel_dict, recursive)
elif self._looks_like_a_tool(child_path):
tool_id = quick_load(child_path, async_load=False)
tool_loaded = bool(tool_id)
if (tool_loaded or force_watch) and self._tool_watcher:
self._tool_watcher.watch_directory(directory, quick_load)
for dirpath, files in walk_tool_directories(directory, recursive):
tool_loaded = False
for child_path in files:
if self._looks_like_a_tool(child_path):
tool_loaded = bool(quick_load(child_path, async_load=False)) or tool_loaded
if (tool_loaded or (force_watch and dirpath == str(directory))) and self._tool_watcher:
self._tool_watcher.watch_directory(dirpath, quick_load)
def load_tool(
self,
config_file: "StrPath",
guid=None,
tool_shed_repository=None,
config_file: StrPath,
guid: str | None = None,
tool_shed_repository: "ToolConfRepository | ToolShedRepository | None" = None,
use_cached: bool = False,
**kwds,
**kwds: Any,
) -> "Tool":
"""Load a single tool from the file named by `config_file` and return an instance of `Tool`."""
# Parse XML configuration file and get the root element
@@ -1244,12 +1269,12 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
if self._tool_config_watcher:
[self._tool_config_watcher.watch_file(macro_path) for macro_path in tool._macro_paths]
def add_tool_to_cache(self, tool: "Tool", config_file: "StrPath") -> None:
def add_tool_to_cache(self, tool: "Tool", config_file: StrPath) -> None:
tool_cache: ToolCache | None = getattr(self.app, "tool_cache", None)
if tool_cache:
tool_cache.cache_tool(config_file, tool)
def load_tool_from_cache(self, config_file: "StrPath", recover_tool: bool = False) -> Union["Tool", None]:
def load_tool_from_cache(self, config_file: StrPath, recover_tool: bool = False) -> Union["Tool", None]:
tool_cache: ToolCache | None = getattr(self.app, "tool_cache", None)
tool = None
if tool_cache:
+16
View File
@@ -39,6 +39,15 @@ class ToolConfSource(metaclass=ABCMeta):
"""Monitor the toolbox configuration source for changes and reload."""
return DEFAULT_MONITOR
def parse_store_name(self) -> str | None:
"""Return the named tool source store this conf routes to, or None.
Lets a single tool_conf opt into a non-default store (e.g. a
CVMFS-resident sqlite bundle) by setting ``store="..."`` on the
XML root or ``store: ...`` in YAML.
"""
return None
class XmlToolConfSource(ToolConfSource):
def __init__(self, config_filename: StrPath):
@@ -59,6 +68,9 @@ class XmlToolConfSource(ToolConfSource):
def parse_monitor(self):
return string_as_bool(self.root.get("monitor", DEFAULT_MONITOR))
def parse_store_name(self) -> str | None:
return self.root.get("store") or None
class YamlToolConfSource(ToolConfSource):
def __init__(self, config_filename: StrPath):
@@ -78,6 +90,10 @@ class YamlToolConfSource(ToolConfSource):
def is_shed_tool_conf(self):
return False
def parse_store_name(self) -> str | None:
store = self.as_dict.get("store")
return store or None
class ToolConfItem:
"""Abstract description of a tool conf item.
+37
View File
@@ -0,0 +1,37 @@
"""
Tool Source Store - standalone storage for Galaxy tool sources.
Tool sources and their derived ``ToolIndex`` live in a standalone SQLAlchemy
database chosen by connection URL (``tool_source_database_connection``;
defaults to a ``sqlite:///`` file, but any SQLAlchemy URL such as
``postgresql://`` works just as well). There is a single store
implementation, ``SqlAlchemyToolSourceStore``; a tool_conf may point at a
named store declared in ``tool_source_stores``, and those are layered over
the default in a ``CompositeToolSourceStore``.
"""
from .factory import (
build_named_store,
build_tool_source_store,
)
from .index import (
ToolIndex,
ToolIndexEntry,
)
from .interface import (
ConfigurationError,
ReadOnlyStoreError,
StoredToolSource,
ToolSourceStore,
)
__all__ = [
"StoredToolSource",
"ToolSourceStore",
"ToolIndex",
"ToolIndexEntry",
"build_tool_source_store",
"build_named_store",
"ConfigurationError",
"ReadOnlyStoreError",
]
+239
View File
@@ -0,0 +1,239 @@
"""
Composite tool source store.
Lets a single Galaxy process serve tools from multiple per-tool-conf
stores. Reads are tried in declared order (first hit wins), writes go to
a designated *default* store. Used to layer e.g. a CVMFS-resident
read-only sqlite bundle on top of the local writable store.
The composite is invisible to the rest of Galaxy: it implements the same
:class:`ToolSourceStore` interface, and consumers / the populator
keep working unchanged.
"""
import logging
from collections.abc import Iterator
from typing import (
Any,
)
from .index import (
ToolIndex,
ToolIndexEntry,
)
from .interface import (
StoredToolSource,
ToolSourceStore,
)
log = logging.getLogger(__name__)
class CompositeToolSourceStore(ToolSourceStore):
"""A read-priority store that fans out across several backends.
Args:
members: Ordered list of ``(name, store)`` pairs consulted for
reads in order. Earlier entries shadow later ones on id/hash
collisions.
default: The store that receives all writes. Must be present in
``members`` (its name is the value used for write routing).
Must not be ``read_only``.
"""
def __init__(
self,
members: list[tuple[str, ToolSourceStore]],
default: str,
) -> None:
if not members:
raise ValueError("CompositeToolSourceStore requires at least one member")
names = [n for n, _ in members]
if default not in names:
raise ValueError(f"default store {default!r} not in members {names!r}")
self._members: list[tuple[str, ToolSourceStore]] = list(members)
self._default_name = default
self._default_store = dict(members)[default]
if self._default_store.read_only:
raise ValueError(f"default store {default!r} is read-only")
# Composite as a whole is writable iff its default store is writable.
self.read_only = False
# --- write ops: always default ------------------------------------
def store(self, tool_source: StoredToolSource) -> str:
return self._default_store.store(tool_source)
def delete(self, hash: str) -> bool:
return self._default_store.delete(hash)
def store_index(self, index: ToolIndex) -> None:
self._default_store.store_index(index)
def update_index_entry(self, entry: ToolIndexEntry) -> None:
self._default_store.update_index_entry(entry)
def remove_index_entry(self, tool_id: str) -> None:
self._default_store.remove_index_entry(tool_id)
# --- read ops: priority order --------------------------------------
def get(self, hash: str) -> StoredToolSource | None:
for _name, member in self._members:
found = member.get(hash)
if found is not None:
return found
return None
def exists(self, hash: str) -> bool:
return any(m.exists(hash) for _, m in self._members)
def get_by_tool_id(self, tool_id: str, version: str | None = None) -> list[StoredToolSource]:
# Union across members, deduped by hash, preserving member order.
seen: set[str] = set()
out: list[StoredToolSource] = []
for _name, member in self._members:
for src in member.get_by_tool_id(tool_id, version):
if src.hash in seen:
continue
seen.add(src.hash)
out.append(src)
return out
def get_by_source_path(self, source_path: str) -> StoredToolSource | None:
for _name, member in self._members:
found = member.get_by_source_path(source_path)
if found is not None:
return found
return None
def list_all(self) -> Iterator[str]:
seen: set[str] = set()
for _name, member in self._members:
for h in member.list_all():
if h in seen:
continue
seen.add(h)
yield h
def list_source_paths(self) -> set[str]:
paths: set[str] = set()
for _name, member in self._members:
paths |= member.list_source_paths()
return paths
@property
def members(self) -> list[tuple[str, ToolSourceStore]]:
"""The ``(name, store)`` pairs, in read-priority order."""
return list(self._members)
@property
def read_only_member_names(self) -> set[str]:
"""Names of member stores no populator can write to.
The boot coverage check treats paths routed to these differently:
a miss there can never be healed by running the populator, so it
must not trigger one.
"""
return {name for name, member in self._members if member.read_only}
def count(self) -> int:
# Distinct hashes across the composite.
return sum(1 for _ in self.list_all())
def get_stats(self) -> dict[str, Any]:
return {
"backend": "composite",
"count": self.count(),
"default": self._default_name,
"members": [{"name": name, **member.get_stats()} for name, member in self._members],
}
# --- index ---------------------------------------------------------
def load_index(self) -> ToolIndex | None:
merged = ToolIndex()
any_loaded = False
for name, member in self._members:
try:
idx = member.load_index()
except Exception as e:
log.error(f"Failed to load index from store {name!r}: {e}")
continue
if idx is None:
continue
any_loaded = True
for tool_id, entry in idx.entries.items():
# Earlier members win on collision.
if tool_id in merged.entries:
continue
merged.entries[tool_id] = entry
# Merge the per-version map too — ``ToolIndex.get(tool_id,
# tool_version)`` resolves exact versions through it, so skipping
# it would make every non-newest version of a member-store tool
# unreachable. Same collision rule, applied per (id, version).
for tool_id, versions in idx.entries_by_version.items():
version_bucket = merged.entries_by_version.setdefault(tool_id, {})
for version, entry in versions.items():
version_bucket.setdefault(version, entry)
for section_id, ids in idx.by_section.items():
bucket = merged.by_section.setdefault(section_id, [])
for tid in ids:
if tid not in bucket:
bucket.append(tid)
# Same collision rule per (tool id, section): earlier members'
# placements win, later members append theirs after.
seen_placements = {(item.tool_id, item.section_id) for item in merged.panel_items}
for item in idx.panel_items:
placement_key = (item.tool_id, item.section_id)
if placement_key in seen_placements:
continue
seen_placements.add(placement_key)
merged.panel_items.append(item)
for view_name, view in idx.panel_views.items():
merged.panel_views.setdefault(view_name, view)
if idx.built_at and (merged.built_at is None or idx.built_at > merged.built_at):
merged.built_at = idx.built_at
if not any_loaded:
return None
return merged
def invalidate_index_cache(self) -> None:
for _name, member in self._members:
member.invalidate_index_cache()
def index_is_fresh(self) -> bool | None:
"""Aggregate the member verdicts.
A stale *writable* member makes the composite stale — the populator
can heal it, so report ``False`` and let boot run it. A read-only
member is trusted whenever its index loads under the current schema
(see the SQLAlchemy backend): ``False`` there means no loadable
index at all, which can't be healed locally — warn and continue,
the publisher owns repopulation. A member without a probe
downgrades an otherwise-fresh verdict to ``None`` so the caller
still runs its coverage scan.
"""
verdict: bool | None = True
for name, member in self._members:
fresh = member.index_is_fresh()
if fresh is False:
if member.read_only:
log.warning(
"Read-only tool source store %r has no loadable index; "
"its tools will parse eagerly until it is repopulated upstream",
name,
)
else:
return False
elif fresh is None:
verdict = None
return verdict
def close(self) -> None:
"""Propagate close() to every member store."""
for _name, member in self._members:
try:
member.close()
except Exception as e:
log.error(f"Composite store close failed for member '{_name}': {e}")
+487
View File
@@ -0,0 +1,487 @@
"""
Tool discovery utilities.
Walks Galaxy's tool configuration files to enumerate every ``<tool>`` referenced
from any tool_conf without booting a full ``ToolBox``. Used by the populator
(``galaxy.tools.source_store.populator``) and by callers that need to compare
on-disk confs against the indexed tool set (cold-start auto-populate,
``reset_shed_tools``).
"""
import logging
import os
import string
from collections.abc import (
Collection,
Iterable,
Iterator,
)
from concurrent.futures import ThreadPoolExecutor
from dataclasses import (
dataclass,
field,
)
from pathlib import Path
from typing import (
TYPE_CHECKING,
)
from galaxy.model import _get_datatypes_registry
from galaxy.tool_util.loader_directory import (
looks_like_a_tool,
looks_like_a_tool_xml,
)
from galaxy.tool_util.toolbox.base import (
resolve_tool_path,
walk_tool_directories,
)
from galaxy.tool_util.toolbox.parser import (
get_toolbox_parser,
ToolConfItem,
ToolConfSection,
)
from galaxy.tools import MODEL_TOOLS_PATH
from galaxy.tools.special_tools import hidden_lib_tool_paths
from galaxy.util import (
listify,
parse_xml,
)
if TYPE_CHECKING:
from galaxy.config import GalaxyAppConfiguration
log = logging.getLogger(__name__)
# Sentinel ``tool_conf`` for datatype-converter discoveries — they have no
# panel conf; the registry loads them after boot via ``load_tool``.
CONVERTER_TOOL_CONF = "<converter>"
@dataclass
class DiscoveredTool:
"""Information about a discovered tool file."""
path: str # Absolute path to tool file
tool_conf: str # Path to the tool_conf file that referenced this tool
tool_path: str | None # The tool_path from the tool_conf
guid: str | None = None # GUID for shed tools
is_shed_tool: bool = False
# Shed conf ``<tool>`` child elements. The populator keys shed entries
# by guid and stamps these on the index entry, so shed stubs answer
# repository metadata without materialising — mirroring what the eager
# walk reads via ``get_tool_repository_from_xml_item``.
tool_shed: str | None = None
repository_name: str | None = None
repository_owner: str | None = None
installed_changeset_revision: str | None = None
# ``<data_manager id="...">`` conf id for tools referenced from data
# manager configs — may differ from the tool XML id, and the registry
# is keyed by it.
data_manager_id: str | None = None
# Conf-level ``hidden="true"`` on the ``<tool>`` element (NOT the XML
# body's ``<tool hidden="true">`` — that's already on the parsed source).
# ToolBox._load_tool_tag_set forces ``tool.hidden = True`` when this is
# set; index consumers need the same flag to reproduce
# ``hidden_tool_versions`` in /api/tools/{id}.
hidden: bool = False
# Conf-level ``labels="a,b"`` on the ``<tool>`` element. Same population
# ownership as ``hidden`` above: parsed once at populator time so the
# toolbox doesn't have to re-discover them in a post-walk sync.
labels: list[str] = field(default_factory=list)
# Parent ``<section id="..." name="...">`` of this tool, if any; the
# populator stamps these onto ``ToolIndexEntry``.
section_id: str | None = None
section_name: str | None = None
def _iter_tool_items(
items: Iterable[ToolConfItem],
parent_section: ToolConfSection | None = None,
) -> Iterator[tuple[ToolConfItem, ToolConfSection | None]]:
"""
Recursively iterate over tool items, including those nested in sections.
Yields ``(item, parent_section)`` pairs for each ``tool`` and ``tool_dir``
entry. ``parent_section`` is the immediate enclosing ``ToolConfSection`` or
``None`` for top-level items. ``tool_dir`` items reference an on-disk
directory rather than a single file; the caller is responsible for walking
the directory.
"""
for item in items:
if item.type in ("tool", "tool_dir"):
yield item, parent_section
elif isinstance(item, ToolConfSection):
yield from _iter_tool_items(item.items, parent_section=item)
def _resolve_tool_dir(item: ToolConfItem, resolved_tool_path: str) -> str | None:
"""Resolve a ``tool_dir`` conf item to an absolute directory path."""
dir_attr = item.get("dir")
if not dir_attr:
return None
dir_attr = string.Template(dir_attr).safe_substitute({"model_tools_path": MODEL_TOOLS_PATH})
if not os.path.isabs(dir_attr):
dir_attr = os.path.join(resolved_tool_path, dir_attr)
return os.path.normpath(dir_attr)
def _tool_dir_recursive(item: ToolConfItem) -> bool:
return str(item.get("recursive", "true")).lower() != "false"
def conf_tool_directories(config: "GalaxyAppConfiguration") -> list[tuple[str, bool]]:
"""Resolve every ``tool_dir`` conf entry to ``(directory, recursive)``.
A ``tool_dir`` places tools on disk without naming them in any conf, so
conf-content hashing alone can't see additions there — the freshness
probe folds these directories' mtimes in on top of the conf contents.
"""
out: list[tuple[str, bool]] = []
for config_filename in config.all_tool_config_files():
if not os.path.exists(config_filename):
continue
try:
tool_conf_source = get_toolbox_parser(config_filename)
except Exception as e:
log.error(f"Failed to parse tool config {config_filename}: {e}")
continue
resolved_tool_path = resolve_tool_path(tool_conf_source.parse_tool_path(), config_filename, config.tool_path)
for item, _section in _iter_tool_items(tool_conf_source.parse_items()):
if item.type != "tool_dir":
continue
directory = _resolve_tool_dir(item, resolved_tool_path)
if directory is not None:
out.append((directory, _tool_dir_recursive(item)))
return out
def _walk_tool_dir(directory: str, recursive: bool) -> Iterator[str]:
"""Yield candidate tool file paths under ``directory`` via the same
``walk_tool_directories`` walk that ``ToolBox.__watch_directory`` uses.
Filtering against ``looks_like_a_tool`` is the caller's responsibility.
"""
if not os.path.isdir(directory):
log.debug(f"tool_dir does not exist: {directory}")
return
for _dirpath, files in walk_tool_directories(directory, recursive):
yield from files
def discover_tools_from_config(
config_filename: str,
default_tool_path: str | None = None,
enable_beta_formats: bool = False,
parallel: int = 1,
) -> Iterator[DiscoveredTool]:
"""
Discover all tools from a single tool configuration file.
Args:
config_filename: Path to a tool_conf.xml or similar file.
default_tool_path: Directory tool files are relative to when the conf
doesn't set ``tool_path`` — same fallback the toolbox applies
(``config.tool_path``).
parallel: Worker count for the per-tool existence checks. On network
filesystems (CVMFS) each stat costs tens of ms, so a shed conf
listing thousands of tools takes minutes checked serially; the
populator threads its ``--parallel`` value through here.
Yields:
DiscoveredTool objects for each tool found.
"""
if not os.path.exists(config_filename):
log.debug(f"Tool config file does not exist: {config_filename}")
return
try:
tool_conf_source = get_toolbox_parser(config_filename)
except Exception as e:
log.error(f"Failed to parse tool config {config_filename}: {e}")
return
tool_path = tool_conf_source.parse_tool_path()
resolved_tool_path = resolve_tool_path(tool_path, config_filename, default_tool_path)
is_shed_conf = tool_conf_source.is_shed_tool_conf()
# Match what AbstractToolBox._path_template_kwds does for ToolBox: tool
# confs may reference Galaxy-internal tool files via ``${model_tools_path}``
# (e.g. ``<tool file="${model_tools_path}/apply_rules.xml" />`` in
# tool_conf.xml.sample). Without expanding this, those tools are silently
# dropped at the os.path.exists check below.
file_template_kwds = {"model_tools_path": MODEL_TOOLS_PATH}
items = list(_iter_tool_items(tool_conf_source.parse_items()))
# Pre-resolve every ``<tool file=...>`` path and run the existence checks
# through a thread pool; the loop below then consumes the results in conf
# document order.
file_paths: dict[int, str] = {}
for i, (item, _section) in enumerate(items):
if item.type == "tool_dir":
continue
tool_file = item.get("file")
if not tool_file:
continue
tool_file = string.Template(tool_file).safe_substitute(file_template_kwds)
if not os.path.isabs(tool_file):
tool_file = os.path.join(resolved_tool_path, tool_file)
file_paths[i] = os.path.normpath(tool_file)
unique_paths = list(set(file_paths.values()))
if parallel > 1 and unique_paths:
with ThreadPoolExecutor(max_workers=parallel) as executor:
exists_by_path = dict(zip(unique_paths, executor.map(os.path.exists, unique_paths)))
else:
exists_by_path = {path: os.path.exists(path) for path in unique_paths}
for i, (item, section) in enumerate(items):
section_id = section.get("id") if section is not None else None
section_name = section.get("name") if section is not None else None
if item.type == "tool_dir":
directory = _resolve_tool_dir(item, resolved_tool_path)
if directory is None:
continue
recursive = _tool_dir_recursive(item)
for candidate in _walk_tool_dir(directory, recursive):
if not looks_like_a_tool(candidate, enable_beta_formats=enable_beta_formats):
continue
yield DiscoveredTool(
path=candidate,
tool_conf=config_filename,
tool_path=resolved_tool_path,
guid=None,
is_shed_tool=is_shed_conf,
section_id=section_id,
section_name=section_name,
)
continue
tool_path_abs = file_paths.get(i)
if tool_path_abs is None:
continue
if not exists_by_path[tool_path_abs]:
log.debug(f"Tool file does not exist: {tool_path_abs}")
continue
tool_shed = repository_name = repository_owner = installed_changeset_revision = None
if is_shed_conf and item.elem is not None:
tool_shed = item.elem.findtext("tool_shed")
repository_name = item.elem.findtext("repository_name")
repository_owner = item.elem.findtext("repository_owner")
installed_changeset_revision = item.elem.findtext("installed_changeset_revision")
yield DiscoveredTool(
path=tool_path_abs,
tool_conf=config_filename,
tool_path=resolved_tool_path,
guid=item.get("guid"),
is_shed_tool=is_shed_conf,
hidden=str(item.get("hidden", "false")).lower() == "true",
labels=list(item.labels or ()),
section_id=section_id,
section_name=section_name,
tool_shed=tool_shed,
repository_name=repository_name,
repository_owner=repository_owner,
installed_changeset_revision=installed_changeset_revision,
)
def _iter_data_manager_tools(config: "GalaxyAppConfiguration") -> Iterator[DiscoveredTool]:
"""Yield tool files referenced by data manager configs.
Data manager tools are loaded via ``DataManagers`` -> ``toolbox.load_hidden_tool``
rather than from a tool_conf, so the normal conf walk misses them.
"""
conf_files = [f for f in listify(config.data_manager_config_file or "") if f]
if config.shed_data_manager_config_file:
conf_files.append(config.shed_data_manager_config_file)
for conf in conf_files:
if not os.path.exists(conf):
continue
try:
root = parse_xml(conf).getroot()
except Exception as e:
log.warning("Skipping data manager config %s: %s", conf, e)
continue
if root.tag != "data_managers":
continue
conf_tool_path = root.get("tool_path") or config.tool_path or "."
for dm_elem in root.findall("data_manager"):
tool_path = conf_tool_path
data_manager_id = dm_elem.get("id")
path = dm_elem.get("tool_file")
guid = None
if path is None:
tool_elem = dm_elem.find("tool")
if tool_elem is None:
continue
path = tool_elem.get("file")
guid = tool_elem.get("guid")
shed_conf_file = dm_elem.get("shed_conf_file")
if shed_conf_file and os.path.exists(shed_conf_file):
try:
shed_tool_path = get_toolbox_parser(shed_conf_file).parse_tool_path()
if shed_tool_path:
tool_path = shed_tool_path
except Exception as e:
log.warning("Could not resolve tool_path from %s: %s", shed_conf_file, e)
if not path:
continue
resolved = os.path.abspath(os.path.join(tool_path, path))
if not os.path.exists(resolved):
# Mirror DataManagers.load_from_xml: fall back to resolving
# relative to the conf file for planemo-managed layouts.
fallback = os.path.abspath(os.path.join(os.path.dirname(conf), path))
if os.path.exists(fallback):
resolved = fallback
tool_path = os.path.dirname(conf)
yield DiscoveredTool(
path=resolved,
tool_conf=conf,
tool_path=tool_path,
guid=guid,
is_shed_tool=guid is not None,
data_manager_id=data_manager_id,
)
def discover_tools(
config: "GalaxyAppConfiguration",
include_bundled: bool = True,
include_converters: bool = True,
parallel: int = 1,
only_confs: Collection[str] | None = None,
) -> Iterator[DiscoveredTool]:
"""
Discover all tools from Galaxy configuration.
This reads all tool configuration files and yields information about
each discovered tool file.
Args:
config: Galaxy configuration object.
include_bundled: Whether to include bundled tools from lib/galaxy/tools/bundled.
include_converters: Whether to enumerate datatype converters. This needs
the datatypes registry; it's off for targeted single-store populates,
which never write the default store that converters route to.
parallel: Worker count for per-tool existence checks (see
:func:`discover_tools_from_config`).
only_confs: When set, walk only these tool conf files. The per-tool
existence checks are the expensive part of discovery on network
filesystems, so callers that would drop a conf's tools anyway
(routed to a read-only or untargeted store) skip its walk
entirely rather than filter afterwards.
Yields:
DiscoveredTool objects for each tool found.
"""
root_dir = config.root
seen_paths: set = set()
# Discover from all tool config files. A file referenced by several conf
# items — top-level and again inside a section is a common layout in
# multi-version confs — is a distinct panel placement per reference and
# the eager walk places each one, so yield them all; the store layer is
# content-addressed and absorbs the repeats. ``seen_paths`` still keeps
# the bundled/hidden-lib/converter/data-manager sweeps below from
# re-discovering conf tools.
for config_filename in config.all_tool_config_files():
if only_confs is not None and config_filename not in only_confs:
continue
for tool in discover_tools_from_config(
config_filename, config.tool_path, config.enable_beta_tool_formats, parallel=parallel
):
seen_paths.add(tool.path)
yield tool
# Include bundled tools if requested
if include_bundled and root_dir:
bundled_dir = Path(root_dir) / "lib" / "galaxy" / "tools" / "bundled"
if bundled_dir.exists():
for xml_file in bundled_dir.rglob("*.xml"):
path_str = str(xml_file)
if path_str in seen_paths or not looks_like_a_tool_xml(path_str):
continue
seen_paths.add(path_str)
yield DiscoveredTool(
path=path_str,
tool_conf="bundled",
tool_path=str(bundled_dir),
is_shed_tool=False,
)
# Galaxy-internal "hidden lib" tools (``set_metadata_tool``, the
# ``imp_exp`` history exporters, ``data_fetch``). They're loaded after
# boot via ``toolbox.load_hidden_lib_tool`` rather than from any
# tool_conf, so the conf walk above misses them; index them too.
for path in hidden_lib_tool_paths():
if path in seen_paths or not os.path.exists(path):
continue
seen_paths.add(path)
yield DiscoveredTool(
path=path,
tool_conf="<hidden-lib>",
tool_path=os.path.dirname(path),
is_shed_tool=False,
)
# Data manager tools are loaded after boot and are not referenced from
# ordinary tool_conf files.
for dm_tool in _iter_data_manager_tools(config):
if dm_tool.path in seen_paths or not os.path.exists(dm_tool.path):
continue
seen_paths.add(dm_tool.path)
yield dm_tool
# Datatype converters. ``Registry.load_datatype_converters`` calls
# ``toolbox.load_tool`` per converter after boot, so converters
# belong in the index. Use the active datatypes registry (populated by
# ``set_datatypes_registry`` at app boot / CLI startup) as the
# source of truth — same list that ``load_datatype_converters``
# iterates, so we never index a converter the registry won't load
# and vice versa.
if include_converters:
try:
registry = _get_datatypes_registry()
if registry.converters_path:
for tool_config, _src_dt, _tgt_dt in registry.converters:
path = os.path.normpath(os.path.join(registry.converters_path, tool_config))
if path in seen_paths or not os.path.exists(path):
continue
seen_paths.add(path)
yield DiscoveredTool(
path=path,
tool_conf=CONVERTER_TOOL_CONF,
tool_path=registry.converters_path,
is_shed_tool=False,
)
except Exception as e:
log.error("Failed to enumerate datatype converters: %s", e)
def discover_tool_files(
config: "GalaxyAppConfiguration",
include_bundled: bool = True,
) -> list[str]:
"""
Get a list of all tool file paths from Galaxy configuration.
This is a convenience function that returns just the paths.
Args:
config: Galaxy configuration object.
include_bundled: Whether to include bundled tools.
Returns:
List of absolute paths to tool files.
"""
return [tool.path for tool in discover_tools(config, include_bundled)]
__all__ = (
"DiscoveredTool",
"discover_tools",
"discover_tools_from_config",
"discover_tool_files",
)
+130
View File
@@ -0,0 +1,130 @@
"""Builders resolving Galaxy configuration into tool source stores."""
import logging
from typing import TYPE_CHECKING
from galaxy.tool_util.toolbox.parser import get_toolbox_parser
from .composite import CompositeToolSourceStore
from .freshness import (
FreshnessProbe,
tool_confs_probe,
)
from .interface import (
ConfigurationError,
ToolSourceStore,
)
from .sqlalchemy import SqlAlchemyToolSourceStore
if TYPE_CHECKING:
from galaxy.config import GalaxyAppConfiguration
log = logging.getLogger(__name__)
def _build_default_store(
config: "GalaxyAppConfiguration",
) -> ToolSourceStore:
"""Build the default store from the top-level SQLAlchemy URI config.
The default store always carries the ``tool_confs`` freshness probe: it
is populated and probed on the same host, so hashing the local conf
files is both cheap and meaningful.
"""
url = config.tool_source_database_connection
if not url:
raise ConfigurationError("tool_source_database_connection is required")
return SqlAlchemyToolSourceStore(url=url, read_only=False, freshness_probe=tool_confs_probe(config))
def _build_freshness_probe(
name: str,
spec: dict,
config: "GalaxyAppConfiguration",
) -> FreshnessProbe | None:
freshness = spec.get("freshness")
if freshness in (None, "none"):
return None
if freshness == "tool_confs":
return tool_confs_probe(config)
raise ConfigurationError(
f"tool_source_stores[{name!r}] freshness must be 'tool_confs' or 'none' (got {freshness!r})"
)
def build_named_store(
name: str,
spec: dict,
config: "GalaxyAppConfiguration",
) -> ToolSourceStore:
"""Build a single named store from a ``tool_source_stores`` entry.
``spec`` is the dict from galaxy.yml - a SQLAlchemy ``url`` plus
optional ``read_only`` and ``freshness`` keys.
Named stores get no probe unless one is declared: a store populated on
a different host (the CVMFS publishing model) would never match a
locally-computed conf hash, so ``tool_confs`` cannot be the default
here the way it is for the default store.
"""
if not isinstance(spec, dict):
raise ConfigurationError(f"tool_source_stores[{name!r}] must be a mapping")
if "backend" in spec or "path" in spec:
raise ConfigurationError(
f"tool_source_stores[{name!r}] must use 'url'; 'backend' and 'path' are no longer supported"
)
url = spec.get("url")
if not url:
raise ConfigurationError(f"tool_source_stores[{name!r}] requires a 'url'")
read_only = bool(spec.get("read_only", False))
probe = _build_freshness_probe(name, spec, config)
return SqlAlchemyToolSourceStore(url=url, read_only=read_only, freshness_probe=probe)
def _collect_per_conf_store_names(config: "GalaxyAppConfiguration") -> set[str]:
"""Walk configured tool_confs and collect referenced store names."""
names: set[str] = set()
for path in config.all_tool_config_files():
try:
parser = get_toolbox_parser(path)
except Exception as e:
log.error(f"skipping tool conf {path}: {e}")
continue
store = parser.parse_store_name()
if store:
names.add(store)
return names
def build_tool_source_store(
config: "GalaxyAppConfiguration",
) -> ToolSourceStore:
"""Build the active tool source store, composing per-conf overrides.
Returns the default store directly when no tool_conf opts into a named
override (zero overhead for the common case). Otherwise wraps the
default plus each referenced named store in a
:class:`CompositeToolSourceStore`, with the default consulted last and
receiving all writes. A ``store="..."`` reference without a matching
``tool_source_stores`` catalog entry is a configuration error.
Args:
config: The Galaxy application configuration.
"""
default_store = _build_default_store(config)
referenced = _collect_per_conf_store_names(config)
if not referenced:
return default_store
catalog = config.tool_source_stores or {}
members: list[tuple[str, ToolSourceStore]] = []
for name in referenced:
if name not in catalog:
raise ConfigurationError(
f"tool_conf references store {name!r} but no such entry exists in tool_source_stores"
)
members.append((name, build_named_store(name, catalog[name], config)))
# Default is consulted last so per-conf overrides shadow it on hash collisions.
members.append(("__default__", default_store))
return CompositeToolSourceStore(members=members, default="__default__")
@@ -0,0 +1,98 @@
"""Freshness probes for tool source stores.
A probe reads a cheap token describing the state of the tool tree a store
indexes. The populator stamps the probe's value into the persisted
``ToolIndex`` (``freshness_token``); a booting process re-probes and
compares. A match certifies the store still covers the current tree, so
boot skips the per-path coverage scan (and the populate it would trigger).
A mismatch is always safe — it only falls back to scanning/populating.
The built-in probe is ``tool_confs``: md5 over the tool and data-manager
conf file contents, plus the (recursive) directory mtimes of any
``tool_dir`` entries they declare. This captures tool
additions/removals/renames — the same class of drift the coverage scan
detects — without touching individual tool files. In-place edits to a
tool's XML are invisible to both, by design: content changes are the
incremental populate's job (raw-file md5), not the coverage check's.
Wired to the default (writable) store automatically.
Read-only stores need no probe for boot freshness — they are trusted as
published (see ``SqlAlchemyToolSourceStore.index_is_fresh``); a probe on
such a store only feeds the watcher's change detection.
"""
import hashlib
import logging
import os
from collections.abc import (
Callable,
Iterator,
)
from typing import TYPE_CHECKING
from .discover import conf_tool_directories
if TYPE_CHECKING:
from galaxy.config import GalaxyAppConfiguration
log = logging.getLogger(__name__)
# A probe returns the current token; it raises FreshnessProbeError (or any
# OSError-ish failure) when the token cannot be read — callers treat that
# as "not fresh", never as fresh.
FreshnessProbe = Callable[[], str]
class FreshnessProbeError(Exception):
"""Raised when a freshness probe cannot read its token."""
def _dir_mtime_chunks(directory: str, recursive: bool) -> Iterator[bytes]:
"""Yield ``directory``'s (and, if recursive, its subdirectories') mtimes.
A file created, deleted, or renamed in a directory bumps that
directory's mtime, so hashing directory mtimes — never file ones —
detects membership changes at stat-per-directory cost instead of
stat-per-file.
"""
try:
yield str(os.stat(directory).st_mtime_ns).encode()
except OSError:
yield b"<missing>"
return
if not recursive:
return
for dirpath, dirnames, _files in os.walk(directory):
dirnames.sort()
for dirname in dirnames:
subdir = os.path.join(dirpath, dirname)
yield subdir.encode()
try:
yield str(os.stat(subdir).st_mtime_ns).encode()
except OSError:
yield b"<missing>"
def tool_confs_token(config: "GalaxyAppConfiguration") -> str:
"""Current ``tool_confs`` probe value for ``config``'s tool tree."""
digest = hashlib.md5()
conf_files = list(config.all_tool_config_files())
for data_manager_conf in (config.data_manager_config_file, config.shed_data_manager_config_file):
if data_manager_conf:
conf_files.append(data_manager_conf)
for path in sorted(set(conf_files)):
digest.update(path.encode())
try:
with open(path, "rb") as f:
digest.update(f.read())
except OSError:
digest.update(b"<missing>")
for directory, recursive in conf_tool_directories(config):
digest.update(directory.encode())
for chunk in _dir_mtime_chunks(directory, recursive):
digest.update(chunk)
return f"confs:{digest.hexdigest()}"
def tool_confs_probe(config: "GalaxyAppConfiguration") -> FreshnessProbe:
return lambda: tool_confs_token(config)
+479
View File
@@ -0,0 +1,479 @@
"""
Tool Index - Lightweight in-memory index for fast API responses.
This module provides the ToolIndex and ToolIndexEntry classes that store
lightweight metadata about tools for efficient API responses without
loading full tool sources.
"""
import json
from datetime import datetime
from typing import (
Any,
)
from pydantic import (
BaseModel,
Field,
PrivateAttr,
)
from galaxy.tool_util.version import parse_version
from galaxy.tools import (
DatabaseOperationTool,
InteractiveTool,
Tool,
tool_types,
)
from galaxy.util.hash_util import md5_hash_str
class ToolIndexEntry(BaseModel):
"""
Lightweight tool metadata for API responses and search.
This class contains all fields needed for batch API endpoints without
requiring the full tool source to be loaded. Serialization is plain
Pydantic (``model_dump(mode="json")`` / ``model_validate``).
"""
# === Identity ===
id: str
uuid: str | None = None
version: str | None = None
tool_shed_repository_id: str | None = None # Link to repository
# === Display ===
name: str = ""
description: str = ""
# === Classification ===
icon: str | None = None
xrefs: list[dict[str, Any]] = Field(default_factory=list)
is_workflow_compatible: bool = True
panel_section_id: str | None = None
panel_section_name: str | None = None
# True when the tool was a ``<tool>`` item of a tool-panel conf — top-level
# or inside a section — i.e. the set the eager conf walk places in the
# panel and records in the integrated tool panel. Converter and
# data-manager discoveries are indexed for lookups but never placed.
in_panel: bool = True
labels: list[str] = Field(default_factory=list)
edam_operations: list[str] = Field(default_factory=list)
edam_topics: list[str] = Field(default_factory=list)
# === Source Reference ===
source_hash: str = ""
source_class: str = "XmlToolSource"
source_path: str | None = None
# Raw md5 of the source file (incremental fast path: an unchanged file
# carries this entry forward instead of re-parsing).
file_hash: str | None = None
# === Status ===
hidden: bool = False
disabled: bool = False
require_login: bool = False
# === Filter metadata ===
# ``tool_type`` is the Tool subclass key (``default``, ``data_manager``,
# ``interactive_tool``, ``data_source``, ...). Filter authors and
# ``DataManagerTool.allow_user_access`` (admin-only) both branch on this.
tool_type: str = "default"
# User-facing tags from ``<tool>`` config (distinct from ``labels``).
# Surfaced for custom tool filters that bucket tools by tag.
tags: list[str] = Field(default_factory=list)
# ``<data_manager id="...">`` from the data manager conf that references
# this tool. The conf id and the tool XML id may differ;
# ``DataManagerTool.exec_after_process`` resolves the registry by conf
# id, so materialise must restore it.
data_manager_id: str | None = None
# === Tests (for /api/tools/tests_summary) ===
test_count: int = 0
# === Requirements (for /api/tools/all_requirements, dependency endpoints) ===
requirements: list[dict[str, Any]] = Field(default_factory=list)
# Example: [{"name": "samtools", "version": "1.9", "type": "package"}]
# === Container Info (for container resolution endpoints) ===
container_requirements: list[dict[str, Any]] = Field(default_factory=list)
# Example: [{"type": "docker", "identifier": "biocontainers/samtools:1.9"}]
# === Tool Shed Info (for sanitize_allow, shed endpoints) ===
tool_shed: str | None = None # e.g., "toolshed.g2.bx.psu.edu"
repository_name: str | None = None
repository_owner: str | None = None
changeset_revision: str | None = None
is_local: bool = True # True if not from tool shed
# === Timestamps ===
indexed_at: datetime | None = None
# ``model_class`` and ``form_style`` are pure functions of ``tool_type``,
# so they are derived at read time rather than persisted — a stored copy
# would go silently stale if the ``Tool.to_dict`` classification changed
# (the index schema hash only catches field-shape drift, not value drift).
@property
def model_class(self) -> str:
return self._tool_class.__name__
@property
def form_style(self) -> str:
tool_class = self._tool_class
regular = tool_class is Tool or issubclass(tool_class, (DatabaseOperationTool, InteractiveTool))
return "regular" if regular else "special"
@property
def _tool_class(self) -> type[Tool]:
return tool_types.get(self.tool_type, Tool)
def to_api_dict(self, detail: bool = False) -> dict[str, Any]:
"""Convert to /api/tools response format."""
result: dict[str, Any] = {
"id": self.id,
"name": self.name,
"version": self.version,
"description": self.description,
"model_class": self.model_class,
"icon": self.icon,
"labels": self.labels,
"panel_section_id": self.panel_section_id,
"panel_section_name": self.panel_section_name,
"is_workflow_compatible": self.is_workflow_compatible,
"xrefs": self.xrefs,
"form_style": self.form_style,
"hidden": self.hidden,
}
if detail:
result.update(
{
"uuid": self.uuid,
"edam_operations": self.edam_operations,
"edam_topics": self.edam_topics,
"tool_shed_repository_id": self.tool_shed_repository_id,
}
)
return result
def to_tests_summary(self) -> dict[str, Any]:
"""Convert to /api/tools/tests_summary format."""
return {"tool_name": self.name, "count": self.test_count}
def to_requirements_list(self) -> list[dict[str, Any]]:
"""Get requirements for /api/tools/all_requirements."""
return self.requirements
def to_sanitize_entry(self) -> dict[str, Any]:
"""Convert to /api/sanitize_allow format."""
entry: dict[str, Any] = {"tool_id": self.id, "name": self.name}
if not self.is_local:
entry.update(
{
"tool_shed": self.tool_shed,
"repository_name": self.repository_name,
"repository_owner": self.repository_owner,
}
)
return entry
class ToolPanelItem(BaseModel):
"""One panel placement — a conf ``<tool>`` item, top-level or sectioned.
Placements are what the eager conf walk iterates; recording them
separately from ``ToolIndex.entries`` (which collapses same-id tools to
one winner) lets the lazy fast panel init rebuild the live and
integrated panels without walking the confs.
"""
tool_id: str
section_id: str | None = None
section_name: str | None = None
hidden: bool = False
class ToolIndex(BaseModel):
"""
In-memory index of all tools for fast API access.
This class maintains a lightweight index of all tools that can be
used to serve API responses without loading full tool sources.
"""
entries: dict[str, ToolIndexEntry] = Field(default_factory=dict)
# Multi-version map. Several tool confs ship the same ``id`` at different
# versions (e.g. multiple_versions_hidden_v01 and _v02 both have id
# ``multiple_versions_hidden``). ``entries`` keeps the default per id (the
# last-written one or the highest version), ``entries_by_version`` keeps
# every version so ``get(tool_id, tool_version=...)`` resolves correctly
# to the matching ``source_hash``. Empty-string version key represents
# tools whose XML lacks a ``version`` attribute.
entries_by_version: dict[str, dict[str, ToolIndexEntry]] = Field(default_factory=dict)
by_section: dict[str, list[str]] = Field(default_factory=dict)
# Ordered panel placements, one per conf ``<tool>`` item, appended by
# ``add_entry`` in discovery (= conf) order. ``entries`` collapses
# same-id tools from different conf placements to one winner, so it
# cannot reproduce the panel: a tool id referenced by two confs (or two
# sections) has one entry but two placements. The lazy fast panel init
# replays these instead of walking the confs.
panel_items: list["ToolPanelItem"] = Field(default_factory=list)
panel_views: dict[str, dict] = Field(default_factory=dict)
built_at: datetime | None = None
# Freshness-probe value captured by the populator run that wrote this
# index; boot re-probes and a match certifies coverage without a
# per-path scan (see :mod:`galaxy.tools.source_store.freshness`).
freshness_token: str | None = None
# Cached computations (not serialized)
_requirements_cache: list[dict[str, Any]] | None = PrivateAttr(default=None)
_tests_summary_cache: dict[str, dict[str, dict]] | None = PrivateAttr(default=None)
_panel_item_by_key: dict[tuple[str, str | None], "ToolPanelItem"] | None = PrivateAttr(default=None)
def invalidate_caches(self) -> None:
"""Invalidate all cached computations."""
self._requirements_cache = None
self._tests_summary_cache = None
def get(self, tool_id: str, tool_version: str | None = None) -> ToolIndexEntry | None:
"""Get a tool entry by ID, optionally honoring a specific version.
``tool_version=None`` returns the default (newest indexed) entry.
``tool_version`` provided returns the matching version's entry, or
``None`` if that exact version isn't indexed.
"""
if tool_version is not None:
versions = self.entries_by_version.get(tool_id)
if versions is None:
# Backwards-compat for indexes serialized before
# entries_by_version existed: fall through to default.
entry = self.entries.get(tool_id)
if entry and (entry.version or "") == tool_version:
return entry
return None
return versions.get(tool_version)
return self.entries.get(tool_id)
def add_entry(self, entry: ToolIndexEntry, *, new_placements_first: bool = False) -> None:
"""Add an entry, populating both the default and per-version maps.
The "default" entry per id (used by ``ToolIndex.get(tool_id)`` and
the ``/api/tools`` listing) is the highest version per Galaxy's
:func:`galaxy.tool_util.version.parse_version`. Pure
string comparison fails on e.g. ``"0.1+galaxy6"`` vs ``"0.2"``
(which compares as ``"0.1+..." < "0.2"`` lexically only by
accident — a different prefix would flip the sign).
``new_placements_first``: place a not-yet-seen panel placement at
the head of its section instead of the tail. Partial updates (a shed
install adding a tool to an existing index) pass this to mirror the
eager runtime behaviour, where ``update_or_append`` inserts the new
tool at its conf-fragment position — the top of the section. Full
rebuilds append, preserving conf order.
"""
self.invalidate_caches()
self.entries_by_version.setdefault(entry.id, {})[entry.version or ""] = entry
self._record_panel_item(entry, new_placements_first=new_placements_first)
existing = self.entries.get(entry.id)
if existing is None:
self.entries[entry.id] = entry
return
try:
new_v = parse_version(entry.version or "0")
old_v = parse_version(existing.version or "0")
replace = new_v >= old_v
except Exception:
replace = (entry.version or "") >= (existing.version or "")
if replace:
self.entries[entry.id] = entry
def _record_panel_item(self, entry: ToolIndexEntry, new_placements_first: bool = False) -> None:
"""Record ``entry``'s panel placement, keyed on (tool id, section).
Every ``add_entry`` call is one conf item, so appending here keeps
placements in conf order; re-adding the same id in the same section
(a rescan, a version bump) updates the existing placement in place.
"""
if not entry.in_panel:
return
# Keyed lookup over ``panel_items`` — rebuilt lazily because private
# attrs reset to their defaults on ``model_validate``.
if self._panel_item_by_key is None or len(self._panel_item_by_key) != len(self.panel_items):
self._panel_item_by_key = {(item.tool_id, item.section_id): item for item in self.panel_items}
key = (entry.id, entry.panel_section_id)
item = self._panel_item_by_key.get(key)
if item is not None:
item.section_name = entry.panel_section_name
item.hidden = entry.hidden
if entry.panel_section_id is not None:
section_tool_ids = self.by_section.setdefault(entry.panel_section_id, [])
if entry.id not in section_tool_ids:
section_tool_ids.append(entry.id)
return
item = ToolPanelItem(
tool_id=entry.id,
section_id=entry.panel_section_id,
section_name=entry.panel_section_name,
hidden=entry.hidden,
)
insert_at = len(self.panel_items)
if new_placements_first:
for position, existing in enumerate(self.panel_items):
if existing.section_id == entry.panel_section_id:
insert_at = position
break
self.panel_items.insert(insert_at, item)
self._panel_item_by_key[key] = item
if entry.panel_section_id is not None:
section_tool_ids = self.by_section.setdefault(entry.panel_section_id, [])
if entry.id not in section_tool_ids:
if new_placements_first:
section_tool_ids.insert(0, entry.id)
else:
section_tool_ids.append(entry.id)
def rebuild_panel_projections(self) -> None:
"""Rebuild panel lookup state after replacing ``panel_items``."""
self._panel_item_by_key = None
by_section: dict[str, list[str]] = {}
for item in self.panel_items:
if item.section_id is None:
continue
section_tool_ids = by_section.setdefault(item.section_id, [])
if item.tool_id not in section_tool_ids:
section_tool_ids.append(item.tool_id)
self.by_section = by_section
def remove_entry(self, tool_id: str) -> bool:
"""Remove a tool and every projection derived from it."""
removed = self.entries.pop(tool_id, None)
removed_versions = self.entries_by_version.pop(tool_id, None)
original_panel_size = len(self.panel_items)
self.panel_items = [item for item in self.panel_items if item.tool_id != tool_id]
if removed is None and removed_versions is None and len(self.panel_items) == original_panel_size:
return False
self.rebuild_panel_projections()
self.invalidate_caches()
return True
def list_all(
self,
section_id: str | None = None,
include_hidden: bool = False,
) -> list[ToolIndexEntry]:
"""
List tools with optional filtering.
Args:
section_id: Optional section ID to filter by.
include_hidden: Whether to include hidden tools.
Returns:
List of matching tool entries.
"""
if section_id:
tool_ids = self.by_section.get(section_id, [])
entries = [self.entries[tid] for tid in tool_ids if tid in self.entries]
else:
entries = list(self.entries.values())
if not include_hidden:
entries = [e for e in entries if not e.hidden]
return entries
def get_tests_summary(self) -> dict[str, dict[str, dict]]:
"""
Return pre-computed tests summary from index.
Returns:
Dictionary of {tool_id: {version: {tool_name, count}}}.
"""
if self._tests_summary_cache is not None:
return self._tests_summary_cache
summary: dict[str, dict[str, dict]] = {}
for entry in self.entries.values():
# Match the eager fallback in services.tools.ToolsService.get_tests_summary:
# tools without tests are excluded entirely.
if not entry.test_count:
continue
if entry.id not in summary:
summary[entry.id] = {}
version_key = entry.version or "default"
summary[entry.id][version_key] = {
"tool_name": entry.name,
"count": entry.test_count,
}
self._tests_summary_cache = summary
return summary
def get_all_requirements(self) -> list[dict[str, Any]]:
"""
Return unique requirements from all tools.
Returns:
List of unique requirement dictionaries.
"""
if self._requirements_cache is not None:
return self._requirements_cache
seen: set[tuple] = set()
reqs: list[dict[str, Any]] = []
for entry in self.entries.values():
for req in entry.requirements:
key = (req.get("name"), req.get("version"), req.get("type"))
if key not in seen:
seen.add(key)
reqs.append(req)
self._requirements_cache = reqs
return reqs
def get_panel_views(self) -> dict[str, dict]:
"""Return pre-computed panel view dictionaries."""
return self.panel_views
def get_panel_view(self, view: str) -> dict | None:
"""Return pre-computed panel view."""
return self.panel_views.get(view)
def get_requirements_summary(self, index_by: str = "requirements") -> list[dict[str, Any]]:
"""
Summarize requirements across toolbox.
Args:
index_by: Either "requirements" to group tools by requirement,
or "tools" to group requirements by tool.
Returns:
List of summary dictionaries.
"""
if index_by == "requirements":
# Group tools by requirement
by_req: dict[tuple, dict[str, Any]] = {}
for entry in self.entries.values():
for req in entry.requirements:
key = (req.get("name", ""), req.get("version", ""))
if key not in by_req:
by_req[key] = {"requirement": req, "tools": []}
by_req[key]["tools"].append(entry.id)
return list(by_req.values())
else:
# Group requirements by tool
return [{"tool_id": e.id, "requirements": e.requirements} for e in self.entries.values()]
def get_tools_needing_containers(self) -> list[ToolIndexEntry]:
"""Return tools with container requirements."""
return [e for e in self.entries.values() if e.container_requirements]
# md5 of the ToolIndex JSON schema (the pattern
# tool_shed.managers.model_cache.hash_model uses — not imported from there,
# galaxy cannot depend on tool_shed). Persisted index blobs are stamped with
# this and discarded on mismatch, so a model change triggers a clean rebuild
# instead of silently loading defaults for fields the old blob never had.
INDEX_SCHEMA_HASH = md5_hash_str(json.dumps(ToolIndex.model_json_schema()))
+253
View File
@@ -0,0 +1,253 @@
"""Interfaces and exceptions for tool source storage backends."""
from abc import (
ABC,
abstractmethod,
)
from collections.abc import Iterator
from dataclasses import (
dataclass,
field,
)
from datetime import datetime
from typing import (
Optional,
TYPE_CHECKING,
)
if TYPE_CHECKING:
from .index import (
ToolIndex,
ToolIndexEntry,
)
@dataclass
class StoredToolSource:
"""Representation of a stored tool source."""
hash: str # Content hash (SHA256)
tool_source_class: str # XmlToolSource, YamlToolSource, etc.
raw_source: str # Serialized tool source string
tool_id: str | None = None # Tool ID if known
tool_version: str | None = None # Tool version if known
tool_dir: str | None = None # Original tool directory
source_path: str | None = None # Original file path (used as a lookup key)
stored_at: datetime | None = None
metadata: dict | None = field(default_factory=dict)
class ToolSourceStore(ABC):
"""Abstract base class for tool source storage backends."""
# Backends that wrap a read-only target (e.g. CVMFS-resident sqlite)
# set this to ``True`` so the populator and reload paths can skip them
# cleanly instead of crashing on a write attempt.
read_only: bool = False
@abstractmethod
def store(self, tool_source: StoredToolSource) -> str:
"""
Store a tool source.
Args:
tool_source: The tool source to store.
Returns:
The storage key (hash).
"""
@abstractmethod
def get(self, hash: str) -> StoredToolSource | None:
"""
Retrieve a tool source by hash.
Args:
hash: The content hash of the tool source.
Returns:
The stored tool source, or None if not found.
"""
@abstractmethod
def exists(self, hash: str) -> bool:
"""
Check if a tool source exists.
Args:
hash: The content hash to check.
Returns:
True if the tool source exists.
"""
@abstractmethod
def delete(self, hash: str) -> bool:
"""
Delete a tool source by hash.
Args:
hash: The content hash of the tool source to delete.
Returns:
True if deleted, False if not found.
"""
@abstractmethod
def list_all(self) -> Iterator[str]:
"""
List all stored tool source hashes.
Yields:
Content hashes of all stored tool sources.
"""
@abstractmethod
def get_by_tool_id(self, tool_id: str, version: str | None = None) -> list[StoredToolSource]:
"""
Get tool sources by tool ID and optional version.
Args:
tool_id: The tool ID to search for.
version: Optional version filter.
Returns:
List of matching tool sources.
"""
@abstractmethod
def get_by_source_path(self, source_path: str) -> StoredToolSource | None:
"""
Get the stored tool source for a given on-disk file path.
The populator records ``source_path`` for every stored entry so
readers can resolve a config file to the already-parsed source
without guessing through ``tool_id`` (which can collide across
directories).
Args:
source_path: Absolute path of the original tool config file.
Returns:
Matching stored source, or None if nothing was populated from that file.
"""
@abstractmethod
def list_source_paths(self) -> set[str]:
"""Return every ``source_path`` recorded in the store.
Bulk companion to :meth:`get_by_source_path` for boot-time coverage
checks: comparing the config-discovered tool paths against this set
costs one query, instead of one lookup round-trip per tool.
"""
@abstractmethod
def count(self) -> int:
"""Return the total number of stored tool sources."""
def get_stats(self) -> dict:
"""Return storage statistics."""
return {"count": self.count()}
# Index operations
@abstractmethod
def store_index(self, index: "ToolIndex") -> None:
"""
Store the complete tool index.
Args:
index: The tool index to store.
"""
@abstractmethod
def load_index(self) -> Optional["ToolIndex"]:
"""
Load the tool index.
Returns:
The tool index, or None if not found.
"""
@abstractmethod
def update_index_entry(self, entry: "ToolIndexEntry") -> None:
"""
Update a single index entry.
Args:
entry: The index entry to update.
"""
def remove_index_entry(self, tool_id: str) -> None:
"""Remove a tool's entry from the persisted index.
Counterpart of :meth:`update_index_entry` for uninstalls: a consumer
pops the entry from its in-memory index, but the persisted singleton
would hand it right back on the next cache invalidation unless the
removal is written through.
"""
index = self.load_index()
if index is None:
return
if not index.remove_entry(tool_id):
return
self.store_index(index)
def invalidate_index_cache(self) -> None: # noqa: B027 — intentional empty default
"""Drop any in-memory cached index so the next load_index() reads fresh.
Backends override this when they cache; the default is a no-op.
"""
@property
def has_freshness_probe(self) -> bool:
"""Whether a freshness probe was configured for this store."""
return False
def compute_freshness_token(self) -> str | None:
"""Return the current value of this store's freshness probe.
``None`` when no probe is configured or the probe failed (the
failure is the backend's to log). Backends with a probe override.
"""
return None
def index_is_fresh(self) -> bool | None:
"""Three-way freshness verdict for the persisted index.
``True``: the probe's current value matches the token the populator
stamped on the persisted index — the store provably covers the
current tool tree. ``False``: a probe is configured but the token
is missing or mismatched — the tree changed since the last
populate. ``None``: no probe — the caller must fall back to a
coverage scan.
"""
return None
def dispose(self) -> None: # noqa: B027 — intentional empty default
"""Drop pooled connections and cached state so the next read reopens.
Needed when the backing file is replaced underneath the store — a
CVMFS publish keeps serving the pre-publish file to descriptors
that were already open, so only a connection opened afterwards
sees the new snapshot. Default is a no-op; engine-holding backends
override.
"""
def close(self) -> None: # noqa: B027 — intentional empty default
"""Release any state the store is holding.
Wired into ``GalaxyUniverseApplication.haltables`` so a Python-side
``app.shutdown()`` (e.g. the embedded ``IntegrationTestCase.restart()``
path) clears references that would otherwise survive into the next
boot. Default is a no-op; backends holding an engine or cache
override, and the composite store propagates.
"""
class ConfigurationError(Exception):
"""Raised when there's a configuration error."""
class ReadOnlyStoreError(Exception):
"""Raised when a write is attempted against a read-only tool source store."""
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
"""Whoosh-backed tool search index built from a ``ToolIndex``.
Single source of truth for populator-built tool search ranking. The schema and field boosts mirror
:class:`galaxy.tools.search.ToolPanelViewSearch`, so a query that ranks a
tool first under the eager toolbox ranks it first here too.
The populator calls :meth:`ToolWhooshIndex.build`
once the JSON index is committed. Store consumers open the on-disk Whoosh
index via :meth:`ToolWhooshIndex.search_scored` (``BM25F`` scoring).
"""
import json
import logging
import os
import re
import shutil
from dataclasses import dataclass
from whoosh import (
analysis,
index,
writing,
)
from whoosh.fields import (
ID,
KEYWORD,
NGRAMWORDS,
Schema,
TEXT,
)
from whoosh.qparser import (
MultifieldParser,
OrGroup,
)
from whoosh.scoring import BM25F
from galaxy.config import GalaxyAppConfiguration
from galaxy.tool_util.ontologies.ontology_data import curated_tool_tags
from galaxy.tools.source_store.index import (
ToolIndex,
ToolIndexEntry,
)
from galaxy.util import unicodify
from galaxy.util.hash_util import md5_hash_str
log = logging.getLogger(__name__)
# Sidecar file in the whoosh dir recording the corpus signature of the last
# successful build; a matching signature lets ``build`` skip the rebuild.
_CORPUS_SIGNATURE_FILE = "corpus.md5"
@dataclass(frozen=True)
class ToolSearchTuning:
"""Whoosh schema/boost knobs for tool search.
Mirrors the ``tool_*`` keys on ``GalaxyAppConfiguration`` so this module
can build a search index without importing the full Galaxy config type
or carrying a god-object reference. Wire one in at the call site —
typically via :meth:`from_config`.
"""
id_boost: float
name_boost: float
name_exact_multiplier: float
stub_boost: float
section_boost: float
description_boost: float
label_boost: float
ngram_minsize: int
ngram_maxsize: int
enable_ngram_search: bool
ngram_factor: float
@classmethod
def from_config(cls, config: GalaxyAppConfiguration) -> "ToolSearchTuning":
return cls(
id_boost=float(config.tool_id_boost),
name_boost=float(config.tool_name_boost),
name_exact_multiplier=float(config.tool_name_exact_multiplier),
stub_boost=float(config.tool_stub_boost),
section_boost=float(config.tool_section_boost),
description_boost=float(config.tool_description_boost),
label_boost=float(config.tool_label_boost),
ngram_minsize=int(config.tool_ngram_minsize),
ngram_maxsize=int(config.tool_ngram_maxsize),
enable_ngram_search=bool(config.tool_enable_ngram_search),
ngram_factor=float(config.tool_ngram_factor),
)
def build_search_schema(tuning: ToolSearchTuning, *, help_boost: float | None = None) -> Schema:
"""Whoosh schema shared by eager toolbox search and store search.
``help_boost`` adds the eager-only help field; the populator cannot index
rendered help text, so store search leaves it out.
"""
schema_conf: dict = {
"id": ID(stored=True, unique=True),
"id_exact": NGRAMWORDS(
minsize=tuning.ngram_minsize,
maxsize=tuning.ngram_maxsize,
field_boost=(tuning.id_boost * tuning.name_exact_multiplier),
),
"name_exact": TEXT(
field_boost=(tuning.name_boost * tuning.name_exact_multiplier),
analyzer=analysis.IDTokenizer() | analysis.LowercaseFilter(),
),
"stub": KEYWORD(field_boost=tuning.stub_boost),
"section": TEXT(field_boost=tuning.section_boost),
"edam_operations": TEXT(field_boost=tuning.section_boost),
"edam_topics": TEXT(field_boost=tuning.section_boost),
"repository": TEXT(field_boost=tuning.section_boost),
"owner": TEXT(field_boost=tuning.section_boost),
"description": TEXT(
field_boost=tuning.description_boost,
analyzer=analysis.StemmingAnalyzer(),
),
"labels": KEYWORD(field_boost=tuning.label_boost),
"tool_tags": TEXT(
field_boost=tuning.label_boost,
analyzer=analysis.KeywordAnalyzer(lowercase=True, commas=True),
),
}
if help_boost is not None:
schema_conf["help"] = TEXT(field_boost=help_boost, analyzer=analysis.StemmingAnalyzer())
if tuning.enable_ngram_search:
schema_conf["name"] = NGRAMWORDS(
minsize=tuning.ngram_minsize,
maxsize=tuning.ngram_maxsize,
field_boost=tuning.name_boost * tuning.ngram_factor,
)
else:
schema_conf["name"] = TEXT(field_boost=tuning.name_boost)
return Schema(**schema_conf)
_REX = analysis.RegexTokenizer()
def _clean(s: str | None) -> str:
"""Tokenise hyphenated text — hyphens are Whoosh wildcards."""
if not s:
return ""
text = unicodify(s)
if "-" in text:
return " ".join(token.text for token in _REX(text))
return text
def _entry_to_doc(entry: ToolIndexEntry) -> dict | None:
"""Turn a ``ToolIndexEntry`` into the document shape ``Schema`` expects."""
# Data manager tools are admin-only; mirror ``ToolPanelViewSearch._create_doc``
# by leaving them out of the public search corpus.
if entry.tool_type == "data_manager":
return None
name_clean = _clean(entry.name)
doc: dict = {
"id": unicodify(entry.id),
"id_exact": unicodify(entry.id),
"name": name_clean,
"name_exact": name_clean,
"description": unicodify(entry.description or ""),
"section": unicodify(entry.panel_section_name or ""),
"edam_operations": [_clean(op) for op in entry.edam_operations or []],
"edam_topics": [_clean(topic) for topic in entry.edam_topics or []],
"repository": unicodify(entry.repository_name or ""),
"owner": unicodify(entry.repository_owner or ""),
}
# GUID has shape ``shed/repos/owner/repo/tool/version``. The eager path
# carves out ``owner/repo/tool`` as the search stub; fall back to the
# plain id for local tools.
if "/" in entry.id:
slash_indexes = [m.start() for m in re.finditer("/", entry.id)]
if len(slash_indexes) >= 5:
doc["stub"] = _clean(entry.id[slash_indexes[1] + 1 : slash_indexes[4]])
else:
doc["stub"] = unicodify(entry.id)
else:
doc["stub"] = unicodify(entry.id)
if entry.labels:
doc["labels"] = unicodify(" ".join(entry.labels))
tool_id = (entry.id or "").lower()
all_ids = [tool_id]
if "/repos/" in tool_id:
all_ids = [tool_id, tool_id.rsplit("/", 1)[0], tool_id.rsplit("/", 2)[-2]]
if tags := curated_tool_tags(all_ids):
doc["tool_tags"] = unicodify(",".join(tags))
return doc
class ToolWhooshIndex:
"""Build + query a Whoosh index over ``ToolIndexEntry`` rows.
Built at populate time and queried by store consumers. The on-disk format is plain Whoosh —
no Galaxy-specific encoding, so an operator can reopen it offline.
"""
def __init__(self, index_dir: str, tuning: ToolSearchTuning) -> None:
self.index_dir = index_dir
self.tuning = tuning
self.schema = build_search_schema(tuning)
def _open(self) -> index.FileIndex:
os.makedirs(self.index_dir, exist_ok=True)
if index.exists_in(self.index_dir):
ix = index.open_dir(self.index_dir)
if ix.schema == self.schema:
return ix
# Schema drift — rebuild from scratch so old field layouts don't
# poison ranking.
log.info("ToolWhooshIndex schema changed; rebuilding %s", self.index_dir)
ix.close()
shutil.rmtree(self.index_dir)
os.makedirs(self.index_dir, exist_ok=True)
return index.create_in(self.index_dir, schema=self.schema)
def _corpus_signature(self, docs: list[dict]) -> str:
"""Fingerprint of what a build over ``docs`` would produce.
Covers the document content, the field set, and the boosts (tuning) —
if none of those changed, the on-disk index is already equivalent and
a rebuild can be skipped.
"""
return md5_hash_str(
repr(self.tuning)
+ json.dumps(sorted(self.schema.names()))
+ json.dumps(sorted(docs, key=lambda d: d["id"]), sort_keys=True)
)
def build(self, tool_index: ToolIndex) -> int:
"""Rebuild the on-disk index from ``tool_index.entries``.
Returns the number of documents written — 0 when the corpus is
unchanged and the rebuild was skipped.
"""
docs = []
for entry in tool_index.entries.values():
if entry.hidden:
continue
doc = _entry_to_doc(entry)
if doc is None:
continue
docs.append(doc)
# Tokenising ~10k documents takes a minute-plus; skip it when the
# corpus signature matches what's already on disk (the common
# populate re-run where nothing changed).
signature = self._corpus_signature(docs)
signature_path = os.path.join(self.index_dir, _CORPUS_SIGNATURE_FILE)
if os.path.isdir(self.index_dir) and index.exists_in(self.index_dir):
try:
with open(signature_path) as f:
if f.read().strip() == signature:
log.info("Whoosh index at %s is up to date; skipping rebuild", self.index_dir)
return 0
except OSError:
pass
# The corpus is complete, so bulk-load fresh documents and commit
# with CLEAR — the new segment atomically replaces all previous
# content. No per-document delete lookups (update_document) and no
# upfront scan of existing ids, which dominated the old build.
ix = self._open()
writer = ix.writer(limitmb=256)
for doc in docs:
writer.add_document(**doc)
writer.commit(mergetype=writing.CLEAR)
with open(signature_path, "w") as f:
f.write(signature)
return len(docs)
def search(self, query: str, limit: int | None = None) -> list[str]:
"""Return tool ids ranked for ``query`` (most-relevant first).
``limit=None`` returns every match — the eager
``ToolPanelViewSearch`` searches unlimited, and capped results
truncate uniform-score hits arbitrarily (a tag query fanning out to
23 tools would silently lose the last 3 in doc-insertion order).
"""
return [tool_id for tool_id, _score in self.search_scored(query, limit=limit)]
def search_scored(self, query: str, limit: int | None = None) -> list[tuple[str, float]]:
"""Like :meth:`search`, but pair each tool id with its BM25 score.
Callers merging hits across several store indexes need the scores
to interleave results instead of concatenating whole result lists.
"""
if not query or not query.strip():
return []
if not (os.path.isdir(self.index_dir) and index.exists_in(self.index_dir)):
return []
ix = index.open_dir(self.index_dir)
search_fields = [
"id_exact",
"name",
"name_exact",
"stub",
"description",
"section",
"edam_operations",
"edam_topics",
"repository",
"owner",
"labels",
"tool_tags",
]
parser = MultifieldParser(search_fields, schema=ix.schema, group=OrGroup)
parsed = parser.parse(query)
with ix.searcher(weighting=BM25F()) as searcher:
hits = searcher.search(parsed, limit=limit)
return [(hit["id"], hit.score) for hit in hits]
+388
View File
@@ -0,0 +1,388 @@
"""
SQLAlchemy-backed tool source store.
A self-contained store that owns its own engine + metadata, decoupled
from ``galaxy.model``. Works with any SQLAlchemy URL (sqlite, postgres,
mysql, …); the SQLite single-file path is the typical use case
(shippable on CVMFS) but nothing about the schema is sqlite-specific.
"""
import gzip
import json
import logging
import os
from collections.abc import (
Callable,
Iterator,
)
from datetime import datetime
from typing import (
Any,
)
from sqlalchemy import (
create_engine,
LargeBinary,
MetaData,
select,
String,
Text,
)
from sqlalchemy.engine import make_url
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
mapped_column,
Session,
sessionmaker,
)
from .index import (
INDEX_SCHEMA_HASH,
ToolIndex,
ToolIndexEntry,
)
from .interface import (
ReadOnlyStoreError,
StoredToolSource,
ToolSourceStore,
)
log = logging.getLogger(__name__)
# Independent SQLAlchemy metadata — a tool source bundle has nothing to
# do with the Galaxy ORM and must be openable without booting Galaxy.
_metadata = MetaData()
class _Base(DeclarativeBase):
metadata = _metadata
class _ToolSourceRow(_Base):
__tablename__ = "tool_source"
# One row per source path; ``hash`` fingerprints the expanded content
# and is non-unique — distinct files can expand to identical content
# yet each path must resolve through ``get_by_source_path``.
id: Mapped[int] = mapped_column(primary_key=True)
hash: Mapped[str] = mapped_column(String(64), index=True)
tool_source_class: Mapped[str] = mapped_column(String(64))
raw_source: Mapped[str] = mapped_column(Text)
tool_id: Mapped[str | None] = mapped_column(String(255), index=True)
tool_version: Mapped[str | None] = mapped_column(String(64))
tool_dir: Mapped[str | None] = mapped_column(Text)
source_path: Mapped[str | None] = mapped_column(String(1024), index=True)
stored_at: Mapped[datetime | None] = mapped_column()
extra_metadata: Mapped[str | None] = mapped_column(Text) # JSON
class _ToolIndexRow(_Base):
__tablename__ = "tool_index"
id: Mapped[int] = mapped_column(primary_key=True)
data: Mapped[bytes] = mapped_column(LargeBinary) # gzip-compressed JSON
built_at: Mapped[datetime | None] = mapped_column()
class SqlAlchemyToolSourceStore(ToolSourceStore):
"""A tool source store backed by any SQLAlchemy-compatible database.
Each instance owns a private engine + metadata, separate from the
Galaxy ORM, so the schema travels with the data and the store is
openable without booting Galaxy.
Args:
url: SQLAlchemy URL.
read_only: If True, refuse all mutating operations with
:class:`ReadOnlyStoreError`. This is enforced at the Python
level only - make sure the connection user lacks write
privileges if that matters. For SQLite connection-level
read-only, use a URI such as
``sqlite:///file:/path/to/store.sqlite?mode=ro&uri=true``.
"""
def __init__(
self,
url: str,
read_only: bool = False,
freshness_probe: Callable[[], str] | None = None,
) -> None:
self.url = url
self.read_only = read_only
self._freshness_probe = freshness_probe
self._cached_index: ToolIndex | None = None
self._ensure_sqlite_parent_directory(url)
self._engine = create_engine(url, future=True)
if not read_only and not self._is_remote_engine():
# Only auto-create schema on local/file backends. For shared
# databases the operator should manage migrations explicitly
# to avoid surprises.
_metadata.create_all(self._engine)
elif not read_only:
# Best-effort create_all on remote backends - harmless if
# tables already exist.
try:
_metadata.create_all(self._engine)
except Exception as e:
log.error(f"could not auto-create tool source tables on {self._engine.url.drivername!r}: {e}")
self._Session = sessionmaker(bind=self._engine, future=True)
def _is_remote_engine(self) -> bool:
return self._engine.url.drivername.split("+")[0] not in {"sqlite"}
def _ensure_sqlite_parent_directory(self, url: str) -> None:
parsed_url = make_url(url)
if parsed_url.drivername.split("+")[0] != "sqlite":
return
if str(parsed_url.query.get("uri", "")).lower() in {"true", "1", "yes"}:
return
database = parsed_url.database
if not database or database == ":memory:":
return
os.makedirs(os.path.dirname(os.path.abspath(database)) or ".", exist_ok=True)
# --- helpers --------------------------------------------------------
def _session(self) -> Session:
return self._Session()
def _ensure_writable(self) -> None:
if self.read_only:
raise ReadOnlyStoreError(f"tool source store at {self.url} is read-only")
# --- ToolSourceStore: per-source ops --------------------------------
def store(self, tool_source: StoredToolSource) -> str:
"""Store a source; one row per ``source_path``, path-less sources
deduplicate on content hash (see :class:`_ToolSourceRow`)."""
self._ensure_writable()
with self._session() as session:
if tool_source.source_path:
existing = (
session.execute(
select(_ToolSourceRow).where(_ToolSourceRow.source_path == tool_source.source_path).limit(1)
)
.scalars()
.first()
)
if existing is not None:
existing.hash = tool_source.hash
existing.tool_source_class = tool_source.tool_source_class
existing.raw_source = tool_source.raw_source
existing.tool_id = tool_source.tool_id
existing.tool_version = tool_source.tool_version
existing.tool_dir = tool_source.tool_dir
existing.stored_at = tool_source.stored_at
existing.extra_metadata = json.dumps(tool_source.metadata) if tool_source.metadata else None
session.commit()
return tool_source.hash
else:
existing = (
session.execute(select(_ToolSourceRow).where(_ToolSourceRow.hash == tool_source.hash).limit(1))
.scalars()
.first()
)
if existing is not None:
return tool_source.hash
row = _ToolSourceRow(
hash=tool_source.hash,
tool_source_class=tool_source.tool_source_class,
raw_source=tool_source.raw_source,
tool_id=tool_source.tool_id,
tool_version=tool_source.tool_version,
tool_dir=tool_source.tool_dir,
source_path=tool_source.source_path,
stored_at=tool_source.stored_at,
extra_metadata=json.dumps(tool_source.metadata) if tool_source.metadata else None,
)
session.add(row)
session.commit()
return tool_source.hash
def get(self, hash: str) -> StoredToolSource | None:
with self._session() as session:
row = session.execute(select(_ToolSourceRow).where(_ToolSourceRow.hash == hash).limit(1)).scalars().first()
if row is None:
return None
return self._row_to_stored(row)
def exists(self, hash: str) -> bool:
with self._session() as session:
row = (
session.execute(select(_ToolSourceRow.id).where(_ToolSourceRow.hash == hash).limit(1)).scalars().first()
)
return row is not None
def delete(self, hash: str) -> bool:
"""Delete all rows carrying this hash (one per source path)."""
self._ensure_writable()
with self._session() as session:
rows = session.execute(select(_ToolSourceRow).where(_ToolSourceRow.hash == hash)).scalars().all()
if not rows:
return False
for row in rows:
session.delete(row)
session.commit()
return True
def list_all(self) -> Iterator[str]:
# Materialize so the session can close before the caller iterates.
with self._session() as session:
hashes = [h for (h,) in session.execute(select(_ToolSourceRow.hash)).all()]
yield from hashes
def get_by_tool_id(self, tool_id: str, version: str | None = None) -> list[StoredToolSource]:
with self._session() as session:
stmt = select(_ToolSourceRow).where(_ToolSourceRow.tool_id == tool_id)
if version is not None:
stmt = stmt.where(_ToolSourceRow.tool_version == version)
rows = session.execute(stmt).scalars().all()
return [self._row_to_stored(r) for r in rows]
def get_by_source_path(self, source_path: str) -> StoredToolSource | None:
with self._session() as session:
row = (
session.execute(select(_ToolSourceRow).where(_ToolSourceRow.source_path == source_path).limit(1))
.scalars()
.first()
)
return self._row_to_stored(row) if row is not None else None
def list_source_paths(self) -> set[str]:
with self._session() as session:
return {
path
for (path,) in session.execute(
select(_ToolSourceRow.source_path).where(_ToolSourceRow.source_path.is_not(None))
).all()
}
def count(self) -> int:
with self._session() as session:
return session.query(_ToolSourceRow).count()
def get_stats(self) -> dict[str, Any]:
return {
"count": self.count(),
"backend": "sqlalchemy",
"url": str(self._engine.url),
"read_only": self.read_only,
}
# --- ToolSourceStore: index ops -------------------------------------
def store_index(self, index: ToolIndex) -> None:
self._ensure_writable()
payload = {"schema_hash": INDEX_SCHEMA_HASH, "index": index.model_dump(mode="json")}
compressed = gzip.compress(json.dumps(payload).encode("utf-8"))
with self._session() as session:
# Singleton row, updated in place.
row = session.execute(select(_ToolIndexRow).order_by(_ToolIndexRow.id)).scalar_one_or_none()
if row is None:
row = _ToolIndexRow(data=compressed, built_at=index.built_at)
session.add(row)
else:
row.data = compressed
row.built_at = index.built_at
session.commit()
self._cached_index = index
def load_index(self) -> ToolIndex | None:
if self._cached_index is not None:
return self._cached_index
with self._session() as session:
row = session.execute(select(_ToolIndexRow).order_by(_ToolIndexRow.id.desc())).scalar_one_or_none()
if row is None or not row.data:
return None
try:
payload = json.loads(gzip.decompress(row.data).decode("utf-8"))
if payload.get("schema_hash") != INDEX_SCHEMA_HASH:
# The index model changed since this blob was written (or the
# blob predates schema stamping): discard rather than load
# stale/defaulted fields — the populator rebuilds from source.
log.info("Persisted tool index in %s has a stale schema; discarding", self.url)
return None
self._cached_index = ToolIndex.model_validate(payload["index"])
return self._cached_index
except Exception as e:
log.error(f"Failed to decode tool index from store {self.url}: {e}")
return None
def update_index_entry(self, entry: ToolIndexEntry) -> None:
self._ensure_writable()
index = self.load_index() or ToolIndex()
# add_entry keeps every index projection in step. Single-entry writes are runtime
# additions (a shed install, a self-heal), so a new placement goes
# to the head of its section like the eager runtime insert does.
index.add_entry(entry, new_placements_first=True)
self.store_index(index)
def invalidate_index_cache(self) -> None:
self._cached_index = None
def dispose(self) -> None:
self._engine.dispose()
self._cached_index = None
def close(self) -> None:
self.dispose()
@property
def has_freshness_probe(self) -> bool:
return self._freshness_probe is not None
def compute_freshness_token(self) -> str | None:
if self._freshness_probe is None:
return None
try:
return self._freshness_probe()
except Exception as e:
log.error("Freshness probe for tool source store %s failed: %s", self.url, e)
return None
def index_is_fresh(self) -> bool | None:
if self.read_only:
# Trust by construction: a read-only store is published together
# with the tools it indexes (the CVMFS model), so a schema-valid
# index is authoritative — ``load_index`` already discards on
# schema-hash mismatch. Comparing a stamped repository revision
# instead would be unsound: the publisher populates *inside* the
# publish transaction at revision N, clients then see N+1, so
# the stamp could never match. The probe, if configured, serves
# the watcher's change detection only.
try:
return self.load_index() is not None
except Exception as e:
# e.g. the sqlite exists but was never populated (no
# tables) — same verdict as "no index": not loadable.
log.error("Loading index from read-only store %s failed: %s", self.url, e)
return False
if self._freshness_probe is None:
return None
current = self.compute_freshness_token()
if current is None:
# Probe configured but unreadable — never report fresh.
return False
index = self.load_index()
if index is None or index.freshness_token is None:
return False
return index.freshness_token == current
# --- internals ------------------------------------------------------
def _row_to_stored(self, row: _ToolSourceRow) -> StoredToolSource:
metadata = json.loads(row.extra_metadata) if row.extra_metadata else {}
return StoredToolSource(
hash=row.hash,
tool_source_class=row.tool_source_class,
raw_source=row.raw_source,
tool_id=row.tool_id,
tool_version=row.tool_version,
tool_dir=row.tool_dir,
source_path=row.source_path,
stored_at=row.stored_at,
metadata=metadata,
)
+86
View File
@@ -0,0 +1,86 @@
"""Polling watcher reacting to externally-updated tool source stores.
A store published outside this Galaxy process — the CVMFS model, where a
publisher repopulates the sqlite bundle in the same transaction that ships
new tools — changes without any local filesystem event (inotify does not
fire on CVMFS). Polling each store's freshness probe is the only reliable
signal, and it is cheap: one probe per store per tick.
The watcher itself is deliberately dumb: it detects token transitions and
hands the changed store names to ``on_change``. Reload mechanics —
disposing engines so new connections see the new CVMFS snapshot, index
reload, panel refresh, whoosh rebuild — belong to the callback owner
(``LazyToolBox``).
"""
import logging
import threading
from collections.abc import Callable
from .interface import ToolSourceStore
log = logging.getLogger(__name__)
class ToolSourceStoreWatcher:
"""Poll member stores' freshness probes; report token transitions.
Args:
members: ``(name, store)`` pairs to watch. Callers pass read-only
stores with a probe — writable stores change through this
process's own populate paths, which broadcast their own
reloads.
interval: Seconds between polls.
on_change: Called from the watcher thread with the names whose
token changed since the previous poll.
"""
def __init__(
self,
members: list[tuple[str, ToolSourceStore]],
interval: float,
on_change: Callable[[list[str]], None],
) -> None:
self._members = members
self._interval = interval
self._on_change = on_change
self._last_token: dict[str, str] = {}
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
self._thread = threading.Thread(target=self._loop, name="ToolSourceStoreWatcher", daemon=True)
self._thread.start()
def _loop(self) -> None:
while not self._stop.wait(self._interval):
try:
self.check()
except Exception:
log.exception("Tool source store freshness poll failed")
def check(self) -> list[str]:
"""One poll tick. Returns the store names whose token transitioned.
The first sighting of a store's token only records the baseline —
comparing against the *persisted* index token instead would re-fire
forever when a store is stale for reasons a reload can't fix (e.g.
the publisher hasn't repopulated yet).
"""
changed: list[str] = []
for name, store in self._members:
token = store.compute_freshness_token()
if token is None:
# Probe failure; the store already logged it.
continue
previous = self._last_token.get(name)
self._last_token[name] = token
if previous is not None and token != previous:
log.info("Tool source store %r freshness token changed (%s -> %s)", name, previous, token)
changed.append(name)
if changed:
self._on_change(changed)
return changed
def shutdown(self) -> None:
self._stop.set()
+23 -1
View File
@@ -3,6 +3,8 @@ import os
log = logging.getLogger(__name__)
# Tool files Galaxy's ``load_lib_tools`` loads after the normal config walk.
# Paths are relative to this file's directory (``lib/galaxy/tools/``).
SPECIAL_TOOLS = {
"history export": "imp_exp/exp_history_to_archive.xml",
"history export to uri": "imp_exp/exp_history_to_uri.xml",
@@ -10,8 +12,28 @@ SPECIAL_TOOLS = {
"data fetch": "data_fetch.xml",
}
# ``set_metadata_tool`` is loaded separately by
# ``datatypes_registry.load_external_metadata_tool``; listed here so
# ``hidden_lib_tool_paths`` covers every Galaxy-internal tool.
_EXTRA_HIDDEN_LIB_TOOLS = {
"set metadata": "../datatypes/set_metadata_tool.xml",
}
def hidden_lib_tool_paths() -> list[str]:
"""Absolute paths of every Galaxy-internal "hidden lib" tool.
Used by :func:`galaxy.tools.source_store.discover.discover_tools` so the
populator indexes these tools alongside the conf-discovered ones.
"""
base = os.path.dirname(__file__)
return [
os.path.abspath(os.path.join(base, p)) for p in (*SPECIAL_TOOLS.values(), *_EXTRA_HIDDEN_LIB_TOOLS.values())
]
def load_lib_tools(toolbox):
base = os.path.dirname(__file__)
for name, path in SPECIAL_TOOLS.items():
tool = toolbox.load_hidden_lib_tool(os.path.abspath(os.path.join(os.path.dirname(__file__), path)))
tool = toolbox.load_hidden_lib_tool(os.path.abspath(os.path.join(base, path)))
log.debug("Loaded %s tool: %s", name, tool.id)
+1
View File
@@ -100,6 +100,7 @@ test = [
[project.scripts]
galaxy-main = "galaxy.main:main"
galaxy-dependencies = "galaxy.dependencies.script:main"
galaxy-populate-tool-source-store = "galaxy.tools.source_store.populator:main"
galaxy-sse-monitor = "galaxy.sse_monitor.__main__:main"
[project.urls]
+1
View File
@@ -0,0 +1 @@
# Tool source management scripts
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Standalone CLI entry for the tool source store populator.
Thin shim: extends ``sys.path`` so ``galaxy.*`` resolves when the script is
invoked directly (no venv-installed galaxy package required), then delegates
to :func:`galaxy.tools.source_store.populator.main`. All flags, including
``--watch``, are documented on that function's argparse setup.
Installations using the ``galaxy-app`` package should prefer the
``galaxy-populate-tool-source-store`` console script it installs; in-process
callers should import the populator module directly.
"""
import sys
from pathlib import Path
galaxy_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(galaxy_root / "lib"))
from galaxy.tools.source_store.populator import main
if __name__ == "__main__":
main()
@@ -0,0 +1,27 @@
"""Shared fixtures for tool source store unit tests."""
import pytest
from galaxy.tools.source_store.index import (
ToolIndex,
ToolIndexEntry,
)
@pytest.fixture
def index_entry():
def _make(id, version=None, **kwargs):
return ToolIndexEntry(id=id, version=version, **kwargs)
return _make
@pytest.fixture
def tool_index(index_entry):
def _make(*entries):
index = ToolIndex()
for entry in entries:
index.add_entry(entry)
return index
return _make
@@ -0,0 +1,201 @@
"""Unit tests for CompositeToolSourceStore + merged ToolIndex."""
import os
import tempfile
import pytest
from galaxy.tools.source_store import StoredToolSource
from galaxy.tools.source_store.composite import CompositeToolSourceStore
from galaxy.tools.source_store.index import (
ToolIndex,
ToolIndexEntry,
)
from galaxy.tools.source_store.sqlalchemy import SqlAlchemyToolSourceStore as SqliteToolSourceStore
@pytest.fixture
def two_paths():
with tempfile.TemporaryDirectory() as d:
yield os.path.join(d, "a.sqlite"), os.path.join(d, "b.sqlite")
def _src(hash, tool_id="t", version="1"):
return StoredToolSource(
hash=hash,
tool_source_class="XmlToolSource",
raw_source=f'<tool id="{tool_id}" version="{version}"/>',
tool_id=tool_id,
tool_version=version,
)
def _sqlite_url(path):
return f"sqlite:///{path}"
def test_priority_order_first_hit_wins(two_paths):
pa, pb = two_paths
a = SqliteToolSourceStore(url=_sqlite_url(pa))
b = SqliteToolSourceStore(url=_sqlite_url(pb))
# Same hash, different tool_id payloads, to prove which member answered.
a.store(_src("dup", tool_id="from_a"))
b.store(_src("dup", tool_id="from_b"))
composite = CompositeToolSourceStore(members=[("a", a), ("b", b)], default="b")
got = composite.get("dup")
assert got is not None
assert got.tool_id == "from_a"
def test_writes_go_to_default(two_paths):
pa, pb = two_paths
a = SqliteToolSourceStore(url=_sqlite_url(pa))
b = SqliteToolSourceStore(url=_sqlite_url(pb))
composite = CompositeToolSourceStore(members=[("a", a), ("b", b)], default="b")
composite.store(_src("h1"))
assert b.exists("h1")
assert not a.exists("h1")
def test_default_must_not_be_read_only(two_paths):
pa, pb = two_paths
rw = SqliteToolSourceStore(url=_sqlite_url(pa))
rw.store(_src("seed")) # so the file exists
ro = SqliteToolSourceStore(url=_sqlite_url(pa), read_only=True)
with pytest.raises(ValueError):
CompositeToolSourceStore(members=[("ro", ro), ("rw", rw)], default="ro")
def test_list_all_dedupes_across_members(two_paths):
pa, pb = two_paths
a = SqliteToolSourceStore(url=_sqlite_url(pa))
b = SqliteToolSourceStore(url=_sqlite_url(pb))
a.store(_src("h1"))
a.store(_src("dup"))
b.store(_src("dup"))
b.store(_src("h2"))
composite = CompositeToolSourceStore(members=[("a", a), ("b", b)], default="b")
assert sorted(composite.list_all()) == ["dup", "h1", "h2"]
assert composite.count() == 3
def test_load_index_merges_and_dedupes(two_paths):
pa, pb = two_paths
a = SqliteToolSourceStore(url=_sqlite_url(pa))
b = SqliteToolSourceStore(url=_sqlite_url(pb))
a.store_index(
ToolIndex(
entries={
"shared": ToolIndexEntry(id="shared", name="from_a"),
"only_a": ToolIndexEntry(id="only_a", name="A only"),
}
)
)
b.store_index(
ToolIndex(
entries={
"shared": ToolIndexEntry(id="shared", name="from_b"),
"only_b": ToolIndexEntry(id="only_b", name="B only"),
}
)
)
composite = CompositeToolSourceStore(members=[("a", a), ("b", b)], default="b")
merged = composite.load_index()
assert merged is not None
assert set(merged.entries.keys()) == {"shared", "only_a", "only_b"}
# Earlier member wins on collision.
assert merged.entries["shared"].name == "from_a"
def test_load_index_returns_none_when_no_member_has_one(two_paths):
pa, pb = two_paths
a = SqliteToolSourceStore(url=_sqlite_url(pa))
b = SqliteToolSourceStore(url=_sqlite_url(pb))
composite = CompositeToolSourceStore(members=[("a", a), ("b", b)], default="b")
assert composite.load_index() is None
def test_invalidate_fans_out(two_paths):
# Verify behaviorally: after invalidation, each member must observe a
# fresh on-disk index rather than a stale cached one. We seed each
# store, prime its cache via load_index(), then overwrite the on-disk
# index out-of-band with a new entry. Without invalidation a stale
# cache hides the new entry; with composite.invalidate_index_cache()
# the next load surfaces it.
pa, pb = two_paths
a = SqliteToolSourceStore(url=_sqlite_url(pa))
b = SqliteToolSourceStore(url=_sqlite_url(pb))
a.store_index(ToolIndex(entries={"x": ToolIndexEntry(id="x")}))
b.store_index(ToolIndex(entries={"y": ToolIndexEntry(id="y")}))
a.load_index()
b.load_index()
# Out-of-band update via a fresh handle so the existing instance's
# cache stays primed with the old value.
SqliteToolSourceStore(url=_sqlite_url(pa)).store_index(
ToolIndex(entries={"x": ToolIndexEntry(id="x"), "x2": ToolIndexEntry(id="x2")})
)
SqliteToolSourceStore(url=_sqlite_url(pb)).store_index(
ToolIndex(entries={"y": ToolIndexEntry(id="y"), "y2": ToolIndexEntry(id="y2")})
)
# Pre-invalidation: stale caches still answer with the old contents.
a_idx = a.load_index()
b_idx = b.load_index()
assert a_idx is not None
assert b_idx is not None
assert "x2" not in a_idx.entries
assert "y2" not in b_idx.entries
composite = CompositeToolSourceStore(members=[("a", a), ("b", b)], default="b")
composite.invalidate_index_cache()
# Post-invalidation: each member re-reads from disk and the new
# entries surface through the merged index.
merged = composite.load_index()
assert merged is not None
assert "x2" in merged.entries
assert "y2" in merged.entries
def test_load_index_merges_entries_by_version(two_paths):
pa, pb = two_paths
a = SqliteToolSourceStore(url=_sqlite_url(pa))
b = SqliteToolSourceStore(url=_sqlite_url(pb))
idx_a = ToolIndex()
idx_a.add_entry(ToolIndexEntry(id="multi", name="v1", version="1.0"))
idx_a.add_entry(ToolIndexEntry(id="multi", name="v2", version="2.0"))
a.store_index(idx_a)
idx_b = ToolIndex()
idx_b.add_entry(ToolIndexEntry(id="multi", name="v3", version="3.0"))
b.store_index(idx_b)
composite = CompositeToolSourceStore(members=[("a", a), ("b", b)], default="b")
merged = composite.load_index()
assert merged is not None
for version, name in (("1.0", "v1"), ("2.0", "v2"), ("3.0", "v3")):
entry = merged.get("multi", version)
assert entry is not None
assert entry.name == name
def test_list_source_paths_unions_members(two_paths):
pa, pb = two_paths
a = SqliteToolSourceStore(url=_sqlite_url(pa))
b = SqliteToolSourceStore(url=_sqlite_url(pb))
src_a = _src("ha", tool_id="ta")
src_a.source_path = "/tools/a.xml"
a.store(src_a)
src_b = _src("hb", tool_id="tb")
src_b.source_path = "/tools/b.xml"
b.store(src_b)
composite = CompositeToolSourceStore(members=[("a", a), ("b", b)], default="b")
assert composite.list_source_paths() == {"/tools/a.xml", "/tools/b.xml"}
def test_read_only_member_names(two_paths):
pa, pb = two_paths
writable = SqliteToolSourceStore(url=_sqlite_url(pa))
SqliteToolSourceStore(url=_sqlite_url(pb)).store(_src("h"))
read_only = SqliteToolSourceStore(url=_sqlite_url(pb), read_only=True)
composite = CompositeToolSourceStore(members=[("ro", read_only), ("rw", writable)], default="rw")
assert composite.read_only_member_names == {"ro"}
@@ -0,0 +1,126 @@
"""Unit tests for tool source store freshness probes."""
import logging
from galaxy.tools.source_store.composite import CompositeToolSourceStore
from galaxy.tools.source_store.freshness import (
FreshnessProbeError,
tool_confs_token,
)
from galaxy.tools.source_store.index import ToolIndex
from galaxy.tools.source_store.sqlalchemy import SqlAlchemyToolSourceStore
def _config(tmp_path, confs):
class _Cfg:
tool_path = str(tmp_path)
data_manager_config_file = None
shed_data_manager_config_file = None
def all_tool_config_files(self):
return [str(c) for c in confs]
return _Cfg()
def test_tool_confs_token_stable_until_conf_content_changes(tmp_path):
conf = tmp_path / "tool_conf.xml"
conf.write_text('<toolbox><tool file="a.xml"/></toolbox>')
cfg = _config(tmp_path, [conf])
first = tool_confs_token(cfg)
assert tool_confs_token(cfg) == first
conf.write_text('<toolbox><tool file="a.xml"/><tool file="b.xml"/></toolbox>')
assert tool_confs_token(cfg) != first
def test_tool_confs_token_sees_tool_dir_membership_changes(tmp_path):
dyn = tmp_path / "dyn"
dyn.mkdir()
(dyn / "sub").mkdir()
conf = tmp_path / "tool_conf.xml"
conf.write_text('<toolbox><tool_dir dir="dyn"/></toolbox>')
cfg = _config(tmp_path, [conf])
before = tool_confs_token(cfg)
(dyn / "new_tool.xml").write_text("<tool/>")
after_top_level = tool_confs_token(cfg)
assert after_top_level != before
(dyn / "sub" / "nested_tool.xml").write_text("<tool/>")
assert tool_confs_token(cfg) != after_top_level
def test_index_is_fresh_tracks_probe(tmp_path):
current = {"token": "confs:a"}
store = SqlAlchemyToolSourceStore(url=f"sqlite:///{tmp_path}/a.sqlite", freshness_probe=lambda: current["token"])
assert store.index_is_fresh() is False
store.store_index(ToolIndex(freshness_token="confs:a"))
assert store.index_is_fresh() is True
current["token"] = "confs:b"
assert store.index_is_fresh() is False
def test_index_is_fresh_none_without_probe(tmp_path):
store = SqlAlchemyToolSourceStore(url=f"sqlite:///{tmp_path}/a.sqlite")
store.store_index(ToolIndex(freshness_token="confs:a"))
assert store.index_is_fresh() is None
def test_index_is_fresh_false_when_probe_fails(tmp_path):
def broken_probe():
raise FreshnessProbeError("repo not mounted")
store = SqlAlchemyToolSourceStore(url=f"sqlite:///{tmp_path}/a.sqlite", freshness_probe=broken_probe)
store.store_index(ToolIndex(freshness_token="bundle:1"))
assert store.index_is_fresh() is False
def _stamped_store(path, token, probe_token, read_only=False):
SqlAlchemyToolSourceStore(url=f"sqlite:///{path}").store_index(ToolIndex(freshness_token=token))
return SqlAlchemyToolSourceStore(url=f"sqlite:///{path}", read_only=read_only, freshness_probe=lambda: probe_token)
def test_composite_fresh_when_all_members_fresh(tmp_path):
ro = _stamped_store(tmp_path / "ro.sqlite", "bundle:1", "bundle:1", read_only=True)
rw = _stamped_store(tmp_path / "rw.sqlite", "confs:x", "confs:x")
composite = CompositeToolSourceStore(members=[("ro", ro), ("rw", rw)], default="rw")
assert composite.index_is_fresh() is True
def test_composite_stale_writable_member_wins(tmp_path):
ro = _stamped_store(tmp_path / "ro.sqlite", "bundle:1", "bundle:1", read_only=True)
rw = _stamped_store(tmp_path / "rw.sqlite", "confs:x", "confs:y")
composite = CompositeToolSourceStore(members=[("ro", ro), ("rw", rw)], default="rw")
assert composite.index_is_fresh() is False
def test_read_only_store_trusts_schema_valid_index_over_probe(tmp_path):
# Stamped token and probe value disagree, but a read-only store is
# trusted whenever its index loads — the probe only feeds the watcher.
ro = _stamped_store(tmp_path / "ro.sqlite", "bundle:1", "bundle:2", read_only=True)
assert ro.index_is_fresh() is True
def test_read_only_store_without_index_is_not_fresh(tmp_path):
SqlAlchemyToolSourceStore(url=f"sqlite:///{tmp_path}/ro.sqlite")
ro = SqlAlchemyToolSourceStore(
url=f"sqlite:///{tmp_path}/ro.sqlite", read_only=True, freshness_probe=lambda: "bundle:1"
)
assert ro.index_is_fresh() is False
def test_composite_read_only_member_without_index_warns_but_stays_fresh(tmp_path, caplog):
SqlAlchemyToolSourceStore(url=f"sqlite:///{tmp_path}/ro.sqlite")
ro = SqlAlchemyToolSourceStore(url=f"sqlite:///{tmp_path}/ro.sqlite", read_only=True)
rw = _stamped_store(tmp_path / "rw.sqlite", "confs:x", "confs:x")
composite = CompositeToolSourceStore(members=[("ro", ro), ("rw", rw)], default="rw")
with caplog.at_level(logging.WARNING):
assert composite.index_is_fresh() is True
assert "no loadable index" in caplog.text
def test_composite_member_without_probe_downgrades_to_none(tmp_path):
ro = _stamped_store(tmp_path / "ro.sqlite", "bundle:1", "bundle:1", read_only=True)
rw = SqlAlchemyToolSourceStore(url=f"sqlite:///{tmp_path}/rw.sqlite")
composite = CompositeToolSourceStore(members=[("ro", ro), ("rw", rw)], default="rw")
assert composite.index_is_fresh() is None
@@ -0,0 +1,112 @@
"""Pin ``ToolIndex`` behaviours that index consumers depend on.
These unit tests run against a real ``ToolIndex`` (no mocks, no Galaxy
app) so they're cheap and pinpoint regressions. Behaviours pinned:
- per-id default selection by ``packaging.version.parse``
- ``entries_by_version`` records every indexed version
- explicit-version lookup via ``ToolIndex.get(tool_id, tool_version)``
- pydantic serialization round-trips ``entries_by_version``
"""
from galaxy.tools.source_store.index import ToolIndex
def test_add_entry_default_picks_highest_packaging_version(index_entry, tool_index):
# ``"0.1+galaxy6"`` lex-compares between ``"0.1"`` and ``"0.2"``, so a
# plain string sort would still pick ``"0.2"`` here — but it would pick
# ``"0.1+galaxy6"`` for ``["0.1+galaxy6", "0.10"]``. Cover the
# packaging-aware path explicitly.
index = tool_index(
index_entry("multi", version="0.1+galaxy6"),
index_entry("multi", version="0.1"),
index_entry("multi", version="0.2"),
)
assert index.entries["multi"].version == "0.2"
def test_add_entry_default_handles_post_release_above_two_digits(index_entry, tool_index):
index = tool_index(
index_entry("multi", version="0.10"),
index_entry("multi", version="0.2"),
)
assert index.entries["multi"].version == "0.10"
def test_add_entry_records_every_version_under_entries_by_version(index_entry, tool_index):
index = tool_index(
index_entry("multi", version="0.1"),
index_entry("multi", version="0.2"),
index_entry("multi", version="0.3"),
)
assert set(index.entries_by_version["multi"]) == {"0.1", "0.2", "0.3"}
def test_get_with_explicit_version_returns_that_entry(index_entry, tool_index):
e1 = index_entry("multi", version="0.1", name="V01")
e2 = index_entry("multi", version="0.2", name="V02")
index = tool_index(e1, e2)
assert index.get("multi", tool_version="0.1") is e1
assert index.get("multi", tool_version="0.2") is e2
def test_get_with_unknown_version_returns_none(index_entry, tool_index):
# Consumers use the ``None`` return to fall back to the
# default-version entry; pin the contract so a future refactor doesn't
# silently start returning the default here.
index = tool_index(index_entry("multi", version="0.1"))
assert index.get("multi", tool_version="9.9") is None
def test_get_with_no_version_returns_default_entry(index_entry, tool_index):
e1 = index_entry("multi", version="0.1")
e2 = index_entry("multi", version="0.2")
index = tool_index(e1, e2)
assert index.get("multi") is e2
def test_serialization_round_trips_entries_by_version(index_entry, tool_index):
index = tool_index(
index_entry("multi", version="0.1"),
index_entry("multi", version="0.2"),
index_entry("solo", version="1.0"),
)
restored = ToolIndex.model_validate(index.model_dump(mode="json"))
assert set(restored.entries_by_version["multi"]) == {"0.1", "0.2"}
assert restored.entries["multi"].version == "0.2"
multi_v01 = restored.get("multi", tool_version="0.1")
assert multi_v01 is not None and multi_v01.version == "0.1"
assert set(restored.entries_by_version["solo"]) == {"1.0"}
def test_add_entry_maintains_section_projection(index_entry, tool_index):
index = tool_index(
index_entry("one", panel_section_id="sec", panel_section_name="Section"),
index_entry("two", panel_section_id="sec", panel_section_name="Section"),
)
assert index.by_section == {"sec": ["one", "two"]}
def test_remove_entry_clears_all_projections(index_entry, tool_index):
index = tool_index(
index_entry("remove", version="1.0", panel_section_id="one"),
index_entry("remove", version="2.0", panel_section_id="two"),
index_entry("keep", version="1.0", panel_section_id="one"),
)
assert index.remove_entry("remove") is True
assert "remove" not in index.entries
assert "remove" not in index.entries_by_version
assert index.by_section == {"one": ["keep"]}
assert all(item.tool_id != "remove" for item in index.panel_items)
def test_add_entry_invalidates_derived_metadata(index_entry, tool_index):
index = tool_index(index_entry("one", requirements=[{"name": "one", "type": "package"}], test_count=1))
assert [requirement["name"] for requirement in index.get_all_requirements()] == ["one"]
assert set(index.get_tests_summary()) == {"one"}
index.add_entry(index_entry("two", requirements=[{"name": "two", "type": "package"}], test_count=1))
assert [requirement["name"] for requirement in index.get_all_requirements()] == ["one", "two"]
assert set(index.get_tests_summary()) == {"one", "two"}
@@ -0,0 +1,141 @@
"""Unit tests for the SQLite tool source store."""
import os
import tempfile
import pytest
import galaxy.tools.source_store.sqlalchemy as sqlalchemy_store_module
from galaxy.tools.source_store import (
ReadOnlyStoreError,
StoredToolSource,
)
from galaxy.tools.source_store.index import (
ToolIndex,
ToolIndexEntry,
)
from galaxy.tools.source_store.sqlalchemy import SqlAlchemyToolSourceStore as SqliteToolSourceStore
@pytest.fixture
def sqlite_path():
with tempfile.TemporaryDirectory() as d:
yield os.path.join(d, "store.sqlite")
def _source(hash="h1", tool_id="t1", version="1.0"):
return StoredToolSource(
hash=hash,
tool_source_class="XmlToolSource",
raw_source=f'<tool id="{tool_id}" version="{version}"/>',
tool_id=tool_id,
tool_version=version,
metadata={"k": "v"},
)
def _sqlite_url(path):
return f"sqlite:///{path}"
def test_store_and_retrieve_round_trip(sqlite_path):
store = SqliteToolSourceStore(url=_sqlite_url(sqlite_path))
store.store(_source())
got = store.get("h1")
assert got is not None
assert got.tool_id == "t1"
assert got.metadata == {"k": "v"}
assert store.exists("h1")
assert store.count() == 1
def test_get_by_tool_id_filters_by_version(sqlite_path):
store = SqliteToolSourceStore(url=_sqlite_url(sqlite_path))
store.store(_source(hash="h1", tool_id="t1", version="1.0"))
store.store(_source(hash="h2", tool_id="t1", version="2.0"))
assert {s.tool_version for s in store.get_by_tool_id("t1")} == {"1.0", "2.0"}
assert {s.tool_version for s in store.get_by_tool_id("t1", "2.0")} == {"2.0"}
def test_delete_returns_false_for_missing(sqlite_path):
store = SqliteToolSourceStore(url=_sqlite_url(sqlite_path))
assert store.delete("nope") is False
store.store(_source())
assert store.delete("h1") is True
assert not store.exists("h1")
def test_index_round_trip(sqlite_path):
store = SqliteToolSourceStore(url=_sqlite_url(sqlite_path))
idx = ToolIndex(entries={"t1": ToolIndexEntry(id="t1", name="T1")})
store.store_index(idx)
store.invalidate_index_cache()
loaded = store.load_index()
assert loaded is not None
assert "t1" in loaded.entries
assert loaded.entries["t1"].name == "T1"
def test_load_index_discards_stale_schema(sqlite_path, monkeypatch):
store = SqliteToolSourceStore(url=_sqlite_url(sqlite_path))
store.store_index(ToolIndex(entries={"t1": ToolIndexEntry(id="t1", name="T1")}))
# Simulate a Galaxy upgrade that changed the ToolIndex model: the
# persisted blob's stamp no longer matches, so the store must report "no
# index" (triggering a rebuild) rather than load stale/defaulted fields.
monkeypatch.setattr(sqlalchemy_store_module, "INDEX_SCHEMA_HASH", "post-upgrade-schema-hash")
fresh = SqliteToolSourceStore(url=_sqlite_url(sqlite_path))
assert fresh.load_index() is None
def test_read_only_refuses_writes(sqlite_path):
rw = SqliteToolSourceStore(url=_sqlite_url(sqlite_path))
rw.store(_source())
rw.store_index(ToolIndex(entries={"t1": ToolIndexEntry(id="t1", name="T1")}))
ro = SqliteToolSourceStore(url=_sqlite_url(sqlite_path), read_only=True)
assert ro.read_only is True
fetched = ro.get("h1")
assert fetched is not None
assert fetched.tool_id == "t1"
with pytest.raises(ReadOnlyStoreError):
ro.store(_source(hash="h2"))
with pytest.raises(ReadOnlyStoreError):
ro.delete("h1")
with pytest.raises(ReadOnlyStoreError):
ro.store_index(ToolIndex())
def test_get_stats_reports_backend_and_url(sqlite_path):
store = SqliteToolSourceStore(url=_sqlite_url(sqlite_path))
stats = store.get_stats()
assert stats["backend"] == "sqlalchemy"
assert stats["url"].startswith("sqlite:///")
assert sqlite_path in stats["url"]
assert stats["read_only"] is False
assert stats["count"] == 0
def test_url_path_works_with_in_memory_sqlite():
# Verify the generic url= path works against a non-file backend.
store = SqliteToolSourceStore(url="sqlite:///:memory:")
store.store(
StoredToolSource(
hash="hmem",
tool_source_class="XmlToolSource",
raw_source="<tool/>",
tool_id="mem",
)
)
fetched = store.get("hmem")
assert fetched is not None
assert fetched.tool_id == "mem"
def test_list_source_paths_skips_pathless_rows(sqlite_path):
store = SqliteToolSourceStore(url=_sqlite_url(sqlite_path))
with_path = _source(hash="h1", tool_id="t1")
with_path.source_path = "/tools/a.xml"
store.store(with_path)
store.store(_source(hash="h2", tool_id="t2"))
assert store.list_source_paths() == {"/tools/a.xml"}
@@ -0,0 +1,389 @@
"""Unit tests for tool source storage backends.
Tests exercise the sqlalchemy/sqlite backend directly and the store
factory / per-conf routing on top of it.
"""
import pytest
from galaxy.tools.source_store import (
build_named_store,
build_tool_source_store,
ConfigurationError,
factory as factory_module,
StoredToolSource,
)
from galaxy.tools.source_store.composite import CompositeToolSourceStore
from galaxy.tools.source_store.index import (
ToolIndex,
ToolIndexEntry,
)
from galaxy.tools.source_store.sqlalchemy import SqlAlchemyToolSourceStore
class FakeConfig:
"""Fake config for testing store factory."""
shed_tool_config_file: str | None = None
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def all_tool_config_files(self):
# Mirror GalaxyAppConfiguration.all_tool_config_files: the tool_config
# list plus the shed conf (store discovery must see both).
configs = list(getattr(self, "tool_configs", None) or [])
if self.shed_tool_config_file and self.shed_tool_config_file not in configs:
configs.append(self.shed_tool_config_file)
return configs
def _sqlite_url(path):
return f"sqlite:///{path}"
class TestSqlAlchemyBackend:
"""Tests for the sqlalchemy/sqlite backend."""
def test_sqlalchemy_store_basic_operations(self, tmp_path):
store = SqlAlchemyToolSourceStore(url=_sqlite_url(tmp_path / "ts.sqlite"))
tool_source = StoredToolSource(
hash="sa_test_hash_123",
tool_source_class="XmlToolSource",
raw_source='<tool id="sa_test" version="2.0"><command>cat</command></tool>',
tool_id="sa_test_tool",
tool_version="2.0",
)
store.store(tool_source)
assert store.exists("sa_test_hash_123")
retrieved = store.get("sa_test_hash_123")
assert retrieved is not None
assert retrieved.tool_id == "sa_test_tool"
assert store.count() >= 1
assert store.delete("sa_test_hash_123")
assert not store.exists("sa_test_hash_123")
def test_sqlalchemy_identical_content_keeps_row_per_source_path(self, tmp_path):
store = SqlAlchemyToolSourceStore(url=_sqlite_url(tmp_path / "twins.sqlite"))
for path in ("/galaxy/tools/a/upload.xml", "/galaxy/tools/b/upload.xml"):
store.store(
StoredToolSource(
hash="twin_hash",
tool_source_class="XmlToolSource",
raw_source="<tool/>",
tool_id="upload1",
tool_version="1.1.7",
source_path=path,
)
)
assert store.get_by_source_path("/galaxy/tools/a/upload.xml") is not None
assert store.get_by_source_path("/galaxy/tools/b/upload.xml") is not None
assert store.delete("twin_hash")
assert store.get_by_source_path("/galaxy/tools/a/upload.xml") is None
def test_count_tracks_store_and_delete(self, tmp_path):
store = SqlAlchemyToolSourceStore(url=_sqlite_url(tmp_path / "count.sqlite"))
assert store.count() == 0
store.store(
StoredToolSource(
hash="count_hash",
tool_source_class="XmlToolSource",
raw_source="<tool/>",
tool_id="count_test",
tool_version="1.0",
)
)
assert store.count() == 1
assert store.delete("count_hash")
assert store.count() == 0
def test_changed_content_updates_path_row_in_place(self, tmp_path):
store = SqlAlchemyToolSourceStore(url=_sqlite_url(tmp_path / "edited.sqlite"))
path = "/galaxy/tools/edited.xml"
def _stored(hash, raw):
return StoredToolSource(
hash=hash,
tool_source_class="XmlToolSource",
raw_source=raw,
tool_id="upload1",
tool_version="1.1.7",
source_path=path,
)
store.store(_stored("edited_hash_v1", "<tool/>"))
store.store(_stored("edited_hash_v2", "<tool><description/></tool>"))
row = store.get_by_source_path(path)
assert row is not None
assert row.hash == "edited_hash_v2"
assert not store.exists("edited_hash_v1")
def test_identical_content_updates_path_metadata(self, tmp_path):
store = SqlAlchemyToolSourceStore(url=_sqlite_url(tmp_path / "metadata.sqlite"))
path = "/galaxy/tools/metadata.xml"
original = StoredToolSource(
hash="same_hash",
tool_source_class="XmlToolSource",
raw_source="<tool/>",
tool_id="old_id",
tool_version="1.0",
tool_dir="/old/tools",
source_path=path,
metadata={"file_hash": "old"},
)
updated = StoredToolSource(
hash="same_hash",
tool_source_class="XmlToolSource",
raw_source="<tool/>",
tool_id="new_id",
tool_version="2.0",
tool_dir="/new/tools",
source_path=path,
metadata={"file_hash": "new"},
)
store.store(original)
store.store(updated)
row = store.get_by_source_path(path)
assert row is not None
assert row.tool_id == "new_id"
assert row.tool_version == "2.0"
assert row.tool_dir == "/new/tools"
assert row.metadata == {"file_hash": "new"}
def test_pathless_sources_dedupe_on_hash(self, tmp_path):
store = SqlAlchemyToolSourceStore(url=_sqlite_url(tmp_path / "pathless.sqlite"))
for _ in range(2):
store.store(
StoredToolSource(
hash="pathless_hash",
tool_source_class="XmlToolSource",
raw_source="<tool/>",
tool_id="upload1",
tool_version="1.1.7",
)
)
assert store.count() == 1
assert store.delete("pathless_hash")
assert not store.exists("pathless_hash")
def test_remove_index_entry_persists_removal(self, tmp_path):
store = SqlAlchemyToolSourceStore(url=_sqlite_url(tmp_path / "ridx.sqlite"))
index = ToolIndex()
index.add_entry(ToolIndexEntry(id="removable", version="1.0", name="Removable", panel_section_id="sec1"))
store.store_index(index)
store.remove_index_entry("removable")
store.invalidate_index_cache()
reloaded = store.load_index()
assert reloaded is not None
assert reloaded.get("removable") is None
assert "removable" not in reloaded.by_section.get("sec1", [])
assert all(item.tool_id != "removable" for item in reloaded.panel_items)
def test_update_index_entry_reaches_versioned_lookups(self, tmp_path):
store = SqlAlchemyToolSourceStore(url=_sqlite_url(tmp_path / "uidx.sqlite"))
index = ToolIndex()
index.add_entry(ToolIndexEntry(id="dm_tool", version="1.0", name="DM"))
store.store_index(index)
store.update_index_entry(ToolIndexEntry(id="dm_tool", version="1.0", name="DM", description="updated"))
store.invalidate_index_cache()
reloaded = store.load_index()
assert reloaded is not None
versioned = reloaded.get("dm_tool", "1.0")
assert versioned is not None
assert versioned.description == "updated"
def test_sqlalchemy_store_persistence(self, tmp_path):
url = _sqlite_url(tmp_path / "ts.sqlite")
store1 = SqlAlchemyToolSourceStore(url=url)
store1.store(
StoredToolSource(
hash="persist_test_hash",
tool_source_class="XmlToolSource",
raw_source='<tool id="persist"><command>echo</command></tool>',
tool_id="persist_tool",
tool_version="1.0",
)
)
store2 = SqlAlchemyToolSourceStore(url=url)
assert store2.exists("persist_test_hash")
retrieved = store2.get("persist_test_hash")
assert retrieved is not None
assert retrieved.tool_id == "persist_tool"
class TestBuildToolSourceStore:
"""Tests for the store factory function."""
def test_build_default_sqlite_store(self, tmp_path):
config = FakeConfig(
tool_source_database_connection=_sqlite_url(tmp_path / "default.sqlite"),
tool_configs=[],
tool_source_stores=None,
use_lazy_toolbox=False,
)
store = build_tool_source_store(config) # type: ignore[arg-type]
assert isinstance(store, SqlAlchemyToolSourceStore)
def test_build_default_in_memory_sqlite_store(self):
config = FakeConfig(
tool_source_database_connection="sqlite:///:memory:",
tool_configs=[],
tool_source_stores=None,
use_lazy_toolbox=False,
)
store = build_tool_source_store(config) # type: ignore[arg-type]
assert isinstance(store, SqlAlchemyToolSourceStore)
def test_build_store_missing_connection_raises(self):
config = FakeConfig(
tool_source_database_connection=None,
tool_configs=[],
tool_source_stores=None,
use_lazy_toolbox=False,
)
with pytest.raises(ConfigurationError):
build_tool_source_store(config) # type: ignore[arg-type]
def test_build_non_sqlite_url_passes_through(self, monkeypatch):
class CapturingStore:
read_only = False
def __init__(self, url: str, read_only: bool, freshness_probe=None):
self.url = url
self.read_only = read_only
self.freshness_probe = freshness_probe
monkeypatch.setattr(factory_module, "SqlAlchemyToolSourceStore", CapturingStore)
config = FakeConfig(
tool_source_database_connection="postgresql://galaxy@example.org/tool_sources",
tool_configs=[],
tool_source_stores=None,
use_lazy_toolbox=False,
)
store = build_tool_source_store(config) # type: ignore[arg-type]
assert isinstance(store, CapturingStore)
assert store.url == "postgresql://galaxy@example.org/tool_sources"
assert store.read_only is False
def test_named_store_missing_url_raises(self):
with pytest.raises(ConfigurationError):
build_named_store("missing", {"read_only": True}, None) # type: ignore[arg-type]
def test_named_store_old_backend_path_spec_raises(self, tmp_path):
with pytest.raises(ConfigurationError):
build_named_store("old", {"backend": "sqlalchemy", "path": str(tmp_path / "old.sqlite")}, None) # type: ignore[arg-type]
class TestPerConfStoreRouting:
"""Tests for per-conf store routing in build_tool_source_store."""
def _config(self, tmp_path, **overrides):
defaults = dict(
tool_source_database_connection=_sqlite_url(tmp_path / "ts.sqlite"),
tool_configs=[],
tool_source_stores={},
)
defaults.update(overrides)
return FakeConfig(**defaults)
def test_no_per_conf_store_returns_default(self, tmp_path):
conf = tmp_path / "extra_tool_conf.xml"
conf.write_text('<?xml version="1.0"?>\n<toolbox/>\n')
config = self._config(tmp_path, tool_configs=[str(conf)])
store = build_tool_source_store(config)
assert isinstance(store, SqlAlchemyToolSourceStore)
assert not isinstance(store, CompositeToolSourceStore)
def test_unknown_per_conf_store_raises(self, tmp_path):
conf = tmp_path / "extra_tool_conf.xml"
conf.write_text('<?xml version="1.0"?>\n<toolbox store="missing_alias"/>\n')
config = self._config(tmp_path, tool_configs=[str(conf)])
with pytest.raises(ConfigurationError):
build_tool_source_store(config)
def test_store_declared_on_shed_conf_is_discovered(self, tmp_path):
# Regression: a store referenced from shed_tool_config_file is reachable
# only via all_tool_config_files(), not the bare tool_configs list, so
# store discovery must walk the former.
conf = tmp_path / "shed_tool_conf.xml"
conf.write_text('<?xml version="1.0"?>\n<toolbox store="cvmfs_main"/>\n')
config = self._config(
tmp_path,
tool_configs=[],
shed_tool_config_file=str(conf),
tool_source_stores={"cvmfs_main": {"url": _sqlite_url(tmp_path / "cvmfs.sqlite")}},
)
store = build_tool_source_store(config)
assert isinstance(store, CompositeToolSourceStore)
class TestToolIndex:
"""Tests for ToolIndex functionality."""
def test_index_serialization(self):
"""Test index model_dump/model_validate round trip."""
index = ToolIndex()
index.entries["test_tool"] = ToolIndexEntry(
id="test_tool",
name="Test",
version="1.0",
description="Test tool",
labels=["genomics"],
)
index.by_section["section1"] = ["test_tool"]
data = index.model_dump(mode="json")
restored = ToolIndex.model_validate(data)
assert "test_tool" in restored.entries
assert restored.entries["test_tool"].name == "Test"
assert "section1" in restored.by_section
def test_index_get_tests_summary(self):
"""Test generating tests summary from index."""
index = ToolIndex()
index.entries["tool1"] = ToolIndexEntry(
id="tool1",
name="Tool 1",
version="1.0",
test_count=3,
)
index.entries["tool2"] = ToolIndexEntry(
id="tool2",
name="Tool 2",
version="2.0",
test_count=0,
)
summary = index.get_tests_summary()
# Tools with tests must appear; tools without tests must not.
assert "tool1" in summary
assert summary["tool1"]["1.0"]["count"] == 3
assert summary["tool1"]["1.0"]["tool_name"] == "Tool 1"
assert "tool2" not in summary
def test_index_get_all_requirements(self):
"""Test aggregating all requirements from index."""
index = ToolIndex()
index.entries["tool1"] = ToolIndexEntry(
id="tool1",
name="Tool 1",
version="1.0",
requirements=[
{"name": "samtools", "version": "1.0", "type": "package"},
],
)
requirements = index.get_all_requirements()
assert isinstance(requirements, list)
assert {"name": "samtools", "version": "1.0", "type": "package"} in requirements
@@ -0,0 +1,60 @@
"""Unit tests for the tool source store freshness watcher."""
from galaxy.tools.source_store.sqlalchemy import SqlAlchemyToolSourceStore
from galaxy.tools.source_store.watcher import ToolSourceStoreWatcher
def _watched_store(tmp_path, current):
return SqlAlchemyToolSourceStore(
url=f"sqlite:///{tmp_path}/store.sqlite",
read_only=True,
freshness_probe=lambda: current["token"],
)
def test_first_check_baselines_without_firing(tmp_path):
current = {"token": "cvmfs:r:1"}
fired: list[list[str]] = []
watcher = ToolSourceStoreWatcher(
members=[("cvmfs", _watched_store(tmp_path, current))], interval=60, on_change=fired.append
)
assert watcher.check() == []
assert fired == []
def test_token_transition_fires_once(tmp_path):
current = {"token": "cvmfs:r:1"}
fired: list[list[str]] = []
watcher = ToolSourceStoreWatcher(
members=[("cvmfs", _watched_store(tmp_path, current))], interval=60, on_change=fired.append
)
watcher.check()
current["token"] = "cvmfs:r:2"
assert watcher.check() == ["cvmfs"]
assert fired == [["cvmfs"]]
# Stable at the new revision: no re-fire.
assert watcher.check() == []
assert fired == [["cvmfs"]]
def test_probe_failure_neither_fires_nor_clears_baseline(tmp_path):
current: dict[str, str | None] = {"token": "cvmfs:r:1"}
fired: list[list[str]] = []
def probe() -> str:
token = current["token"]
if token is None:
raise OSError("repo unmounted")
return token
store = SqlAlchemyToolSourceStore(url=f"sqlite:///{tmp_path}/store.sqlite", read_only=True, freshness_probe=probe)
watcher = ToolSourceStoreWatcher(members=[("cvmfs", store)], interval=60, on_change=fired.append)
watcher.check()
current["token"] = None
assert watcher.check() == []
# Probe recovers at the same revision: still no spurious fire.
current["token"] = "cvmfs:r:1"
assert watcher.check() == []
current["token"] = "cvmfs:r:2"
assert watcher.check() == ["cvmfs"]
assert fired == [["cvmfs"]]
@@ -0,0 +1,73 @@
"""Pin ``ToolWhooshIndex.build`` rebuild/skip semantics."""
from galaxy.tools.source_store.index import (
ToolIndex,
ToolIndexEntry,
)
from galaxy.tools.source_store.search import (
ToolSearchTuning,
ToolWhooshIndex,
)
_TUNING = ToolSearchTuning(
id_boost=20.0,
name_boost=10.0,
name_exact_multiplier=2.0,
stub_boost=5.0,
section_boost=4.0,
description_boost=3.0,
label_boost=3.0,
ngram_minsize=3,
ngram_maxsize=4,
enable_ngram_search=True,
ngram_factor=0.5,
)
def _index(*names: str) -> ToolIndex:
idx = ToolIndex()
for name in names:
idx.add_entry(ToolIndexEntry(id=name, version="1.0", name=name.replace("_", " ").title()))
return idx
def test_unchanged_corpus_skips_rebuild(tmp_path):
index_dir = str(tmp_path / "ix")
assert ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).build(_index("mapper", "caller")) == 2
# Same corpus again: nothing written, existing index still serves queries.
written = ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).build(_index("mapper", "caller"))
assert written == 0
assert ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).search("mapper") == ["mapper"]
def test_changed_corpus_rebuilds_and_drops_stale_docs(tmp_path):
index_dir = str(tmp_path / "ix")
ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).build(_index("mapper", "caller"))
written = ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).build(_index("mapper", "trimmer"))
assert written == 2
searcher = ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING)
assert searcher.search("trimmer") == ["trimmer"]
# "caller" left the corpus; the CLEAR rebuild must not serve it anymore.
assert searcher.search("caller") == []
def test_tuning_change_rebuilds_despite_same_docs(tmp_path):
index_dir = str(tmp_path / "ix")
ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).build(_index("mapper"))
retuned = ToolSearchTuning(
id_boost=99.0,
name_boost=10.0,
name_exact_multiplier=2.0,
stub_boost=5.0,
section_boost=4.0,
description_boost=3.0,
label_boost=3.0,
ngram_minsize=3,
ngram_maxsize=4,
enable_ngram_search=True,
ngram_factor=0.5,
)
assert ToolWhooshIndex(index_dir=index_dir, tuning=retuned).build(_index("mapper")) == 1
+7
View File
@@ -19,6 +19,10 @@ def test_root(appconfig):
def test_common_base_config(appconfig):
assert appconfig.shed_tools_dir == os.path.join(appconfig.data_dir, "shed_tools")
assert (
appconfig.tool_source_database_connection
== f"sqlite:///{os.path.join(appconfig.data_dir, 'tool_sources.sqlite')}"
)
if running_from_source:
expected_path = os.path.join(appconfig.root, "lib", "galaxy", "config", "sample")
else:
@@ -64,6 +68,9 @@ def test_error_if_database_connection_contains_brackets(bracket):
with pytest.raises(ConfigurationError):
config.GalaxyAppConfiguration(override_tempdir=False, install_database_connection=uri)
with pytest.raises(ConfigurationError):
config.GalaxyAppConfiguration(override_tempdir=False, tool_source_database_connection=uri)
with pytest.raises(ConfigurationError):
config.GalaxyAppConfiguration(override_tempdir=False, amqp_internal_connection=uri)
View File
@@ -0,0 +1,261 @@
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import (
Any,
cast,
)
galaxy_root = Path(__file__).parent.parent.parent.parent.parent
sys.path.insert(0, str(galaxy_root / "lib"))
from galaxy.tool_util.deps.requirements import (
ContainerDescription,
ToolRequirement,
ToolRequirements,
)
from galaxy.tool_util.parser.interface import ToolSource
from galaxy.tools.source_store.discover import DiscoveredTool
from galaxy.tools.source_store.index import ToolIndexEntry
from galaxy.tools.source_store.interface import StoredToolSource
from galaxy.tools.source_store.populator import build_index_entry_from_source
@dataclass
class _StoredStub:
hash: str = "abc123"
tool_source_class: str = "XmlToolSource"
tool_id: str = "bowtie2"
source_path: str | None = "/tools/bowtie2.xml"
metadata: dict | None = None
class _ToolSourceStub:
def __init__(
self,
tool_id: str = "bowtie2",
version: str = "2.5.0",
name: str = "Bowtie2",
description: str = "Fast aligner",
hidden: bool = False,
require_login: bool = False,
tool_type: str = "default",
edam_operations: list[str] | None = None,
edam_topics: list[str] | None = None,
requirements: list[ToolRequirement | dict[str, Any]] | None = None,
containers: list[ContainerDescription] | None = None,
test_count: int = 0,
):
self._id = tool_id
self._version = version
self._name = name
self._description = description
self._hidden = hidden
self._require_login = require_login
self._tool_type = tool_type
self._edam_operations = edam_operations or []
self._edam_topics = edam_topics or []
self._requirements = ToolRequirements(requirements)
self._containers = containers or []
self._test_count = test_count
def parse_id(self) -> str:
return self._id
def parse_version(self) -> str:
return self._version
def parse_name(self) -> str:
return self._name
def parse_description(self) -> str:
return self._description
def parse_hidden(self) -> bool:
return self._hidden
def parse_require_login(self, default: bool) -> bool:
return self._require_login
def parse_tool_type(self) -> str:
return self._tool_type
def parse_edam_operations(self) -> list[str]:
return self._edam_operations
def parse_edam_topics(self) -> list[str]:
return self._edam_topics
def parse_xrefs(self) -> list[dict[str, str]]:
return []
def parse_icon(self) -> str | None:
return None
def parse_input_pages(self):
return None
def parse_profile(self) -> str:
return "21.09"
def parse_requirements(self):
return self._requirements, self._containers, [], [], []
def parse_tests_to_dict(self):
return {"tests": [{} for _ in range(self._test_count)]}
def _discovered(**overrides: Any) -> DiscoveredTool:
base: dict[str, Any] = dict(path="/tools/bowtie2.xml", tool_conf="tool_conf.xml", tool_path="/tools")
base.update(overrides)
return DiscoveredTool(**base)
def _entry(discovered: DiscoveredTool, stored: Any, source: Any) -> ToolIndexEntry:
"""Build an entry from stubs, casting them to the real interfaces and
asserting the common non-None result."""
entry = build_index_entry_from_source(discovered, cast(StoredToolSource, stored), cast(ToolSource, source))
assert entry is not None
return entry
def _entry_optional(discovered: DiscoveredTool, stored: Any, source: Any) -> ToolIndexEntry | None:
return build_index_entry_from_source(discovered, cast(StoredToolSource, stored), cast(ToolSource, source))
def test_basic_fields_threaded_through():
entry = _entry(_discovered(), _StoredStub(), _ToolSourceStub())
assert entry is not None
assert entry.id == "bowtie2"
assert entry.version == "2.5.0"
assert entry.name == "Bowtie2"
assert entry.description == "Fast aligner"
assert entry.source_hash == "abc123"
assert entry.source_class == "XmlToolSource"
assert entry.tool_type == "default"
def test_shed_conf_guid_keys_entry_and_stamps_repository():
guid = "toolshed.g2.bx.psu.edu/repos/iuc/bowtie2/bowtie2/2.5.0"
entry = _entry(
_discovered(
guid=guid,
is_shed_tool=True,
tool_shed="toolshed.g2.bx.psu.edu",
repository_name="bowtie2",
repository_owner="iuc",
installed_changeset_revision="abc123def",
),
_StoredStub(),
_ToolSourceStub(),
)
assert entry.id == guid
assert entry.tool_shed == "toolshed.g2.bx.psu.edu"
assert entry.repository_name == "bowtie2"
assert entry.repository_owner == "iuc"
assert entry.changeset_revision == "abc123def"
assert entry.is_local is False
def test_section_metadata_from_discovered():
entry = _entry(
_discovered(section_id="ngs", section_name="NGS Tools"),
_StoredStub(),
_ToolSourceStub(),
)
assert entry.panel_section_id == "ngs"
assert entry.panel_section_name == "NGS Tools"
def test_labels_from_discovered():
entry = _entry(
_discovered(labels=["beta", "experimental"]),
_StoredStub(),
_ToolSourceStub(),
)
assert entry.labels == ["beta", "experimental"]
def test_conf_level_hidden_forces_entry_hidden():
# Body says not hidden; conf says hidden — entry honors the conf.
entry = _entry(
_discovered(hidden=True),
_StoredStub(),
_ToolSourceStub(hidden=False),
)
assert entry.hidden is True
def test_body_hidden_alone_also_forces_hidden():
entry = _entry(
_discovered(),
_StoredStub(),
_ToolSourceStub(hidden=True),
)
assert entry.hidden is True
def test_neither_hidden_means_false():
entry = _entry(_discovered(), _StoredStub(), _ToolSourceStub(hidden=False))
assert entry.hidden is False
def test_edam_lists_threaded_through():
entry = _entry(
_discovered(),
_StoredStub(),
_ToolSourceStub(edam_operations=["operation_0292"], edam_topics=["topic_0102"]),
)
assert entry.edam_operations == ["operation_0292"]
assert entry.edam_topics == ["topic_0102"]
def test_require_login_threaded_through():
entry = _entry(_discovered(), _StoredStub(), _ToolSourceStub(require_login=True))
assert entry.require_login is True
def test_tests_requirements_and_containers_threaded_through():
requirement = ToolRequirement(name="samtools", type="package", version="1.19")
container = ContainerDescription(identifier="quay.io/biocontainers/samtools:1.19--h50ea8bc_0")
entry = _entry(
_discovered(),
_StoredStub(),
_ToolSourceStub(requirements=[requirement], containers=[container], test_count=2),
)
assert entry.test_count == 2
assert entry.requirements == [requirement.to_dict()]
assert entry.container_requirements == [container.to_dict()]
def test_tool_source_class_taken_from_stored():
entry = _entry(
_discovered(),
_StoredStub(tool_source_class="YamlToolSource"),
_ToolSourceStub(),
)
assert entry.source_class == "YamlToolSource"
def test_no_id_yields_none():
entry = _entry_optional(_discovered(), _StoredStub(tool_id=""), _ToolSourceStub(tool_id=""))
assert entry is None
def test_fallback_id_from_stored_when_source_has_none():
entry = _entry(
_discovered(),
_StoredStub(tool_id="from_stored"),
_ToolSourceStub(tool_id=""),
)
assert entry is not None
assert entry.id == "from_stored"
def test_data_manager_tool_type_preserved():
entry = _entry(
_discovered(),
_StoredStub(),
_ToolSourceStub(tool_type="data_manager"),
)
assert entry.tool_type == "data_manager"
@@ -0,0 +1,169 @@
import sys
import textwrap
from pathlib import Path
galaxy_root = Path(__file__).parent.parent.parent.parent.parent
sys.path.insert(0, str(galaxy_root / "lib"))
from galaxy.tools.source_store.discover import (
discover_tools,
discover_tools_from_config,
DiscoveredTool,
)
def _write_conf(tmp_path: Path, body: str) -> tuple[Path, Path]:
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
(tools_dir / "alpha.xml").write_text("<tool id='alpha' version='1.0'><description/></tool>")
(tools_dir / "beta.xml").write_text("<tool id='beta' version='1.0'><description/></tool>")
conf = tmp_path / "tool_conf.xml"
conf.write_text(textwrap.dedent(body).strip())
return conf, tools_dir
def test_top_level_tool_has_no_section(tmp_path):
conf, _ = _write_conf(
tmp_path,
f"""
<toolbox tool_path="{tmp_path}/tools">
<tool file="alpha.xml"/>
</toolbox>
""",
)
tools = list(discover_tools_from_config(str(conf)))
assert len(tools) == 1
assert tools[0].section_id is None
assert tools[0].section_name is None
assert tools[0].labels == []
def test_section_stamps_id_and_name(tmp_path):
conf, _ = _write_conf(
tmp_path,
f"""
<toolbox tool_path="{tmp_path}/tools">
<section id="ngs" name="NGS Tools">
<tool file="alpha.xml"/>
<tool file="beta.xml"/>
</section>
</toolbox>
""",
)
tools = {t.path: t for t in discover_tools_from_config(str(conf))}
assert len(tools) == 2
for t in tools.values():
assert t.section_id == "ngs"
assert t.section_name == "NGS Tools"
def test_labels_attribute_parsed_into_list(tmp_path):
conf, _ = _write_conf(
tmp_path,
f"""
<toolbox tool_path="{tmp_path}/tools">
<tool file="alpha.xml" labels="beta, experimental"/>
</toolbox>
""",
)
tools = list(discover_tools_from_config(str(conf)))
assert tools[0].labels == ["beta", "experimental"]
def test_hidden_attribute_still_captured(tmp_path):
conf, _ = _write_conf(
tmp_path,
f"""
<toolbox tool_path="{tmp_path}/tools">
<tool file="alpha.xml" hidden="true"/>
<tool file="beta.xml"/>
</toolbox>
""",
)
by_name = {Path(t.path).name: t for t in discover_tools_from_config(str(conf))}
assert by_name["alpha.xml"].hidden is True
assert by_name["beta.xml"].hidden is False
def test_mixed_top_level_and_section(tmp_path):
conf, _ = _write_conf(
tmp_path,
f"""
<toolbox tool_path="{tmp_path}/tools">
<tool file="alpha.xml" labels="featured"/>
<section id="ngs" name="NGS">
<tool file="beta.xml"/>
</section>
</toolbox>
""",
)
by_name = {Path(t.path).name: t for t in discover_tools_from_config(str(conf))}
assert by_name["alpha.xml"].section_id is None
assert by_name["alpha.xml"].labels == ["featured"]
assert by_name["beta.xml"].section_id == "ngs"
assert by_name["beta.xml"].section_name == "NGS"
assert by_name["beta.xml"].labels == []
def test_section_propagates_to_tool_dir_walk(tmp_path):
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
nested = tools_dir / "ngs_pack"
nested.mkdir()
(nested / "one.xml").write_text("<tool id='one' version='1'><description/></tool>")
conf = tmp_path / "tool_conf.xml"
conf.write_text(textwrap.dedent(f"""
<toolbox tool_path="{tmp_path}/tools">
<section id="ngs" name="NGS Tools">
<tool_dir dir="ngs_pack"/>
</section>
</toolbox>
""").strip())
tools = list(discover_tools_from_config(str(conf)))
assert len(tools) == 1
assert tools[0].section_id == "ngs"
assert tools[0].section_name == "NGS Tools"
def test_dataclass_defaults_are_independent_lists():
# Regression guard for the field(default_factory=list) wiring on labels —
# two instances must not share the same underlying list.
a = DiscoveredTool(path="/a", tool_conf="/c", tool_path=None)
b = DiscoveredTool(path="/b", tool_conf="/c", tool_path=None)
a.labels.append("x")
assert b.labels == []
def test_only_confs_skips_unlisted_conf_walk(tmp_path):
conf_bodies = {}
for name in ("a", "b"):
tool = tmp_path / f"{name}_tool.xml"
tool.write_text(f'<tool id="{name}" version="1.0"><command>echo</command></tool>')
conf = tmp_path / f"{name}_conf.xml"
conf.write_text(f'<toolbox tool_path="{tmp_path}"><tool file="{tool.name}"/></toolbox>')
conf_bodies[name] = str(conf)
class _Cfg:
root = None
tool_path = str(tmp_path)
enable_beta_tool_formats = False
data_manager_config_file = None
shed_data_manager_config_file = None
def all_tool_config_files(self):
return [conf_bodies["a"], conf_bodies["b"]]
cfg = _Cfg()
walked = {
d.tool_conf
for d in discover_tools(cfg, include_bundled=False, include_converters=False) # type: ignore[arg-type]
if d.tool_conf in conf_bodies.values()
}
assert walked == set(conf_bodies.values())
walked = {
d.tool_conf
for d in discover_tools(cfg, include_bundled=False, include_converters=False, only_confs={conf_bodies["a"]}) # type: ignore[arg-type]
if d.tool_conf in conf_bodies.values()
}
assert walked == {conf_bodies["a"]}
@@ -0,0 +1,447 @@
"""Tests for the populate_store.py script.
These tests cover the core functionality of the tool source population script,
including hash computation, file watching, and store updates.
Following test guidelines:
- Fakes over mocks where practical
- State verification over call count assertions
- Real objects with constraints (temp files, fake stores)
"""
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import (
Any,
)
# Add Galaxy lib to path for imports
galaxy_root = Path(__file__).parent.parent.parent.parent.parent
import sys
sys.path.insert(0, str(galaxy_root / "lib"))
from galaxy.tools.source_store import build_tool_source_store
from galaxy.tools.source_store.factory import _build_default_store
from galaxy.tools.source_store.freshness import tool_confs_token
from galaxy.tools.source_store.populator import (
compute_hash,
DEFAULT_STORE_NAME,
populate_store_inline,
send_reload_notification,
ToolFileWatcher,
)
# --- Fakes for testing ---
class FakeToolSourceStore:
"""In-memory fake implementation of a tool source store."""
def __init__(self):
self.stored_sources: dict[str, Any] = {}
self.store_calls: list[Any] = []
def exists(self, hash_key: str) -> bool:
return hash_key in self.stored_sources
def store(self, source) -> None:
self.stored_sources[source.hash] = source
self.store_calls.append(source)
def get(self, hash_key: str) -> Any | None:
return self.stored_sources.get(hash_key)
@property
def count(self) -> int:
return len(self.stored_sources)
@dataclass
class FakeTool:
"""Minimal tool representation for testing iter_tool_sources."""
version: str
tool_source: object = None
tool_dir: str = "/fake/path"
class FakeToolbox:
"""Minimal toolbox representation for testing."""
def __init__(self, tools: dict[str, FakeTool]):
self._tools_by_id = tools
@dataclass
class FakeConfig:
"""Minimal config for testing."""
amqp_internal_connection: str | None = None
database_connection: str = "sqlite:///:memory:"
# --- Tests ---
class TestComputeHash:
"""Tests for the compute_hash function."""
def test_returns_sha256_hex_digest(self):
"""Verify hash is valid SHA256 format."""
result = compute_hash("any content")
assert len(result) == 64
assert all(c in "0123456789abcdef" for c in result)
def test_deterministic_for_same_input(self):
"""Same input always produces same output."""
content = "test content"
assert compute_hash(content) == compute_hash(content)
def test_different_inputs_produce_different_hashes(self):
"""Different content should produce different hashes."""
assert compute_hash("content one") != compute_hash("content two")
class TestSendReloadNotification:
"""Tests for the send_reload_notification function."""
def test_returns_false_when_no_amqp_url_configured(self):
"""Without AMQP URL, notification should fail gracefully."""
config = FakeConfig(amqp_internal_connection=None)
result = send_reload_notification(config)
assert result is False
def _recording_notify(sink: list):
"""Build a fake notify callable that records every invocation in ``sink``.
A plain lambda doesn't work here because ``list.append`` returns ``None``,
which mypy's ``func-returns-value`` check rejects.
"""
def notify(config: Any) -> bool:
sink.append(config)
return True
return notify
def _recording_populate(sink: list):
"""Fake populate callable that records each call's kwargs in ``sink``."""
def populate(config: Any, **kwargs: Any) -> dict:
sink.append(kwargs)
return {}
return populate
class TestToolFileWatcher:
"""Tests for the ToolFileWatcher class."""
def test_initialization_stores_config(self):
"""Watcher should store configuration parameters."""
config = FakeConfig()
store = FakeToolSourceStore()
tools_dirs = [Path("/tmp/tools")]
watcher = ToolFileWatcher(
config=config,
store=store,
tools_dirs=tools_dirs,
verbose=True,
)
assert watcher.config is config
assert watcher.store is store
assert watcher.tools_dirs == tools_dirs
assert watcher.verbose is True
def test_process_tool_file_ignores_non_tool_xml(self):
"""XML files without <tool> tag should be ignored."""
store = FakeToolSourceStore()
watcher = ToolFileWatcher(
config=FakeConfig(),
store=store,
tools_dirs=[],
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".xml", delete=False) as f:
f.write("<data>not a tool</data>")
temp_path = f.name
try:
result = watcher._process_tool_file(temp_path)
assert result is False
assert store.count == 0
finally:
os.unlink(temp_path)
def test_process_tool_file_populates_changed_tool(self):
calls: list = []
watcher = ToolFileWatcher(
config=FakeConfig(),
store=FakeToolSourceStore(),
tools_dirs=[],
populate_callable=_recording_populate(calls),
)
tool_content = """<tool id="test_tool" name="Test" version="1.0">
<command>echo hello</command>
</tool>"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".xml", delete=False) as f:
f.write(tool_content)
temp_path = f.name
try:
result = watcher._process_tool_file(temp_path)
assert result is True
assert len(calls) == 1
assert calls[0]["paths"] == [temp_path]
finally:
os.unlink(temp_path)
def test_process_tool_file_skips_unchanged_tool(self):
"""Tool already in store with same hash should be skipped."""
store = FakeToolSourceStore()
tool_content = """<tool id="test_tool" name="Test" version="1.0">
<command>echo hello</command>
</tool>"""
content_hash = compute_hash(tool_content)
# Pre-populate store with this hash
store.stored_sources[content_hash] = "already stored"
watcher = ToolFileWatcher(
config=FakeConfig(),
store=store,
tools_dirs=[],
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".xml", delete=False) as f:
f.write(tool_content)
temp_path = f.name
try:
result = watcher._process_tool_file(temp_path)
assert result is False
# Store count unchanged (still just the pre-populated one)
assert store.count == 1
# No new store calls
assert len(store.store_calls) == 0
finally:
os.unlink(temp_path)
def test_process_tool_file_macro_repopulates_siblings(self, tmp_path):
# A changed macros file re-expands the sibling tools that import it,
# excluding the macro file itself.
calls: list = []
watcher = ToolFileWatcher(
config=FakeConfig(),
store=FakeToolSourceStore(),
tools_dirs=[],
populate_callable=_recording_populate(calls),
)
tool_path = tmp_path / "sometool.xml"
tool_path.write_text('<tool id="t" name="T" version="1.0"><command>echo</command></tool>')
macro_path = tmp_path / "macros.xml"
macro_path.write_text('<macros><token name="@X@">1</token></macros>')
result = watcher._process_tool_file(str(macro_path))
assert result is True
assert len(calls) == 1
assert str(tool_path) in calls[0]["paths"]
assert str(macro_path) not in calls[0]["paths"]
def test_shutdown_sets_event(self):
"""Shutdown should signal the shutdown event."""
watcher = ToolFileWatcher(
config=FakeConfig(),
store=FakeToolSourceStore(),
tools_dirs=[],
)
watcher.shutdown()
assert watcher._shutdown_event.is_set()
def test_on_change_notifies_on_update(self):
"""A changed tool file re-populates and fires one notification."""
notification_sent: list = []
watcher = ToolFileWatcher(
config=FakeConfig(),
store=FakeToolSourceStore(),
tools_dirs=[],
notify_callable=_recording_notify(notification_sent),
populate_callable=_recording_populate([]),
)
tool_content = """<tool id="test" version="1.0">
<command>echo</command>
</tool>"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".xml", delete=False) as f:
f.write(tool_content)
temp_path = f.name
try:
watcher._on_change(temp_path)
assert len(notification_sent) == 1
finally:
os.unlink(temp_path)
def test_on_change_no_notification_when_unchanged(self):
"""No notification fires when the tool is already stored."""
store = FakeToolSourceStore()
tool_content = """<tool id="test" version="1.0">
<command>echo</command>
</tool>"""
store.stored_sources[compute_hash(tool_content)] = "exists"
notification_sent: list = []
watcher = ToolFileWatcher(
config=FakeConfig(),
store=store,
tools_dirs=[],
notify_callable=_recording_notify(notification_sent),
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".xml", delete=False) as f:
f.write(tool_content)
temp_path = f.name
try:
watcher._on_change(temp_path)
assert len(store.store_calls) == 0
assert len(notification_sent) == 0
finally:
os.unlink(temp_path)
def _populate_config(tmp_path, conf):
class _Cfg:
enable_beta_tool_formats = False
tool_source_stores: dict = {}
tool_search_index_dir = None
root = str(tmp_path)
tool_path = str(tmp_path)
tool_source_database_connection = f"sqlite:///{tmp_path}/ts.sqlite"
data_manager_config_file: str | None = None
shed_data_manager_config_file: str | None = None
biotools_content_directory = None
biotools_use_api = False
biotools_service_cache_type = "memory"
biotools_service_cache_data_dir = None
biotools_service_cache_lock_dir = None
biotools_service_cache_url = None
biotools_service_cache_table_name = None
biotools_service_cache_schema_name = None
def all_tool_config_files(self):
return [str(conf)]
return _Cfg()
class TestIncrementalFastPath:
"""A second populate carries byte-identical tools forward without re-parsing."""
def test_unchanged_tools_carried_forward(self, tmp_path):
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
for i in (1, 2):
(tools_dir / f"itest_{i}.xml").write_text(
f'<tool id="itest_{i}" name="ITest {i}" version="1.0" profile="21.09"><command>echo</command></tool>'
)
conf = tmp_path / "tool_conf.xml"
conf.write_text(
f'<toolbox tool_path="{tools_dir}"><tool file="itest_1.xml"/><tool file="itest_2.xml"/></toolbox>'
)
cfg = _populate_config(tmp_path, conf)
r1 = populate_store_inline(cfg, target=DEFAULT_STORE_NAME, pattern="itest_", incremental=True)
assert (r1["stored"], r1["unchanged"]) == (2, 0)
# Nothing changed on disk: the second run skips the parse and store
# write, carrying both index entries forward.
r2 = populate_store_inline(cfg, target=DEFAULT_STORE_NAME, pattern="itest_", incremental=True)
assert (r2["stored"], r2["unchanged"]) == (0, 2)
class TestTwinDeterminism:
"""Same-id twins resolve by conf document order, stably across re-runs.
Regression: the winner among same-id/same-version twins is decided by
index add-order. Consuming pool results as_completed — or adding
carried-forward entries before re-parsed ones — flipped the winner (and
its panel section in the whoosh corpus) between runs, defeating the
whoosh corpus-signature rebuild skip.
"""
def test_twin_winner_stable_across_runs(self, tmp_path):
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
for suffix in ("a", "b"):
(tools_dir / f"twin_{suffix}.xml").write_text(
f'<tool id="twin" name="Twin" version="1.0" profile="21.09"><command>echo {suffix}</command></tool>'
)
conf = tmp_path / "tool_conf.xml"
conf.write_text(
f'<toolbox tool_path="{tools_dir}">'
'<section id="sec_a" name="A"><tool file="twin_a.xml"/></section>'
'<section id="sec_b" name="B"><tool file="twin_b.xml"/></section>'
"</toolbox>"
)
cfg = _populate_config(tmp_path, conf)
# Run 2 is the interesting one: the run-1 winner (sec_b) comes back
# via the unchanged fast path while its twin re-parses — the winner
# must still be decided by document order, not by which path parsed.
for run in (1, 2, 3):
populate_store_inline(cfg, target=DEFAULT_STORE_NAME, pattern="twin_", incremental=True, parallel=4)
index = build_tool_source_store(cfg).load_index()
assert index is not None
assert index.entries["twin"].panel_section_id == "sec_b", f"wrong twin won on run {run}"
class TestFreshnessStamping:
"""Populate stamps the default store's tool_confs token; boot trusts it."""
def test_populate_stamps_token_and_conf_edit_invalidates(self, tmp_path):
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
(tools_dir / "ftest_1.xml").write_text(
'<tool id="ftest_1" name="FTest" version="1.0" profile="21.09"><command>echo</command></tool>'
)
conf = tmp_path / "tool_conf.xml"
conf.write_text(f'<toolbox tool_path="{tools_dir}"><tool file="ftest_1.xml"/></toolbox>')
cfg = _populate_config(tmp_path, conf)
populate_store_inline(cfg, target=DEFAULT_STORE_NAME, pattern="ftest_", incremental=True)
store = _build_default_store(cfg)
index = store.load_index()
assert index is not None
assert index.freshness_token == tool_confs_token(cfg)
assert store.index_is_fresh() is True
(tools_dir / "ftest_2.xml").write_text(
'<tool id="ftest_2" name="FTest 2" version="1.0" profile="21.09"><command>echo</command></tool>'
)
conf.write_text(
f'<toolbox tool_path="{tools_dir}"><tool file="ftest_1.xml"/><tool file="ftest_2.xml"/></toolbox>'
)
assert store.index_is_fresh() is False
populate_store_inline(cfg, target=DEFAULT_STORE_NAME, pattern="ftest_", incremental=True)
store.invalidate_index_cache()
assert store.index_is_fresh() is True
@@ -0,0 +1,25 @@
import sys
from pathlib import Path
galaxy_root = Path(__file__).parent.parent.parent.parent.parent
sys.path.insert(0, str(galaxy_root / "lib"))
from galaxy.tools.source_store.populator import (
DEFAULT_STORE_NAME,
whoosh_dir_for_store,
)
def test_default_store_maps_to_store_default_subdir():
assert (
whoosh_dir_for_store("/var/galaxy/tool_search", DEFAULT_STORE_NAME) == "/var/galaxy/tool_search/_store_default"
)
def test_named_store_gets_named_subdir():
assert whoosh_dir_for_store("/var/galaxy/tool_search", "cvmfs_mirror") == "/var/galaxy/tool_search/cvmfs_mirror"
def test_none_or_empty_base_yields_none():
assert whoosh_dir_for_store(None, DEFAULT_STORE_NAME) is None
assert whoosh_dir_for_store("", DEFAULT_STORE_NAME) is None