diff --git a/lib/galaxy/files/__init__.py b/lib/galaxy/files/__init__.py
index e25948ee0ca..fa95154274b 100644
--- a/lib/galaxy/files/__init__.py
+++ b/lib/galaxy/files/__init__.py
@@ -51,6 +51,8 @@ class ProvidesFileSourcesTransaction(Protocol):
log = logging.getLogger(__name__)
+USER_FILE_SOURCES_SCHEME = "gxuserfiles"
+
class FileSourcePath(NamedTuple):
file_source: BaseFilesSource
@@ -70,8 +72,7 @@ class UserDefinedFileSources(Protocol):
"""Entry-point for Galaxy to inject user-defined file sources.
Supplied object of this class is used to write out concrete
- description of file sources when serializing all file sources
- available to a user.
+ descriptions of user file sources selected for serialization.
"""
def validate_uri_root(self, uri: str, user_context: "FileSourcesUserContext") -> None:
@@ -87,6 +88,7 @@ class UserDefinedFileSources(Protocol):
browsable_only: bool | None = False,
include_kind: set[PluginKind] | None = None,
exclude_kind: set[PluginKind] | None = None,
+ referenced_uris: set[str] | None = None,
) -> list[dict[str, Any]]:
"""Write out user file sources as list of config dictionaries."""
# config_dicts: List[FilesSourceProperties] = []
@@ -111,6 +113,7 @@ class NullUserDefinedFileSources(UserDefinedFileSources):
browsable_only: bool | None = False,
include_kind: set[PluginKind] | None = None,
exclude_kind: set[PluginKind] | None = None,
+ referenced_uris: set[str] | None = None,
) -> list[dict[str, Any]]:
return []
@@ -209,6 +212,15 @@ class ConfiguredFileSources:
def _parse_plugin_source(self, plugin_source: PluginConfigSource):
return self._plugin_loader.load_plugins(plugin_source, self._file_sources_config)
+ @staticmethod
+ def _best_score(scores: list[FileSourceScore]) -> FileSourceScore | None:
+ best = max(scores, key=lambda candidate: candidate.score, default=None)
+ return best if best is not None and best.score > 0 else None
+
+ def _best_configured_match(self, url: str) -> FileSourceScore | None:
+ scores = [FileSourceScore(file_source, file_source.score_url_match(url)) for file_source in self._file_sources]
+ return self._best_score(scores)
+
def find_best_match(self, url: str) -> BaseFilesSource | None:
"""Returns the best matching file source for handling a particular url. Each filesource scores its own
ability to match a particular url, and the highest scorer with a score > 0 is selected."""
@@ -216,8 +228,8 @@ class ConfiguredFileSources:
user_best_score = self._user_defined_file_sources.find_best_match(url)
if user_best_score is not None:
scores.append(user_best_score)
- scores.sort(key=lambda f: f.score, reverse=True)
- return next((fsscore.file_source for fsscore in scores if fsscore.score > 0), None)
+ best = self._best_score(scores)
+ return best.file_source if best is not None else None
def get_file_source_path(self, uri):
"""Parse uri into a FileSource object and a path relative to its base."""
@@ -280,8 +292,14 @@ class ConfiguredFileSources:
browsable_only: bool | None = False,
include_kind: set[PluginKind] | None = None,
exclude_kind: set[PluginKind] | None = None,
+ referenced_uris: set[str] | None = None,
) -> list[dict[str, Any]]:
rval: list[dict[str, Any]] = []
+ referenced_file_sources = None
+ if referenced_uris is not None:
+ referenced_file_sources = [
+ match.file_source for uri in referenced_uris if (match := self._best_configured_match(uri)) is not None
+ ]
for file_source in self._file_sources:
if not file_source.user_has_access(user_context):
continue
@@ -291,6 +309,9 @@ class ConfiguredFileSources:
continue
if browsable_only and not file_source.get_browsable():
continue
+ # Skip sources that are not the best match for any URI required by this job.
+ if referenced_file_sources is not None and file_source not in referenced_file_sources:
+ continue
el = file_source.to_dict(for_serialization=for_serialization, user_context=user_context)
rval.append(el)
if user_context:
@@ -301,13 +322,23 @@ class ConfiguredFileSources:
browsable_only=browsable_only,
include_kind=include_kind,
exclude_kind=exclude_kind,
+ referenced_uris=referenced_uris,
)
)
return rval
- def to_dict(self, for_serialization: bool = False, user_context: "OptionalUserContext" = None) -> dict[str, Any]:
+ def to_dict(
+ self,
+ for_serialization: bool = False,
+ user_context: "OptionalUserContext" = None,
+ referenced_uris: set[str] | None = None,
+ ) -> dict[str, Any]:
return {
- "file_sources": self.plugins_to_dict(for_serialization=for_serialization, user_context=user_context),
+ "file_sources": self.plugins_to_dict(
+ for_serialization=for_serialization,
+ user_context=user_context,
+ referenced_uris=referenced_uris,
+ ),
"config": self._file_sources_config.to_dict(),
}
diff --git a/lib/galaxy/files/sources/elabftw.py b/lib/galaxy/files/sources/elabftw.py
index 21d70175540..092f0f8052c 100644
--- a/lib/galaxy/files/sources/elabftw.py
+++ b/lib/galaxy/files/sources/elabftw.py
@@ -178,16 +178,9 @@ class eLabFTWFilesSource(BaseFilesSource[eLabFTWFileSourceTemplateConfiguration,
def get_prefix(self) -> str | None:
endpoint: ParseResult = self._get_endpoint()
return self.id if self.scheme not in {"elabftw", DEFAULT_SCHEME} else (endpoint.netloc or None)
- # it would make better sense to return
- # `self.id if self.scheme == USER_FILE_SOURCES_SCHEME else (endpoint.netloc or None)`, where
- # `USER_FILE_SOURCES_SCHEME` comes from `galaxy.managers.file_source_instances`; however, that would lead to a
- # circular import (maybe `USER_FILE_SOURCES_SCHEME` should be moved to a module in a layer deeper than
- # `galaxy.managers`)
def get_scheme(self) -> str:
return self.scheme if self.scheme and self.scheme != DEFAULT_SCHEME else "elabftw"
- # it would make better sense to return `self.scheme if self.scheme == USER_FILE_SOURCES_SCHEME else "elabftw"`,
- # but the same circular import issue as above arises
def score_url_match(self, url: str) -> int:
parsed_url = urlparse(url)
diff --git a/lib/galaxy/jobs/__init__.py b/lib/galaxy/jobs/__init__.py
index fb4a9524ea5..d0d8f0ca44b 100644
--- a/lib/galaxy/jobs/__init__.py
+++ b/lib/galaxy/jobs/__init__.py
@@ -106,7 +106,10 @@ from galaxy.tools.evaluation import (
ToolEvaluator,
UserToolEvaluator,
)
-from galaxy.tools.parameters import params_to_json_internal
+from galaxy.tools.parameters import (
+ collect_directory_uris,
+ params_to_json_internal,
+)
from galaxy.util import (
parse_xml_string,
RWXRWXRWX,
@@ -1081,6 +1084,22 @@ class MinimalJobWrapper(HasResourceParameters):
if authnz_manager and trans.user:
authnz_manager.refresh_expiring_oidc_tokens(trans, trans.user)
+ def _referenced_file_source_uris(self, job: Job) -> set[str] | None:
+ """Return required URIs, or ``None`` when action discovery is incomplete."""
+ uris: set[str] = set()
+ if self.tool is not None:
+ tool_action = self.tool.tool_action
+ if not tool_action.has_complete_file_source_uri_discovery():
+ return None
+ param_dict = self.get_param_dict(job)
+ uris.update(collect_directory_uris(self.tool.inputs, param_dict))
+ uris.update(tool_action.iter_referenced_file_source_uris(param_dict))
+ for input_association in job.input_datasets + job.input_library_datasets:
+ dataset = input_association.dataset
+ if dataset is not None and dataset.has_deferred_data and dataset.dataset is not None:
+ uris.update(dataset.dataset.source_uris)
+ return uris
+
@property
def job_io(self) -> JobIO:
if self._job_io is None:
@@ -1088,6 +1107,7 @@ class MinimalJobWrapper(HasResourceParameters):
work_request = WorkRequestContext(self.app, user=job.user, galaxy_session=job.galaxy_session)
user_context = ProvidesFileSourcesUserContext(work_request)
self._refresh_oidc_tokens_for_job(work_request)
+ referenced_uris = self._referenced_file_source_uris(job)
tool_source = self.tool.tool_source.to_string() if self.tool else None
tool_dir = self.tool.tool_dir if self.tool else None
self._job_io = JobIO(
@@ -1106,7 +1126,11 @@ class MinimalJobWrapper(HasResourceParameters):
new_file_path=self.app.config.new_file_path,
builds_file_path=self.app.config.builds_file_path,
len_file_path=self.app.config.len_file_path,
- file_sources_dict=self.app.file_sources.to_dict(for_serialization=True, user_context=user_context),
+ file_sources_dict=self.app.file_sources.to_dict(
+ for_serialization=True,
+ user_context=user_context,
+ referenced_uris=referenced_uris,
+ ),
user_context=user_context,
check_job_script_integrity=self.app.config.check_job_script_integrity,
check_job_script_integrity_count=self.app.config.check_job_script_integrity_count,
@@ -1240,15 +1264,18 @@ class MinimalJobWrapper(HasResourceParameters):
return os.path.abspath(os.path.join(self.working_directory, "outputs", COMMAND_VERSION_FILENAME))
def __prepare_upload_paramfile(self, job):
- """Special case paramfile handling for the upload tool. Copies the paramfile to the working directory"""
+ """Copy the upload paramfile into the working directory and use the stable path."""
new = os.path.join(self.working_directory, "upload_params.json")
- param_file_path = json.loads(next(iter(param.value for param in job.parameters if param.name == "paramfile")))
- try:
- shutil.copy2(param_file_path, new)
- except OSError as exc:
- # It won't exist at the old path if setup was interrupted and tried again later
- if exc.errno != errno.ENOENT or not os.path.exists(new):
- raise
+ paramfile_parameter = next(iter(param for param in job.parameters if param.name == "paramfile"))
+ param_file_path = json.loads(paramfile_parameter.value)
+ if param_file_path != new:
+ try:
+ shutil.copy2(param_file_path, new)
+ except OSError as exc:
+ # It won't exist at the old path if setup was interrupted and tried again later
+ if exc.errno != errno.ENOENT or not os.path.exists(new):
+ raise
+ paramfile_parameter.value = json.dumps(new)
def prepare(self, compute_environment=None):
"""
@@ -1413,7 +1440,9 @@ class MinimalJobWrapper(HasResourceParameters):
return tool_evaluator
def _fix_output_permissions(self):
- for path in [dp.real_path for dp in self.job_io.get_mutable_output_fnames()]:
+ if self._job_io is None:
+ return
+ for path in [dp.real_path for dp in self._job_io.get_mutable_output_fnames()]:
if os.path.exists(path):
util.umask_fix_perms(path, self.app.config.umask, 0o666, self.app.config.gid)
diff --git a/lib/galaxy/managers/file_source_instances.py b/lib/galaxy/managers/file_source_instances.py
index d666b9f4151..a32663683b9 100644
--- a/lib/galaxy/managers/file_source_instances.py
+++ b/lib/galaxy/managers/file_source_instances.py
@@ -4,6 +4,7 @@ from typing import (
cast,
Literal,
)
+from urllib.parse import urlsplit
from uuid import uuid4
from pydantic import (
@@ -26,6 +27,7 @@ from galaxy.files import (
FileSourceScore,
FileSourcesUserContext,
ProvidesFileSourcesUserContext,
+ USER_FILE_SOURCES_SCHEME,
UserDefinedFileSources,
)
from galaxy.files.plugins import (
@@ -53,6 +55,7 @@ from galaxy.files.templates.capabilities import (
)
from galaxy.managers.context import ProvidesUserContext
from galaxy.model import (
+ get_uuid,
User,
UserFileSource,
)
@@ -111,7 +114,21 @@ from ._config_templates import (
log = logging.getLogger(__name__)
-USER_FILE_SOURCES_SCHEME = "gxuserfiles"
+
+def referenced_user_file_source_ids(referenced_uris: set[str]) -> set[str]:
+ """Return canonical UUID hex strings addressed by ``gxuserfiles`` URIs."""
+ ids: set[str] = set()
+ for uri in referenced_uris:
+ if not uri.startswith(f"{USER_FILE_SOURCES_SCHEME}://"):
+ continue
+ try:
+ split = urlsplit(uri)
+ if not split.netloc:
+ raise ValueError("URI has no authority")
+ ids.add(get_uuid(split.netloc).hex)
+ except ValueError as exc:
+ raise RequestParameterInvalidException(f"Invalid user file source URI [{uri}]") from exc
+ return ids
class UserFileSourceModel(BaseModel):
@@ -675,15 +692,24 @@ class UserDefinedFileSourcesImpl(UserDefinedFileSources):
)[0]
return file_source
- def _all_user_file_source_properties(self, user_context: FileSourcesUserContext) -> list[dict[str, Any]]:
+ def _all_user_file_source_properties(
+ self,
+ user_context: FileSourcesUserContext,
+ referenced_uris: set[str] | None = None,
+ ) -> list[dict[str, Any]]:
username_filter = User.__table__.c.username == user_context.username
user: User | None = self._sa_session.query(User).filter(username_filter).one_or_none()
if user is None:
return []
+ referenced_ids = None if referenced_uris is None else referenced_user_file_source_ids(referenced_uris)
all_file_source_properties: list[dict[str, Any]] = []
for user_file_source in user.file_sources:
if user_file_source.hidden:
continue
+ # Filter before resolving properties because resolution can access the vault or mint
+ # an OAuth access token.
+ if referenced_ids is not None and get_uuid(user_file_source.uuid).hex not in referenced_ids:
+ continue
try:
files_source_properties = self._file_source_properties(user_file_source)
except ValidationError:
@@ -736,13 +762,16 @@ class UserDefinedFileSourcesImpl(UserDefinedFileSources):
browsable_only: bool | None = False,
include_kind: set[PluginKind] | None = None,
exclude_kind: set[PluginKind] | None = None,
+ referenced_uris: set[str] | None = None,
) -> list[dict[str, Any]]:
"""Write out user file sources as list of config dictionaries."""
if user_context.anonymous:
return []
as_dicts = []
- for files_source_properties in self._all_user_file_source_properties(user_context):
+ for files_source_properties in self._all_user_file_source_properties(
+ user_context, referenced_uris=referenced_uris
+ ):
files_source_type = files_source_properties["type"]
plugin_type_class = self._plugin_loader.get_plugin_type_class(files_source_type)
plugin_kind = plugin_type_class.plugin_kind
diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py
index f855d1f165a..dfd53d87d16 100644
--- a/lib/galaxy/model/__init__.py
+++ b/lib/galaxy/model/__init__.py
@@ -4858,6 +4858,11 @@ class Dataset(Base, StorableObject, Serializable):
def is_new(self):
return self.state == self.states.NEW
+ @property
+ def source_uris(self) -> list[str]:
+ """The URIs this dataset was populated from (e.g. remote/deferred sources)."""
+ return [source.source_uri for source in self.sources if source.source_uri]
+
def in_ready_state(self):
return self.state in self.ready_states
diff --git a/lib/galaxy/tools/actions/__init__.py b/lib/galaxy/tools/actions/__init__.py
index 65ee7d743aa..fc54362e186 100644
--- a/lib/galaxy/tools/actions/__init__.py
+++ b/lib/galaxy/tools/actions/__init__.py
@@ -4,6 +4,7 @@ import os
import re
from abc import abstractmethod
from collections.abc import (
+ Iterable,
Mapping,
MutableMapping,
)
@@ -98,6 +99,15 @@ class ToolAction:
"""
produces_real_jobs: bool
+ file_source_uri_discovery_complete = False
+
+ def has_complete_file_source_uri_discovery(self) -> bool:
+ """Return whether this concrete action has audited URI discovery."""
+ return type(self).__dict__.get("file_source_uri_discovery_complete", False)
+
+ def iter_referenced_file_source_uris(self, param_dict: ToolStateJobInstancePopulatedT) -> Iterable[str]:
+ """Yield file source URIs embedded in action-specific parameters."""
+ return ()
@abstractmethod
def execute(
@@ -141,6 +151,7 @@ class DefaultToolAction(ToolAction):
"""Default tool action is to run an external command"""
produces_real_jobs: bool = True
+ file_source_uri_discovery_complete = True
def _collect_input_datasets(
self,
diff --git a/lib/galaxy/tools/actions/data_manager.py b/lib/galaxy/tools/actions/data_manager.py
index c15b3815630..2d28c4031db 100644
--- a/lib/galaxy/tools/actions/data_manager.py
+++ b/lib/galaxy/tools/actions/data_manager.py
@@ -29,6 +29,8 @@ log = logging.getLogger(__name__)
class DataManagerToolAction(DefaultToolAction):
"""Tool action used for Data Manager Tools"""
+ file_source_uri_discovery_complete = True
+
def execute(
self,
tool,
diff --git a/lib/galaxy/tools/actions/data_source.py b/lib/galaxy/tools/actions/data_source.py
index 14f3a1b1044..0a485a16eff 100644
--- a/lib/galaxy/tools/actions/data_source.py
+++ b/lib/galaxy/tools/actions/data_source.py
@@ -8,6 +8,8 @@ log = logging.getLogger(__name__)
class DataSourceToolAction(DefaultToolAction):
"""Tool action used for Data Source Tools"""
+ file_source_uri_discovery_complete = True
+
def _get_default_data_name(
self, dataset, tool, on_text=None, trans=None, incoming=None, history=None, params=None, job_params=None, **kwd
):
diff --git a/lib/galaxy/tools/actions/history_imp_exp.py b/lib/galaxy/tools/actions/history_imp_exp.py
index 9b7ea531f27..5af668a949f 100644
--- a/lib/galaxy/tools/actions/history_imp_exp.py
+++ b/lib/galaxy/tools/actions/history_imp_exp.py
@@ -2,6 +2,7 @@ import datetime
import logging
import os
import tempfile
+from collections.abc import Iterable
from galaxy.job_execution.setup import JobWorkingDirectory
from galaxy.model import (
@@ -40,6 +41,13 @@ class ImportHistoryToolAction(ToolAction):
"""Tool action used for importing a history to an archive."""
produces_real_jobs: bool = True
+ file_source_uri_discovery_complete = True
+
+ def iter_referenced_file_source_uris(self, param_dict: ToolStateJobInstancePopulatedT) -> Iterable[str]:
+ if param_dict.get("__ARCHIVE_TYPE__") == "url":
+ archive_source = param_dict.get("__ARCHIVE_SOURCE__")
+ if isinstance(archive_source, str) and archive_source:
+ yield archive_source
def execute(
self,
@@ -117,6 +125,7 @@ class ExportHistoryToolAction(ToolAction):
"""Tool action used for exporting a history to an archive."""
produces_real_jobs: bool = True
+ file_source_uri_discovery_complete = True
def execute(
self,
diff --git a/lib/galaxy/tools/actions/metadata.py b/lib/galaxy/tools/actions/metadata.py
index 1c0a5d2dc41..7fb433299a4 100644
--- a/lib/galaxy/tools/actions/metadata.py
+++ b/lib/galaxy/tools/actions/metadata.py
@@ -40,6 +40,7 @@ class SetMetadataToolAction(ToolAction):
produces_real_jobs: bool = False
set_output_hid: bool = False
+ file_source_uri_discovery_complete = True
def execute(
self,
diff --git a/lib/galaxy/tools/actions/model_operations.py b/lib/galaxy/tools/actions/model_operations.py
index a640547228c..6497679f8ab 100644
--- a/lib/galaxy/tools/actions/model_operations.py
+++ b/lib/galaxy/tools/actions/model_operations.py
@@ -40,6 +40,7 @@ log = logging.getLogger(__name__)
class ModelOperationToolAction(DefaultToolAction):
produces_real_jobs: bool = False
+ file_source_uri_discovery_complete = True
def check_inputs_ready(self, tool, trans, incoming, history, execution_cache=None, collection_info=None):
if execution_cache is None:
diff --git a/lib/galaxy/tools/actions/upload.py b/lib/galaxy/tools/actions/upload.py
index bfedfd06819..200b3c447d8 100644
--- a/lib/galaxy/tools/actions/upload.py
+++ b/lib/galaxy/tools/actions/upload.py
@@ -1,8 +1,12 @@
import json
import logging
import os
+from collections.abc import Iterable
-from galaxy.exceptions import RequestParameterMissingException
+from galaxy.exceptions import (
+ RequestParameterInvalidException,
+ RequestParameterMissingException,
+)
from galaxy.job_execution.output_collect import copy_collection_metadata_from_target_dict
from galaxy.managers.context import ProvidesHistoryContext
from galaxy.model import (
@@ -16,6 +20,7 @@ from galaxy.model.dataset_collections.structure import UninitializedTree
from galaxy.schema.credentials import CredentialsContext
from galaxy.tools._types import ToolStateJobInstancePopulatedT
from galaxy.tools.actions import upload_common
+from galaxy.tools.data_fetch_utils import iter_fetch_request_urls
from galaxy.tools.execute import (
DatasetCollectionElementsSliceT,
DEFAULT_DATASET_COLLECTION_ELEMENTS,
@@ -38,6 +43,7 @@ log = logging.getLogger(__name__)
class BaseUploadToolAction(ToolAction):
produces_real_jobs = True
+ file_source_uri_discovery_complete = False
def execute(
self,
@@ -86,6 +92,25 @@ class BaseUploadToolAction(ToolAction):
class UploadToolAction(BaseUploadToolAction):
+ file_source_uri_discovery_complete = True
+
+ def iter_referenced_file_source_uris(self, param_dict: ToolStateJobInstancePopulatedT) -> Iterable[str]:
+ paramfile = param_dict.get("paramfile")
+ if not isinstance(paramfile, str):
+ raise RequestParameterInvalidException("Legacy upload job is missing its paramfile")
+ with open(paramfile) as f:
+ upload_params = json.load(f)
+ if not isinstance(upload_params, list):
+ raise RequestParameterInvalidException("Legacy upload paramfile must contain a list")
+ for upload_param in upload_params:
+ if not isinstance(upload_param, dict):
+ raise RequestParameterInvalidException("Legacy upload paramfile entries must be objects")
+ if upload_param.get("type") == "url":
+ path = upload_param.get("path")
+ if not isinstance(path, str) or not path:
+ raise RequestParameterInvalidException("Legacy URL upload entry is missing its path")
+ yield path
+
def _setup_job(
self, tool, trans: ProvidesHistoryContext, incoming, dataset_upload_inputs, history, preferred_object_store_id
):
@@ -112,6 +137,11 @@ class UploadToolAction(BaseUploadToolAction):
class FetchUploadToolAction(BaseUploadToolAction):
+ file_source_uri_discovery_complete = True
+
+ def iter_referenced_file_source_uris(self, param_dict: ToolStateJobInstancePopulatedT) -> Iterable[str]:
+ return iter_fetch_request_urls(param_dict)
+
def _setup_job(
self, tool, trans: ProvidesHistoryContext, incoming, dataset_upload_inputs, history, preferred_object_store_id
):
diff --git a/lib/galaxy/tools/data_fetch_utils.py b/lib/galaxy/tools/data_fetch_utils.py
index 2b63e20680a..ce93ac07258 100644
--- a/lib/galaxy/tools/data_fetch_utils.py
+++ b/lib/galaxy/tools/data_fetch_utils.py
@@ -1,3 +1,5 @@
+import json
+from collections.abc import Iterator
from datetime import (
datetime,
timezone,
@@ -6,6 +8,7 @@ from typing import Any
from galaxy.authnz.psa_authnz import locate_token_expiration
from galaxy.model import User
+from galaxy.tools._types import ToolStateJobInstancePopulatedT
def iter_fetch_urls(value: Any):
@@ -19,6 +22,15 @@ def iter_fetch_urls(value: Any):
yield from iter_fetch_urls(child)
+def iter_fetch_request_urls(param_dict: ToolStateJobInstancePopulatedT) -> Iterator[str]:
+ """Yield URLs from a data-fetch tool's serialized request."""
+ request_json = param_dict.get("request_json")
+ if request_json:
+ for url in iter_fetch_urls(json.loads(request_json)):
+ if isinstance(url, str) and url:
+ yield url
+
+
def fetch_uses_authorization_header(request: dict[str, Any], file_sources, user_context) -> bool:
for url in iter_fetch_urls(request):
file_source_path = file_sources.get_file_source_path(url)
diff --git a/lib/galaxy/tools/parameters/__init__.py b/lib/galaxy/tools/parameters/__init__.py
index 93870d45f9e..f939f455114 100644
--- a/lib/galaxy/tools/parameters/__init__.py
+++ b/lib/galaxy/tools/parameters/__init__.py
@@ -22,6 +22,7 @@ from .basic import (
ColumnListParameter,
DataCollectionToolParameter,
DataToolParameter,
+ DirectoryUriToolParameter,
ParameterValueError,
SelectToolParameter,
TextToolParameter,
@@ -286,6 +287,21 @@ def visit_input_values(
)
+def collect_directory_uris(
+ inputs: ToolInputsT,
+ input_values: ToolStateJobInstancePopulatedT,
+) -> set[str]:
+ """Collect the values of every ``directory_uri`` parameter (file source write destinations)."""
+ uris: set[str] = set()
+
+ def _collect(input, value, **kwargs):
+ if isinstance(input, DirectoryUriToolParameter) and isinstance(value, str) and value:
+ uris.add(value)
+
+ visit_input_values(inputs, input_values, _collect)
+ return uris
+
+
def check_param(
trans, param: ToolParameter, incoming_value, param_values, simple_errors: bool = True
) -> tuple[Any, str | ValueError | None]:
diff --git a/test/unit/app/jobs/test_job_wrapper.py b/test/unit/app/jobs/test_job_wrapper.py
index 1876cefd737..690be9b22f5 100644
--- a/test/unit/app/jobs/test_job_wrapper.py
+++ b/test/unit/app/jobs/test_job_wrapper.py
@@ -1,10 +1,12 @@
import abc
import os
from contextlib import contextmanager
+from types import SimpleNamespace
from typing import (
cast,
TYPE_CHECKING,
)
+from uuid import uuid4
from galaxy.app_unittest_utils.tools_support import (
MockContext,
@@ -12,6 +14,7 @@ from galaxy.app_unittest_utils.tools_support import (
)
from galaxy.jobs import (
JobWrapper,
+ MinimalJobWrapper,
TaskWrapper,
)
from galaxy.jobs.handler import BaseJobHandlerQueue
@@ -23,6 +26,8 @@ from galaxy.model import (
)
from galaxy.objectstore import BaseObjectStore
from galaxy.tools import ToolBox
+from galaxy.tools.parameters.basic import DirectoryUriToolParameter
+from galaxy.util import XML
from galaxy.util.bunch import Bunch
from galaxy.util.unittest import TestCase
@@ -150,6 +155,14 @@ class MockTool:
self.home_target = None
self.tmp_target = None
self.tool_source = Bunch(to_string=lambda: "")
+ self.inputs = {}
+ self.tool_action = SimpleNamespace(
+ has_complete_file_source_uri_discovery=lambda: True,
+ iter_referenced_file_source_uris=lambda param_dict: (),
+ )
+
+ def params_from_strings(self, param_dict):
+ return param_dict
def get_job_destination(self, params):
return Bunch(runner="local", id="local", params={})
@@ -197,3 +210,77 @@ class MockObjectStore:
if kwds.get("base_dir", "") == "job_work":
return self.working_directory
return None
+
+
+def _minimal_wrapper(param_dict=None, inputs=None, action_uris=(), action_discovery_complete=True):
+ tool_action = SimpleNamespace(
+ has_complete_file_source_uri_discovery=lambda: action_discovery_complete,
+ iter_referenced_file_source_uris=lambda param_dict: action_uris,
+ )
+ return SimpleNamespace(
+ tool=SimpleNamespace(inputs=inputs or {}, tool_action=tool_action),
+ get_param_dict=lambda job: param_dict or {},
+ )
+
+
+def _job_with_file_source_inputs(input_datasets=None, input_library_datasets=None):
+ return SimpleNamespace(
+ id=1,
+ input_datasets=input_datasets or [],
+ input_library_datasets=input_library_datasets or [],
+ )
+
+
+def test_referenced_file_source_uris_reads_tool_parameters_and_action():
+ destination = "gxfiles://good/out"
+ fetched = f"gxuserfiles://{uuid4().hex}/input"
+ destination_param = DirectoryUriToolParameter(None, XML(''))
+ wrapper = _minimal_wrapper(
+ param_dict={"destination": destination},
+ inputs={"destination": destination_param},
+ action_uris=(fetched,),
+ )
+ assert MinimalJobWrapper._referenced_file_source_uris(wrapper, _job_with_file_source_inputs()) == {
+ destination,
+ fetched,
+ }
+
+
+def test_referenced_file_source_uris_empty_for_job_without_sources():
+ assert MinimalJobWrapper._referenced_file_source_uris(_minimal_wrapper(), _job_with_file_source_inputs()) == set()
+
+
+def test_referenced_file_source_uris_unknown_for_unaudited_action():
+ wrapper = _minimal_wrapper(action_discovery_complete=False)
+ assert MinimalJobWrapper._referenced_file_source_uris(wrapper, _job_with_file_source_inputs()) is None
+
+
+def test_referenced_file_source_uris_adds_regular_and_library_input_sources():
+ regular_src = f"gxuserfiles://{uuid4().hex}/regular.txt"
+ library_src = f"gxuserfiles://{uuid4().hex}/library.txt"
+ hda = SimpleNamespace(has_deferred_data=True, dataset=SimpleNamespace(source_uris=[regular_src]))
+ ldda = SimpleNamespace(has_deferred_data=True, dataset=SimpleNamespace(source_uris=[library_src]))
+ job = _job_with_file_source_inputs(
+ input_datasets=[SimpleNamespace(dataset=hda)],
+ input_library_datasets=[SimpleNamespace(dataset=ldda)],
+ )
+ assert MinimalJobWrapper._referenced_file_source_uris(_minimal_wrapper(), job) == {regular_src, library_src}
+
+
+def test_referenced_file_source_uris_ignores_materialized_input_sources():
+ source = f"gxuserfiles://{uuid4().hex}/materialized.txt"
+ hda = SimpleNamespace(has_deferred_data=False, dataset=SimpleNamespace(source_uris=[source]))
+ job = _job_with_file_source_inputs(input_datasets=[SimpleNamespace(dataset=hda)])
+ assert MinimalJobWrapper._referenced_file_source_uris(_minimal_wrapper(), job) == set()
+
+
+def test_fix_output_permissions_does_not_initialize_job_io():
+ class WrapperWithoutJobIO:
+ _job_io = None
+
+ @property
+ def job_io(self):
+ raise AssertionError("job_io should not be initialized during failure cleanup")
+
+ wrapper = cast(MinimalJobWrapper, WrapperWithoutJobIO())
+ MinimalJobWrapper._fix_output_permissions(wrapper)
diff --git a/test/unit/app/managers/test_user_file_sources.py b/test/unit/app/managers/test_user_file_sources.py
index 0c9900b99f5..19c24fadf23 100644
--- a/test/unit/app/managers/test_user_file_sources.py
+++ b/test/unit/app/managers/test_user_file_sources.py
@@ -2,7 +2,10 @@ import os
from typing import (
cast,
)
-from uuid import uuid4
+from uuid import (
+ UUID,
+ uuid4,
+)
import pytest
import responses
@@ -15,7 +18,12 @@ from galaxy.exceptions import (
RequestParameterInvalidException,
RequestParameterMissingException,
)
-from galaxy.files import FileSourcesUserContext
+from galaxy.files import (
+ ConfiguredFileSources,
+ FileSourcesUserContext,
+ USER_FILE_SOURCES_SCHEME,
+)
+from galaxy.files.plugins import FileSourcePluginsConfig
from galaxy.files.sources import dropbox
from galaxy.files.templates import ConfiguredFileSourceTemplates
from galaxy.files.templates.examples import get_example
@@ -24,13 +32,13 @@ from galaxy.managers.file_source_instances import (
CreateInstancePayload,
FileSourceInstancesManager,
ModifyInstancePayload,
+ referenced_user_file_source_ids,
TemplateFormDataRequest,
TestUpdateInstancePayload,
TestUpgradeInstancePayload,
UpdateInstancePayload,
UpdateInstanceSecretPayload,
UpgradeInstancePayload,
- USER_FILE_SOURCES_SCHEME,
UserDefinedFileSourcesConfig,
UserDefinedFileSourcesImpl,
UserFileSourceModel,
@@ -326,6 +334,97 @@ class TestFileSourcesTestCase(BaseTestCase):
assert not status.connection.is_not_ok
assert fsspec_fs_init_kwd["token"] == "my_test_access_token"
+ def test_serialization_mints_no_tokens_when_no_sources_referenced(self, tmp_path, monkeypatch):
+ self._init_dropbox_env(tmp_path, monkeypatch)
+ self._create_dropbox_oauth_source(uuid4().hex)
+ self._create_dropbox_oauth_source(uuid4().hex)
+ calls = self._count_refresh_token_mints(monkeypatch)
+ as_dicts = self.file_sources.user_file_sources_to_dicts(
+ True,
+ cast(FileSourcesUserContext, self.trans),
+ referenced_uris=set(),
+ )
+ assert calls == []
+ assert as_dicts == []
+
+ def test_serialization_mints_only_referenced_source(self, tmp_path, monkeypatch):
+ self._init_dropbox_env(tmp_path, monkeypatch)
+ referenced_uuid = uuid4().hex
+ self._create_dropbox_oauth_source(referenced_uuid)
+ self._create_dropbox_oauth_source(uuid4().hex)
+ calls = self._count_refresh_token_mints(monkeypatch)
+ as_dicts = self.file_sources.user_file_sources_to_dicts(
+ True,
+ cast(FileSourcesUserContext, self.trans),
+ referenced_uris={f"gxuserfiles://{referenced_uuid}/some/path"},
+ )
+ assert calls == [f"refresh_token_{referenced_uuid}"]
+ assert len(as_dicts) == 1
+ assert UUID(as_dicts[0]["id"]).hex == referenced_uuid
+ assert as_dicts[0]["access_token"] == "my_test_access_token"
+
+ def test_serialization_without_reference_filter_includes_all_sources(self, tmp_path, monkeypatch):
+ self._init_dropbox_env(tmp_path, monkeypatch)
+ self._create_dropbox_oauth_source(uuid4().hex)
+ self._create_dropbox_oauth_source(uuid4().hex)
+ calls = self._count_refresh_token_mints(monkeypatch)
+ as_dicts = self.file_sources.user_file_sources_to_dicts(
+ True,
+ cast(FileSourcesUserContext, self.trans),
+ )
+ assert len(calls) == 2
+ assert len(as_dicts) == 2
+
+ def test_configured_file_sources_to_dict_threads_referenced_uris(self, tmp_path, monkeypatch):
+ self._init_dropbox_env(tmp_path, monkeypatch)
+ referenced_uuid = uuid4().hex
+ self._create_dropbox_oauth_source(referenced_uuid)
+ self._create_dropbox_oauth_source(uuid4().hex)
+ calls = self._count_refresh_token_mints(monkeypatch)
+ configured = ConfiguredFileSources(
+ FileSourcePluginsConfig(),
+ user_defined_file_sources=self.file_sources,
+ )
+ as_dict = configured.to_dict(
+ for_serialization=True,
+ user_context=cast(FileSourcesUserContext, self.trans),
+ referenced_uris={f"gxuserfiles://{referenced_uuid}/x"},
+ )
+ ids = [s["id"] for s in as_dict["file_sources"]]
+ assert calls == [f"refresh_token_{referenced_uuid}"]
+ assert len(ids) == 1
+ assert UUID(ids[0]).hex == referenced_uuid
+
+ def _create_dropbox_oauth_source(self, uuid: str) -> None:
+ config_secret_key = UserFileSource.vault_key_from_uuid(uuid, "_oauth2_refresh_token", None)
+ # Seed a per-source refresh token so a captured mint identifies which source minted.
+ self.trans.user_vault.write_secret(config_secret_key, f"refresh_token_{uuid}")
+ self._create_instance(
+ CreateInstancePayload(
+ name=SIMPLE_FILE_SOURCE_NAME,
+ description=SIMPLE_FILE_SOURCE_DESCRIPTION,
+ template_id="dropbox",
+ template_version=0,
+ variables={},
+ secrets={},
+ uuid=uuid,
+ )
+ )
+
+ def _count_refresh_token_mints(self, monkeypatch) -> list:
+ calls: list = []
+
+ class MockDropboxDriveFileSystem:
+ pass
+
+ def mock_get_token_from_refresh_raw(refresh_token, client_pair, config):
+ calls.append(refresh_token)
+ return MockResponse({"access_token": "my_test_access_token"})
+
+ monkeypatch.setattr(config_templates, "get_token_from_refresh_raw", mock_get_token_from_refresh_raw)
+ monkeypatch.setattr(dropbox.DropboxFilesSource, "required_module", MockDropboxDriveFileSystem)
+ return calls
+
def test_onedrive_oauth2_flow(self, tmp_path, monkeypatch):
json = {
"refresh_token": "my_test_refresh_token",
@@ -1197,3 +1296,33 @@ class OneDriveMockResponse:
def json(self):
return self._json_data
+
+
+def test_referenced_user_file_source_ids_selects_only_user_sources():
+ user_uuid = uuid4().hex
+ uris = {
+ f"gxuserfiles://{user_uuid}/some/path",
+ "gxfiles://dropbox/other",
+ "https://example.com/file.txt",
+ "drs://example.org/abc",
+ }
+ assert referenced_user_file_source_ids(uris) == {user_uuid}
+
+
+def test_referenced_user_file_source_ids_normalizes_dashed_uuid():
+ dashed = str(uuid4())
+ assert referenced_user_file_source_ids({f"gxuserfiles://{dashed}/x"}) == {dashed.replace("-", "")}
+
+
+def test_referenced_user_file_source_ids_handles_no_match():
+ uris = {"gxfiles://dropbox/x", "not-a-uri", ""}
+ assert referenced_user_file_source_ids(uris) == set()
+
+
+@pytest.mark.parametrize(
+ "uri",
+ ["gxuserfiles://[invalid-authority/x", "gxuserfiles://not-a-uuid/x", "gxuserfiles:///x"],
+)
+def test_referenced_user_file_source_ids_rejects_invalid_user_source_uri(uri):
+ with pytest.raises(RequestParameterInvalidException, match="Invalid user file source URI"):
+ referenced_user_file_source_ids({uri})
diff --git a/test/unit/app/tools/test_collect_directory_uris.py b/test/unit/app/tools/test_collect_directory_uris.py
new file mode 100644
index 00000000000..b2b20418811
--- /dev/null
+++ b/test/unit/app/tools/test_collect_directory_uris.py
@@ -0,0 +1,30 @@
+from galaxy.tools.parameters import collect_directory_uris
+from galaxy.tools.parameters.basic import (
+ DirectoryUriToolParameter,
+ TextToolParameter,
+)
+from galaxy.util import XML
+
+
+def _directory_uri_param(name):
+ return DirectoryUriToolParameter(None, XML(f''))
+
+
+def _text_param(name):
+ return TextToolParameter(None, XML(f''))
+
+
+def test_collect_directory_uris_selects_only_directory_uri_params():
+ inputs = {"dest": _directory_uri_param("dest"), "other": _text_param("other")}
+ values = {"dest": "gxfiles://target/out", "other": "gxfiles://not-a-destination/x"}
+ assert collect_directory_uris(inputs, values) == {"gxfiles://target/out"}
+
+
+def test_collect_directory_uris_empty_when_no_directory_uri_params():
+ inputs = {"other": _text_param("other")}
+ assert collect_directory_uris(inputs, {"other": "text"}) == set()
+
+
+def test_collect_directory_uris_skips_empty_values():
+ inputs = {"dest": _directory_uri_param("dest")}
+ assert collect_directory_uris(inputs, {"dest": ""}) == set()
diff --git a/test/unit/app/tools/test_data_fetch_utils.py b/test/unit/app/tools/test_data_fetch_utils.py
index cc8a5a8b2dd..f2d8d9355d8 100644
--- a/test/unit/app/tools/test_data_fetch_utils.py
+++ b/test/unit/app/tools/test_data_fetch_utils.py
@@ -1,12 +1,26 @@
+import json
from datetime import (
datetime,
timedelta,
timezone,
)
-from typing import cast
+from typing import (
+ Any,
+ cast,
+)
from galaxy.model import User
-from galaxy.tools.data_fetch_utils import compute_token_expiry_for_provider
+from galaxy.tools.actions.upload import FetchUploadToolAction
+from galaxy.tools.data_fetch_utils import (
+ compute_token_expiry_for_provider,
+ iter_fetch_request_urls,
+ iter_fetch_urls,
+)
+
+
+class ConcreteFetchUploadToolAction(FetchUploadToolAction):
+ def get_output_name(self, *args: Any, **kwargs: Any) -> str:
+ raise NotImplementedError
class DummyToken:
@@ -62,3 +76,32 @@ def test_compute_token_expiry_for_provider_returns_none_when_token_missing_auth_
token.extra_data = {}
user = DummyUser([token])
assert compute_token_expiry_for_provider(cast(User, user), "oidc") is None
+
+
+def test_iter_fetch_request_urls_extracts_urls():
+ request = {
+ "targets": [
+ {"elements": [{"src": "url", "url": "gxuserfiles://abc/x"}, {"src": "url", "url": "https://e.com/y"}]}
+ ]
+ }
+ param_dict = {"request_json": json.dumps(request)}
+ assert set(iter_fetch_request_urls(param_dict)) == {"gxuserfiles://abc/x", "https://e.com/y"}
+
+
+def test_iter_fetch_request_urls_empty_without_request_json():
+ assert list(iter_fetch_request_urls({})) == []
+ assert list(iter_fetch_request_urls({"request_json": ""})) == []
+
+
+def test_iter_fetch_request_urls_ignores_non_string_and_empty_urls():
+ request = {"targets": [{"elements": [{"src": "url", "url": None}, {"src": "url", "url": ""}]}]}
+ assert list(iter_fetch_urls(request)) == [None, ""]
+ assert list(iter_fetch_request_urls({"request_json": json.dumps(request)})) == []
+
+
+def test_fetch_upload_action_reports_referenced_file_source_uris():
+ request = {"targets": [{"elements": [{"src": "url", "url": "gxfiles://source/input"}]}]}
+ action = ConcreteFetchUploadToolAction()
+ assert list(action.iter_referenced_file_source_uris({"request_json": json.dumps(request)})) == [
+ "gxfiles://source/input"
+ ]
diff --git a/test/unit/app/tools/test_history_imp_exp.py b/test/unit/app/tools/test_history_imp_exp.py
index c0b15687322..8b07515c97a 100644
--- a/test/unit/app/tools/test_history_imp_exp.py
+++ b/test/unit/app/tools/test_history_imp_exp.py
@@ -3,6 +3,7 @@ import os
import tarfile
import tempfile
from shutil import rmtree
+from typing import Any
from unittest.mock import Mock
from sqlalchemy import select
@@ -12,6 +13,7 @@ from galaxy.app_unittest_utils.galaxy_mock import MockApp
from galaxy.exceptions import MalformedContents
from galaxy.model.orm.util import add_object_to_object_session
from galaxy.objectstore.unittest_utils import Config as TestConfig
+from galaxy.tools.actions.history_imp_exp import ImportHistoryToolAction
from galaxy.tools.imp_exp import (
JobExportHistoryArchiveWrapper,
JobImportHistoryArchiveWrapper,
@@ -28,6 +30,24 @@ HISTORY_ATTRS = """{"hid_counter": 2, "update_time": "2016-02-08 18:38:38.705058
JOBS_ATTRS = """[{"info": null, "tool_id": "upload1", "update_time": "2016-02-08T18:39:23.356482", "stdout": "", "input_mapping": {}, "tool_version": "1.1.4", "traceback": null, "command_line": "python /galaxy/tools/data_source/upload.py /galaxy /scratch/tmppwU9rD /scratch/tmpP4_45Y 1:/scratch/jobs/000/dataset_1_files:/data/000/dataset_1.dat", "exit_code": 0, "output_datasets": [1], "state": "ok", "create_time": "2016-02-08T18:38:39.153873", "params": {"files": [{"to_posix_lines": "Yes", "NAME": "None", "file_data": null, "space_to_tab": null, "url_paste": "/scratch/strio_url_paste_o6nrv8", "__index__": 0, "ftp_files": "", "uuid": "None"}], "paramfile": "/scratch/tmpP4_45Y", "file_type": "auto", "files_metadata": {"file_type": "auto", "__current_case__": 41}, "async_datasets": "None", "dbkey": "?"}, "stderr": ""}]"""
+class ConcreteImportHistoryToolAction(ImportHistoryToolAction):
+ def get_output_name(self, *args: Any, **kwargs: Any) -> str:
+ raise NotImplementedError
+
+
+def test_import_history_action_reports_url_archive_source():
+ action = ConcreteImportHistoryToolAction()
+ archive_source = "https://example.org/history.tar.gz"
+ assert list(
+ action.iter_referenced_file_source_uris({"__ARCHIVE_TYPE__": "url", "__ARCHIVE_SOURCE__": archive_source})
+ ) == [archive_source]
+ assert not list(
+ action.iter_referenced_file_source_uris(
+ {"__ARCHIVE_TYPE__": "file", "__ARCHIVE_SOURCE__": "/tmp/history.tar.gz"}
+ )
+ )
+
+
def t_data_path(name):
return os.path.join(galaxy_directory(), "test-data", name)
diff --git a/test/unit/app/tools/test_upload_actions.py b/test/unit/app/tools/test_upload_actions.py
new file mode 100644
index 00000000000..354d9ff8768
--- /dev/null
+++ b/test/unit/app/tools/test_upload_actions.py
@@ -0,0 +1,65 @@
+import json
+import tempfile
+from typing import Any
+
+import pytest
+
+from galaxy.exceptions import RequestParameterInvalidException
+from galaxy.tools.actions.upload import UploadToolAction
+
+
+class ConcreteUploadToolAction(UploadToolAction):
+ def get_output_name(self, *args: Any, **kwargs: Any) -> str:
+ raise NotImplementedError
+
+
+def test_upload_action_reports_urls_from_paramfile():
+ upload_params = [
+ {"type": "url", "path": "https://example.org/input.txt"},
+ {"type": "file", "path": "/tmp/pasted-input.txt"},
+ ]
+ with tempfile.NamedTemporaryFile(mode="w") as paramfile:
+ json.dump(upload_params, paramfile)
+ paramfile.flush()
+ action = ConcreteUploadToolAction()
+ assert list(action.iter_referenced_file_source_uris({"paramfile": paramfile.name})) == [
+ "https://example.org/input.txt"
+ ]
+
+
+@pytest.mark.parametrize(
+ ("upload_params", "message"),
+ [
+ ({"type": "url", "path": "https://example.org/input.txt"}, "must contain a list"),
+ (["not an object"], "entries must be objects"),
+ ([{"type": "url", "path": None}], "URL upload entry is missing its path"),
+ ],
+)
+def test_upload_action_rejects_invalid_paramfile_shape(upload_params, message):
+ with tempfile.NamedTemporaryFile(mode="w") as paramfile:
+ json.dump(upload_params, paramfile)
+ paramfile.flush()
+ action = ConcreteUploadToolAction()
+ with pytest.raises(RequestParameterInvalidException, match=message):
+ list(action.iter_referenced_file_source_uris({"paramfile": paramfile.name}))
+
+
+def test_upload_action_rejects_missing_paramfile_parameter():
+ action = ConcreteUploadToolAction()
+ with pytest.raises(RequestParameterInvalidException, match="missing its paramfile"):
+ list(action.iter_referenced_file_source_uris({}))
+
+
+def test_upload_action_does_not_hide_missing_paramfile(tmp_path):
+ action = ConcreteUploadToolAction()
+ with pytest.raises(FileNotFoundError):
+ list(action.iter_referenced_file_source_uris({"paramfile": str(tmp_path / "missing.json")}))
+
+
+def test_upload_action_does_not_hide_malformed_paramfile():
+ with tempfile.NamedTemporaryFile(mode="w") as paramfile:
+ paramfile.write("{")
+ paramfile.flush()
+ action = ConcreteUploadToolAction()
+ with pytest.raises(json.JSONDecodeError):
+ list(action.iter_referenced_file_source_uris({"paramfile": paramfile.name}))
diff --git a/test/unit/files/test_http.py b/test/unit/files/test_http.py
index c0543f446a5..c985b4669de 100644
--- a/test/unit/files/test_http.py
+++ b/test/unit/files/test_http.py
@@ -40,6 +40,18 @@ def test_file_source_http_specific():
assert_realizes_as(file_sources, test_url, "hello specific world", user_context=user_context)
+def test_plugins_to_dict_serializes_only_best_matching_http_source():
+ test_url = "https://www.usegalaxy.org/myfile.txt"
+ user_context = user_context_fixture()
+ file_sources = configured_file_sources(FILE_SOURCES_CONF)
+ plugins = file_sources.plugins_to_dict(
+ for_serialization=True,
+ user_context=user_context,
+ referenced_uris={test_url},
+ )
+ assert [plugin["id"] for plugin in plugins] == ["test1"]
+
+
def test_file_source_another_http_specific():
test_url = "http://www.galaxyproject.org/anotherfile.txt"
diff --git a/test/unit/files/test_posix.py b/test/unit/files/test_posix.py
index c130c5f541b..e34c2bba01f 100644
--- a/test/unit/files/test_posix.py
+++ b/test/unit/files/test_posix.py
@@ -21,6 +21,7 @@ from galaxy.files.unittest_utils import (
from ._util import (
assert_realizes_as,
assert_realizes_throws_exception,
+ configured_file_sources,
find,
find_file_a,
list_dir,
@@ -556,3 +557,33 @@ def test_get_file_source_path_strips_whitespace():
resolved = file_sources.get_file_source_path("\ngxfiles://test1/a\n")
assert resolved.file_source is not None
assert resolved.path == "/a"
+
+
+def _two_posix_file_sources(tmp_path):
+ root_good = tmp_path / "good"
+ root_other = tmp_path / "other"
+ root_good.mkdir()
+ root_other.mkdir()
+ return configured_file_sources(
+ [
+ {"type": "posix", "id": "good", "root": str(root_good)},
+ {"type": "posix", "id": "other", "root": str(root_other)},
+ ]
+ )
+
+
+def test_plugins_to_dict_serializes_only_referenced_sources(tmp_path):
+ file_sources = _two_posix_file_sources(tmp_path)
+ plugins = file_sources.plugins_to_dict(for_serialization=True, referenced_uris={"gxfiles://good/some/file"})
+ assert [p["id"] for p in plugins] == ["good"]
+
+
+def test_plugins_to_dict_serializes_nothing_when_no_uris_referenced(tmp_path):
+ file_sources = _two_posix_file_sources(tmp_path)
+ assert file_sources.plugins_to_dict(for_serialization=True, referenced_uris=set()) == []
+
+
+def test_plugins_to_dict_serializes_all_when_referenced_uris_none(tmp_path):
+ file_sources = _two_posix_file_sources(tmp_path)
+ plugins = file_sources.plugins_to_dict(for_serialization=True)
+ assert {p["id"] for p in plugins} == {"good", "other"}