From cb1218e534fe13b4418a9443d4562bcca9931a9c Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 7 Apr 2026 18:24:55 +0200 Subject: [PATCH 01/12] Add workflow tests for mapped collection object store selection These tests expose issue #21846: storage class selection is ignored for collection outputs in workflows when the collection is produced by a step that maps over a collection input. Root cause: WorkflowInvocationStep.job_id only references a single Job, but for mapped execution each job is linked via ImplicitCollectionJobs. As a result, individual mapped jobs have job.workflow_invocation_step = None and _set_object_store_ids_full() cannot retrieve the invocation's preferred_object_store_id, so outputs fall through to the history/user default. The new tests (expected to FAIL until the underlying bug is fixed): - test_workflow_mapped_collection_objectstore_selection - test_workflow_mapped_and_simple_mixed_objectstore - test_workflow_mapped_collection_objectstore_selection_split The mixed test directly mirrors the scenario from the last comment on issue #21846: simple dataset outputs respect the invocation preference, but mapped collection elements do not. --- ...ection_with_user_preferred_object_store.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/test/integration/objectstore/test_selection_with_user_preferred_object_store.py b/test/integration/objectstore/test_selection_with_user_preferred_object_store.py index 4519d57ec15..673304e8f8b 100644 --- a/test/integration/objectstore/test_selection_with_user_preferred_object_store.py +++ b/test/integration/objectstore/test_selection_with_user_preferred_object_store.py @@ -141,6 +141,32 @@ text_input1: | samp2\t20.0 """ +WORKFLOW_WITH_MAPPED_COLLECTION_OUTPUT = """ +class: GalaxyWorkflow +inputs: + input_collection: + type: collection + collection_type: list +outputs: + wf_output_1: + outputSource: cat_mapped/out_file1 +steps: + cat_mapped: + tool_id: cat + in: + input1: input_collection +""" + +WORKFLOW_MAPPED_COLLECTION_TEST_DATA = """ +input_collection: + collection_type: list + elements: + - identifier: el1 + content: "data 1" + - identifier: el2 + content: "data 2" +""" + def assert_storage_name_is(storage_dict: dict[str, Any], name: str): storage_name = storage_dict["name"] @@ -366,6 +392,67 @@ class TestObjectStoreSelectionWithPreferredObjectStoresIntegration(BaseObjectSto assert_storage_name_is(output_info, "Static Storage") assert_storage_name_is(intermediate_dict, "Dynamic EBS") + def test_workflow_mapped_collection_objectstore_selection(self): + # Regression for https://github.com/galaxyproject/galaxy/issues/21846 + with self.dataset_populator.test_history() as history_id: + element_storages = self._run_workflow_with_mapped_collection( + history_id, + WORKFLOW_WITH_MAPPED_COLLECTION_OUTPUT, + WORKFLOW_MAPPED_COLLECTION_TEST_DATA, + ) + for storage in element_storages: + assert_storage_name_is(storage, "Default Store") + + with self.dataset_populator.test_history() as history_id: + element_storages = self._run_workflow_with_mapped_collection( + history_id, + WORKFLOW_WITH_MAPPED_COLLECTION_OUTPUT, + WORKFLOW_MAPPED_COLLECTION_TEST_DATA, + extra_invocation_kwds={"preferred_object_store_id": "static"}, + ) + for storage in element_storages: + assert_storage_name_is(storage, "Static Storage") + + def test_workflow_mapped_collection_objectstore_selection_split(self): + # Regression for https://github.com/galaxyproject/galaxy/issues/21846 + with self.dataset_populator.test_history() as history_id: + element_storages = self._run_workflow_with_mapped_collection( + history_id, + WORKFLOW_WITH_MAPPED_COLLECTION_OUTPUT, + WORKFLOW_MAPPED_COLLECTION_TEST_DATA, + extra_invocation_kwds={ + "preferred_outputs_object_store_id": "static", + "preferred_intermediate_object_store_id": "dynamic_ebs", + }, + ) + for storage in element_storages: + assert_storage_name_is(storage, "Static Storage") + + def _run_workflow_with_mapped_collection( + self, + history_id: str, + workflow: str, + test_data: str, + extra_invocation_kwds: Optional[dict[str, Any]] = None, + ): + self.workflow_populator.run_workflow( + workflow, + test_data=test_data, + history_id=history_id, + extra_invocation_kwds=extra_invocation_kwds, + ) + # Find the implicit collection in the history and inspect element storage + history_contents = self.dataset_populator.get_history_contents(history_id, data={"v": "dev"}) + hdca = None + for entry in history_contents: + if entry.get("history_content_type") == "dataset_collection" and entry.get("visible", True): + hdca = entry + assert hdca is not None, "No collection found in history" + hdca_details = self.dataset_populator.get_history_collection_details(history_id, content_id=hdca["id"]) + elements = hdca_details["elements"] + assert len(elements) > 0, "Collection has no elements" + return [self._storage_info(element["object"]) for element in elements] + def _run_workflow_with_collections_1(self, history_id: str, extra_invocation_kwds: Optional[dict[str, Any]] = None): wf_run = self.workflow_populator.run_workflow( WORKFLOW_WITH_COLLECTIONS_1, From 56ce315c55e984ca2ddd68d91ac206e6c274049f Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 7 Apr 2026 18:24:55 +0200 Subject: [PATCH 02/12] Respect workflow object store preference for mapped step outputs Fix issue #21846: storage class selection was ignored for collection outputs produced by workflow steps that map over a collection input. For a non-mapped step, WorkflowInvocationStep.job_id references the single job and the back-ref job.workflow_invocation_step is used in Job._set_object_store_ids_full() to apply the invocation's preferred object store. For a mapped step, WorkflowInvocationStep.job_id is NULL; the step instead references an ImplicitCollectionJobs, and each of the individual mapped jobs is linked to that ICJ via ImplicitCollectionJobsJobAssociation. Those jobs therefore had job.workflow_invocation_step == None and fell through to the user or history default store, silently ignoring the invocation preference. Add Job.effective_workflow_invocation_step which falls back to resolving the step via ImplicitCollectionJobs, and use it in _set_object_store_ids_full() so the split- and unified-config branches both work for mapped outputs. --- lib/galaxy/jobs/__init__.py | 4 ++-- lib/galaxy/model/__init__.py | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/jobs/__init__.py b/lib/galaxy/jobs/__init__.py index 1611cfd106b..d3e70f6c944 100644 --- a/lib/galaxy/jobs/__init__.py +++ b/lib/galaxy/jobs/__init__.py @@ -1855,8 +1855,8 @@ class MinimalJobWrapper(HasResourceParameters): if object_store_id is None: object_store_id = job.preferred_object_store_id - if object_store_id is None and job.workflow_invocation_step: - workflow_invocation_step = job.workflow_invocation_step + workflow_invocation_step = job.effective_workflow_invocation_step + if object_store_id is None and workflow_invocation_step: invocation_object_stores = workflow_invocation_step.preferred_object_stores if invocation_object_stores.is_split_configuration: # Redo for subworkflows... diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index a96fd55b8c5..ad9068f2059 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -1657,7 +1657,7 @@ class Job(Base, JobLike, UsesCreateAndUpdateTime, Dictifiable, Serializable): interactivetool_entry_points: Mapped[list["InteractiveToolEntryPoint"]] = relationship( back_populates="job", uselist=True ) - implicit_collection_jobs_association: Mapped["ImplicitCollectionJobsJobAssociation"] = relationship( + implicit_collection_jobs_association: Mapped[Optional["ImplicitCollectionJobsJobAssociation"]] = relationship( back_populates="job", uselist=False ) container: Mapped[Optional["JobContainerAssociation"]] = relationship(back_populates="job", uselist=False) @@ -1674,6 +1674,27 @@ class Job(Base, JobLike, UsesCreateAndUpdateTime, Dictifiable, Serializable): back_populates="job" ) + @property + def effective_workflow_invocation_step(self) -> Optional["WorkflowInvocationStep"]: + """The WorkflowInvocationStep backing this job, including mapped steps. + + For non-mapped steps this is the direct ``workflow_invocation_step`` back-ref. + For mapped steps ``WorkflowInvocationStep.job_id`` is NULL — the step points + at an ``ImplicitCollectionJobs`` instead, and each job is linked to that ICJ + via ``ImplicitCollectionJobsJobAssociation``. Resolve it by querying + ``WorkflowInvocationStep`` using the ICJ id. + """ + if self.workflow_invocation_step is not None: + return self.workflow_invocation_step + icj_assoc = self.implicit_collection_jobs_association + if icj_assoc is None: + return None + icj_id = icj_assoc.implicit_collection_jobs_id + session = required_object_session(self) + return session.execute( + select(WorkflowInvocationStep).where(WorkflowInvocationStep.implicit_collection_jobs_id == icj_id) + ).scalar_one_or_none() + dict_collection_visible_keys = [ "id", "state", From 24db72ed341bfc0e78e504015f62d9cfc3e8a907 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Wed, 8 Apr 2026 15:21:53 +0200 Subject: [PATCH 03/12] Flush instead of commit in FileParameter.wrap FileParameter.wrap called session.commit() as a last resort when the MetadataFile uuid it was asked to wrap was not yet visible to the session (because it had been add()-ed in a sibling code path but not yet flushed). Committing leaks every pending change in the session to concurrent readers. Inside JobWrapper.finish(), a mid-loop load_metadata() reaches wrap() via MetadataCollection.from_JSON_dict. If that wrap() commits, it flushes a scratch dataset's Dataset.state = OK assignment (set in a prior iteration at jobs/__init__.py:2199) to the DB. A concurrent workflow scheduler then sees Dataset.state == OK while exec_after_process is still mid-run -- and between the deletion of the scratch expression.json in full_delete() and the deliberate commit at jobs/__init__.py:2297, the HDA's purge flag, dataset_id swap, and extension change are all still uncommitted. to_cwl() falls through all its existing guards, opens get_file_name() (which returns "" because object_store.exists is False), and crashes with FileNotFoundError. Replacing commit with flush sends the pending INSERT into the current transaction so the retry SELECT finds the MetadataFile (wrap's contract is preserved) without publishing any unrelated pending state to other sessions. The existing TODO on the line already flagged that the commit should go away. Fixes https://github.com/galaxyproject/galaxy/issues/22194 --- lib/galaxy/model/metadata.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/model/metadata.py b/lib/galaxy/model/metadata.py index 611e2908c52..fa61baf5c1a 100644 --- a/lib/galaxy/model/metadata.py +++ b/lib/galaxy/model/metadata.py @@ -633,10 +633,16 @@ class FileParameter(MetadataParameter): if wrapped_value: return wrapped_value else: - # If we've simultaneously copied the dataset and we've changed the datatype on the - # copy we may not have committed the MetadataFile yet, so we need to commit the session. - # TODO: It would be great if we can avoid the commit in the future. - session.commit() + # If we've simultaneously copied the dataset and we've changed the datatype on the + # copy we may not have flushed the pending MetadataFile to the DB yet, so the + # select above may have missed it. Flush (NOT commit) so the pending INSERT is + # visible to the retry SELECT within this session without leaking every other + # pending change in the session to concurrent readers. Committing here caused + # https://github.com/galaxyproject/galaxy/issues/22194: a mid-loop wrap() inside + # JobWrapper.finish() flushed an intermedia expression.json's dataset's + # Dataset.state = OK to the DB before exec_after_process had replaced the file, + # exposing a globall inconsistent state to the workflow scheduler. + session.flush() return session.execute(select(galaxy.model.MetadataFile).filter_by(uuid=value)).scalar_one_or_none() def make_copy(self, value, target_context: MetadataCollection, source_context): From 43e000bd808e95236434b714293876efcb18c4f7 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 9 Apr 2026 13:13:54 +0200 Subject: [PATCH 04/12] Fix unqualified structured_like resolution, add linters Move the profile version gate for unqualified structured_like resolution from 18.09 to 26.0 so tools with newer profiles can still resolve unqualified references when the input is inside a conditional or section. Also track the resolved qualified name for downstream is_implicit_input/subcollection_mapping_type lookups. Add linters OutputsStructuredLikeReference and OutputsFormatSourceReference that warn when structured_like or format_source use unqualified references to nested inputs. Fixes https://github.com/galaxyproject/galaxy/issues/22429 --- lib/galaxy/tool_util/linters/output.py | 116 ++++++++++++++++++++++ lib/galaxy/tools/execute.py | 19 ++-- test/unit/tool_util/test_tool_linters.py | 120 ++++++++++++++++++++++- 3 files changed, 245 insertions(+), 10 deletions(-) diff --git a/lib/galaxy/tool_util/linters/output.py b/lib/galaxy/tool_util/linters/output.py index b1d48fd2a60..428eb6670bb 100644 --- a/lib/galaxy/tool_util/linters/output.py +++ b/lib/galaxy/tool_util/linters/output.py @@ -259,6 +259,122 @@ def _check_pattern(node): return True +class OutputsStructuredLikeReference(Linter): + @classmethod + def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): + tool_xml = getattr(tool_source, "xml_tree", None) + if not tool_xml: + return + param_qualified_paths = _collect_param_qualified_paths(tool_xml) + for output in tool_xml.findall("./outputs/collection[@structured_like]"): + structured_like = output.attrib["structured_like"] + _check_unqualified_reference( + lint_ctx, cls.name(), output, structured_like, "structured_like", param_qualified_paths + ) + + +class OutputsFormatSourceReference(Linter): + @classmethod + def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): + tool_xml = getattr(tool_source, "xml_tree", None) + if not tool_xml: + return + param_qualified_paths = _collect_param_qualified_paths(tool_xml) + output_names = { + o.attrib["name"] + for o in tool_xml.findall("./outputs/data[@name]") + tool_xml.findall("./outputs/collection[@name]") + } + for output in tool_xml.findall("./outputs/data[@format_source]") + tool_xml.findall( + "./outputs/collection[@format_source]" + ): + format_source = output.attrib["format_source"] + # format_source can reference other outputs, skip if it matches an output name + if format_source in output_names: + continue + _check_unqualified_reference( + lint_ctx, cls.name(), output, format_source, "format_source", param_qualified_paths + ) + + +def _check_unqualified_reference( + lint_ctx: "LintContext", + linter_name: str, + node: "Element", + ref_value: str, + attr_name: str, + param_qualified_paths: dict, +): + if "|" in ref_value: + return + # Check if it matches a top-level param directly + top_level_match = any(qp == ref_value for paths in param_qualified_paths.values() for qp in paths) + if top_level_match: + return + matches = param_qualified_paths.get(ref_value, []) + output_name = node.attrib.get("name", "unknown") + if len(matches) == 1: + lint_ctx.warn( + f"Output '{output_name}' uses unqualified {attr_name}='{ref_value}'. " + f"Use the qualified name '{matches[0]}'.", + linter=linter_name, + node=node, + ) + elif len(matches) > 1: + lint_ctx.warn( + f"Output '{output_name}' uses ambiguous unqualified {attr_name}='{ref_value}' " + f"matching multiple inputs: {', '.join(matches)}. Use a qualified name.", + linter=linter_name, + node=node, + ) + else: + lint_ctx.error( + f"Output '{output_name}' references {attr_name}='{ref_value}' " + f"which does not match any input parameter.", + linter=linter_name, + node=node, + ) + + +def _collect_param_qualified_paths(tool_xml: "ElementTree") -> dict: + """Build a map of unqualified param name -> list of qualified paths.""" + param_paths: dict = {} + parent_map = {child: parent for parent in tool_xml.iter() for child in parent} + for param in tool_xml.findall("./inputs//param"): + name = param.attrib.get("name") + if not name: + argument = param.attrib.get("argument") + if argument: + name = argument.lstrip("-").replace("-", "_") + if not name: + continue + qualified = _get_qualified_name(param, parent_map) + param_paths.setdefault(name, []).append(qualified) + return param_paths + + +def _get_qualified_name(param_elem: "Element", parent_map: dict) -> str: + """Walk up the XML tree to build the qualified path for a param element.""" + name = param_elem.attrib.get("name") + if not name: + argument = param_elem.attrib.get("argument") + if argument: + name = argument.lstrip("-").replace("-", "_") + parts = [name] if name else [] + current = param_elem + while True: + parent = parent_map.get(current) + if parent is None: + break + if parent.tag in ("conditional", "section"): + parent_name = parent.attrib.get("name") + if parent_name: + parts.insert(0, parent_name) + elif parent.tag in ("inputs", "tool"): + break + current = parent + return "|".join(parts) + + def _has_tool_provided_metadata(tool_xml: "ElementTree") -> bool: outputs = tool_xml.find("./outputs") if outputs is not None: diff --git a/lib/galaxy/tools/execute.py b/lib/galaxy/tools/execute.py index 65b56c2fe9a..cf229b18f06 100644 --- a/lib/galaxy/tools/execute.py +++ b/lib/galaxy/tools/execute.py @@ -481,25 +481,26 @@ class ExecutionTracker: return output_collection_name def sliced_input_collection_structure(self, input_name): - unqualified_recurse = Version(str(self.tool.profile)) < Version("18.09") and "|" not in input_name + unqualified_recurse = Version(str(self.tool.profile)) < Version("26.0") and "|" not in input_name - def find_collection(input_dict, input_name): + def find_collection(input_dict, input_name, path_prefix=""): for key, value in input_dict.items(): if key == input_name: - return value + return value, f"{path_prefix}{key}" if isinstance(value, dict): if "|" in input_name: prefix, rest_input_name = input_name.split("|", 1) if key == prefix: - return find_collection(value, rest_input_name) + return find_collection(value, rest_input_name, f"{path_prefix}{key}|") elif unqualified_recurse: # Looking for "input1" instead of "cond|input1" for instance. # See discussion on https://github.com/galaxyproject/galaxy/issues/6157. - unqualified_match = find_collection(value, input_name) + unqualified_match, qualified_path = find_collection(value, input_name, f"{path_prefix}{key}|") if unqualified_match: - return unqualified_match + return unqualified_match, qualified_path + return None, None - input_collection = find_collection(self.example_params, input_name) + input_collection, qualified_name = find_collection(self.example_params, input_name) if input_collection is None: raise Exception("Failed to find referenced collection in inputs.") @@ -512,10 +513,10 @@ class ExecutionTracker: ) ) subcollection_mapping_type = None - if self.is_implicit_input(input_name): + if self.is_implicit_input(qualified_name): collection_info = self.collection_info assert collection_info - subcollection_mapping_type = collection_info.subcollection_mapping_type(input_name) + subcollection_mapping_type = collection_info.subcollection_mapping_type(qualified_name) return get_structure( input_collection, collection_type_description, leaf_subcollection_type=subcollection_mapping_type diff --git a/test/unit/tool_util/test_tool_linters.py b/test/unit/tool_util/test_tool_linters.py index d14234b70f3..ddedaba79bc 100644 --- a/test/unit/tool_util/test_tool_linters.py +++ b/test/unit/tool_util/test_tool_linters.py @@ -711,6 +711,89 @@ OUTPUTS_FILTER_EXPRESSION = """ """ +OUTPUTS_STRUCTURED_LIKE_UNQUALIFIED = """ + + + + + + + + + + + + + + + +""" + +OUTPUTS_STRUCTURED_LIKE_QUALIFIED = """ + + + + + + + + + + + + + + + +""" + +OUTPUTS_STRUCTURED_LIKE_MISSING = """ + + + + + + + + +""" + +OUTPUTS_FORMAT_SOURCE_UNQUALIFIED = """ + + + + + + + + + + + + + + + +""" + +OUTPUTS_FORMAT_SOURCE_QUALIFIED = """ + + + + + + + + + + + + + + + +""" + # tool xml for repeats linter REPEATS = """ @@ -1881,6 +1964,41 @@ def test_outputs_filter_expression(lint_ctx): assert not lint_ctx.error_messages +def test_outputs_structured_like_unqualified(lint_ctx): + tool_source = get_xml_tool_source(OUTPUTS_STRUCTURED_LIKE_UNQUALIFIED) + run_lint_module(lint_ctx, output, tool_source) + assert "unqualified structured_like='input1'" in lint_ctx.warn_messages + assert "cond|input1" in lint_ctx.warn_messages + assert "structured_like" not in lint_ctx.error_messages + + +def test_outputs_structured_like_qualified(lint_ctx): + tool_source = get_xml_tool_source(OUTPUTS_STRUCTURED_LIKE_QUALIFIED) + run_lint_module(lint_ctx, output, tool_source) + assert "structured_like" not in lint_ctx.warn_messages + assert "structured_like" not in lint_ctx.error_messages + + +def test_outputs_structured_like_missing(lint_ctx): + tool_source = get_xml_tool_source(OUTPUTS_STRUCTURED_LIKE_MISSING) + run_lint_module(lint_ctx, output, tool_source) + assert "does not match any input" in lint_ctx.error_messages + + +def test_outputs_format_source_unqualified(lint_ctx): + tool_source = get_xml_tool_source(OUTPUTS_FORMAT_SOURCE_UNQUALIFIED) + run_lint_module(lint_ctx, output, tool_source) + assert "unqualified format_source='input1'" in lint_ctx.warn_messages + assert "cond|input1" in lint_ctx.warn_messages + + +def test_outputs_format_source_qualified(lint_ctx): + tool_source = get_xml_tool_source(OUTPUTS_FORMAT_SOURCE_QUALIFIED) + run_lint_module(lint_ctx, output, tool_source) + assert "format_source" not in lint_ctx.warn_messages + assert "format_source" not in lint_ctx.error_messages + + def test_stdio_default_for_default_profile(lint_ctx): tool_source = get_xml_tool_source(STDIO_DEFAULT_FOR_DEFAULT_PROFILE) run_lint_module(lint_ctx, stdio, tool_source) @@ -2425,7 +2543,7 @@ def test_skip_by_module(lint_ctx): def test_list_linters(): linter_names = Linter.list_listers() # make sure to add/remove a test for new/removed linters if this number changes - assert len(linter_names) == 143 + assert len(linter_names) == 145 assert "Linter" not in linter_names # make sure that linters from all modules are available for prefix in [ From 1422779d4be1255b24714e9e0fc35c7b9cbc86e0 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 9 Apr 2026 15:15:37 +0200 Subject: [PATCH 05/12] Fix job files API purged input check and update type annotation to match reality. Make JobToInputDatasetAssociation.dataset_id and .dataset Optional to reflect the actual database schema (nullable=True). Update type annotations in JobSearch and JobFilesAPIController to match. Fixes https://github.com/galaxyproject/galaxy/issues/22369 --- lib/galaxy/jobs/dynamic_tool_destination.py | 4 +- lib/galaxy/managers/jobs.py | 10 ++--- lib/galaxy/model/__init__.py | 8 +++- lib/galaxy/webapps/galaxy/api/job_files.py | 45 ++++++++++++--------- lib/galaxy/webapps/galaxy/api/jobs.py | 2 +- 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/lib/galaxy/jobs/dynamic_tool_destination.py b/lib/galaxy/jobs/dynamic_tool_destination.py index 9189758707d..9c694e784b7 100755 --- a/lib/galaxy/jobs/dynamic_tool_destination.py +++ b/lib/galaxy/jobs/dynamic_tool_destination.py @@ -1310,8 +1310,8 @@ def map_tool_to_destination( raise JobMappingException(e) # Get all inputs from tool and databases - inp_data: dict[str, DatasetInstance] = {da.name: da.dataset for da in job.input_datasets} - inp_data.update([(da.name, da.dataset) for da in job.input_library_datasets]) + inp_data: dict[str, DatasetInstance] = {da.name: da.dataset for da in job.input_datasets if da.dataset} + inp_data.update([(da.name, da.dataset) for da in job.input_library_datasets if da.dataset]) if config is not None and str(tool.old_id) in config["tools"]: if "rules" in config["tools"][str(tool.old_id)]: diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 7635fbc3c5d..39695ccfbbf 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -552,7 +552,7 @@ class JobSearch: # and the ids that have been used in the job that has already been run in `used_ids`. requested_ids = [] data_types = [] - used_ids: list[Label[int]] = [] + used_ids: list[Label[int] | Label[int | None]] = [] for k, input_list in input_data.items(): # k will be matched against the JobParameter.name column. This can be prefixed depending on whether # the input is in a repeat, or not (section and conditional) @@ -787,7 +787,7 @@ class JobSearch: self, stmt: "Select[tuple[int]]", data_conditions: list["ColumnElement[bool]"], - used_ids: list["Label[int]"], + used_ids: list["Label[int] | Label[int | None]"], k, v, identifier, @@ -854,7 +854,7 @@ class JobSearch: self, stmt: "Select[tuple[int]]", data_conditions: list["ColumnElement[bool]"], - used_ids: list["Label[int]"], + used_ids: list["Label[int] | Label[int | None]"], k, v, value_index: int, @@ -877,7 +877,7 @@ class JobSearch: self, stmt: "Select[tuple[int]]", data_conditions: list["ColumnElement[bool]"], - used_ids: list["Label[int]"], + used_ids: list["Label[int] | Label[int | None]"], k, v, user_id: int, @@ -1125,7 +1125,7 @@ class JobSearch: self, stmt: "Select[tuple[int]]", data_conditions: list["ColumnElement[bool]"], - used_ids: list["Label[int]"], + used_ids: list["Label[int] | Label[int | None]"], k, v, user_id: int, diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 8c62c5fb9b0..e021beb2b37 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -2658,11 +2658,15 @@ class JobToInputDatasetAssociation(Base, RepresentById): id: Mapped[int] = mapped_column(primary_key=True) job_id: Mapped[int] = mapped_column(ForeignKey("job.id"), index=True, nullable=True) - dataset_id: Mapped[int] = mapped_column(ForeignKey("history_dataset_association.id"), index=True, nullable=True) + dataset_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("history_dataset_association.id"), index=True, nullable=True + ) dataset_version: Mapped[Optional[int]] name: Mapped[str] = mapped_column(String(255), nullable=True) adapter: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONType, nullable=True) - dataset: Mapped["HistoryDatasetAssociation"] = relationship(lazy="joined", back_populates="dependent_jobs") + dataset: Mapped[Optional["HistoryDatasetAssociation"]] = relationship( + lazy="joined", back_populates="dependent_jobs" + ) job: Mapped["Job"] = relationship(back_populates="input_datasets") def __init__(self, name, dataset, adapter_json=None): diff --git a/lib/galaxy/webapps/galaxy/api/job_files.py b/lib/galaxy/webapps/galaxy/api/job_files.py index 31a4974376e..a125a05a59b 100644 --- a/lib/galaxy/webapps/galaxy/api/job_files.py +++ b/lib/galaxy/webapps/galaxy/api/job_files.py @@ -6,13 +6,19 @@ import logging import os import re import shutil +from typing import Union from galaxy import ( exceptions, util, ) from galaxy.managers.context import ProvidesAppContext -from galaxy.model import Job +from galaxy.model import ( + Job, + JobToOutputDatasetAssociation, + JobToOutputLibraryDatasetAssociation, +) +from galaxy.structured_app import MinimalManagerApp from galaxy.web import ( expose_api_anonymous_and_sessionless, expose_api_raw_anonymous_and_sessionless, @@ -35,7 +41,7 @@ class JobFilesAPIController(BaseGalaxyAPIController): """ @expose_api_raw_anonymous_and_sessionless - def index(self, trans: ProvidesAppContext, job_id, **kwargs): + def index(self, trans: ProvidesAppContext, job_id: str, **kwargs): """ GET /api/jobs/{job_id}/files @@ -74,7 +80,7 @@ class JobFilesAPIController(BaseGalaxyAPIController): raise @expose_api_anonymous_and_sessionless - def create(self, trans, job_id, payload, **kwargs): + def create(self, trans: ProvidesAppContext, job_id: str, payload, **kwargs): """ create( self, trans, job_id, payload, **kwargs ) * POST /api/jobs/{job_id}/files @@ -180,7 +186,7 @@ class JobFilesAPIController(BaseGalaxyAPIController): return None @expose_api_anonymous_and_sessionless - def tus_hooks(self, trans, **kwds): + def tus_hooks(self, trans: ProvidesAppContext, **kwds): """No-op but if hook specified the way we do for user upload it would hit this action. Exposed as PATCH /api/job_files/tus_hooks and documented in the docstring for @@ -188,7 +194,7 @@ class JobFilesAPIController(BaseGalaxyAPIController): """ pass - def __authorize_job_access(self, trans, encoded_job_id, **kwargs): + def __authorize_job_access(self, trans: ProvidesAppContext, encoded_job_id: str, **kwargs): for key in ["path", "job_key"]: if key not in kwargs: error_message = f"Job files action requires a valid '{key}'." @@ -201,12 +207,13 @@ class JobFilesAPIController(BaseGalaxyAPIController): # Verify job is active. Don't update the contents of complete jobs. job = trans.sa_session.get(Job, job_id) + assert job if job.state not in Job.non_ready_states: error_message = "Attempting to read or modify the files of a job that has already completed." raise exceptions.ItemAccessibilityException(error_message) return job - def __check_job_can_write_to_path(self, trans, job, path): + def __check_job_can_write_to_path(self, trans: ProvidesAppContext, job: Job, path: str): """Verify an idealized job runner should actually be able to write to the specified path - it must be a dataset output, a dataset "extra file", or a some place in the working directory of this job. @@ -219,23 +226,25 @@ class JobFilesAPIController(BaseGalaxyAPIController): if not in_work_dir and not self.__is_output_dataset_path(job, path): raise exceptions.ItemAccessibilityException("Job is not authorized to write to supplied path.") - def __is_output_dataset_path(self, job, path): + def __is_output_dataset_path(self, job: Job, path: str): """Check if is an output path for this job or a file in the an output's extra files path. """ - da_lists = [job.output_datasets, job.output_library_datasets] - for da_list in da_lists: - for job_dataset_association in da_list: - dataset = job_dataset_association.dataset - if not dataset: - continue - if os.path.abspath(dataset.get_file_name()) == os.path.abspath(path): - return True - elif util.in_directory(path, dataset.extra_files_path): - return True + all_output_assocs: list[Union[JobToOutputDatasetAssociation, JobToOutputLibraryDatasetAssociation]] = [ + *job.output_datasets, + *job.output_library_datasets, + ] + for assoc in all_output_assocs: + dataset = assoc.dataset + if not dataset: + continue + if os.path.abspath(dataset.get_file_name()) == os.path.abspath(path): + return True + elif util.in_directory(path, dataset.extra_files_path): + return True return False - def __in_working_directory(self, job, path, app): + def __in_working_directory(self, job: Job, path: str, app: MinimalManagerApp): working_directory = app.object_store.get_filename( job, base_dir="job_work", dir_only=True, extra_dir=str(job.id) ) diff --git a/lib/galaxy/webapps/galaxy/api/jobs.py b/lib/galaxy/webapps/galaxy/api/jobs.py index 4e53f05f1e4..73bc40c1e5b 100644 --- a/lib/galaxy/webapps/galaxy/api/jobs.py +++ b/lib/galaxy/webapps/galaxy/api/jobs.py @@ -339,7 +339,7 @@ class FastAPIJobs: for job_input_assoc in job.input_datasets: input_dataset_instance = job_input_assoc.dataset if input_dataset_instance is None: - continue # type: ignore[unreachable] # TODO if job_input_assoc.dataset is indeed never None, remove the above check + continue if input_dataset_instance.get_total_size() == 0: has_empty_inputs = True input_instance_id = input_dataset_instance.id From daa4d02d96965b1e1ceda89d40f4eed4cc27f4ae Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 9 Apr 2026 16:32:55 +0200 Subject: [PATCH 06/12] Set job.history to fix email PJA If we don't commit we only have job.history_id, and that fails when templating the email. Fixes https://github.com/galaxyproject/galaxy/issues/22438 --- lib/galaxy/tools/actions/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/galaxy/tools/actions/__init__.py b/lib/galaxy/tools/actions/__init__.py index 8cf73ace2fd..e35a14b4920 100644 --- a/lib/galaxy/tools/actions/__init__.py +++ b/lib/galaxy/tools/actions/__init__.py @@ -963,6 +963,7 @@ class DefaultToolAction(ToolAction): job.user = trans.user if history: job.history_id = model.cached_id(history) + job.history = history job.tool_id = tool.id try: # For backward compatibility, some tools may not have versions yet. From b1a81153c7ba7efc68b9af9002c524ff8b8f372e Mon Sep 17 00:00:00 2001 From: Nate Coraor Date: Thu, 9 Apr 2026 13:23:49 -0400 Subject: [PATCH 07/12] Change Tool Shed `/api/users/current` from 404 to 403 when not authenticated --- lib/tool_shed/webapp/api2/users.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/tool_shed/webapp/api2/users.py b/lib/tool_shed/webapp/api2/users.py index e7886d92f7f..cbb20205b6d 100644 --- a/lib/tool_shed/webapp/api2/users.py +++ b/lib/tool_shed/webapp/api2/users.py @@ -19,6 +19,7 @@ from sqlalchemy import ( import tool_shed.util.shed_util_common as suc from galaxy.exceptions import ( + AuthenticationRequired, InsufficientPermissionsException, ObjectNotFound, RequestParameterInvalidException, @@ -134,7 +135,7 @@ class FastAPIUsers: def current(self, trans: SessionRequestContext = DependsOnTrans) -> User: user = trans.user if not user: - raise ObjectNotFound() + raise AuthenticationRequired() return get_api_user(trans.app, user) From 4f30ae1e177a2a97c78b3dd70b707d2812cf759e Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 10 Apr 2026 12:25:15 +0200 Subject: [PATCH 08/12] Move job-level filters inside CTE in job cache query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job cache/deduplication query wraps the initial job-matching statement in a materialized CTE (job_ids_cte), then applies selective filters (tool_id, state, user_id, etc.) outside the CTE. This forces PostgreSQL to materialize the entire inner query — up to a full sequential scan of the job table — before any filtering can occur. The CTE itself is valuable: it acts as a materialization fence that prevents poor join ordering on the subsequent job_parameter joins, which matters when tool_id is not highly selective. The fix moves the job-level filters before the CTE so they participate in the materialized scan, while keeping the CTE boundary before the job_parameter joins. Before (filters outside CTE — full table scan): WITH job_ids_cte AS ( SELECT job.id AS job_id FROM job GROUP BY job.id ) SELECT job_ids_cte.job_id FROM job_ids_cte JOIN job ON job.id = job_ids_cte.job_id JOIN history ON job.history_id = history.id JOIN job_parameter AS p1 ON p1.job_id = job.id WHERE job.tool_id = '...' AND job.state IN ('ok') AND p1.name = '...' AND p1.value = '...' After (filters inside CTE — index scan): WITH job_ids_cte AS ( SELECT job.id AS job_id FROM job JOIN history ON job.history_id = history.id WHERE job.tool_id = '...' AND job.state IN ('ok') GROUP BY job.id ) SELECT job_ids_cte.job_id FROM job_ids_cte JOIN job ON job.id = job_ids_cte.job_id JOIN job_parameter AS p1 ON p1.job_id = job.id WHERE p1.name = '...' AND p1.value = '...' --- lib/galaxy/managers/jobs.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 7635fbc3c5d..ffdcafb125a 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -666,21 +666,18 @@ class JobSearch: history_id: Union[int, None], ) -> "Select[tuple[int]]": """Build subquery that selects a job with correct job parameters.""" - job_ids_materialized_cte = stmt.cte("job_ids_cte") - outer_select_columns = [job_ids_materialized_cte.c[col.name] for col in stmt.selected_columns] - stmt = select(*outer_select_columns).select_from(job_ids_materialized_cte) - stmt = ( - stmt.join(model.Job, model.Job.id == job_ids_materialized_cte.c.job_id) - .join(model.History, model.Job.history_id == model.History.id) - .where( - and_( - model.Job.tool_id == tool_id, - or_( - model.Job.user_id == user_id, - model.History.published == true(), - ), - model.Job.copied_from_job_id.is_(None), # Always pick original job - ) + # Apply job-level filters BEFORE the CTE so they are included in the + # materialized result. This lets PostgreSQL use selective indexes + # (e.g. on tool_id) inside the CTE instead of scanning the entire job + # table first and filtering afterwards. + stmt = stmt.join(model.History, model.Job.history_id == model.History.id).where( + and_( + model.Job.tool_id == tool_id, + or_( + model.Job.user_id == user_id, + model.History.published == true(), + ), + model.Job.copied_from_job_id.is_(None), # Always pick original job ) ) if tool_version: @@ -706,6 +703,14 @@ class JobSearch: job_states = {Job.states.SKIPPED} stmt = stmt.where(Job.state.in_(job_states)) + # Wrap in a CTE to materialize the filtered job IDs. This prevents + # the planner from choosing poor join orders for the subsequent + # job_parameter joins (important when tool_id is not highly selective). + job_ids_materialized_cte = stmt.cte("job_ids_cte") + outer_select_columns = [job_ids_materialized_cte.c[col.name] for col in stmt.selected_columns] + stmt = select(*outer_select_columns).select_from(job_ids_materialized_cte) + stmt = stmt.join(model.Job, model.Job.id == job_ids_materialized_cte.c.job_id) + for k, v in wildcard_param_dump.items(): if v == {"__class__": "RuntimeValue"}: # TODO: verify this is always None. e.g. run with runtime input input From c160194ed90a8dfa465731beb1b000b3eded011b Mon Sep 17 00:00:00 2001 From: Nicola Soranzo Date: Thu, 9 Apr 2026 18:57:48 +0100 Subject: [PATCH 09/12] Test and publish galaxy-test-selenium package --- lib/galaxy_test/selenium/framework.py | 2 +- packages/packages_by_dep_dag.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/galaxy_test/selenium/framework.py b/lib/galaxy_test/selenium/framework.py index b8399ed2ee2..824f1d95349 100644 --- a/lib/galaxy_test/selenium/framework.py +++ b/lib/galaxy_test/selenium/framework.py @@ -69,7 +69,7 @@ from galaxy_test.base.testcase import FunctionalTestCase try: from galaxy_test.driver.driver_util import GalaxyTestDriver except ImportError: - GalaxyTestDriver = None # type: ignore[misc,assignment] + GalaxyTestDriver = None # type: ignore[assignment, misc, unused-ignore] def _load_config_file() -> None: diff --git a/packages/packages_by_dep_dag.txt b/packages/packages_by_dep_dag.txt index c7b058853a2..9974ae3e0ce 100644 --- a/packages/packages_by_dep_dag.txt +++ b/packages/packages_by_dep_dag.txt @@ -20,6 +20,7 @@ tours auth job_execution test_api +test_selenium web_framework web_stack From 1e9758cd1a5ebbf5994e9de0e14009317515f091 Mon Sep 17 00:00:00 2001 From: Nicola Soranzo Date: Sun, 12 Apr 2026 19:08:35 +0100 Subject: [PATCH 10/12] Use a separate virtualenv to test each package --- packages/test.sh | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/test.sh b/packages/test.sh index fa9114e7ee6..6a33c64584c 100755 --- a/packages/test.sh +++ b/packages/test.sh @@ -36,28 +36,16 @@ fi # Change to packages directory. cd "$(dirname "$0")" -# Use a throw-away virtualenv TEST_PYTHON=${TEST_PYTHON:-"python3"} -TEST_ENV_DIR=${TEST_ENV_DIR:-$(mktemp -d -t gxpkgtestenvXXXXXX)} if command -v uv >/dev/null; then - uv venv --python "$TEST_PYTHON" "$TEST_ENV_DIR" + VENV_CMD="uv venv --python $TEST_PYTHON" PIP_CMD="$(command -v uv) pip" else - "$TEST_PYTHON" -m venv "$TEST_ENV_DIR" + VENV_CMD="$TEST_PYTHON -m venv" PIP_CMD='python -m pip' fi -# shellcheck disable=SC1091 -. "${TEST_ENV_DIR}/bin/activate" -if [ "${PIP_CMD}" = 'python -m pip' ]; then - ${PIP_CMD} install --upgrade pip setuptools wheel -fi -if [ $FOR_PULSAR -eq 0 ]; then - # shellcheck disable=SC2086 - word splitting is intentional for PIP_EXTRA_ARGS - ${PIP_CMD} install ${PIP_EXTRA_ARGS} -r ../lib/galaxy/dependencies/pinned-typecheck-requirements.txt -fi - # Ensure ordered by dependency DAG while read -r package_dir || [ -n "$package_dir" ]; do # https://stackoverflow.com/questions/12916352/shell-script-read-missing-last-line # Ignore empty lines @@ -77,8 +65,17 @@ while read -r package_dir || [ -n "$package_dir" ]; do # https://stackoverflow. cd "$package_dir" + # Use a throw-away virtualenv + TEST_ENV_DIR=$(mktemp -d -t gxpkgtestenvXXXXXX) + ${VENV_CMD} "$TEST_ENV_DIR" + # shellcheck disable=SC1091 + . "${TEST_ENV_DIR}/bin/activate" + if [ "${PIP_CMD}" = 'python -m pip' ]; then + ${PIP_CMD} install --upgrade pip setuptools wheel + fi + # Install extras (if needed) - # shellcheck disable=SC2086 - word splitting is intentional for PIP_EXTRA_ARGS + # shellcheck disable=SC2086 # word splitting is intentional for PIP_EXTRA_ARGS if [ "$package_dir" = "util" ]; then ${PIP_CMD} install ${PIP_EXTRA_ARGS} '.[image-util,template,jstree,config-template,test]' elif [ "$package_dir" = "tool_util" ]; then @@ -97,6 +94,8 @@ while read -r package_dir || [ -n "$package_dir" ]; do # https://stackoverflow. # Ignore exit code 5 (no tests ran) pytest "${marker_args[@]}" . || test $? -eq 5 if [ $FOR_PULSAR -eq 0 ]; then + # shellcheck disable=SC2086 # word splitting is intentional for PIP_EXTRA_ARGS + ${PIP_CMD} install ${PIP_EXTRA_ARGS} -r ../../lib/galaxy/dependencies/pinned-typecheck-requirements.txt # make mypy uses uv now and so this legacy code should just run mypy # directly to use the venv we have already activated mypy . From 59b0074b1666e3a19b3de6814e7d202eabe450b1 Mon Sep 17 00:00:00 2001 From: Nicola Soranzo Date: Sun, 12 Apr 2026 19:50:19 +0100 Subject: [PATCH 11/12] Fix missing package dependencies --- lib/galaxy/tool_util/cwl/cwltool_deps.py | 10 ++++++++-- packages/app/setup.cfg | 4 +++- packages/data/setup.cfg | 1 + packages/files/setup.cfg | 1 + packages/schema/setup.cfg | 1 + packages/tool_shed/setup.cfg | 3 ++- packages/tool_util/setup.cfg | 3 ++- packages/tours/setup.cfg | 6 ++++++ packages/util/setup.cfg | 1 + test/unit/data/security/test_validate_user_input.py | 4 ++-- test/unit/selenium/test_has_driver.py | 2 +- 11 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/tool_util/cwl/cwltool_deps.py b/lib/galaxy/tool_util/cwl/cwltool_deps.py index a248152da71..43294205667 100644 --- a/lib/galaxy/tool_util/cwl/cwltool_deps.py +++ b/lib/galaxy/tool_util/cwl/cwltool_deps.py @@ -12,6 +12,14 @@ warnings.filterwarnings("ignore", message=r"[\n.]DEPRECATION: Python 2", module= from galaxy.util import requests +try: + from cwl_utils.types import CWLObjectType +except ImportError: + try: + from cwltool.utils import CWLObjectType # type: ignore[attr-defined, unused-ignore] + except ImportError: + CWLObjectType = object # type: ignore[assignment, misc] + try: from cwltool import ( main, @@ -67,13 +75,11 @@ except ImportError: try: from cwltool.utils import ( - CWLObjectType, JobsType, normalizeFilesDirs, visit_class, ) except ImportError: - CWLObjectType = object # type: ignore[assignment, misc] JobsType = object # type: ignore[misc, unused-ignore] visit_class = None # type: ignore[assignment] normalizeFilesDirs = None # type: ignore[assignment] diff --git a/packages/app/setup.cfg b/packages/app/setup.cfg index 408358ca758..4e710ef1263 100644 --- a/packages/app/setup.cfg +++ b/packages/app/setup.cfg @@ -41,7 +41,7 @@ install_requires = galaxy-tool-util[cwl,edam] galaxy-tool-shed-schema galaxy-tours - galaxy-util[image-util] + galaxy-util[image-util,jstree] galaxy-web-framework galaxy-web-stack Beaker @@ -91,6 +91,8 @@ test = pykwalify pytest pytest-asyncio + pytest-mock + responses testfixtures [options.entry_points] diff --git a/packages/data/setup.cfg b/packages/data/setup.cfg index 020021ede0b..1cde2b1c811 100644 --- a/packages/data/setup.cfg +++ b/packages/data/setup.cfg @@ -69,6 +69,7 @@ python_requires = >=3.10 [options.extras_require] test = + galaxy-config pytest roc-validator diff --git a/packages/files/setup.cfg b/packages/files/setup.cfg index 0d47832e1d6..eafd662972c 100644 --- a/packages/files/setup.cfg +++ b/packages/files/setup.cfg @@ -45,6 +45,7 @@ test = galaxy-test-base pytest gcsfs + responses s3fs>=2023.1.0 [options.entry_points] diff --git a/packages/schema/setup.cfg b/packages/schema/setup.cfg index b9e80d239d6..c19c5591319 100644 --- a/packages/schema/setup.cfg +++ b/packages/schema/setup.cfg @@ -31,6 +31,7 @@ version = 26.0.1.dev1 [options] include_package_data = True install_requires = + galaxy-tool-util-models galaxy-util pydantic[email]>=2.7.4 packages = find: diff --git a/packages/tool_shed/setup.cfg b/packages/tool_shed/setup.cfg index 0d774c50d1a..a1ed196532d 100644 --- a/packages/tool_shed/setup.cfg +++ b/packages/tool_shed/setup.cfg @@ -59,7 +59,8 @@ packages = find: python_requires = >=3.10 [options.extras_require] -test = +test = + galaxy-test-base pytest [options.packages.find] diff --git a/packages/tool_util/setup.cfg b/packages/tool_util/setup.cfg index 457b8e31ebb..49202b0a73b 100644 --- a/packages/tool_util/setup.cfg +++ b/packages/tool_util/setup.cfg @@ -34,7 +34,7 @@ version = 26.0.1.dev1 include_package_data = True install_requires = galaxy-tool-util-models - galaxy-util[image-util]>=22.1 + galaxy-util[template,image-util]>=22.1 conda-package-streaming lxml!=4.2.2 MarkupSafe @@ -64,6 +64,7 @@ console_scripts = [options.extras_require] cwl = + cwl-utils cwltool>=3.1.20230624081518 mulled = jinja2 diff --git a/packages/tours/setup.cfg b/packages/tours/setup.cfg index 2fefb67f627..d111a843088 100644 --- a/packages/tours/setup.cfg +++ b/packages/tours/setup.cfg @@ -32,6 +32,8 @@ version = 26.0.1.dev1 include_package_data = True install_requires = galaxy-navigation + galaxy-schema + galaxy-util pydantic>=2.7.4 PyYAML packages = find: @@ -41,6 +43,10 @@ python_requires = >=3.10 console_scripts = gx-validate-tours = galaxy.tours.validate:main +[options.extras_require] +test = + pytest + [options.packages.find] exclude = tests* diff --git a/packages/util/setup.cfg b/packages/util/setup.cfg index 5e0c541adc5..c73794a5217 100644 --- a/packages/util/setup.cfg +++ b/packages/util/setup.cfg @@ -56,6 +56,7 @@ template = fissix;python_version>='3.13' future>=1.0.0 config-template = + galaxy-tool-util-models Jinja2 pydantic>=2.7.4 test = diff --git a/test/unit/data/security/test_validate_user_input.py b/test/unit/data/security/test_validate_user_input.py index 4f9837500f8..3b46595bcd7 100644 --- a/test/unit/data/security/test_validate_user_input.py +++ b/test/unit/data/security/test_validate_user_input.py @@ -80,7 +80,7 @@ class TestIsEmailBanned: assert is_email_banned("ab+bar@gmail.com", "_", rules) assert not is_email_banned("ab-bar@gmail.com", "_", rules) # different sub-addressing delimiter - def test_no_canonical_rules(self, monkeypatch, appconfig): + def test_no_canonical_rules(self, monkeypatch): """No rules loaded.""" monkeypatch.setattr(validate_user_input, "_read_email_ban_list", lambda a: self.mock_ban_list) @@ -97,7 +97,7 @@ class TestIsEmailBanned: assert not is_email_banned("a.b@gmail.com", "_", rules) assert not is_email_banned("ab+bar@gmail.com", "_", rules) - def test_custom_canonical_rules(self, monkeypatch, appconfig): + def test_custom_canonical_rules(self, monkeypatch): """No rules loaded.""" monkeypatch.setattr(validate_user_input, "_read_email_ban_list", lambda a: self.mock_ban_list) diff --git a/test/unit/selenium/test_has_driver.py b/test/unit/selenium/test_has_driver.py index f157797bc38..f5d427aca50 100644 --- a/test/unit/selenium/test_has_driver.py +++ b/test/unit/selenium/test_has_driver.py @@ -888,7 +888,7 @@ class TestUtilityMethods: def test_prepend_timeout_message(self, has_driver_instance, request): """Test prepending message to timeout exception.""" backend = request.node.callspec.params.get("has_driver_instance") - if backend == "selenium": + if backend in ("selenium", "proxy-selenium"): original_selenium_exc = SeleniumTimeoutException(msg="original message") new_selenium_exception = has_driver_instance.prepend_timeout_message(original_selenium_exc, "New prefix:") assert "New prefix:" in new_selenium_exception.msg From d9d9632294e45f1785d15e6050171614504dfbef Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:42:43 +0200 Subject: [PATCH 12/12] Fix filtering of Data Source Tools in Upload Activity The previous filter was matching "get" and "data" individually which included other unwanted sections. --- .../components/Panels/Upload/methods/DataSourceToolsUpload.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/Panels/Upload/methods/DataSourceToolsUpload.vue b/client/src/components/Panels/Upload/methods/DataSourceToolsUpload.vue index bee6ca1ab33..35fce3f3c07 100644 --- a/client/src/components/Panels/Upload/methods/DataSourceToolsUpload.vue +++ b/client/src/components/Panels/Upload/methods/DataSourceToolsUpload.vue @@ -10,7 +10,7 @@ import ToolsListTable from "@/components/ToolsList/ToolsListTable.vue"; const toolStore = useToolStore(); const { loading } = storeToRefs(toolStore); -const whooshQuery = computed(() => createWhooshQuery({ section: "Get Data" })); +const whooshQuery = computed(() => createWhooshQuery({ section: '"Get Data"' })); const toolsInGetDataSection = computed(() => Object.values(toolStore.getToolsById(whooshQuery.value)));