diff --git a/lib/galaxy/tool_util/parameters/case.py b/lib/galaxy/tool_util/parameters/case.py new file mode 100644 index 000000000000..da723e658847 --- /dev/null +++ b/lib/galaxy/tool_util/parameters/case.py @@ -0,0 +1,185 @@ +from dataclasses import dataclass +from re import compile +from typing import ( + Any, + cast, + Dict, + List, + Optional, + Set, +) + +from galaxy.tool_util.parser.interface import ( + ToolSourceTest, + ToolSourceTestInput, + ToolSourceTestInputs, +) +from galaxy.tool_util.workflow_state._validation_util import validate_explicit_conditional_test_value +from galaxy.util import asbool +from .models import ( + BooleanParameterModel, + ConditionalParameterModel, + ConditionalWhen, + DataCollectionParameterModel, + DataColumnParameterModel, + DataParameterModel, + FloatParameterModel, + IntegerParameterModel, + parameters_by_name, + ToolParameterBundle, + ToolParameterT, +) +from .state import TestCaseToolState +from .visitor import flat_state_path + +INTEGER_STR_PATTERN = compile(r"(\d+)") +COLUMN_NAME_STR_PATTERN = compile(r"c(\d+): .*") + + +@dataclass +class TestCaseStateAndWarnings: + tool_state: TestCaseToolState + warnings: List[str] + + +def legacy_from_string(parameter: ToolParameterT, value: str, warnings: List[str], profile: str) -> Any: + """Convert string values in XML test cases into typed variants. + + This should only be used when parsing XML test cases into a TestCaseToolState object. + We have to maintain backward compatibility on these for older Galaxy tool profile versions. + """ + is_string = isinstance(value, str) + result_value: Any = value + if is_string and isinstance(parameter, (IntegerParameterModel,)): + warnings.append( + f"Implicitly converted {parameter.name} to an integer from a string value, please use 'value_json' to define this test input parameter value instead." + ) + result_value = int(value) + elif is_string and isinstance(parameter, (FloatParameterModel,)): + warnings.append( + f"Implicitly converted {parameter.name} to a floating point number from a string value, please use 'value_json' to define this test input parameter value instead." + ) + result_value = float(value) + elif is_string and isinstance(parameter, (BooleanParameterModel,)): + warnings.append( + f"Implicitly converted {parameter.name} to a boolean from a string value, please use 'value_json' to define this test input parameter value instead." + ) + result_value = asbool(value) + elif is_string and isinstance(parameter, (DataColumnParameterModel,)): + integer_match = INTEGER_STR_PATTERN.match(value) + if integer_match: + warnings.append( + f"Implicitly converted {parameter.name} to a column index integer from a string value, please use 'value_json' to define this test input parameter value instead." + ) + result_value = int(value) + else: + warnings.append( + f"Using column names as test case values is deprecated, please adjust {parameter.name} to just use an integer column index." + ) + column_name_value_match = COLUMN_NAME_STR_PATTERN.match(value) + column_part = column_name_value_match.group(1) + result_value = int(column_part) + return result_value + + +def test_case_state( + test_dict: ToolSourceTest, tool_parameter_bundle: List[ToolParameterT], profile: str +) -> TestCaseStateAndWarnings: + warnings: List[str] = [] + inputs: ToolSourceTestInputs = test_dict["inputs"] + state = {} + + handled_inputs = _merge_level_into_state(tool_parameter_bundle, inputs, state, profile, warnings, None) + + for test_input in inputs: + input_name = test_input["name"] + if input_name not in handled_inputs: + raise Exception(f"Invalid parameter name found {input_name}") + + tool_state = TestCaseToolState(state) + tool_state.validate(tool_parameter_bundle) + return TestCaseStateAndWarnings(tool_state, warnings) + + +def _merge_level_into_state( + tool_inputs: List[ToolParameterT], + inputs: ToolSourceTestInputs, + state_at_level: dict, + profile: str, + warnings: List[str], + prefix: Optional[str], +) -> Set[str]: + handled_inputs: Set[str] = set() + for tool_input in tool_inputs: + handled_inputs.update(_merge_into_state(tool_input, inputs, state_at_level, profile, warnings, prefix)) + + return handled_inputs + + +def _merge_into_state( + tool_input: ToolParameterT, + inputs: ToolSourceTestInputs, + state_at_level: dict, + profile: str, + warnings: List[str], + prefix: Optional[str], +) -> Set[str]: + handled_inputs = set() + + input_name = tool_input.name + state_path = flat_state_path(input_name, prefix) + handled_inputs.add(state_path) + + if isinstance(tool_input, (ConditionalParameterModel,)): + conditional_state = state_at_level.get(input_name, {}) + if input_name not in state_at_level: + state_at_level[input_name] = conditional_state + + conditional = cast(ConditionalParameterModel, tool_input) + when: ConditionalWhen = _select_which_when(conditional, conditional_state, inputs, state_path) + test_parameter = conditional.test_parameter + handled_inputs.update( + _merge_into_state(test_parameter, inputs, conditional_state, profile, warnings, state_path) + ) + handled_inputs.update( + _merge_level_into_state(when.parameters, inputs, conditional_state, profile, warnings, state_path) + ) + else: + test_input = _input_for(state_path, inputs) + if test_input is not None: + if isinstance(tool_input, (DataCollectionParameterModel,)): + input_value = test_input.get("attributes", {}).get("collection") + else: + input_value = test_input["value"] + input_value = legacy_from_string(tool_input, input_value, warnings, profile) + + state_at_level[input_name] = input_value + + return handled_inputs + + +def _select_which_when( + conditional: ConditionalParameterModel, state: dict, inputs: ToolSourceTestInputs, prefix: str +) -> ConditionalWhen: + test_parameter = conditional.test_parameter + test_parameter_name = test_parameter.name + test_parameter_flat_path = flat_state_path(prefix, test_parameter_name) + + test_input = _input_for(test_parameter_flat_path, inputs) + explicit_test_value = test_input["value"] if test_input else None + test_value = validate_explicit_conditional_test_value(test_parameter_name, explicit_test_value) + for when in conditional.whens: + if test_value is None and when.is_default_when: + return when + elif test_value == when.discriminator: + return when + else: + raise Exception(f"Invalid conditional test value ({explicit_test_value}) for parameter ({test_parameter_name})") + + +def _input_for(flat_state_path: str, inputs: ToolSourceTestInputs) -> Optional[ToolSourceTestInput]: + for input in inputs: + if input["name"] == flat_state_path: + return input + else: + return None diff --git a/lib/galaxy/tool_util/parameters/models.py b/lib/galaxy/tool_util/parameters/models.py index 3a9cf59097ac..850c51198c38 100644 --- a/lib/galaxy/tool_util/parameters/models.py +++ b/lib/galaxy/tool_util/parameters/models.py @@ -37,7 +37,10 @@ ) from galaxy.exceptions import RequestParameterInvalidException -from galaxy.tool_util.parser.interface import DrillDownOptionsDict +from galaxy.tool_util.parser.interface import ( + DrillDownOptionsDict, + TestCollectionDict, +) from ._types import ( cast_as_type, is_optional, @@ -58,7 +61,7 @@ # + request_internal: This is a pydantic model to validate what Galaxy expects to find in the database, # in particular dataset and collection references should be decoded integers. StateRepresentationT = Literal[ - "request", "request_internal", "job_internal", "test_case", "workflow_step", "workflow_step_linked" + "request", "request_internal", "job_internal", "test_case_xml", "workflow_step", "workflow_step_linked" ] @@ -311,9 +314,9 @@ def py_type_internal(self) -> Type: def py_type_test_case(self) -> Type: base_model: Type if self.multiple: - base_model = MultiDataRequestInternal + base_model = str else: - base_model = DataTestCaseValue + base_model = str return optional_if_needed(base_model, self.optional) def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: @@ -325,7 +328,7 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam ) elif state_representation == "job_internal": return dynamic_model_information_from_py_type(self, self.py_type_internal) - elif state_representation == "test_case": + elif state_representation == "test_case_xml": return dynamic_model_information_from_py_type(self, self.py_type_test_case) elif state_representation == "workflow_step": return dynamic_model_information_from_py_type(self, type(None), requires_value=False) @@ -369,6 +372,8 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam return dynamic_model_information_from_py_type(self, type(None), requires_value=False) elif state_representation == "workflow_step_linked": return dynamic_model_information_from_py_type(self, ConnectedValue) + elif state_representation == "test_case_xml": + return dynamic_model_information_from_py_type(self, TestCollectionDict) else: raise NotImplementedError( f"Have not implemented data collection parameter models for state representation {state_representation}" @@ -529,7 +534,7 @@ class SelectParameterModel(BaseGalaxyToolParameterModelDefinition): options: Optional[List[LabelValue]] = None multiple: bool - def py_type_if_required(self, allow_connections=False) -> Type: + def py_type_if_required(self, allow_connections: bool = False, expect_list: bool = True) -> Type: if self.options is not None: literal_options: List[Type] = [cast_as_type(Literal[o.value]) for o in self.options] py_type = union_type(literal_options) @@ -537,9 +542,15 @@ def py_type_if_required(self, allow_connections=False) -> Type: py_type = StrictStr if self.multiple: if allow_connections: - py_type = list_type(allow_connected_value(py_type)) + if expect_list: + py_type = list_type(allow_connected_value(py_type)) + else: + py_type = allow_connected_value(py_type) else: - py_type = list_type(py_type) + if expect_list: + py_type = list_type(py_type) + else: + py_type = py_type elif allow_connections: py_type = allow_connected_value(py_type) return py_type @@ -559,6 +570,10 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam elif state_representation == "workflow_step_linked": py_type = self.py_type_if_required(allow_connections=True) return dynamic_model_information_from_py_type(self, optional_if_needed(py_type, self.optional)) + elif state_representation == "test_case_xml": + # in a YAML test case representation this can be string, in XML we are still expecting a comma separated string + py_type = self.py_type_if_required(allow_connections=False, expect_list=False) + return dynamic_model_information_from_py_type(self, optional_if_needed(py_type, self.optional)) else: return dynamic_model_information_from_py_type(self, self.py_type) @@ -632,8 +647,16 @@ def py_type(self) -> Type: return py_type + @property + def py_type_test_case_xml(self) -> Type: + base_model = str + return optional_if_needed(base_model, not self.request_requires_value) + def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: - return dynamic_model_information_from_py_type(self, self.py_type) + if state_representation == "test_case_xml": + return dynamic_model_information_from_py_type(self, self.py_type_test_case_xml) + else: + return dynamic_model_information_from_py_type(self, self.py_type) @property def request_requires_value(self) -> bool: @@ -1054,9 +1077,15 @@ class ToolParameterBundleModel(BaseModel): input_models: List[ToolParameterT] -def parameters_by_name(tool_parameter_bundle: ToolParameterBundle) -> Dict[str, ToolParameterT]: +def parameters_by_name( + inputs: Union[Iterable[ToolParameterModel], Iterable[ToolParameterT], ToolParameterBundle] +) -> Dict[str, ToolParameterT]: as_dict = {} - for input_model in simple_input_models(tool_parameter_bundle.input_models): + if hasattr(inputs, "input_models"): + inputs_list = simple_input_models(cast(ToolParameterBundle, inputs.input_models)) + else: + inputs_list = cast(Union[Iterable[ToolParameterModel], Iterable[ToolParameterT]], inputs) + for input_model in inputs_list: as_dict[input_model.name] = input_model return as_dict @@ -1094,7 +1123,7 @@ def create_job_internal_model(tool: ToolParameterBundle, name: str = "DynamicMod def create_test_case_model(tool: ToolParameterBundle, name: str = "DynamicModelForTool") -> Type[BaseModel]: - return create_field_model(tool.input_models, name, "test_case") + return create_field_model(tool.input_models, name, "test_case_xml") def create_workflow_step_model(tool: ToolParameterBundle, name: str = "DynamicModelForTool") -> Type[BaseModel]: diff --git a/lib/galaxy/tool_util/parameters/state.py b/lib/galaxy/tool_util/parameters/state.py index 3c5389c9c230..c21163d1e786 100644 --- a/lib/galaxy/tool_util/parameters/state.py +++ b/lib/galaxy/tool_util/parameters/state.py @@ -17,6 +17,7 @@ create_job_internal_model, create_request_internal_model, create_request_model, + create_test_case_model, create_workflow_step_linked_model, create_workflow_step_model, StateRepresentationT, @@ -91,12 +92,12 @@ def _parameter_model_for(cls, input_models: ToolParameterBundle) -> Type[BaseMod class TestCaseToolState(ToolState): - state_representation: Literal["test_case"] = "test_case" + state_representation: Literal["test_case_xml"] = "test_case_xml" @classmethod def _parameter_model_for(cls, input_models: ToolParameterBundle) -> Type[BaseModel]: # implement a test case model... - return create_request_internal_model(input_models) + return create_test_case_model(input_models) class WorkflowStepToolState(ToolState): diff --git a/lib/galaxy/tool_util/parser/interface.py b/lib/galaxy/tool_util/parser/interface.py index 89b17698353e..07bf4a8478a1 100644 --- a/lib/galaxy/tool_util/parser/interface.py +++ b/lib/galaxy/tool_util/parser/interface.py @@ -19,6 +19,7 @@ import packaging.version from pydantic import BaseModel from typing_extensions import ( + Literal, NotRequired, TypedDict, ) @@ -376,7 +377,7 @@ def paths_and_modtimes(self): paths_and_modtimes[self.source_path] = os.path.getmtime(self.source_path) return paths_and_modtimes - def parse_tests_to_dict(self) -> ToolSourceTests: + def parse_tests_to_dict(self, for_json: bool = False) -> ToolSourceTests: return {"tests": []} def __str__(self): @@ -549,6 +550,23 @@ def parse_input_sources(self) -> List[InputSource]: """Return a list of InputSource objects.""" +TestCollectionAttributeDict = Dict[str, Any] +CollectionType = str + + +class TestCollectionDictElement(TypedDict): + element_identifier: str + element_definition: Union["TestCollectionDict", "ToolSourceTestInput"] + + +class TestCollectionDict(TypedDict): + model_class: Literal["TestCollectionDef"] = "TestCollectionDef" + attributes: TestCollectionAttributeDict + collection_type: CollectionType + elements: List[TestCollectionDictElement] + name: str + + class TestCollectionDef: __test__ = False # Prevent pytest from discovering this class (issue #12071) diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index ef9b8e5d2b48..8b0fb93cdd4d 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -659,7 +659,7 @@ def macro_paths(self): def source_path(self): return self._source_path - def parse_tests_to_dict(self) -> ToolSourceTests: + def parse_tests_to_dict(self, for_json: bool = False) -> ToolSourceTests: tests_elem = self.root.find("tests") tests: List[ToolSourceTest] = [] rval: ToolSourceTests = dict(tests=tests) @@ -667,7 +667,7 @@ def parse_tests_to_dict(self) -> ToolSourceTests: if tests_elem is not None: for i, test_elem in enumerate(tests_elem.findall("test")): profile = self.parse_profile() - tests.append(_test_elem_to_dict(test_elem, i, profile)) + tests.append(_test_elem_to_dict(test_elem, i, profile, for_json=for_json)) return rval @@ -724,11 +724,11 @@ def parse_creator(self): return creators -def _test_elem_to_dict(test_elem, i, profile=None) -> ToolSourceTest: +def _test_elem_to_dict(test_elem, i, profile=None, for_json=False) -> ToolSourceTest: rval: ToolSourceTest = dict( outputs=__parse_output_elems(test_elem), output_collections=__parse_output_collection_elems(test_elem, profile=profile), - inputs=__parse_input_elems(test_elem, i), + inputs=__parse_input_elems(test_elem, i, for_json=for_json), expect_num_outputs=test_elem.get("expect_num_outputs"), command=__parse_assert_list_from_elem(test_elem.find("assert_command")), command_version=__parse_assert_list_from_elem(test_elem.find("assert_command_version")), @@ -743,9 +743,9 @@ def _test_elem_to_dict(test_elem, i, profile=None) -> ToolSourceTest: return rval -def __parse_input_elems(test_elem, i) -> ToolSourceTestInputs: +def __parse_input_elems(test_elem, i, for_json=False) -> ToolSourceTestInputs: __expand_input_elems(test_elem) - return __parse_inputs_elems(test_elem, i) + return __parse_inputs_elems(test_elem, i, for_json=for_json) def __parse_output_elems(test_elem) -> ToolSourceTestOutputs: @@ -991,15 +991,15 @@ def _copy_to_dict_if_present(elem, rval, attributes): return rval -def __parse_inputs_elems(test_elem, i) -> ToolSourceTestInputs: +def __parse_inputs_elems(test_elem, i, for_json=False) -> ToolSourceTestInputs: raw_inputs: ToolSourceTestInputs = [] for param_elem in test_elem.findall("param"): - raw_inputs.append(__parse_param_elem(param_elem, i)) + raw_inputs.append(__parse_param_elem(param_elem, i, for_json=for_json)) return raw_inputs -def __parse_param_elem(param_elem, i=0) -> ToolSourceTestInput: +def __parse_param_elem(param_elem, i=0, for_json=False) -> ToolSourceTestInput: attrib: ToolSourceTestInputAttributes = dict(param_elem.attrib) if "values" in attrib: value = attrib["values"].split(",") @@ -1037,7 +1037,8 @@ def __parse_param_elem(param_elem, i=0) -> ToolSourceTestInput: elif child.tag == "edit_attributes": attrib["edit_attributes"].append(child) elif child.tag == "collection": - attrib["collection"] = TestCollectionDef.from_xml(child, __parse_param_elem) + collection = TestCollectionDef.from_xml(child, lambda elem: __parse_param_elem(elem, for_json=for_json)) + attrib["collection"] = collection if not for_json else collection.to_dict() if composite_data_name: # Composite datasets need implicit renaming; # inserted at front of list so explicit declarations diff --git a/lib/galaxy/tool_util/parser/yaml.py b/lib/galaxy/tool_util/parser/yaml.py index 6813db0f5211..0e9574cdac78 100644 --- a/lib/galaxy/tool_util/parser/yaml.py +++ b/lib/galaxy/tool_util/parser/yaml.py @@ -189,7 +189,7 @@ def _parse_output_collection(self, tool, name, output_dict): ) return output_collection - def parse_tests_to_dict(self) -> ToolSourceTests: + def parse_tests_to_dict(self, for_json: bool = False) -> ToolSourceTests: tests: List[ToolSourceTest] = [] rval: ToolSourceTests = dict(tests=tests) diff --git a/lib/galaxy/tool_util/unittest_utils/__init__.py b/lib/galaxy/tool_util/unittest_utils/__init__.py index d179aff465bc..05849a7bedd8 100644 --- a/lib/galaxy/tool_util/unittest_utils/__init__.py +++ b/lib/galaxy/tool_util/unittest_utils/__init__.py @@ -31,5 +31,9 @@ def get_content(filename: Optional[str]) -> bytes: return get_content +def functional_test_tool_directory() -> str: + return os.path.join(galaxy_directory(), "test/functional/tools") + + def functional_test_tool_path(test_path: str) -> str: - return os.path.join(galaxy_directory(), "test/functional/tools", test_path) + return os.path.join(functional_test_tool_directory(), test_path) diff --git a/lib/galaxy/tool_util/workflow_state/convert.py b/lib/galaxy/tool_util/workflow_state/convert.py index 6f1fe0cdec10..6b146b2284bd 100644 --- a/lib/galaxy/tool_util/workflow_state/convert.py +++ b/lib/galaxy/tool_util/workflow_state/convert.py @@ -11,17 +11,17 @@ from galaxy.tool_util.models import ParsedTool from galaxy.tool_util.parameters import ToolParameterT +from ._types import ( + Format2StateDict, + GetToolInfo, + NativeStepDict, +) +from .validation_format2 import validate_step_against from .validation_native import ( get_parsed_tool_for_native_step, native_tool_state, validate_native_step_against, ) -from .validation_format2 import validate_step_against -from ._types import ( - GetToolInfo, - NativeStepDict, - Format2StateDict, -) Format2InputsDictT = Dict[str, str] @@ -82,10 +82,12 @@ def _convert_valid_state_to_format2(native_step_dict: NativeStepDict, parsed_too root_tool_state = native_tool_state(native_step_dict) tool_inputs = parsed_tool.inputs _convert_state_level(native_step_dict, tool_inputs, root_tool_state, format2_state, format2_in) - return Format2State(**{ - "state": format2_state, - "in": format2_in, - }) + return Format2State( + **{ + "state": format2_state, + "in": format2_in, + } + ) def _convert_state_level( @@ -106,7 +108,7 @@ def _convert_state_at_level( native_state_at_level: dict, format2_state_at_level: dict, format2_in: Format2InputsDictT, - prefix: str + prefix: str, ) -> None: parameter_type = tool_input.parameter_type parameter_name = tool_input.name diff --git a/lib/galaxy/tool_util/workflow_state/validation.py b/lib/galaxy/tool_util/workflow_state/validation.py index 7fe9f53bb4cd..56a225fbb8f8 100644 --- a/lib/galaxy/tool_util/workflow_state/validation.py +++ b/lib/galaxy/tool_util/workflow_state/validation.py @@ -3,7 +3,6 @@ GetToolInfo, WorkflowFormat, ) - from .validation_format2 import validate_workflow_format2 from .validation_native import validate_workflow_native diff --git a/lib/galaxy/tool_util/workflow_state/validation_format2.py b/lib/galaxy/tool_util/workflow_state/validation_format2.py index 0ef8a192cbbd..ef6a01daa605 100644 --- a/lib/galaxy/tool_util/workflow_state/validation_format2.py +++ b/lib/galaxy/tool_util/workflow_state/validation_format2.py @@ -25,9 +25,9 @@ WorkflowStepToolState, ) from ._types import ( - GetToolInfo, - Format2WorkflowDict, Format2StepDict, + Format2WorkflowDict, + GetToolInfo, ) from ._validation_util import validate_explicit_conditional_test_value diff --git a/lib/galaxy/tool_util/workflow_state/validation_native.py b/lib/galaxy/tool_util/workflow_state/validation_native.py index d3518f52a222..fa6ed6efde8c 100644 --- a/lib/galaxy/tool_util/workflow_state/validation_native.py +++ b/lib/galaxy/tool_util/workflow_state/validation_native.py @@ -16,9 +16,9 @@ ) from ._types import ( GetToolInfo, - NativeWorkflowDict, NativeStepDict, NativeToolStateDict, + NativeWorkflowDict, ) from ._validation_util import validate_explicit_conditional_test_value from .validation_format2 import repeat_inputs_to_array @@ -54,7 +54,9 @@ def _validate_native_state_level( raise Exception(f"Unknown key found {key}, failing state validation") -def _validate_native_state_at_level(step: NativeStepDict, tool_input: ToolParameterT, state_at_level: dict, prefix: Optional[str] = None): +def _validate_native_state_at_level( + step: NativeStepDict, tool_input: ToolParameterT, state_at_level: dict, prefix: Optional[str] = None +): parameter_type = tool_input.parameter_type parameter_name = tool_input.name value = state_at_level.get(parameter_name, None) @@ -73,7 +75,9 @@ def _validate_native_state_at_level(step: NativeStepDict, tool_input: ToolParame connections = native_connections_for(step, tool_input, prefix) optional = tool_input.optional if not optional and not connections: - raise Exception("Disconnected non-optional input found, not attempting to validate non-practice workflow") + raise Exception( + "Disconnected non-optional input found, not attempting to validate non-practice workflow" + ) elif parameter_type == "gx_select": select = cast(SelectParameterModel, tool_input) diff --git a/test/unit/tool_util/parameter_specification.yml b/test/unit/tool_util/parameter_specification.yml index c7e2929a5cdb..129a81cb8128 100644 --- a/test/unit/tool_util/parameter_specification.yml +++ b/test/unit/tool_util/parameter_specification.yml @@ -27,10 +27,10 @@ gx_int: - parameter: "None" - parameter: { 5 } - parameter: {__class__: 'ConnectedValue'} - test_case_valid: + test_case_xml_valid: - parameter: 5 - {} - test_case_invalid: + test_case_xml_invalid: - parameter: null - parameter: "5" workflow_step_valid: @@ -189,11 +189,11 @@ gx_select: - parameter: "ex2" request_internal_invalid: - parameter: {} - test_case_valid: + test_case_xml_valid: - parameter: 'ex2' - parameter: '--ex1' - {} - test_case_invalid: + test_case_xml_invalid: - parameter: {} - parameter: null workflow_step_valid: @@ -357,11 +357,11 @@ gx_float: - parameter: "5" - parameter: "5.0" - parameter: { "moo": "cow" } - test_case_valid: + test_case_xml_valid: - parameter: 5 - parameter: 5.0 - {} - test_case_invalid: + test_case_xml_invalid: - parameter: null - parameter: "5.0" - parameter: "5.1" @@ -394,12 +394,12 @@ gx_float_optional: - parameter: "5.0" - parameter: {} - parameter: { "moo": "cow" } - test_case_valid: + test_case_xml_valid: - parameter: 5 - parameter: 5.0 - {} - parameter: null - test_case_invalid: + test_case_xml_invalid: - parameter: "5.0" - parameter: "5.1" workflow_step_valid: diff --git a/test/unit/tool_util/test_parameter_specification.py b/test/unit/tool_util/test_parameter_specification.py index 994611afdbfa..4fc6df702b22 100644 --- a/test/unit/tool_util/test_parameter_specification.py +++ b/test/unit/tool_util/test_parameter_specification.py @@ -68,8 +68,8 @@ def _test_file(file: str, specification=None): "request_internal_invalid": _assert_internal_requests_invalid, "job_internal_valid": _assert_internal_jobs_validate, "job_internal_invalid": _assert_internal_jobs_invalid, - "test_case_valid": _assert_test_cases_validate, - "test_case_invalid": _assert_test_cases_invalid, + "test_case_xml_valid": _assert_test_cases_validate, + "test_case_xml_invalid": _assert_test_cases_invalid, "workflow_step_valid": _assert_workflow_steps_validate, "workflow_step_invalid": _assert_workflow_steps_invalid, "workflow_step_linked_valid": _assert_workflow_steps_linked_validate, diff --git a/test/unit/tool_util/test_parameter_test_cases.py b/test/unit/tool_util/test_parameter_test_cases.py new file mode 100644 index 000000000000..54d404221ef4 --- /dev/null +++ b/test/unit/tool_util/test_parameter_test_cases.py @@ -0,0 +1,85 @@ +import os +from typing import List + +from galaxy.tool_util.parser.interface import ToolSourceTest +from galaxy.tool_util.parameters.case import test_case_state as case_state +from galaxy.tool_util.unittest_utils.parameters import ( + parameter_bundle_for_file, + parameter_tool_source, +) +from galaxy.tool_util.parser.factory import get_tool_source +from galaxy.tool_util.unittest_utils import functional_test_tool_directory +from galaxy.tool_util.models import parse_tool + + +TEST_TOOL_THAT_DO_NOT_VALIDATE = [ + # Doesn't validate because it uses legacy functionality of setting nested parameters + # as unqualified root parameters. + "boolean_conditional.xml", + # TODO: handle repeats + "inputs_as_json.xml", + "min_repeat.xml", + # unhandled tool parameter types... + "test_data_source.xml", + # collection defaults not handled + "collection_nested_default.xml", +] + + +def test_parameter_test_cases_validate(): + validate_test_cases_for("gx_int") + warnings = validate_test_cases_for("gx_float") + assert len(warnings[0]) == 0 + assert len(warnings[1]) == 1 + + +VALIDATING_TOOL_NAMES = [ + "checksum.xml", + "all_output_types.xml", + "cheetah_casting.xml", + "collection_creates_dynamic_list_of_pairs.xml", + "collection_creates_dynamic_nested.xml", + "collection_mixed_param.xml", + "collection_type_source.xml", +] + + +def test_validate_framework_test_tools(): + test_tool_directory = functional_test_tool_directory() + for tool_name in os.listdir(test_tool_directory): + if tool_name in TEST_TOOL_THAT_DO_NOT_VALIDATE: + continue + tool_path = os.path.join(test_tool_directory, tool_name) + if not tool_path.endswith(".xml") or os.path.isdir(tool_path): + continue + + try: + _validate_path(tool_path) + except Exception as e: + raise Exception(f"Failed to validate {tool_path}: {str(e)}") + +def _validate_path(tool_path: str): + tool_source = get_tool_source(tool_path) + parsed_tool = parse_tool(tool_source) + profile = tool_source.parse_profile() + test_cases: List[ToolSourceTest] = tool_source.parse_tests_to_dict(for_json=True)["tests"] + for test_case in test_cases: + test_case_state_and_warnings = case_state(test_case, parsed_tool.inputs, profile) + tool_state = test_case_state_and_warnings.tool_state + warnings = test_case_state_and_warnings.warnings + assert tool_state.state_representation == "test_case_xml" + + +def validate_test_cases_for(tool_name: str) -> List[List[str]]: + tool_parameter_bundle = parameter_bundle_for_file(tool_name) + tool_source = parameter_tool_source(tool_name) + profile = tool_source.parse_profile() + test_cases: List[ToolSourceTest] = tool_source.parse_tests_to_dict(for_json=True)["tests"] + warnings_by_test = [] + for test_case in test_cases: + test_case_state_and_warnings = case_state(test_case, tool_parameter_bundle.input_models, profile) + tool_state = test_case_state_and_warnings.tool_state + warnings = test_case_state_and_warnings.warnings + assert tool_state.state_representation == "test_case_xml" + warnings_by_test.append(warnings) + return warnings_by_test