diff --git a/lib/galaxy/tool_util/linters/tests.py b/lib/galaxy/tool_util/linters/tests.py
index cbaa723f3e0..13070d55cfd 100644
--- a/lib/galaxy/tool_util/linters/tests.py
+++ b/lib/galaxy/tool_util/linters/tests.py
@@ -426,9 +426,14 @@ class TestsOutputCheckDiscovered(Linter):
discover_datasets = corresponding_output.find(".//discover_datasets")
if discover_datasets is None:
continue
- if "count" not in output.attrib and output.find("./discovered_dataset") is None:
+ if (
+ "count" not in output.attrib
+ and "min" not in output.attrib
+ and "max" not in output.attrib
+ and output.find("./discovered_dataset") is None
+ ):
lint_ctx.error(
- f"Test {test_idx}: test output '{name}' must have a 'count' attribute and/or 'discovered_dataset' children",
+ f"Test {test_idx}: test output '{name}' must have a 'count/min/max' attribute and/or 'discovered_dataset' children",
linter=cls.name(),
node=output,
)
@@ -456,12 +461,16 @@ class TestsOutputCollectionCheckDiscovered(Linter):
continue
# - test/collection to outputs/output_collection
corresponding_output = output_data_or_collection[name]
- discover_datasets = corresponding_output.find(".//discover_datasets")
- if discover_datasets is None:
+ if corresponding_output.find(".//discover_datasets") is None:
continue
- if "count" not in output.attrib and output.find("./element") is None:
+ if (
+ "count" not in output.attrib
+ and "min" not in output.attrib
+ and "max" not in output.attrib
+ and output.find("./element") is None
+ ):
lint_ctx.error(
- f"Test {test_idx}: test collection '{name}' must have a 'count' attribute or 'element' children",
+ f"Test {test_idx}: test collection '{name}' must have a 'count/min/max' attribute or 'element' children",
linter=cls.name(),
node=output,
)
@@ -491,10 +500,10 @@ class TestsOutputCollectionCheckDiscoveredNested(Linter):
continue
if corresponding_output.get("type", "") in ["list:list", "list:paired"]:
nested_elements = output.find("./element/element")
- element_with_count = output.find("./element[@count]")
- if nested_elements is None and element_with_count is None:
+ elements_with_count = output.xpath("./element[@count or @min or @max]")
+ if nested_elements is None and not elements_with_count:
lint_ctx.error(
- f"Test {test_idx}: test collection '{name}' must contain nested 'element' tags and/or element children with a 'count' attribute",
+ f"Test {test_idx}: test collection '{name}' must contain nested 'element' tags and/or element children with a 'count/min/max' attribute",
linter=cls.name(),
node=output,
)
diff --git a/lib/galaxy/tool_util/parser/interface.py b/lib/galaxy/tool_util/parser/interface.py
index 7ac2d7a2ab1..5354d6fe10f 100644
--- a/lib/galaxy/tool_util/parser/interface.py
+++ b/lib/galaxy/tool_util/parser/interface.py
@@ -89,6 +89,8 @@ class ToolSourceTestOutputAttributes(TypedDict):
metric: str
pin_labels: Optional[Any]
count: Optional[int]
+ min: Optional[int]
+ max: Optional[int]
metadata: Dict[str, Any]
md5: Optional[str]
checksum: Optional[str]
@@ -865,6 +867,10 @@ class TestCollectionOutputDef:
else:
count = attrib.get("count")
self.count = int(count) if count is not None else None
+ min = attrib.get("min")
+ self.min = int(min) if min is not None else None
+ max = attrib.get("max")
+ self.max = int(max) if max is not None else None
self.attrib = attrib
self.element_tests = element_tests
diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py
index 7642d2139e8..702cc82aa41 100644
--- a/lib/galaxy/tool_util/parser/xml.py
+++ b/lib/galaxy/tool_util/parser/xml.py
@@ -922,6 +922,17 @@ def __parse_test_attributes(
count = int(attrib.pop("count"))
except KeyError:
pass
+ min: Optional[int] = None
+ try:
+ min = int(attrib.pop("min"))
+ except KeyError:
+ pass
+ max: Optional[int] = None
+ try:
+ max = int(attrib.pop("max"))
+ except KeyError:
+ pass
+ has_count_assertions = count is not None or min is not None or max is not None
extra_files: List[Dict[str, Any]] = []
ftype: Optional[str] = None
if "ftype" in attrib:
@@ -949,7 +960,7 @@ def __parse_test_attributes(
has_checksum = md5sum or checksum
has_nested_tests = extra_files or element_tests or primary_datasets
has_object = value_object is not VALUE_OBJECT_UNSET
- if not (assert_list or file or metadata or has_checksum or has_nested_tests or has_object):
+ if not (assert_list or file or metadata or has_checksum or has_nested_tests or has_object or has_count_assertions):
raise Exception(
"Test output defines nothing to check (e.g. must have a 'file' check against, assertions to check, metadata or checksum tests, etc...)"
)
@@ -966,6 +977,8 @@ def __parse_test_attributes(
pin_labels=pin_labels,
location=location,
count=count,
+ min=min,
+ max=max,
metadata=metadata,
md5=md5sum,
checksum=checksum,
diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py
index 57bfc4af316..c5c5a9f6a6b 100644
--- a/lib/galaxy/tool_util/verify/interactor.py
+++ b/lib/galaxy/tool_util/verify/interactor.py
@@ -332,6 +332,8 @@ class GalaxyInteractorApi:
attributes = output_testdef.attributes
name = output_testdef.name
expected_count = attributes.get("count")
+ min_count = attributes.get("min")
+ max_count = attributes.get("max")
hid = self.__output_id(output_data)
# TODO: Twill version verifies dataset is 'ok' in here.
try:
@@ -357,6 +359,14 @@ class GalaxyInteractorApi:
raise AssertionError(
f"Output '{name}': expected to have '{expected_count}' datasets, but it had '{found_datasets}'"
)
+ if min_count is not None and min_count > found_datasets:
+ raise AssertionError(
+ f"Output '{name}': expected to have at least '{min_count}' datasets, but it had '{found_datasets}'"
+ )
+ if max_count is not None and max_count < found_datasets:
+ raise AssertionError(
+ f"Output '{name}': expected to have at most '{max_count}' datasets, but it had '{found_datasets}'"
+ )
for designation, (primary_outfile, primary_attributes) in primary_datasets.items():
primary_output = None
for output in outputs:
@@ -1335,12 +1345,16 @@ def verify_collection(output_collection_def, data_collection, verify_dataset):
message = f"Output collection '{name}': expected to be of type [{expected_collection_type}], was of type [{collection_type}]."
raise AssertionError(message)
- expected_element_count = output_collection_def.count
- if expected_element_count is not None:
- actual_element_count = len(data_collection["elements"])
- if expected_element_count != actual_element_count:
- message = f"Output collection '{name}': expected to have {expected_element_count} elements, but it had {actual_element_count}."
- raise AssertionError(message)
+ actual_element_count = len(data_collection["elements"])
+ if output_collection_def.count and output_collection_def.count != actual_element_count:
+ message = f"Output collection '{name}': expected to have {output_collection_def.count} elements, but it had {actual_element_count}."
+ raise AssertionError(message)
+ if output_collection_def.min and output_collection_def.min > actual_element_count:
+ message = f"Output collection '{name}': expected to have at least {output_collection_def.min} elements, but it had {actual_element_count}."
+ raise AssertionError(message)
+ if output_collection_def.max and output_collection_def.max < actual_element_count:
+ message = f"Output collection '{name}': expected to have at most {output_collection_def.max} elements, but it had {actual_element_count}."
+ raise AssertionError(message)
def get_element(elements, id):
for element in elements:
@@ -1356,7 +1370,6 @@ def verify_collection(output_collection_def, data_collection, verify_dataset):
element_outfile, element_attrib = None, element_test
else:
element_outfile, element_attrib = element_test
- expected_count = element_attrib.get("count")
if "expected_sort_order" in element_attrib:
expected_sort_order[element_attrib["expected_sort_order"]] = element_identifier
@@ -1373,10 +1386,21 @@ def verify_collection(output_collection_def, data_collection, verify_dataset):
elements = element["object"]["elements"]
element_count = len(elements)
verify_elements(elements, element_attrib.get("elements", {}))
+ expected_count = element_attrib.get("count")
if expected_count is not None and expected_count != element_count:
raise AssertionError(
f"Element '{element_identifier}': expected to have {expected_count} elements, but it had {element_count}"
)
+ max = element_attrib.get("max")
+ if max is not None and max < element_count:
+ raise AssertionError(
+ f"Element '{element_identifier}': expected to have at most {max} elements, but it had {element_count}"
+ )
+ min = element_attrib.get("min")
+ if min is not None and min > element_count:
+ raise AssertionError(
+ f"Element '{element_identifier}': expected to have at least {min} elements, but it had {element_count}"
+ )
if len(expected_sort_order) > 0:
generated_sort_order = [_["element_identifier"] for _ in element_objects]
diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd
index 9d1b3de78dd..b40cb6ca586 100644
--- a/lib/galaxy/tool_util/xsd/galaxy.xsd
+++ b/lib/galaxy/tool_util/xsd/galaxy.xsd
@@ -1996,6 +1996,16 @@ This is available in Galaxy since release 17.05 and was introduced in [pull requ
Number or datasets for this output. Should be used for outputs with ``discover_datasets``
+
+
+ Minimum number or datasets for this output. Should be used for outputs with ``discover_datasets``
+
+
+
+
+ Maximum number or datasets for this output. Should be used for outputs with ``discover_datasets``
+
+
URL that points to a remote output file that will downloaded and used for output comparison.
@@ -2348,6 +2358,16 @@ This value is the same as the value of the ``name`` attribute of the
Number of elements in output collection.
+
+
+ Minimum number of elements in output collection.
+
+
+
+
+ Maximum number of elements in output collection.
+
+
diff --git a/test/functional/tools/expect_num_outputs.xml b/test/functional/tools/expect_num_outputs.xml
index c2690cc3038..376766baa04 100644
--- a/test/functional/tools/expect_num_outputs.xml
+++ b/test/functional/tools/expect_num_outputs.xml
@@ -157,6 +157,18 @@ true
+
+
+
+
+
+
+
+
+
+
+
+
@@ -322,5 +334,49 @@ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/unit/tool_util/test_tool_linters.py b/test/unit/tool_util/test_tool_linters.py
index 3d5543cfc79..20b82c6567e 100644
--- a/test/unit/tool_util/test_tool_linters.py
+++ b/test/unit/tool_util/test_tool_linters.py
@@ -2098,19 +2098,19 @@ def test_tests_discover_outputs(lint_ctx):
tool_source = get_xml_tool_source(TESTS_DISCOVER_OUTPUTS)
run_lint_module(lint_ctx, tests, tool_source)
assert (
- "Test 3: test output 'data_name' must have a 'count' attribute and/or 'discovered_dataset' children"
+ "Test 3: test output 'data_name' must have a 'count/min/max' attribute and/or 'discovered_dataset' children"
in lint_ctx.error_messages
)
assert (
- "Test 3: test collection 'collection_name' must have a 'count' attribute or 'element' children"
+ "Test 3: test collection 'collection_name' must have a 'count/min/max' attribute or 'element' children"
in lint_ctx.error_messages
)
assert (
- "Test 3: test collection 'collection_name' must contain nested 'element' tags and/or element children with a 'count' attribute"
+ "Test 3: test collection 'collection_name' must contain nested 'element' tags and/or element children with a 'count/min/max' attribute"
in lint_ctx.error_messages
)
assert (
- "Test 5: test collection 'collection_name' must contain nested 'element' tags and/or element children with a 'count' attribute"
+ "Test 5: test collection 'collection_name' must contain nested 'element' tags and/or element children with a 'count/min/max' attribute"
in lint_ctx.error_messages
)
assert len(lint_ctx.error_messages) == 4