diff --git a/doc/source/dev/tool_state_state_classes.plantuml.svg b/doc/source/dev/tool_state_state_classes.plantuml.svg index b0c086bf18b0..07270f21f7a4 100644 --- a/doc/source/dev/tool_state_state_classes.plantuml.svg +++ b/doc/source/dev/tool_state_state_classes.plantuml.svg @@ -41,14 +41,36 @@ state_representation = "job_internal" } note bottom: Object references of the form \n{src: "hda", id: }.\n Mapping constructs expanded out.\n (Defaults are inserted?) +class TestCaseToolState { +state_representation = "test_case" ++ _to_base_model(input_models: ToolParameterBundle): Type[BaseModel] +} +note bottom: Object references of the form file name and URIs.\n Mapping constructs not allowed.\n + +class WorkflowStepToolState { +state_representation = "workflow_step" ++ _to_base_model(input_models: ToolParameterBundle): Type[BaseModel] +} +note bottom: Nearly everything optional except conditional discriminators.\n + +class WorkflowStepLinkedToolState { +state_representation = "workflow_step_linked" ++ _to_base_model(input_models: ToolParameterBundle): Type[BaseModel] +} +note bottom: Expect pre-process ``in`` dictionaries and bring in representation\n of links and defaults and validate them in model.\n + ToolState <|- - RequestToolState ToolState <|- - RequestInternalToolState ToolState <|- - JobInternalToolState +ToolState <|- - TestCaseToolState +ToolState <|- - WorkflowStepToolState +ToolState <|- - WorkflowStepLinkedToolState RequestToolState - RequestInternalToolState : decode > RequestInternalToolState o- - JobInternalToolState : expand > +WorkflowStepToolState o- - WorkflowStepLinkedToolState : preprocess_links_and_defaults > } @enduml @@ -132,14 +154,36 @@ state_representation = "job_internal" } note bottom: Object references of the form \n{src: "hda", id: }.\n Mapping constructs expanded out.\n (Defaults are inserted?) +class TestCaseToolState { +state_representation = "test_case" ++ _to_base_model(input_models: ToolParameterBundle): Type[BaseModel] +} +note bottom: Object references of the form file name and URIs.\n Mapping constructs not allowed.\n + +class WorkflowStepToolState { +state_representation = "workflow_step" ++ _to_base_model(input_models: ToolParameterBundle): Type[BaseModel] +} +note bottom: Nearly everything optional except conditional discriminators.\n + +class WorkflowStepLinkedToolState { +state_representation = "workflow_step_linked" ++ _to_base_model(input_models: ToolParameterBundle): Type[BaseModel] +} +note bottom: Expect pre-process ``in`` dictionaries and bring in representation\n of links and defaults and validate them in model.\n + ToolState <|- - RequestToolState ToolState <|- - RequestInternalToolState ToolState <|- - JobInternalToolState +ToolState <|- - TestCaseToolState +ToolState <|- - WorkflowStepToolState +ToolState <|- - WorkflowStepLinkedToolState RequestToolState - RequestInternalToolState : decode > RequestInternalToolState o- - JobInternalToolState : expand > +WorkflowStepToolState o- - WorkflowStepLinkedToolState : preprocess_links_and_defaults > } @enduml diff --git a/doc/source/dev/tool_state_state_classes.plantuml.txt b/doc/source/dev/tool_state_state_classes.plantuml.txt index 612c13d8e683..67da8a30c725 100644 --- a/doc/source/dev/tool_state_state_classes.plantuml.txt +++ b/doc/source/dev/tool_state_state_classes.plantuml.txt @@ -29,13 +29,35 @@ state_representation = "job_internal" } note bottom: Object references of the form \n{src: "hda", id: }.\n Mapping constructs expanded out.\n (Defaults are inserted?) +class TestCaseToolState { +state_representation = "test_case" ++ _to_base_model(input_models: ToolParameterBundle): Type[BaseModel] +} +note bottom: Object references of the form file name and URIs.\n Mapping constructs not allowed.\n + +class WorkflowStepToolState { +state_representation = "workflow_step" ++ _to_base_model(input_models: ToolParameterBundle): Type[BaseModel] +} +note bottom: Nearly everything optional except conditional discriminators.\n + +class WorkflowStepLinkedToolState { +state_representation = "workflow_step_linked" ++ _to_base_model(input_models: ToolParameterBundle): Type[BaseModel] +} +note bottom: Expect pre-process ``in`` dictionaries and bring in representation\n of links and defaults and validate them in model.\n + ToolState <|-- RequestToolState ToolState <|-- RequestInternalToolState ToolState <|-- JobInternalToolState +ToolState <|-- TestCaseToolState +ToolState <|-- WorkflowStepToolState +ToolState <|-- WorkflowStepLinkedToolState RequestToolState - RequestInternalToolState : decode > RequestInternalToolState o-- JobInternalToolState : expand > +WorkflowStepToolState o-- WorkflowStepLinkedToolState : preprocess_links_and_defaults > } @enduml \ No newline at end of file diff --git a/lib/galaxy/tool_util/parameters/__init__.py b/lib/galaxy/tool_util/parameters/__init__.py index 4c50680fa118..d0b96535e78c 100644 --- a/lib/galaxy/tool_util/parameters/__init__.py +++ b/lib/galaxy/tool_util/parameters/__init__.py @@ -42,6 +42,8 @@ validate_internal_request, validate_request, validate_test_case, + validate_workflow_step, + validate_workflow_step_linked, ) from .state import ( JobInternalToolState, @@ -49,6 +51,8 @@ RequestToolState, TestCaseToolState, ToolState, + WorkflowStepLinkedToolState, + WorkflowStepToolState, ) from .visitor import visit_input_values @@ -89,6 +93,8 @@ "validate_internal_request", "validate_request", "validate_test_case", + "validate_workflow_step", + "validate_workflow_step_linked", "ToolState", "TestCaseToolState", "ToolParameterT", @@ -98,4 +104,6 @@ "visit_input_values", "decode", "encode", + "WorkflowStepToolState", + "WorkflowStepLinkedToolState", ) diff --git a/lib/galaxy/tool_util/parameters/_types.py b/lib/galaxy/tool_util/parameters/_types.py index 4a33f6406a50..2b97ee16c200 100644 --- a/lib/galaxy/tool_util/parameters/_types.py +++ b/lib/galaxy/tool_util/parameters/_types.py @@ -20,10 +20,15 @@ ) +def optional(type: Type) -> Type: + return_type: Type = Optional[type] # type: ignore[assignment] + return return_type + + def optional_if_needed(type: Type, is_optional: bool) -> Type: return_type: Type = type if is_optional: - return_type = Optional[type] # type: ignore[assignment] + return_type = optional(type) return return_type diff --git a/lib/galaxy/tool_util/parameters/factory.py b/lib/galaxy/tool_util/parameters/factory.py index 19ceb24e5f65..dbb2b6c0abe0 100644 --- a/lib/galaxy/tool_util/parameters/factory.py +++ b/lib/galaxy/tool_util/parameters/factory.py @@ -37,6 +37,7 @@ RulesParameterModel, SectionParameterModel, SelectParameterModel, + cond_test_parameter_default_value, TextParameterModel, ToolParameterBundle, ToolParameterBundleModel, @@ -155,16 +156,7 @@ def _from_input_source_galaxy(input_source: InputSource) -> ToolParameterT: test_param_input_source = input_source.parse_test_input_source() test_parameter = _from_input_source_galaxy(test_param_input_source) whens = [] - default_value = object() - if isinstance(test_parameter, BooleanParameterModel): - default_value = test_parameter.value - elif isinstance(test_parameter, SelectParameterModel): - select_parameter = cast(SelectParameterModel, test_parameter) - select_default_value = select_parameter.default_value - if select_default_value is not None: - default_value = select_default_value - - # TODO: handle select parameter model... + default_value = cond_test_parameter_default_value(test_parameter) for value, case_inputs_sources in input_source.parse_when_input_sources(): if isinstance(test_parameter, BooleanParameterModel): # TODO: investigate truevalue/falsevalue when... diff --git a/lib/galaxy/tool_util/parameters/models.py b/lib/galaxy/tool_util/parameters/models.py index 2cb05b1bdad1..fd589a677d64 100644 --- a/lib/galaxy/tool_util/parameters/models.py +++ b/lib/galaxy/tool_util/parameters/models.py @@ -41,6 +41,7 @@ cast_as_type, is_optional, list_type, + optional, optional_if_needed, union_type, ) @@ -55,7 +56,9 @@ # + request: Return info needed to build request pydantic model at runtime. # + 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"] +StateRepresentationT = Literal[ + "request", "request_internal", "job_internal", "test_case", "workflow_step", "workflow_step_linked" +] # could be made more specific - validators need to be classmethod @@ -72,6 +75,14 @@ class StrictModel(BaseModel): model_config = ConfigDict(extra="forbid") +class ConnectedValue(BaseModel): + discriminator: Literal["ConnectedValue"] = Field(alias="__class__") + + +def allow_connected_value(type: Type): + return union_type([type, ConnectedValue]) + + def allow_batching(job_template: DynamicModelInformation, batch_type: Optional[Type] = None) -> DynamicModelInformation: job_py_type: Type = job_template.definition[0] default_value = job_template.definition[1] @@ -107,11 +118,15 @@ def request_requires_value(self) -> bool: ... -def dynamic_model_information_from_py_type(param_model: ParamModel, py_type: Type): +def dynamic_model_information_from_py_type( + param_model: ParamModel, py_type: Type, requires_value: Optional[bool] = None +): name = param_model.name - initialize = ... if param_model.request_requires_value else None + if requires_value is None: + requires_value = param_model.request_requires_value + initialize = ... if requires_value else None py_type_is_optional = is_optional(py_type) - if not py_type_is_optional and not param_model.request_requires_value: + if not py_type_is_optional and not requires_value: validators = {"not_null": field_validator(name)(Validators.validate_not_none)} else: validators = {} @@ -161,7 +176,10 @@ def py_type(self) -> Type: return optional_if_needed(StrictStr, self.optional) def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: - return dynamic_model_information_from_py_type(self, self.py_type) + py_type = self.py_type + if state_representation == "workflow_step_linked": + py_type = allow_connected_value(py_type) + return dynamic_model_information_from_py_type(self, py_type) @property def request_requires_value(self) -> bool: @@ -180,7 +198,10 @@ def py_type(self) -> Type: return optional_if_needed(StrictInt, self.optional) def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: - return dynamic_model_information_from_py_type(self, self.py_type) + py_type = self.py_type + if state_representation == "workflow_step_linked": + py_type = allow_connected_value(py_type) + return dynamic_model_information_from_py_type(self, py_type) @property def request_requires_value(self) -> bool: @@ -198,7 +219,10 @@ def py_type(self) -> Type: return optional_if_needed(union_type([StrictInt, StrictFloat]), self.optional) def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: - return dynamic_model_information_from_py_type(self, self.py_type) + py_type = self.py_type + if state_representation == "workflow_step_linked": + py_type = allow_connected_value(py_type) + return dynamic_model_information_from_py_type(self, py_type) @property def request_requires_value(self) -> bool: @@ -302,6 +326,10 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam return dynamic_model_information_from_py_type(self, self.py_type_internal) elif state_representation == "test_case": 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) + elif state_representation == "workflow_step_linked": + return dynamic_model_information_from_py_type(self, ConnectedValue) @property def request_requires_value(self) -> bool: @@ -336,8 +364,10 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam return allow_batching(dynamic_model_information_from_py_type(self, self.py_type)) elif state_representation == "request_internal": return allow_batching(dynamic_model_information_from_py_type(self, self.py_type_internal)) - else: - raise NotImplementedError("...") + elif state_representation == "workflow_step": + 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) @property def request_requires_value(self) -> bool: @@ -352,7 +382,15 @@ def py_type(self) -> Type: return optional_if_needed(StrictStr, self.optional) def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: - return dynamic_model_information_from_py_type(self, self.py_type) + py_type = self.py_type + requires_value = not self.optional + if state_representation == "workflow_step_linked": + py_type = allow_connected_value(py_type) + elif state_representation == "workflow_step" and not self.optional: + # allow it to be linked in so force allow optional... + py_type = optional(py_type) + requires_value = False + return dynamic_model_information_from_py_type(self, py_type, requires_value=requires_value) @property def request_requires_value(self) -> bool: @@ -388,11 +426,34 @@ def validate_color_str(value) -> str: ensure_color_valid(value) return value + @staticmethod + def validate_color_str_if_value(value) -> str: + if value: + ensure_color_valid(value) + return value + + @staticmethod + def validate_color_str_or_connected_value(value) -> str: + if not isinstance(value, ConnectedValue): + ensure_color_valid(value) + return value + def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: - validators = {"color_format": field_validator(self.name)(ColorParameterModel.validate_color_str)} + py_type = self.py_type + initialize: Any = ... + if state_representation == "workflow_step_linked": + py_type = allow_connected_value(py_type) + validators = { + "color_format": field_validator(self.name)(ColorParameterModel.validate_color_str_or_connected_value) + } + elif state_representation == "workflow_step": + initialize = None + validators = {"color_format": field_validator(self.name)(ColorParameterModel.validate_color_str_if_value)} + else: + validators = {"color_format": field_validator(self.name)(ColorParameterModel.validate_color_str)} return DynamicModelInformation( self.name, - (self.py_type, ...), + (py_type, initialize), validators, ) @@ -412,7 +473,10 @@ def py_type(self) -> Type: return optional_if_needed(StrictBool, self.optional) def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: - return dynamic_model_information_from_py_type(self, self.py_type) + py_type = self.py_type + if state_representation == "workflow_step_linked": + py_type = allow_connected_value(py_type) + return dynamic_model_information_from_py_type(self, py_type) @property def request_requires_value(self) -> bool: @@ -460,19 +524,38 @@ class SelectParameterModel(BaseGalaxyToolParameterModelDefinition): options: Optional[List[LabelValue]] = None multiple: bool - @property - def py_type(self) -> Type: + def py_type_if_required(self, allow_connections=False) -> 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) else: py_type = StrictStr if self.multiple: - py_type = list_type(py_type) - return optional_if_needed(py_type, self.optional) + if allow_connections: + py_type = list_type(allow_connected_value(py_type)) + else: + py_type = list_type(py_type) + elif allow_connections: + py_type = allow_connected_value(py_type) + return py_type + + @property + def py_type(self) -> Type: + return optional_if_needed(self.py_type_if_required(), self.optional) + + @property + def py_type_workflow_step(self) -> Type: + # this is always optional in this context + return optional(self.py_type_if_required()) def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: - return dynamic_model_information_from_py_type(self, self.py_type) + if state_representation == "workflow_step": + return dynamic_model_information_from_py_type(self, self.py_type_workflow_step, requires_value=False) + 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)) + else: + return dynamic_model_information_from_py_type(self, self.py_type) @property def has_selected_static_option(self): @@ -501,6 +584,18 @@ def request_requires_value(self) -> bool: DiscriminatorType = Union[bool, str] +def cond_test_parameter_default_value(test_parameter: Union["BooleanParameterModel", "SelectParameterModel"]) -> Optional[DiscriminatorType]: + default_value: Optional[DiscriminatorType] = None + if isinstance(test_parameter, BooleanParameterModel): + default_value = test_parameter.value + elif isinstance(test_parameter, SelectParameterModel): + select_parameter = cast(SelectParameterModel, test_parameter) + select_default_value = select_parameter.default_value + if select_default_value is not None: + default_value = select_default_value + return default_value + + class ConditionalWhen(StrictModel): discriminator: DiscriminatorType parameters: List["ToolParameterT"] @@ -904,6 +999,14 @@ def create_test_case_model(tool: ToolParameterBundle, name: str = "DynamicModelF return create_field_model(tool.input_models, name, "test_case") +def create_workflow_step_model(tool: ToolParameterBundle, name: str = "DynamicModelForTool") -> Type[BaseModel]: + return create_field_model(tool.input_models, name, "workflow_step") + + +def create_workflow_step_linked_model(tool: ToolParameterBundle, name: str = "DynamicModelForTool") -> Type[BaseModel]: + return create_field_model(tool.input_models, name, "workflow_step_linked") + + def create_field_model( tool_parameter_models: Union[List[ToolParameterModel], List[ToolParameterT]], name: str, @@ -956,3 +1059,13 @@ def validate_internal_job(tool: ToolParameterBundle, request: Dict[str, Any]) -> def validate_test_case(tool: ToolParameterBundle, request: Dict[str, Any]) -> None: pydantic_model = create_test_case_model(tool) validate_against_model(pydantic_model, request) + + +def validate_workflow_step(tool: ToolParameterBundle, request: Dict[str, Any]) -> None: + pydantic_model = create_workflow_step_model(tool) + validate_against_model(pydantic_model, request) + + +def validate_workflow_step_linked(tool: ToolParameterBundle, request: Dict[str, Any]) -> None: + pydantic_model = create_workflow_step_linked_model(tool) + validate_against_model(pydantic_model, request) diff --git a/lib/galaxy/tool_util/parameters/state.py b/lib/galaxy/tool_util/parameters/state.py index 3991054bbd33..0849d1512882 100644 --- a/lib/galaxy/tool_util/parameters/state.py +++ b/lib/galaxy/tool_util/parameters/state.py @@ -18,6 +18,8 @@ create_job_internal_model, create_request_internal_model, create_request_model, + create_workflow_step_linked_model, + create_workflow_step_model, StateRepresentationT, ToolParameterBundle, ToolParameterBundleModel, @@ -96,3 +98,19 @@ class TestCaseToolState(ToolState): def _parameter_model_for(cls, input_models: ToolParameterBundle) -> Type[BaseModel]: # implement a test case model... return create_request_internal_model(input_models) + + +class WorkflowStepToolState(ToolState): + state_representation: Literal["workflow_step"] = "workflow_step" + + @classmethod + def _parameter_model_for(cls, input_models: ToolParameterBundle) -> Type[BaseModel]: + return create_workflow_step_model(input_models) + + +class WorkflowStepLinkedToolState(ToolState): + state_representation: Literal["workflow_step_linked"] = "workflow_step_linked" + + @classmethod + def _parameter_model_for(cls, input_models: ToolParameterBundle) -> Type[BaseModel]: + return create_workflow_step_linked_model(input_models) diff --git a/lib/galaxy/workflow/gx_validator.py b/lib/galaxy/workflow/gx_validator.py new file mode 100644 index 000000000000..200e80af19cb --- /dev/null +++ b/lib/galaxy/workflow/gx_validator.py @@ -0,0 +1,60 @@ +""""A validator for Galaxy workflows that is hooked up to Galaxy internals. + +The interface is designed to be usable from the tool shed for external tooling, +but for internal tooling - Galaxy should have its own tool available. +""" + +from typing import Dict + +from galaxy.tool_util.models import ( + parse_tool, + ParsedTool, +) +from galaxy.tool_util.version import parse_version +from galaxy.tool_util.version_util import AnyVersionT +from galaxy.tools.stock import stock_tool_sources +from .validator import ( + GetToolInfo, + validate_workflow as validate_workflow_generic, +) + + +class GalaxyGetToolInfo(GetToolInfo): + stock_tools_by_version: Dict[str, Dict[AnyVersionT, ParsedTool]] + stock_tools_latest_version: Dict[str, AnyVersionT] + + def __init__(self): + # todo take in a toolbox in the future... + stock_tools: Dict[str, Dict[str, ParsedTool]] = {} + stock_tools_latest_version: Dict[str, AnyVersionT] = {} + for stock_tool in stock_tool_sources(): + id = stock_tool.parse_id() + version = stock_tool.parse_version() + if version is not None: + version_object = parse_version(version) + if id not in stock_tools: + stock_tools[id] = {} + if version_object is not None: + stock_tools_latest_version[id] = version_object + try: + stock_tools[id][version_object] = parse_tool(stock_tool) + except Exception: + pass + if version_object and version_object > stock_tools_latest_version[id]: + stock_tools_latest_version[id] = version_object + self.stock_tools = stock_tools + self.stock_tools_latest_version = stock_tools_latest_version + + def get_tool_info(self, tool_id: str, tool_version: str) -> ParsedTool: + if tool_version is not None: + return self.stock_tools[tool_id][parse_version(tool_version)] + else: + latest_verison = self.stock_tools_latest_version[tool_id] + return self.stock_tools[tool_id][latest_verison] + + +GET_TOOL_INFO = GalaxyGetToolInfo() + + +def validate_workflow(as_dict): + return validate_workflow_generic(as_dict, get_tool_info=GET_TOOL_INFO) diff --git a/lib/galaxy/workflow/validator.py b/lib/galaxy/workflow/validator.py new file mode 100644 index 000000000000..65bef0cf3d16 --- /dev/null +++ b/lib/galaxy/workflow/validator.py @@ -0,0 +1,179 @@ +"""Validator for gxformat2 workflows using tool state work. + +This shouldn't make use of Galaxy internals outside of galaxy-tool-util, +this is meant to belong to a package external to Galaxy and can be connected +to the tool shed for tool information for instance. +""" + +from typing import ( + Any, + cast, + Dict, + Optional, + Protocol, + Union, +) + +from gxformat2.model import ( + ConnectDict, + get_native_step_type, + pop_connect_from_step_dict, + setup_connected_values, + steps_as_list, +) + +from galaxy.tool_util.models import ParsedTool +from galaxy.tool_util.parameters import ( + ConditionalParameterModel, + ConditionalWhen, + RepeatParameterModel, + ToolParameterT, + WorkflowStepLinkedToolState, + WorkflowStepToolState, +) + +StepDict = Dict[str, Any] + + +class GetToolInfo(Protocol): + + def get_tool(self, tool_id: str, tool_version: str) -> ParsedTool: ... + + +def validate_step_against(step_dict: StepDict, parsed_tool: ParsedTool): + source_tool_state_model = WorkflowStepToolState.parameter_model_for(parsed_tool.inputs) + linked_tool_state_model = WorkflowStepLinkedToolState.parameter_model_for(parsed_tool.inputs) + contains_format2_state = "state" in step_dict + contains_native_state = "tool_state" in step_dict + if contains_format2_state: + source_tool_state_model.model_validate(step_dict["state"]) + if not contains_native_state: + if not contains_format2_state: + step_dict["state"] = {} + # setup links and then validate against model... + linked_step = merge_inputs(step_dict, parsed_tool) + linked_tool_state_model.model_validate(linked_step["state"]) + + +def merge_inputs(step_dict: StepDict, parsed_tool: ParsedTool) -> StepDict: + connect = pop_connect_from_step_dict(step_dict) + step_dict = setup_connected_values(step_dict, connect) + tool_inputs = parsed_tool.inputs + + state_at_level = step_dict["state"] + + for tool_input in tool_inputs: + _merge_into_state(connect, tool_input, state_at_level) + + for key in connect: + raise Exception(f"Failed to find parameter definition matching workflow linked key {key}") + return step_dict + + +def _merge_into_state(connect, tool_input: ToolParameterT, state: dict, prefix: Optional[str] = None, branch_connect=None): + if branch_connect is None: + branch_connect = connect + + name = tool_input.name + parameter_type = tool_input.parameter_type + state_path = name if prefix is None else f"{prefix}|{name}" + if parameter_type == "gx_conditional": + conditional_state = state.get(name, {}) + if name not in state: + state[name] = conditional_state + + conditional = cast(ConditionalParameterModel, tool_input) + when: ConditionalWhen = _select_which_when(conditional, conditional_state) + test_parameter = conditional.test_parameter + test_parameter_name = test_parameter.name + + conditional_connect = keys_starting_with(branch_connect, state_path) + _merge_into_state(connect, test_parameter, conditional_state, prefix=state_path, branch_connect=conditional_connect) + for when_parameter in when.parameters: + _merge_into_state(connect, when_parameter, conditional_state, prefix=state_path, branch_connect=conditional_connect) + elif parameter_type == "gx_repeat": + repeat_state_array = state.get(name, []) + repeat = cast(RepeatParameterModel, tool_input) + repeat_instance_connects = repeat_inputs_to_array(state_path, connect) + for i, repeat_instance_connect in enumerate(repeat_instance_connects): + while len(repeat_state_array) <= i: + repeat_state_array.append({}) + + repeat_instance_prefix = f"{state_path}_{i}" + for repeat_parameter in repeat.parameters: + _merge_into_state( + connect, repeat_parameter, repeat_state_array[i], prefix=repeat_instance_prefix, branch_connect=repeat_instance_connect + ) + if repeat_state_array and not name in state: + state[name] = repeat_state_array + else: + if state_path in branch_connect: + state[name] = {"__class__": "ConnectedValue"} + del connect[state_path] + + +def _select_which_when(conditional: ConditionalParameterModel, state: dict) -> ConditionalWhen: + test_parameter = conditional.test_parameter + test_parameter_name = test_parameter.name + explicit_test_value = state.get(test_parameter_name) + if explicit_test_value is not None and not isinstance(explicit_test_value, (str, bool)): + raise Exception(f"Invalid conditional test value ({explicit_test_value}) for parameter ({test_parameter_name})") + test_value = cast(Optional[Union[str, bool]], 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 repeat_inputs_to_array(state_path: str, inputs: dict): + repeat_connect = keys_starting_with(inputs, state_path + "_") + highest_count = -1 + for key, value in repeat_connect.items(): + repeat_num_str = key[len(state_path) + 1 :].split("|")[0] + try: + repeat_num = int(repeat_num_str) + if repeat_num > highest_count: + highest_count = repeat_num + except ValueError: + continue + + params = [] + for i in range(highest_count + 1): + instance_params = {} + params.append(instance_params) + for key, value in repeat_connect.items(): + repeat_num_str = key[len(state_path) + 1 :].split("|")[0] + try: + repeat_num = int(repeat_num_str) + params[repeat_num][key] = value + except ValueError: + continue + return params + + +def keys_starting_with(connect: ConnectDict, path: str) -> ConnectDict: + subset = {} + for key, value in connect.items(): + if key.startswith(path): + subset[key] = value + return subset + + +def validate_step(step_dict: StepDict, get_tool_info: GetToolInfo): + step_type = get_native_step_type(step_dict) + if step_type != "tool": + return + tool_id = step_dict.get("tool_id") + tool_version = step_dict.get("tool_version") + parsed_tool = get_tool_info.get_tool_info(tool_id, tool_version) + if parsed_tool is not None: + validate_step_against(step_dict, parsed_tool) + + +def validate_workflow(as_dict, get_tool_info: GetToolInfo): + steps = steps_as_list(as_dict) + for step in steps: + validate_step(step, get_tool_info) diff --git a/test/unit/tool_util/parameter_specification.yml b/test/unit/tool_util/parameter_specification.yml index 31580d3422d4..ace6a7516caf 100644 --- a/test/unit/tool_util/parameter_specification.yml +++ b/test/unit/tool_util/parameter_specification.yml @@ -14,6 +14,7 @@ # Things to verify: # - non optional, multi-selects require a selection (see TODO below...) +# - https://github.com/galaxyproject/galaxy/issues/18541 gx_int: request_valid: - parameter: 5 @@ -25,12 +26,27 @@ gx_int: - parameter: "null" - parameter: "None" - parameter: { 5 } + - parameter: {__class__: 'ConnectedValue'} test_case_valid: - parameter: 5 - {} test_case_invalid: - parameter: null - parameter: "5" + workflow_step_valid: + - parameter: 5 + - {} + workflow_step_invalid: + - parameterx: 5 + - parameter: 'foobar' + workflow_step_linked_valid: + - parameter: 5 + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - parameter: null + - parameter: 'foobar' + - parameter: {__class__: 'ConnectedValue2'} + gx_boolean: request_valid: @@ -44,6 +60,21 @@ gx_boolean: # Marius and John were on fence here. - parameter: "mytrue" - parameter: null + - parameter: {__class__: 'ConnectedValue'} + workflow_step_valid: + - parameter: True + - {} + workflow_step_invalid: + - parameter: "true" + - parameter: mytrue + - parameter: null + workflow_step_linked_valid: + - parameter: True + - {} + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - parameter: "true" + - parameter: {__class__: 'ConnectedValue3'} gx_int_optional: request_valid: @@ -55,17 +86,57 @@ gx_int_optional: - parameter: "None" - parameter: "null" - parameter: [5] + - parameter: {__class__: 'ConnectedValue'} + workflow_step_valid: + - parameter: 5 + - parameter: null + - {} + workflow_step_invalid: + - parameter: "5" + - parameter: "None" + - parameter: "null" + - parameter: [5] + workflow_step_linked_valid: + - parameter: 5 + - parameter: null + - {} + - parameter: {__class__: 'ConnectedValue'} gx_text: request_valid: - parameter: moocow - parameter: 'some spaces' - parameter: '' + - {} request_invalid: - parameter: 5 - parameter: null - parameter: {} - parameter: { "moo": "cow" } + - parameter: {__class__: 'ConnectedValue'} + workflow_step_valid: + - parameter: moocow + - parameter: 'some spaces' + - parameter: '' + - {} + workflow_step_invalid: + - parameter: 5 + - parameter: null + - parameter: {} + - parameter: { "moo": "cow" } + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_valid: + - parameter: moocow + - parameter: 'some spaces' + - parameter: '' + - {} + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - parameter: 5 + - parameter: null + - parameter: {} + - parameter: { "moo": "cow" } + - parameter: {"class": 'ConnectedValue'} gx_text_optional: request_valid: @@ -77,6 +148,26 @@ gx_text_optional: - parameter: 5 - parameter: {} - parameter: { "moo": "cow" } + workflow_step_valid: + - parameter: moocow + - parameter: 'some spaces' + - parameter: '' + - parameter: null + workflow_step_invalid: + - parameter: 5 + - parameter: {} + - parameter: { "moo": "cow" } + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_valid: + - parameter: moocow + - parameter: 'some spaces' + - parameter: '' + - parameter: null + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - parameter: 5 + - parameter: {} + - parameter: { "moo": "cow" } gx_select: request_valid: @@ -105,6 +196,19 @@ gx_select: test_case_invalid: - parameter: {} - parameter: null + workflow_step_valid: + - parameter: "--ex1" + - {} + workflow_step_invalid: + - parameter: 'foobar' + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_valid: + - parameter: "--ex1" + - parameter: {__class__: 'ConnectedValue'} + - {} + workflow_step_linked_invalid: + - parameter: 'foobar' + - parameter: null gx_select_optional: request_valid: @@ -120,6 +224,28 @@ gx_select_optional: - parameter: ["ex2"] - parameter: {} - parameter: 5 + workflow_step_valid: + - parameter: "--ex1" + - parameter: "ex2" + - parameter: null + - {} + workflow_step_invalid: + - parameter: "Ex1" + - parameter: ["ex2"] + - parameter: {} + - parameter: 5 + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_valid: + - parameter: "--ex1" + - parameter: "ex2" + - parameter: null + - {} + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - parameter: "Ex1" + - parameter: ["ex2"] + - parameter: {} + - parameter: 5 # TODO: confirm null should vaguely not be allowed here gx_select_multiple: @@ -132,6 +258,32 @@ gx_select_multiple: - parameter: {} - parameter: 5 - {} + workflow_step_valid: + - parameter: ["--ex1"] + - parameter: ["ex2"] + - {} # could come in linked... + # ... hmmm? this should maybe be invalid right? + - parameter: null + workflow_step_invalid: + - parameter: ["Ex1"] + - parameter: {} + - parameter: 5 + - parameter: {__class__: 'ConnectedValue'} + - parameter: [{__class__: 'ConnectedValue'}] + workflow_step_linked_valid: + - parameter: ["--ex1"] + - parameter: ["ex2"] + - parameter: [{__class__: 'ConnectedValue'}] + workflow_step_linked_invalid: + - parameter: ["Ex1"] + - parameter: {} + - parameter: 5 + - {} + # might be wrong? I guess we would expect the semantic of this to do like a map-over + # but as far as I am aware that is not implemented https://github.com/galaxyproject/galaxy/issues/18541 + - parameter: {__class__: 'ConnectedValue'} + # they are non-optinoal right? + - parameter: null gx_select_multiple_optional: request_valid: @@ -154,6 +306,19 @@ gx_hidden: - parameter: 5 - parameter: {} - parameter: { "moo": "cow" } + workflow_step_valid: + - parameter: moocow + - {} + workflow_step_invalid: + - parmaeter: 5 + - parameter: {} + - parameter: { "moo": "cow" } + workflow_step_linked_valid: + - parameter: moocow + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - parameter: 5 + - parameter: null gx_hidden_optional: request_valid: @@ -165,7 +330,21 @@ gx_hidden_optional: - parameter: 5 - parameter: {} - parameter: { "moo": "cow" } - + workflow_step_valid: + - parameter: moocow + - {} + - parameter: null + workflow_step_invalid: + - parmaeter: 5 + - parameter: {} + - parameter: { "moo": "cow" } + workflow_step_linked_valid: + - parameter: moocow + - parameter: {__class__: 'ConnectedValue'} + - parameter: null + workflow_step_linked_invalid: + - parameter: 5 + gx_float: request_valid: - parameter: 5 @@ -178,6 +357,30 @@ gx_float: - parameter: "5" - parameter: "5.0" - parameter: { "moo": "cow" } + test_case_valid: + - parameter: 5 + - parameter: 5.0 + - {} + test_case_invalid: + - parameter: null + - parameter: "5.0" + - parameter: "5.1" + workflow_step_valid: + - parameter: 5 + - parameter: 5.0 + - {} + workflow_step_invalid: + - parameterx: 5 + - parameter: 'foobar' + workflow_step_linked_valid: + - parameter: 5 + - parameter: 5.4 + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - parameter: null + - parameter: 'foobar' + - parameter: {__class__: 'ConnectedValue2'} + gx_float_optional: request_valid: @@ -191,6 +394,29 @@ gx_float_optional: - parameter: "5.0" - parameter: {} - parameter: { "moo": "cow" } + test_case_valid: + - parameter: 5 + - parameter: 5.0 + - {} + - parameter: null + test_case_invalid: + - parameter: "5.0" + - parameter: "5.1" + workflow_step_valid: + - parameter: 5 + - parameter: 5.0 + - {} + workflow_step_invalid: + - parameterx: 5 + - parameter: 'foobar' + workflow_step_linked_valid: + - parameter: 5 + - parameter: 5.4 + - parameter: {__class__: 'ConnectedValue'} + - parameter: null + workflow_step_linked_invalid: + - parameter: 'foobar' + - parameter: {__class__: 'ConnectedValue2'} gx_color: request_valid: @@ -200,6 +426,22 @@ gx_color: - parameter: null - parameter: {} - parameter: '#abcd' + workflow_step_valid: + - parameter: '#aabbcc' + - parameter: '#000000' + - {} + workflow_step_invalid: + - parameterx: '#aabbcc' + - parameter: 'foobar' + - parameter: 5 + workflow_step_linked_valid: + - parameter: '#aabbcc' + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - parameter: null + - parameter: 'foobar' + - parameter: 5 + - parameter: {__class__: 'ConnectedValue2'} gx_data: request_valid: @@ -236,6 +478,21 @@ gx_data: # expanded out. - parameter: {__class__: "Batch", values: [{src: hdca, id: 5}]} - parameter: {src: hda, id: abcdabcd} + workflow_step_valid: + - {} + workflow_step_invalid: + - {src: hda, id: abcdabcd} + - {src: hda, id: 7} + - parameter: {__class__: "Batch", values: [{src: hdca, id: 5}]} + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_valid: + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - {} + - {src: hda, id: abcdabcd} + - {src: hda, id: 7} + - parameter: {__class__: "Batch", values: [{src: hdca, id: 5}]} + - parameter: {__class__: 'ConnectedValueX'} gx_data_optional: @@ -265,6 +522,21 @@ gx_data_optional: - parameter: true - parameter: 5 - parameter: "5" + workflow_step_valid: + - {} + workflow_step_invalid: + - {src: hda, id: abcdabcd} + - {src: hda, id: 7} + - parameter: {__class__: "Batch", values: [{src: hdca, id: 5}]} + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_valid: + - {} + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - {src: hda, id: abcdabcd} + - {src: hda, id: 7} + - parameter: {__class__: "Batch", values: [{src: hdca, id: 5}]} + - parameter: {__class__: 'ConnectedValueX'} gx_data_multiple: request_valid: @@ -353,6 +625,16 @@ gx_data_collection: - parameter: true - parameter: 5 - parameter: "5" + workflow_step_valid: + - {} + workflow_step_invalid: + - parameter: {src: hdca, id: abcdabcd} + - parameter: 5 + - parameter: {} + workflow_step_linked_valid: + - parameter: {__class__: 'ConnectedValue'} + workflow_step_linked_invalid: + - {} gx_data_collection_optional: request_valid: diff --git a/test/unit/tool_util/test_parameter_specification.py b/test/unit/tool_util/test_parameter_specification.py index a731a694d274..1ce5ce040049 100644 --- a/test/unit/tool_util/test_parameter_specification.py +++ b/test/unit/tool_util/test_parameter_specification.py @@ -19,6 +19,8 @@ validate_internal_request, validate_request, validate_test_case, + validate_workflow_step, + validate_workflow_step_linked, ) from galaxy.tool_util.parameters.json import to_json_schema_string from galaxy.tool_util.unittest_utils.parameters import ( @@ -70,6 +72,10 @@ def _test_file(file: str, specification=None): "job_internal_invalid": _assert_internal_jobs_invalid, "test_case_valid": _assert_test_cases_validate, "test_case_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, + "workflow_step_linked_invalid": _assert_workflow_steps_linked_invalid, } for valid_or_invalid, tests in combos.items(): @@ -158,6 +164,44 @@ def _assert_test_case_invalid(parameter, test_case) -> None: ), f"Parameter {parameter} didn't result in validation error on test_case {test_case} as expected." +def _assert_workflow_step_validates(parameter, workflow_step) -> None: + try: + validate_workflow_step(parameter_bundle(parameter), workflow_step) + except RequestParameterInvalidException as e: + raise AssertionError(f"Parameter {parameter} failed to validate workflow step {workflow_step}. {e}") + + +def _assert_workflow_step_invalid(parameter, workflow_step) -> None: + exc = None + try: + validate_workflow_step(parameter_bundle(parameter), workflow_step) + except RequestParameterInvalidException as e: + exc = e + assert ( + exc is not None + ), f"Parameter {parameter} didn't result in validation error on workflow step {workflow_step} as expected." + + +def _assert_workflow_step_linked_validates(parameter, workflow_step_linked) -> None: + try: + validate_workflow_step_linked(parameter_bundle(parameter), workflow_step_linked) + except RequestParameterInvalidException as e: + raise AssertionError( + f"Parameter {parameter} failed to validate linked workflow step {workflow_step_linked}. {e}" + ) + + +def _assert_workflow_step_linked_invalid(parameter, workflow_step_linked) -> None: + exc = None + try: + validate_workflow_step_linked(parameter_bundle(parameter), workflow_step_linked) + except RequestParameterInvalidException as e: + exc = e + assert ( + exc is not None + ), f"Parameter {parameter} didn't result in validation error on linked workflow step {workflow_step_linked} as expected." + + _assert_requests_validate = partial(_for_each, _assert_request_validates) _assert_requests_invalid = partial(_for_each, _assert_request_invalid) _assert_internal_requests_validate = partial(_for_each, _assert_internal_request_validates) @@ -166,6 +210,10 @@ def _assert_test_case_invalid(parameter, test_case) -> None: _assert_internal_jobs_invalid = partial(_for_each, _assert_internal_job_invalid) _assert_test_cases_validate = partial(_for_each, _assert_test_case_validates) _assert_test_cases_invalid = partial(_for_each, _assert_test_case_invalid) +_assert_workflow_steps_validate = partial(_for_each, _assert_workflow_step_validates) +_assert_workflow_steps_invalid = partial(_for_each, _assert_workflow_step_invalid) +_assert_workflow_steps_linked_validate = partial(_for_each, _assert_workflow_step_linked_validates) +_assert_workflow_steps_linked_invalid = partial(_for_each, _assert_workflow_step_linked_invalid) def decode_val(val: str) -> int: diff --git a/test/unit/workflows/invalid/extra_attribute.gxwf.yml b/test/unit/workflows/invalid/extra_attribute.gxwf.yml new file mode 100644 index 000000000000..6ae50799394c --- /dev/null +++ b/test/unit/workflows/invalid/extra_attribute.gxwf.yml @@ -0,0 +1,15 @@ +class: GalaxyWorkflow +inputs: + input: + type: int +outputs: + output: + outputSource: the_step/output +steps: + the_step: + tool_id: gx_int + tool_version: "1.0.0" + state: + parameter2: 6 + in: + parameter: input diff --git a/test/unit/workflows/invalid/missing_link.gxwf.yml b/test/unit/workflows/invalid/missing_link.gxwf.yml new file mode 100644 index 000000000000..526b40f6f502 --- /dev/null +++ b/test/unit/workflows/invalid/missing_link.gxwf.yml @@ -0,0 +1,11 @@ +class: GalaxyWorkflow +inputs: + input: + type: data +outputs: + output: + outputSource: the_step/output +steps: + the_step: + tool_id: gx_data + tool_version: "1.0.0" diff --git a/test/unit/workflows/invalid/wrong_link_name.gxwf.yml b/test/unit/workflows/invalid/wrong_link_name.gxwf.yml new file mode 100644 index 000000000000..f0e0e8d12004 --- /dev/null +++ b/test/unit/workflows/invalid/wrong_link_name.gxwf.yml @@ -0,0 +1,13 @@ +class: GalaxyWorkflow +inputs: + input: + type: int +outputs: + output: + outputSource: the_step/output +steps: + the_step: + tool_id: gx_int + tool_version: "1.0.0" + in: + parameterx: input diff --git a/test/unit/workflows/test_workflow_validation.py b/test/unit/workflows/test_workflow_validation.py new file mode 100644 index 000000000000..2eeccc8288b5 --- /dev/null +++ b/test/unit/workflows/test_workflow_validation.py @@ -0,0 +1,63 @@ +import os +from typing import Optional + +from gxformat2.yaml import ordered_load + +from galaxy.util import galaxy_directory +from galaxy.workflow.gx_validator import validate_workflow + +TEST_WORKFLOW_DIRECTORY = os.path.join(galaxy_directory(), "lib", "galaxy_test", "workflow") +SCRIPT_DIRECTORY = os.path.abspath(os.path.dirname(__file__)) + + +def test_validate_simple_functional_test_case_workflow(): + as_dict = framework_test_workflow_as_dict("multiple_versions") + validate_workflow(as_dict) + + +def test_validate_unit_test_workflows(): + validate_workflow(unit_test_workflow_as_dict("valid/simple_int")) + validate_workflow(unit_test_workflow_as_dict("valid/simple_data")) + validate_workflow(framework_test_workflow_as_dict("zip_collection")) + validate_workflow(framework_test_workflow_as_dict("empty_collection_sort")) + validate_workflow(framework_test_workflow_as_dict("flatten_collection")) + + +def test_invalidate_with_extra_attribute(): + e = _assert_validation_failure("invalid/extra_attribute") + assert "parameter2" in str(e) + + +def test_invalidate_with_wrong_link_name(): + e = _assert_validation_failure("invalid/wrong_link_name") + assert "parameterx" in str(e) + + +def test_invalidate_with_missing_link(): + e = _assert_validation_failure("invalid/missing_link") + assert "parameter" in str(e) + assert "type=missing" in str(e) + + +def _assert_validation_failure(workflow_name: str) -> Exception: + as_dict = unit_test_workflow_as_dict(workflow_name) + exc: Optional[Exception] = None + try: + validate_workflow(as_dict) + except Exception as e: + exc = e + assert exc, f"Target workflow ({workflow_name}) did not failure validation as expected." + return exc + + +def unit_test_workflow_as_dict(workflow_name: str) -> dict: + return _load(os.path.join(SCRIPT_DIRECTORY, f"{workflow_name}.gxwf.yml")) + + +def framework_test_workflow_as_dict(workflow_name: str) -> dict: + return _load(os.path.join(TEST_WORKFLOW_DIRECTORY, f"{workflow_name}.gxwf.yml")) + + +def _load(path: str) -> dict: + with open(path) as f: + return ordered_load(f) diff --git a/test/unit/workflows/test_workflow_validation_helpers.py b/test/unit/workflows/test_workflow_validation_helpers.py new file mode 100644 index 000000000000..5c24d6c42c03 --- /dev/null +++ b/test/unit/workflows/test_workflow_validation_helpers.py @@ -0,0 +1,56 @@ +from gxformat2.yaml import ordered_load + +from galaxy.workflow.gx_validator import GET_TOOL_INFO +from galaxy.workflow.validator import repeat_inputs_to_array + + +WORKFLOW_WITH_OUTPUTS = """ +class: GalaxyWorkflow +inputs: + input1: data +outputs: + wf_output_1: + outputSource: first_cat/out_file1 +steps: + first_cat: + tool_id: cat1 + in: + input1: input1 + queries_0|input2: input1 +""" + + +def test_get_tool(): + parsed_tool = GET_TOOL_INFO.get_tool_info("cat1", "1.0.0") + assert parsed_tool + assert parsed_tool.id == "cat1" + assert parsed_tool.version == "1.0.0" + + parsed_tool = GET_TOOL_INFO.get_tool_info("cat1", None) + assert parsed_tool + assert parsed_tool.id == "cat1" + assert parsed_tool.version == "1.0.0" + + +def test_repeat_inputs_to_array(): + rval = repeat_inputs_to_array( + "repeatfoo", + { + "moo": "cow", + }, + ) + assert not rval + rval = repeat_inputs_to_array( + "repeatfoo", + { + "moo": "cow", + "repeatfoo_0|moocow": ["moo"], + "repeatfoo_2|moocow": ["cow"], + }, + ) + assert len(rval) == 3 + assert "repeatfoo_0|moocow" in rval[0] + assert "repeatfoo_0|moocow" not in rval[1] + assert "repeatfoo_0|moocow" not in rval[2] + assert "repeatfoo_2|moocow" not in rval[1] + assert "repeatfoo_2|moocow" in rval[2] diff --git a/test/unit/workflows/valid/simple_data.gxwf.yml b/test/unit/workflows/valid/simple_data.gxwf.yml new file mode 100644 index 000000000000..44f0a90f3dd9 --- /dev/null +++ b/test/unit/workflows/valid/simple_data.gxwf.yml @@ -0,0 +1,13 @@ +class: GalaxyWorkflow +inputs: + input: + type: data +outputs: + output: + outputSource: the_step/output +steps: + the_step: + tool_id: gx_data + tool_version: "1.0.0" + in: + parameter: input diff --git a/test/unit/workflows/valid/simple_int.gxwf.yml b/test/unit/workflows/valid/simple_int.gxwf.yml new file mode 100644 index 000000000000..d7c53f78d0a6 --- /dev/null +++ b/test/unit/workflows/valid/simple_int.gxwf.yml @@ -0,0 +1,13 @@ +class: GalaxyWorkflow +inputs: + input: + type: int +outputs: + output: + outputSource: the_step/output +steps: + the_step: + tool_id: gx_int + tool_version: "1.0.0" + in: + parameter: input