mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-29 17:42:12 +08:00
Merge pull request #23067 from mvdbeek/store-infra
Add pluggable tool source store infrastructure
This commit is contained in:
@@ -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``
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -22,6 +22,7 @@ Galaxy Deployment & Administration
|
||||
ai_agents
|
||||
enable_headers_in_fetch_requests
|
||||
tool_panel
|
||||
tool_source_storage
|
||||
data_tables
|
||||
mq
|
||||
dependency_resolvers
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}")
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()))
|
||||
@@ -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
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Tool source management scripts
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user