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