diff --git a/lib/galaxy/tool_util/models.py b/lib/galaxy/tool_util/models.py
index deafef1a5243..84e5c2b029c2 100644
--- a/lib/galaxy/tool_util/models.py
+++ b/lib/galaxy/tool_util/models.py
@@ -5,11 +5,19 @@
"""
from typing import (
+ Any,
+ Dict,
List,
Optional,
+ Union,
)
-from pydantic import BaseModel
+from pydantic import (
+ AnyUrl,
+ BaseModel,
+ ConfigDict,
+ RootModel,
+)
from .parameters import (
input_models_for_tool_source,
@@ -25,6 +33,7 @@
from_tool_source,
ToolOutput,
)
+from .verify.assertion_models import assertions
class ParsedTool(BaseModel):
@@ -73,3 +82,68 @@ def parse_tool(tool_source: ToolSource) -> ParsedTool:
xrefs=xrefs,
help=help,
)
+
+
+class StrictModel(BaseModel):
+
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+
+
+class BaseTestOutputModel(StrictModel):
+ file: Optional[str] = None
+ path: Optional[str] = None
+ location: Optional[AnyUrl] = None
+ ftype: Optional[str] = None
+ sorted: Optional[bool] = None
+ compare: Optional[str] = None
+ checksum: Optional[str] = None
+ metadata: Optional[Dict[str, Any]] = None
+ asserts: Optional[assertions] = None
+ compare: Optional[str] = None # to do make more specific
+ delta: Optional[int] = None
+ delta_frac: Optional[float] = None
+ lines_diff: Optional[int] = None
+ decompress: Optional[bool] = None
+
+
+class TestDataOutputAssertions(BaseTestOutputModel):
+ pass
+
+
+class TestCollectionCollectionElementAssertions(StrictModel):
+ elements: Optional[Dict[str, "TestCollectionElementAssertion"]] = None
+ element_tests: Optional[Dict[str, "TestCollectionElementAssertion"]] = None
+
+
+class TestCollectionDatasetElementAssertions(BaseTestOutputModel):
+ pass
+
+
+TestCollectionElementAssertion = Union[
+ TestCollectionDatasetElementAssertions, TestCollectionCollectionElementAssertions
+]
+TestCollectionCollectionElementAssertions.model_rebuild()
+
+
+class CollectionAttributes(StrictModel):
+ collection_type: Optional[str] = None
+
+
+class TestCollectionOutputAssertions(StrictModel):
+ elements: Optional[Dict[str, TestCollectionElementAssertion]] = None
+ element_tests: Optional[Dict[str, "TestCollectionElementAssertion"]] = None
+ attributes: Optional[CollectionAttributes] = None
+
+
+TestOutputAssertions = Union[TestCollectionOutputAssertions, TestDataOutputAssertions]
+
+
+class TestJob(StrictModel):
+ doc: Optional[str]
+ job: Dict[str, Any]
+ outputs: Dict[str, TestOutputAssertions]
+
+
+Tests = RootModel[List[TestJob]]
diff --git a/lib/galaxy/tool_util/verify/assertion_models.py b/lib/galaxy/tool_util/verify/assertion_models.py
index 5f21e488e52b..eb4429170655 100644
--- a/lib/galaxy/tool_util/verify/assertion_models.py
+++ b/lib/galaxy/tool_util/verify/assertion_models.py
@@ -8,6 +8,7 @@
BeforeValidator,
ConfigDict,
Field,
+ model_validator,
RootModel,
StrictFloat,
StrictInt,
diff --git a/lib/galaxy/tool_util/verify/asserts/size.py b/lib/galaxy/tool_util/verify/asserts/size.py
index e4b3e8a6ef1f..0e3eebe06f86 100644
--- a/lib/galaxy/tool_util/verify/asserts/size.py
+++ b/lib/galaxy/tool_util/verify/asserts/size.py
@@ -14,9 +14,7 @@
def assert_has_size(
output_bytes: OutputBytes,
- value: Annotated[
- OptionalXmlInt, AssertionParameter("Deprecated alias for `size`", xml_type="Bytes", deprecated=True)
- ] = None,
+ value: Annotated[OptionalXmlInt, AssertionParameter("Deprecated alias for `size`", xml_type="Bytes")] = None,
size: Annotated[
OptionalXmlInt,
AssertionParameter(
diff --git a/lib/galaxy/tool_util/verify/codegen.py b/lib/galaxy/tool_util/verify/codegen.py
index 7219d40b6dcb..2e93d29ccbf0 100644
--- a/lib/galaxy/tool_util/verify/codegen.py
+++ b/lib/galaxy/tool_util/verify/codegen.py
@@ -55,6 +55,7 @@
BeforeValidator,
ConfigDict,
Field,
+ model_validator,
RootModel,
StrictFloat,
StrictInt,
@@ -113,9 +114,8 @@ def check_non_negative_if_int(v: typing.Any):
{{assertion.name}}_{{ parameter.name }}_description = '''{{ parameter.description }}'''
{% endfor %}
-class {{assertion.name}}_model(AssertionModel):
- r\"\"\"{{ assertion.docstring }}\"\"\"
- that: Literal["{{assertion.name}}"] = "{{assertion.name}}"
+class base_{{assertion.name}}_model(AssertionModel):
+ '''base model for {{assertion.name}} describing attributes.'''
{% for parameter in assertion.parameters %}
{% if not parameter.is_deprecated %}
{{ parameter.name }}: {{ parameter.type_str }} = Field(
@@ -124,21 +124,52 @@ class {{assertion.name}}_model(AssertionModel):
)
{% endif %}
{% endfor %}
+{% if assertion.children in ["required", "allowed"] %}
+ children: typing.Optional["assertion_list"] = None
+ asserts: typing.Optional["assertion_list"] = None
+
{% if assertion.children == "required" %}
- children: "assertion_list"
+ @model_validator(mode='before')
+ @classmethod
+ def validate_children(self, data: typing.Any):
+ if isinstance(data, dict) and 'children' not in data and 'asserts' not in data:
+ raise ValueError("At least one of 'children' or 'asserts' must be specified for this assertion type.")
+ return data
{% endif %}
-{% if assertion.children == "allowed" %}
- children: typing.Optional["assertion_list"] = None
{% endif %}
+
+
+class {{assertion.name}}_model(base_{{assertion.name}}_model):
+ r\"\"\"{{ assertion.docstring }}\"\"\"
+ that: Literal["{{assertion.name}}"] = "{{assertion.name}}"
+
+class {{assertion.name}}_model_nested(AssertionModel):
+ r\"\"\"Nested version of this assertion model.\"\"\"
+ {{assertion.name}}: base_{{assertion.name}}_model
{% endfor %}
-any_assertion_model = Annotated[typing.Union[
+any_assertion_model_flat = Annotated[typing.Union[
{% for assertion in assertions %}
{{assertion.name}}_model,
{% endfor %}
], Field(discriminator="that")]
-assertion_list = RootModel[typing.List[any_assertion_model]]
+any_assertion_model_nested = typing.Union[
+{% for assertion in assertions %}
+ {{assertion.name}}_model_nested,
+{% endfor %}
+]
+
+assertion_list = RootModel[typing.List[typing.Union[any_assertion_model_flat, any_assertion_model_nested]]]
+
+
+class assertion_dict(AssertionModel):
+{% for assertion in assertions %}
+ {{assertion.name}}: typing.Optional[base_{{assertion.name}}_model] = None
+{% endfor %}
+
+
+assertions = typing.Union[assertion_list, assertion_dict]
"""
diff --git a/lib/galaxy/workflow/scheduling_manager.py b/lib/galaxy/workflow/scheduling_manager.py
index 3868e24c13a9..8d31130bad21 100644
--- a/lib/galaxy/workflow/scheduling_manager.py
+++ b/lib/galaxy/workflow/scheduling_manager.py
@@ -329,7 +329,6 @@ def __schedule(self, workflow_scheduler_id, workflow_scheduler):
def __attempt_schedule(self, invocation_id, workflow_scheduler):
with self.app.model.context() as session:
workflow_invocation = session.get(model.WorkflowInvocation, invocation_id)
-
try:
if workflow_invocation.state == workflow_invocation.states.CANCELLING:
workflow_invocation.cancel_invocation_steps()
diff --git a/lib/galaxy_test/workflow/flatten_collection.gxwf-tests.yml b/lib/galaxy_test/workflow/flatten_collection.gxwf-tests.yml
index 1a2b5c65b596..bfd0a6a02435 100644
--- a/lib/galaxy_test/workflow/flatten_collection.gxwf-tests.yml
+++ b/lib/galaxy_test/workflow/flatten_collection.gxwf-tests.yml
@@ -3,6 +3,7 @@
job: {}
outputs:
out:
+ attributes: {collection_type: 'list'}
elements:
'oe1-ie1':
asserts:
diff --git a/test/functional/tools/sample_tool_conf.xml b/test/functional/tools/sample_tool_conf.xml
index 8e22ee39eace..ebc4fb3cbd59 100644
--- a/test/functional/tools/sample_tool_conf.xml
+++ b/test/functional/tools/sample_tool_conf.xml
@@ -139,6 +139,7 @@
+
diff --git a/test/unit/tool_util/test_test_format_model.py b/test/unit/tool_util/test_test_format_model.py
new file mode 100644
index 000000000000..49f29ab27875
--- /dev/null
+++ b/test/unit/tool_util/test_test_format_model.py
@@ -0,0 +1,42 @@
+import os
+from pathlib import Path
+
+import yaml
+
+from galaxy.tool_util.models import Tests
+from galaxy.util import galaxy_directory
+from galaxy.util.unittest_utils import skip_unless_environ
+
+TEST_WORKFLOW_DIRECTORY = os.path.join(galaxy_directory(), "lib", "galaxy_test", "workflow")
+USING_UNVERIFIED_SYNTAX = [
+ "Assembly-Hifi-Trio-phasing-VGP5-tests.yml",
+ "Assembly-Hifi-HiC-phasing-VGP4-tests.yml",
+ "Assembly-Hifi-only-VGP3-tests.yml",
+]
+
+
+def test_validate_workflow_tests():
+ path = Path(TEST_WORKFLOW_DIRECTORY)
+ test_files = path.glob("*.gxwf-tests.yml")
+ for test_file in test_files:
+ with open(test_file) as f:
+ json = yaml.safe_load(f)
+ Tests.model_validate(json)
+
+
+@skip_unless_environ("GALAXY_TEST_IWC_DIRECTORY")
+def test_iwc_directory():
+ path = Path(os.environ["GALAXY_TEST_IWC_DIRECTORY"])
+ test_files = path.glob("workflows/**/*-test*.yml")
+ print(f"test_files:: {test_files}")
+ for test_file in test_files:
+ print(test_file)
+ skip_file = False
+ for unverified in USING_UNVERIFIED_SYNTAX:
+ if str(test_file).endswith(unverified):
+ skip_file = True
+ if skip_file:
+ continue
+ with open(test_file) as f:
+ json = yaml.safe_load(f)
+ Tests.model_validate(json)