diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index cf799974dffe..79635751754c 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -11556,6 +11556,21 @@ export interface components { */ workflow_step_id: number; }; + /** InvocationFailureWorkflowParameterInvalidResponse */ + InvocationFailureWorkflowParameterInvalidResponse: { + /** + * Details + * @description Message raised by validator + */ + details: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + reason: "workflow_parameter_invalid"; + /** Workflow parameter step that failed validation */ + workflow_step_id: number; + }; /** InvocationInput */ InvocationInput: { /** @@ -11637,7 +11652,8 @@ export interface components { | components["schemas"]["InvocationFailureExpressionEvaluationFailedResponse"] | components["schemas"]["InvocationFailureWhenNotBooleanResponse"] | components["schemas"]["InvocationUnexpectedFailureResponse"] - | components["schemas"]["InvocationEvaluationWarningWorkflowOutputNotFoundResponse"]; + | components["schemas"]["InvocationEvaluationWarningWorkflowOutputNotFoundResponse"] + | components["schemas"]["InvocationFailureWorkflowParameterInvalidResponse"]; /** InvocationOutput */ InvocationOutput: { /** diff --git a/client/src/components/Workflow/Run/WorkflowRun.vue b/client/src/components/Workflow/Run/WorkflowRun.vue index 4190257e3cdd..deaae371a21f 100644 --- a/client/src/components/Workflow/Run/WorkflowRun.vue +++ b/client/src/components/Workflow/Run/WorkflowRun.vue @@ -195,7 +195,7 @@ defineExpose({ Workflow submission failed: {{ submissionError }} { return `Defined workflow output '${invocationMessage.output_name}' was not found in step ${ invocationMessage.workflow_step_id + 1 }.`; + } else if (reason === "workflow_parameter_invalid") { + return `Workflow parameter on step ${invocationMessage.workflow_step_id + 1} failed validation: ${ + invocationMessage.details + }`; } else { return reason; } diff --git a/lib/galaxy/schema/invocation.py b/lib/galaxy/schema/invocation.py index 4d5ce80548e8..047e4250df2d 100644 --- a/lib/galaxy/schema/invocation.py +++ b/lib/galaxy/schema/invocation.py @@ -77,6 +77,7 @@ class FailureReason(str, Enum): expression_evaluation_failed = "expression_evaluation_failed" when_not_boolean = "when_not_boolean" unexpected_failure = "unexpected_failure" + workflow_parameter_invalid = "workflow_parameter_invalid" # The reasons below are attached to the invocation and user-actionable. @@ -212,6 +213,14 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound( ) +class GenericInvocationFailureWorkflowParameterInvalid(InvocationFailureMessageBase[DatabaseIdT], Generic[DatabaseIdT]): + reason: Literal[FailureReason.workflow_parameter_invalid] + workflow_step_id: int = Field( + ..., title="Workflow parameter step that failed validation", validation_alias="workflow_step_index" + ) + details: str = Field(..., description="Message raised by validator") + + InvocationCancellationReviewFailed = GenericInvocationCancellationReviewFailed[int] InvocationCancellationHistoryDeleted = GenericInvocationCancellationHistoryDeleted[int] InvocationCancellationUserRequest = GenericInvocationCancellationUserRequest[int] @@ -223,6 +232,7 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound( InvocationFailureWhenNotBoolean = GenericInvocationFailureWhenNotBoolean[int] InvocationUnexpectedFailure = GenericInvocationUnexpectedFailure[int] InvocationWarningWorkflowOutputNotFound = GenericInvocationEvaluationWarningWorkflowOutputNotFound[int] +InvocationFailureWorkflowParameterInvalid = GenericInvocationFailureWorkflowParameterInvalid[int] InvocationMessageUnion = Union[ InvocationCancellationReviewFailed, @@ -236,6 +246,7 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound( InvocationFailureWhenNotBoolean, InvocationUnexpectedFailure, InvocationWarningWorkflowOutputNotFound, + InvocationFailureWorkflowParameterInvalid, ] InvocationCancellationReviewFailedResponseModel = GenericInvocationCancellationReviewFailed[EncodedDatabaseIdField] @@ -253,6 +264,9 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound( InvocationWarningWorkflowOutputNotFoundResponseModel = GenericInvocationEvaluationWarningWorkflowOutputNotFound[ EncodedDatabaseIdField ] +InvocationFailureWorkflowParameterInvalidResponseModel = GenericInvocationFailureWorkflowParameterInvalid[ + EncodedDatabaseIdField +] _InvocationMessageResponseUnion = Annotated[ Union[ @@ -267,6 +281,7 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound( InvocationFailureWhenNotBooleanResponseModel, InvocationUnexpectedFailureResponseModel, InvocationWarningWorkflowOutputNotFoundResponseModel, + InvocationFailureWorkflowParameterInvalidResponseModel, ], Field(discriminator="reason"), ] diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index acfb3342443a..72a1685bc972 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -1337,7 +1337,34 @@ def parse_sanitizer_elem(self): return self.input_elem.find("sanitizer") def parse_validator_elems(self): - return self.input_elem.findall("validator") + elements = [] + attributes = { + "type": str, + "message": str, + "negate": string_as_bool, + "check": str, + "table_name": str, + "filename": str, + "metadata_name": str, + "metadata_column": str, + "min": float, + "max": float, + "exclude_min": string_as_bool, + "exclude_max": string_as_bool, + "split": str, + "skip": str, + "value": str, + "value_json": lambda v: json.loads(v) if v else None, + "line_startswith": str, + } + for elem in self.input_elem.findall("validator"): + elem_dict = {"content": elem.text} + for attribute, type_cast in attributes.items(): + val = elem.get(attribute) + if val: + elem_dict[attribute] = type_cast(val) + elements.append(elem_dict) + return elements def parse_dynamic_options(self) -> Optional[XmlDynamicOptions]: """Return a XmlDynamicOptions to describe dynamic options if options elem is available.""" diff --git a/lib/galaxy/tool_util/parser/yaml.py b/lib/galaxy/tool_util/parser/yaml.py index c2cde0752f3a..4957d5124aa9 100644 --- a/lib/galaxy/tool_util/parser/yaml.py +++ b/lib/galaxy/tool_util/parser/yaml.py @@ -375,6 +375,21 @@ def parse_when_input_sources(self): sources.append((value, case_page_source)) return sources + def parse_validator_elems(self): + elements = [] + if "validators" in self.input_dict: + for elem in self.input_dict["validators"]: + if "regex_match" in elem: + elements.append( + { + "message": elem.get("regex_doc"), + "content": elem["regex_match"], + "negate": elem.get("negate", False), + "type": "regex", + } + ) + return elements + def parse_static_options(self) -> List[Tuple[str, str, bool]]: static_options = [] input_dict = self.input_dict diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 6be6568578d1..f37bbbe436b6 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -134,7 +134,6 @@ DataCollectionToolParameter, DataToolParameter, HiddenToolParameter, - ImplicitConversionRequired, SelectTagParameter, SelectToolParameter, ToolParameter, @@ -153,6 +152,7 @@ ) from galaxy.tools.parameters.input_translation import ToolInputTranslator from galaxy.tools.parameters.meta import expand_meta_parameters +from galaxy.tools.parameters.populate_model import populate_model from galaxy.tools.parameters.workflow_utils import workflow_building_modes from galaxy.tools.parameters.wrapped_json import json_wrap from galaxy.util import ( @@ -2678,63 +2678,13 @@ def populate_model(self, request_context, inputs, state_inputs, group_inputs, ot """ Populates the tool model consumed by the client form builder. """ - other_values = ExpressionContext(state_inputs, other_values) - for input_index, input in enumerate(inputs.values()): - tool_dict = None - group_state = state_inputs.get(input.name, {}) - if input.type == "repeat": - tool_dict = input.to_dict(request_context) - group_size = len(group_state) - tool_dict["cache"] = [None] * group_size - group_cache: List[List[str]] = tool_dict["cache"] - for i in range(group_size): - group_cache[i] = [] - self.populate_model(request_context, input.inputs, group_state[i], group_cache[i], other_values) - elif input.type == "conditional": - tool_dict = input.to_dict(request_context) - if "test_param" in tool_dict: - test_param = tool_dict["test_param"] - test_param["value"] = input.test_param.value_to_basic( - group_state.get( - test_param["name"], input.test_param.get_initial_value(request_context, other_values) - ), - self.app, - ) - test_param["text_value"] = input.test_param.value_to_display_text(test_param["value"]) - for i in range(len(tool_dict["cases"])): - current_state = {} - if i == group_state.get("__current_case__"): - current_state = group_state - self.populate_model( - request_context, - input.cases[i].inputs, - current_state, - tool_dict["cases"][i]["inputs"], - other_values, - ) - elif input.type == "section": - tool_dict = input.to_dict(request_context) - self.populate_model(request_context, input.inputs, group_state, tool_dict["inputs"], other_values) - else: - try: - initial_value = input.get_initial_value(request_context, other_values) - tool_dict = input.to_dict(request_context, other_values=other_values) - tool_dict["value"] = input.value_to_basic( - state_inputs.get(input.name, initial_value), self.app, use_security=True - ) - tool_dict["default_value"] = input.value_to_basic(initial_value, self.app, use_security=True) - tool_dict["text_value"] = input.value_to_display_text(tool_dict["value"]) - except ImplicitConversionRequired: - tool_dict = input.to_dict(request_context) - # This hack leads client to display a text field - tool_dict["textable"] = True - except Exception: - tool_dict = input.to_dict(request_context) - log.exception("tools::to_json() - Skipping parameter expansion '%s'", input.name) - if input_index >= len(group_inputs): - group_inputs.append(tool_dict) - else: - group_inputs[input_index] = tool_dict + populate_model( + request_context=request_context, + inputs=inputs, + state_inputs=state_inputs, + group_inputs=group_inputs, + other_values=other_values, + ) def _map_source_to_history(self, trans, tool_inputs, params): # Need to remap dataset parameters. Job parameters point to original diff --git a/lib/galaxy/tools/parameters/basic.py b/lib/galaxy/tools/parameters/basic.py index 9669b62771e9..ff977483d992 100644 --- a/lib/galaxy/tools/parameters/basic.py +++ b/lib/galaxy/tools/parameters/basic.py @@ -315,7 +315,7 @@ def validate(self, value, trans=None) -> None: try: validator.validate(value, trans) except ValueError as e: - raise ValueError(f"Parameter {self.name}: {e}") from None + raise ParameterValueError(str(e), self.name, value) from None def to_dict(self, trans, other_values=None): """to_dict tool parameter. This can be overridden by subclasses.""" @@ -1982,7 +1982,7 @@ def do_validate(v): try: validator.validate(v, trans) except ValueError as e: - raise ValueError(f"Parameter {self.name}: {e}") from None + raise ParameterValueError(str(e), self.name, v) from None dataset_count = 0 if value: diff --git a/lib/galaxy/tools/parameters/populate_model.py b/lib/galaxy/tools/parameters/populate_model.py new file mode 100644 index 000000000000..41950bc3a03f --- /dev/null +++ b/lib/galaxy/tools/parameters/populate_model.py @@ -0,0 +1,74 @@ +import logging +from typing import ( + Any, + Dict, + List, +) + +from galaxy.util.expressions import ExpressionContext +from .basic import ImplicitConversionRequired + +log = logging.getLogger(__name__) + + +def populate_model(request_context, inputs, state_inputs, group_inputs: List[Dict[str, Any]], other_values=None): + """ + Populates the tool model consumed by the client form builder. + """ + other_values = ExpressionContext(state_inputs, other_values) + for input_index, input in enumerate(inputs.values()): + tool_dict = None + group_state = state_inputs.get(input.name, {}) + if input.type == "repeat": + tool_dict = input.to_dict(request_context) + group_size = len(group_state) + tool_dict["cache"] = [None] * group_size + group_cache: List[List[Dict[str, Any]]] = tool_dict["cache"] + for i in range(group_size): + group_cache[i] = [] + populate_model(request_context, input.inputs, group_state[i], group_cache[i], other_values) + elif input.type == "conditional": + tool_dict = input.to_dict(request_context) + if "test_param" in tool_dict: + test_param = tool_dict["test_param"] + test_param["value"] = input.test_param.value_to_basic( + group_state.get( + test_param["name"], input.test_param.get_initial_value(request_context, other_values) + ), + request_context.app, + ) + test_param["text_value"] = input.test_param.value_to_display_text(test_param["value"]) + for i in range(len(tool_dict["cases"])): + current_state = {} + if i == group_state.get("__current_case__"): + current_state = group_state + populate_model( + request_context, + input.cases[i].inputs, + current_state, + tool_dict["cases"][i]["inputs"], + other_values, + ) + elif input.type == "section": + tool_dict = input.to_dict(request_context) + populate_model(request_context, input.inputs, group_state, tool_dict["inputs"], other_values) + else: + try: + initial_value = input.get_initial_value(request_context, other_values) + tool_dict = input.to_dict(request_context, other_values=other_values) + tool_dict["value"] = input.value_to_basic( + state_inputs.get(input.name, initial_value), request_context.app, use_security=True + ) + tool_dict["default_value"] = input.value_to_basic(initial_value, request_context.app, use_security=True) + tool_dict["text_value"] = input.value_to_display_text(tool_dict["value"]) + except ImplicitConversionRequired: + tool_dict = input.to_dict(request_context) + # This hack leads client to display a text field + tool_dict["textable"] = True + except Exception: + tool_dict = input.to_dict(request_context) + log.exception("tools::to_json() - Skipping parameter expansion '%s'", input.name) + if input_index >= len(group_inputs): + group_inputs.append(tool_dict) + else: + group_inputs[input_index] = tool_dict diff --git a/lib/galaxy/tools/parameters/validation.py b/lib/galaxy/tools/parameters/validation.py index 6334fd95f8b8..a93a4cf05a6c 100644 --- a/lib/galaxy/tools/parameters/validation.py +++ b/lib/galaxy/tools/parameters/validation.py @@ -3,7 +3,6 @@ """ import abc -import json import logging import os.path @@ -86,7 +85,7 @@ class RegexValidator(Validator): @classmethod def from_element(cls, param, elem): - return cls(elem.get("message"), elem.text, elem.get("negate", "false")) + return cls(elem.get("message"), elem.get("content"), elem.get("negate", False)) def __init__(self, message, expression, negate): if message is None: @@ -111,11 +110,11 @@ class ExpressionValidator(Validator): @classmethod def from_element(cls, param, elem): - return cls(elem.get("message"), elem.text, elem.get("negate", "false")) + return cls(elem.get("message"), elem.get("content"), elem.get("negate", False)) def __init__(self, message, expression, negate): if message is None: - message = f"Value '%s' does not evaluate to {'True' if negate == 'false' else 'False'} for '{expression}'" + message = f"Value '%s' does not evaluate to {'True' if not negate else 'False'} for '{expression}'" super().__init__(message, negate) self.expression = expression # Save compiled expression, code objects are thread safe (right?) @@ -140,9 +139,9 @@ def from_element(cls, param, elem): elem.get("message"), elem.get("min"), elem.get("max"), - elem.get("exclude_min", "false"), - elem.get("exclude_max", "false"), - elem.get("negate", "false"), + elem.get("exclude_min", False), + elem.get("exclude_max", False), + elem.get("negate", False), ) def __init__(self, message, range_min, range_max, exclude_min=False, exclude_max=False, negate=False): @@ -178,7 +177,7 @@ class LengthValidator(InRangeValidator): @classmethod def from_element(cls, param, elem): - return cls(elem.get("message"), elem.get("min"), elem.get("max"), elem.get("negate", "false")) + return cls(elem.get("message"), elem.get("min"), elem.get("max"), elem.get("negate", False)) def __init__(self, message, length_min, length_max, negate): if message is None: @@ -198,10 +197,10 @@ class DatasetOkValidator(Validator): @classmethod def from_element(cls, param, elem): - negate = elem.get("negate", "false") + negate = elem.get("negate", False) message = elem.get("message") if message is None: - if negate == "false": + if not negate: message = "The selected dataset is still being generated, select another dataset or wait until it is completed" else: message = "The selected dataset must not be in state OK" @@ -220,9 +219,9 @@ class DatasetEmptyValidator(Validator): @classmethod def from_element(cls, param, elem): message = elem.get("message") - negate = elem.get("negate", "false") + negate = elem.get("negate", False) if not message: - message = f"The selected dataset is {'non-' if negate == 'true' else ''}empty, this tool expects {'non-' if negate == 'false' else ''}empty files." + message = f"The selected dataset is {'non-' if negate else ''}empty, this tool expects {'non-' if negate == 'false' else ''}empty files." return cls(message, negate) def validate(self, value, trans=None): @@ -238,7 +237,7 @@ class DatasetExtraFilesPathEmptyValidator(Validator): @classmethod def from_element(cls, param, elem): message = elem.get("message") - negate = elem.get("negate", "false") + negate = elem.get("negate", False) if not message: message = f"The selected dataset's extra_files_path directory is {'non-' if negate == 'true' else ''}empty or does {'not ' if negate == 'false' else ''}exist, this tool expects {'non-' if negate == 'false' else ''}empty extra_files_path directories associated with the selected input." return cls(message, negate) @@ -259,10 +258,10 @@ class MetadataValidator(Validator): def from_element(cls, param, elem): message = elem.get("message") return cls( - message=message, check=elem.get("check", ""), skip=elem.get("skip", ""), negate=elem.get("negate", "false") + message=message, check=elem.get("check", ""), skip=elem.get("skip", ""), negate=elem.get("negate", False) ) - def __init__(self, message=None, check="", skip="", negate="false"): + def __init__(self, message=None, check="", skip="", negate=False): if not message: if not util.asbool(negate): message = "Metadata '%s' missing, click the pencil icon in the history item to edit / save the metadata attributes" @@ -292,7 +291,7 @@ class MetadataEqualValidator(Validator): requires_dataset_metadata = True - def __init__(self, metadata_name=None, value=None, message=None, negate="false"): + def __init__(self, metadata_name=None, value=None, message=None, negate=False): if not message: if not util.asbool(negate): message = f"Metadata value for '{metadata_name}' must be '{value}', but it is '%s'." @@ -304,12 +303,12 @@ def __init__(self, metadata_name=None, value=None, message=None, negate="false") @classmethod def from_element(cls, param, elem): - value = elem.get("value", None) or json.loads(elem.get("value_json", "null")) + value = elem.get("value") or elem.get("value_json") return cls( - metadata_name=elem.get("metadata_name", None), + metadata_name=elem.get("metadata_name"), value=value, - message=elem.get("message", None), - negate=elem.get("negate", "false"), + message=elem.get("message"), + negate=elem.get("negate", False), ) def validate(self, value, trans=None): @@ -328,9 +327,9 @@ class UnspecifiedBuildValidator(Validator): @classmethod def from_element(cls, param, elem): message = elem.get("message") - negate = elem.get("negate", "false") + negate = elem.get("negate", False) if not message: - message = f"{'Unspecified' if negate == 'false' else 'Specified'} genome build, click the pencil icon in the history item to {'set' if negate == 'false' else 'remove'} the genome build" + message = f"{'Unspecified' if not negate else 'Specified'} genome build, click the pencil icon in the history item to {'set' if negate == 'false' else 'remove'} the genome build" return cls(message, negate) def validate(self, value, trans=None): @@ -351,7 +350,7 @@ class NoOptionsValidator(Validator): @classmethod def from_element(cls, param, elem): message = elem.get("message") - negate = elem.get("negate", "false") + negate = elem.get("negate", False) if not message: message = f"{'No options' if negate == 'false' else 'Options'} available for selection" return cls(message, negate) @@ -368,9 +367,9 @@ class EmptyTextfieldValidator(Validator): @classmethod def from_element(cls, param, elem): message = elem.get("message") - negate = elem.get("negate", "false") + negate = elem.get("negate", False) if not message: - if negate == "false": + if not negate: message = elem.get("message", "Field requires a value") else: message = elem.get("message", "Field must not set a value") @@ -406,7 +405,7 @@ def from_element(cls, param, elem): line_startswith = elem.get("line_startswith") if line_startswith: line_startswith = line_startswith.strip() - negate = elem.get("negate", "false") + negate = elem.get("negate", False) return cls(filename, metadata_name, metadata_column, message, line_startswith, split, negate) def __init__( @@ -417,7 +416,7 @@ def __init__( message="Value for metadata not found.", line_startswith=None, split="\t", - negate="false", + negate=False, ): super().__init__(message, negate) self.metadata_name = metadata_name @@ -456,10 +455,10 @@ def from_element(cls, param, elem): except ValueError: pass message = elem.get("message", f"Value was not found in {table_name}.") - negate = elem.get("negate", "false") + negate = elem.get("negate", False) return cls(tool_data_table, column, message, negate) - def __init__(self, tool_data_table, column, message="Value not found.", negate="false"): + def __init__(self, tool_data_table, column, message="Value not found.", negate=False): super().__init__(message, negate) self.valid_values = [] self._data_table_content_version = None @@ -496,7 +495,7 @@ class ValueNotInDataTableColumnValidator(ValueInDataTableColumnValidator): note: this is covered in a framework test (validation_value_in_datatable) """ - def __init__(self, tool_data_table, metadata_column, message="Value already present.", negate="false"): + def __init__(self, tool_data_table, metadata_column, message="Value already present.", negate=False): super().__init__(tool_data_table, metadata_column, message, negate) def validate(self, value, trans=None): @@ -532,11 +531,11 @@ def from_element(cls, param, elem): except ValueError: pass message = elem.get("message", f"Value for metadata {metadata_name} was not found in {table_name}.") - negate = elem.get("negate", "false") + negate = elem.get("negate", False) return cls(tool_data_table, metadata_name, metadata_column, message, negate) def __init__( - self, tool_data_table, metadata_name, metadata_column, message="Value for metadata not found.", negate="false" + self, tool_data_table, metadata_name, metadata_column, message="Value for metadata not found.", negate=False ): super().__init__(tool_data_table, metadata_column, message, negate) self.metadata_name = metadata_name @@ -558,7 +557,12 @@ class MetadataNotInDataTableColumnValidator(MetadataInDataTableColumnValidator): requires_dataset_metadata = True def __init__( - self, tool_data_table, metadata_name, metadata_column, message="Value for metadata not found.", negate="false" + self, + tool_data_table, + metadata_name, + metadata_column, + message="Value for metadata not found.", + negate=False, ): super().__init__(tool_data_table, metadata_name, metadata_column, message, negate) @@ -590,9 +594,9 @@ def from_element(cls, param, elem): elem.get("message"), elem.get("min"), elem.get("max"), - elem.get("exclude_min", "false"), - elem.get("exclude_max", "false"), - elem.get("negate", "false"), + elem.get("exclude_min", False), + elem.get("exclude_max", False), + elem.get("negate", False), ) ret.message = "Metadata: " + ret.message return ret diff --git a/lib/galaxy/workflow/modules.py b/lib/galaxy/workflow/modules.py index 610f804e0f47..1b72eb6d6309 100644 --- a/lib/galaxy/workflow/modules.py +++ b/lib/galaxy/workflow/modules.py @@ -4,15 +4,19 @@ import json import logging +import math import re from collections import defaultdict from typing import ( Any, cast, Dict, + get_args, Iterable, List, + Literal, Optional, + Tuple, Type, TYPE_CHECKING, Union, @@ -47,6 +51,7 @@ InvocationFailureExpressionEvaluationFailed, InvocationFailureOutputNotFound, InvocationFailureWhenNotBoolean, + InvocationFailureWorkflowParameterInvalid, ) from galaxy.tool_util.cwl.util import set_basename_and_derived_properties from galaxy.tool_util.parser.output_objects import ToolExpressionOutput @@ -70,12 +75,9 @@ from galaxy.tools.parameters.basic import ( BaseDataToolParameter, BooleanToolParameter, - ColorToolParameter, DataCollectionToolParameter, DataToolParameter, - FloatToolParameter, HiddenToolParameter, - IntegerToolParameter, parameter_types, raw_to_galaxy, SelectToolParameter, @@ -84,8 +86,10 @@ from galaxy.tools.parameters.grouping import ( Conditional, ConditionalWhen, + Repeat, ) from galaxy.tools.parameters.history_query import HistoryQuery +from galaxy.tools.parameters.populate_model import populate_model from galaxy.tools.parameters.workflow_utils import ( ConnectedValue, is_runtime_value, @@ -102,6 +106,7 @@ from galaxy.util.rules_dsl import RuleSet from galaxy.util.template import fill_template from galaxy.util.tool_shed.common_util import get_tool_shed_url_from_tool_shed_registry +from galaxy.workflow.workflow_parameter_input_definitions import get_default_parameter if TYPE_CHECKING: from galaxy.schema.invocation import InvocationMessageUnion @@ -117,6 +122,9 @@ # ones. RUNTIME_POST_JOB_ACTIONS_KEY = "__POST_JOB_ACTIONS__" +INPUT_PARAMETER_TYPES = Literal["text", "integer", "float", "boolean", "color"] +POSSIBLE_PARAMETER_TYPES: Tuple[INPUT_PARAMETER_TYPES] = get_args(INPUT_PARAMETER_TYPES) + class NoReplacement: def __str__(self): @@ -910,7 +918,7 @@ def to_dict(self, *args, **kwds): return as_dict -def optional_param(optional): +def optional_param(optional=None): bool_source = dict(name="optional", label="Optional", type="boolean", checked=optional) optional_value = BooleanToolParameter(None, bool_source) return optional_value @@ -1179,7 +1187,7 @@ def _parse_state_into_dict(self): class InputParameterModule(WorkflowModule): - POSSIBLE_PARAMETER_TYPES = ["text", "integer", "float", "boolean", "color"] + POSSIBLE_PARAMETER_TYPES = POSSIBLE_PARAMETER_TYPES type = "parameter_input" name = "Input parameter" default_parameter_type = "text" @@ -1192,7 +1200,6 @@ class InputParameterModule(WorkflowModule): def get_inputs(self): parameter_def = self._parse_state_into_dict() parameter_type = parameter_def["parameter_type"] - optional = parameter_def["optional"] select_source = dict(name="parameter_type", label="Parameter type", type="select", value=parameter_type) select_source["options"] = [ {"value": "text", "label": "Text"}, @@ -1214,54 +1221,10 @@ def get_inputs(self): parameter_type_cond.test_param = input_parameter_type cases = [] - for param_type in ["text", "integer", "float", "boolean", "color"]: - default_source: Dict[str, Union[int, float, bool, str]] = dict( - name="default", label="Default Value", type=param_type - ) - if param_type == "text": - if parameter_type == "text": - text_default = parameter_def.get("default") or "" - else: - text_default = "" - default_source["value"] = text_default - input_default_value: Union[ - TextToolParameter, - IntegerToolParameter, - FloatToolParameter, - BooleanToolParameter, - ColorToolParameter, - ] = TextToolParameter(None, default_source) - elif param_type == "integer": - if parameter_type == "integer": - integer_default = parameter_def.get("default") or 0 - else: - integer_default = 0 - default_source["value"] = integer_default - input_default_value = IntegerToolParameter(None, default_source) - elif param_type == "float": - if parameter_type == "float": - float_default = parameter_def.get("default") or 0.0 - else: - float_default = 0.0 - default_source["value"] = float_default - input_default_value = FloatToolParameter(None, default_source) - elif param_type == "boolean": - if parameter_type == "boolean": - boolean_default = parameter_def.get("default") or False - else: - boolean_default = False - default_source["value"] = boolean_default - default_source["checked"] = boolean_default - input_default_value = BooleanToolParameter(None, default_source) - elif param_type == "color": - if parameter_type == "color": - color_default = parameter_def.get("default") or "#000000" - else: - color_default = "#000000" - default_source["value"] = color_default - input_default_value = ColorToolParameter(None, default_source) + for param_type in POSSIBLE_PARAMETER_TYPES: + default_parameter = get_default_parameter(param_type) - optional_value = optional_param(optional) + optional_value = optional_param() optional_cond = Conditional("optional") optional_cond.test_param = optional_value @@ -1270,9 +1233,10 @@ def get_inputs(self): when_this_type.inputs = {} when_this_type.inputs["optional"] = optional_cond - specify_default_checked = "default" in parameter_def specify_default_source = dict( - name="specify_default", label="Specify a default value", type="boolean", checked=specify_default_checked + name="specify_default", + label="Specify a default value", + type="boolean", ) specify_default = BooleanToolParameter(None, specify_default_source) specify_default_cond = Conditional("specify_default") @@ -1281,7 +1245,7 @@ def get_inputs(self): when_specify_default_true = ConditionalWhen() when_specify_default_true.value = "true" when_specify_default_true.inputs = {} - when_specify_default_true.inputs["default"] = input_default_value + when_specify_default_true.inputs["default"] = default_parameter when_specify_default_false = ConditionalWhen() when_specify_default_false.value = "false" @@ -1292,12 +1256,15 @@ def get_inputs(self): when_true = ConditionalWhen() when_true.value = "true" - when_true.inputs = {} - when_true.inputs["default"] = specify_default_cond + when_true.inputs = {"default": specify_default_cond} when_false = ConditionalWhen() when_false.value = "false" - when_false.inputs = {} + # This is only present for backwards compatibility, + # We don't need this conditional since you can set + # a default value for optional and required parameters. + # TODO: version the state and upgrade it to a simpler version + when_false.inputs = {"default": specify_default_cond} optional_cases = [when_true, when_false] optional_cond.cases = optional_cases @@ -1309,43 +1276,61 @@ def get_inputs(self): label="Allow multiple selection", help="Only applies when connected to multi-select parameter(s)", type="boolean", - checked=parameter_def.get("multiple", False), ) + specify_multiple = BooleanToolParameter(None, specify_multiple_source) + + add_validators_repeat = Repeat("validators") + add_validators_repeat._title = "Add validator to restrict valid input" + add_validators_repeat.min = 0 + add_validators_repeat.max = math.inf + add_validators_repeat.inputs = { + "regex_match": TextToolParameter( + None, + { + "optional": False, + "name": "regex_match", + "label": "Specify regex", + "help": "Provided regex must match input value for input to be valid", + }, + ), + "regex_doc": TextToolParameter( + None, + { + "optional": False, + "name": "regex_doc", + "label": "Specify a message", + "help": "This message will be shown if the regex does not match the input", + }, + ), + } + # Insert multiple option as first option, which is determined by dictionary insert order - when_this_type.inputs = {"multiple": specify_multiple, **when_this_type.inputs} + when_this_type.inputs = { + "multiple": specify_multiple, + "validators": add_validators_repeat, + **when_this_type.inputs, + } restrict_how_source: Dict[str, Union[str, List[Dict[str, Union[str, bool]]]]] = dict( name="how", label="Restrict Text Values?", type="select" ) - if parameter_def.get("restrictions") is not None: - restrict_how_value = "staticRestrictions" - elif parameter_def.get("restrictOnConnections") is True: - restrict_how_value = "onConnections" - elif parameter_def.get("suggestions") is not None: - restrict_how_value = "staticSuggestions" - else: - restrict_how_value = "none" restrict_how_source["options"] = [ { "value": "none", "label": "Do not specify restrictions (default).", - "selected": restrict_how_value == "none", }, { "value": "onConnections", "label": "Attempt restriction based on connections.", - "selected": restrict_how_value == "onConnections", }, { "value": "staticRestrictions", "label": "Provide list of all possible values.", - "selected": restrict_how_value == "staticRestrictions", }, { "value": "staticSuggestions", "label": "Provide list of suggested values.", - "selected": restrict_how_value == "staticSuggestions", }, ] restrictions_cond = Conditional("restrictions") @@ -1408,6 +1393,12 @@ def get_inputs(self): parameter_type_cond.cases = cases return {"parameter_definition": parameter_type_cond} + def get_config_form(self, step=None): + """Serializes input parameters of a module into input dictionaries.""" + group_inputs: List[Dict[str, Any]] = [] + populate_model(self.trans, self.get_inputs(), self.state.inputs, group_inputs) + return {"title": self.name, "inputs": group_inputs} + def restrict_options(self, step, connections: Iterable[WorkflowStepConnection], default_value): try: static_options = [] @@ -1511,6 +1502,9 @@ def _parameter_def_list_to_options(parameter_value): parameter_kwds["options"] = _parameter_def_list_to_options(restriction_values) restricted_inputs = True + if is_text and parameter_def.get("validators"): + parameter_kwds["validators"] = parameter_def["validators"] + client_parameter_type = parameter_type if restricted_inputs: client_parameter_type = "select" @@ -1560,7 +1554,10 @@ def execute( self, trans, progress: "WorkflowProgress", invocation_step, use_cached_job: bool = False ) -> Optional[bool]: step = invocation_step.workflow_step - input_value = step.state.inputs["input"] + if step.id in progress.inputs_by_step_id: + input_value = progress.inputs_by_step_id[step.id] + else: + input_value = step.state.inputs["input"] if input_value is None: default_value = step.get_input_default_value(NO_REPLACEMENT) # TODO: look at parameter type and infer if value should be a dictionary @@ -1569,6 +1566,16 @@ def execute( if not isinstance(default_value, dict): default_value = {"value": default_value} input_value = default_value.get("value", NO_REPLACEMENT) + input_param = self.get_runtime_inputs(self)["input"] + # TODO: raise DelayedWorkflowEvaluation if replacement not ready ? Need test + try: + input_param.validate(input_value) + except ValueError as e: + raise FailWorkflowEvaluation( + why=InvocationFailureWorkflowParameterInvalid( + reason=FailureReason.workflow_parameter_invalid, workflow_step_id=step.id, details=str(e) + ) + ) step_outputs = dict(output=input_value) progress.set_outputs_for_input(invocation_step, step_outputs) return None @@ -1579,8 +1586,8 @@ def step_state_to_tool_state(self, state): if "default" in state: default_set = True default_value = state["default"] - state["optional"] = True multiple = state.get("multiple") + validators = state.get("validators") restrictions = state.get("restrictions") restrictOnConnections = state.get("restrictOnConnections") suggestions = state.get("suggestions") @@ -1600,6 +1607,8 @@ def step_state_to_tool_state(self, state): } if multiple is not None: state["parameter_definition"]["multiple"] = multiple + if validators is not None: + state["parameter_definition"]["validators"] = validators state["parameter_definition"]["restrictions"] = {} state["parameter_definition"]["restrictions"]["how"] = restrictions_how @@ -1643,6 +1652,8 @@ def _parse_state_into_dict(self): optional = False if "multiple" in parameters_def: rval["multiple"] = parameters_def["multiple"] + if "validators" in parameters_def: + rval["validators"] = parameters_def["validators"] restrictions_cond_values = parameters_def.get("restrictions") if restrictions_cond_values: diff --git a/lib/galaxy/workflow/run.py b/lib/galaxy/workflow/run.py index 5ebb586fd56f..d83ff65bedee 100644 --- a/lib/galaxy/workflow/run.py +++ b/lib/galaxy/workflow/run.py @@ -570,7 +570,7 @@ def set_outputs_for_input( step_id = step.id if step_id not in self.inputs_by_step_id and "output" not in outputs: default_value = step.input_default_value - if default_value or step.input_optional: + if step.default_value_set: outputs["output"] = default_value else: log.error(f"{step.log_str()} not found in inputs_step_id {self.inputs_by_step_id}") diff --git a/lib/galaxy/workflow/run_request.py b/lib/galaxy/workflow/run_request.py index 516c85d07eb1..cbf401b7c43b 100644 --- a/lib/galaxy/workflow/run_request.py +++ b/lib/galaxy/workflow/run_request.py @@ -25,7 +25,9 @@ ensure_object_added_to_session, transaction, ) +from galaxy.tools.parameters.basic import ParameterValueError from galaxy.tools.parameters.meta import expand_workflow_inputs +from galaxy.workflow.modules import WorkflowModuleInjector from galaxy.workflow.resources import get_resource_mapper_function if TYPE_CHECKING: @@ -358,11 +360,20 @@ def build_workflow_run_configs( steps_by_id = workflow.steps_by_id # Set workflow inputs. + module_injector = WorkflowModuleInjector(trans, False) for key, input_dict in normalized_inputs.items(): if input_dict is None: continue step = steps_by_id[key] if step.type == "parameter_input": + module_injector.inject(step) + input_param = step.module.get_runtime_inputs(step.module)["input"] + try: + input_param.validate(input_dict) + except ParameterValueError as e: + raise exceptions.RequestParameterInvalidException( + f"{step.label or step.order_index + 1}: {e.message_suffix}" + ) continue if "src" not in input_dict: raise exceptions.RequestParameterInvalidException( diff --git a/lib/galaxy/workflow/workflow_parameter_input_definitions.py b/lib/galaxy/workflow/workflow_parameter_input_definitions.py new file mode 100644 index 000000000000..2b31e793e40e --- /dev/null +++ b/lib/galaxy/workflow/workflow_parameter_input_definitions.py @@ -0,0 +1,48 @@ +from typing import ( + Dict, + Literal, + Union, +) + +from galaxy.tools.parameters.basic import ( + BooleanToolParameter, + ColorToolParameter, + FloatToolParameter, + IntegerToolParameter, + TextToolParameter, +) + +param_types = Literal["text", "integer", "float", "color", "boolean"] +default_source_type = Dict[str, Union[int, float, bool, str]] +tool_param_type = Union[ + TextToolParameter, + IntegerToolParameter, + FloatToolParameter, + BooleanToolParameter, + ColorToolParameter, +] + + +def get_default_parameter(param_type: param_types) -> tool_param_type: + """ + param_type is the type of parameter we want to build up, stored_parameter_type is the parameter_type + as stored in the tool state + """ + default_source: default_source_type = dict(name="default", label="Default Value", type=param_type, optional=False) + if param_type == "text": + input_default_value: Union[ + TextToolParameter, + IntegerToolParameter, + FloatToolParameter, + BooleanToolParameter, + ColorToolParameter, + ] = TextToolParameter(None, default_source) + elif param_type == "integer": + input_default_value = IntegerToolParameter(None, default_source) + elif param_type == "float": + input_default_value = FloatToolParameter(None, default_source) + elif param_type == "boolean": + input_default_value = BooleanToolParameter(None, default_source) + elif param_type == "color": + input_default_value = ColorToolParameter(None, default_source) + return input_default_value diff --git a/lib/galaxy_test/base/workflow_fixtures.py b/lib/galaxy_test/base/workflow_fixtures.py index b736196dd63a..7b4eb6186b99 100644 --- a/lib/galaxy_test/base/workflow_fixtures.py +++ b/lib/galaxy_test/base/workflow_fixtures.py @@ -617,6 +617,7 @@ int_input: type: integer default: 3 + optional: true steps: random: tool_id: random_lines1 diff --git a/test/unit/app/tools/test_parameter_validation.py b/test/unit/app/tools/test_parameter_validation.py index a7798927eb65..17c0ccc520cc 100644 --- a/test/unit/app/tools/test_parameter_validation.py +++ b/test/unit/app/tools/test_parameter_validation.py @@ -107,7 +107,7 @@ def test_EmptyTextfieldValidator(self): """ ) p.validate("foo") - with self.assertRaisesRegex(ValueError, "Parameter blah: Field requires a value"): + with self.assertRaisesRegex(ValueError, "Parameter 'blah': Field requires a value"): p.validate("") p = self._parameter_for( @@ -116,7 +116,7 @@ def test_EmptyTextfieldValidator(self): """ ) - with self.assertRaisesRegex(ValueError, "Parameter blah: Field must not set a value"): + with self.assertRaisesRegex(ValueError, "Parameter 'blah': Field must not set a value"): p.validate("foo") p.validate("") @@ -130,14 +130,14 @@ def test_RegexValidator(self): p.validate("Foo") p.validate("foo") with self.assertRaisesRegex( - ValueError, r"Parameter blah: Value 'Fop' does not match regular expression '\[Ff\]oo'" + ValueError, r"Parameter 'blah': Value 'Fop' does not match regular expression '\[Ff\]oo'" ): p.validate("Fop") # test also valitation of lists (for select parameters) p.validate(["Foo", "foo"]) with self.assertRaisesRegex( - ValueError, r"Parameter blah: Value 'Fop' does not match regular expression '\[Ff\]oo'" + ValueError, r"Parameter 'blah': Value 'Fop' does not match regular expression '\[Ff\]oo'" ): p.validate(["Foo", "Fop"]) @@ -148,16 +148,16 @@ def test_RegexValidator(self): """ ) with self.assertRaisesRegex( - ValueError, r"Parameter blah: Value 'Foo' does match regular expression '\[Ff\]oo'" + ValueError, r"Parameter 'blah': Value 'Foo' does match regular expression '\[Ff\]oo'" ): p.validate("Foo") with self.assertRaisesRegex( - ValueError, r"Parameter blah: Value 'foo' does match regular expression '\[Ff\]oo'" + ValueError, r"Parameter 'blah': Value 'foo' does match regular expression '\[Ff\]oo'" ): p.validate("foo") p.validate("Fop") with self.assertRaisesRegex( - ValueError, r"Parameter blah: Value 'foo' does match regular expression '\[Ff\]oo'" + ValueError, r"Parameter 'blah': Value 'foo' does match regular expression '\[Ff\]oo'" ): p.validate(["Fop", "foo"]) p.validate(["Fop", "fop"]) @@ -171,9 +171,9 @@ def test_LengthValidator(self): ) p.validate("foo") p.validate("bar") - with self.assertRaisesRegex(ValueError, "Parameter blah: Must have length of at least 2 and at most 8"): + with self.assertRaisesRegex(ValueError, "Parameter 'blah': Must have length of at least 2 and at most 8"): p.validate("f") - with self.assertRaisesRegex(ValueError, "Parameter blah: Must have length of at least 2 and at most 8"): + with self.assertRaisesRegex(ValueError, "Parameter 'blah': Must have length of at least 2 and at most 8"): p.validate("foobarbaz") p = self._parameter_for( @@ -182,9 +182,9 @@ def test_LengthValidator(self): """ ) - with self.assertRaisesRegex(ValueError, "Parameter blah: Must not have length of at least 2 and at most 8"): + with self.assertRaisesRegex(ValueError, "Parameter 'blah': Must not have length of at least 2 and at most 8"): p.validate("foo") - with self.assertRaisesRegex(ValueError, "Parameter blah: Must not have length of at least 2 and at most 8"): + with self.assertRaisesRegex(ValueError, "Parameter 'blah': Must not have length of at least 2 and at most 8"): p.validate("bar") p.validate("f") p.validate("foobarbaz") @@ -204,11 +204,11 @@ def test_InRangeValidator(self): """ ) - with self.assertRaisesRegex(ValueError, "Parameter blah: Doh!! 10 not in range"): + with self.assertRaisesRegex(ValueError, "Parameter 'blah': Doh!! 10 not in range"): p.validate(10) p.validate(15) p.validate(20) - with self.assertRaisesRegex(ValueError, "Parameter blah: Doh!! 21 not in range"): + with self.assertRaisesRegex(ValueError, "Parameter 'blah': Doh!! 21 not in range"): p.validate(21) p = self._parameter_for( @@ -220,12 +220,12 @@ def test_InRangeValidator(self): p.validate(10) with self.assertRaisesRegex( ValueError, - r"Parameter blah: Value \('15'\) must not fulfill float\('10'\) < float\(value\) <= float\('20'\)", + r"Parameter 'blah': Value \('15'\) must not fulfill float\('10'\) < float\(value\) <= float\('20'\)", ): p.validate(15) with self.assertRaisesRegex( ValueError, - r"Parameter blah: Value \('20'\) must not fulfill float\('10'\) < float\(value\) <= float\('20'\)", + r"Parameter 'blah': Value \('20'\) must not fulfill float\('10'\) < float\(value\) <= float\('20'\)", ): p.validate(20) p.validate(21) @@ -253,7 +253,7 @@ def test_DatasetOkValidator(self): p.validate(ok_hda) with self.assertRaisesRegex( ValueError, - "Parameter blah: The selected dataset is still being generated, select another dataset or wait until it is completed", + "Parameter 'blah': The selected dataset is still being generated, select another dataset or wait until it is completed", ): p.validate(notok_hda) p = self._parameter_for( @@ -262,7 +262,7 @@ def test_DatasetOkValidator(self): """ ) - with self.assertRaisesRegex(ValueError, "Parameter blah: The selected dataset must not be in state OK"): + with self.assertRaisesRegex(ValueError, "Parameter 'blah': The selected dataset must not be in state OK"): p.validate(ok_hda) p.validate(notok_hda) @@ -288,7 +288,7 @@ def test_DatasetEmptyValidator(self): ) p.validate(full_hda) with self.assertRaisesRegex( - ValueError, "Parameter blah: The selected dataset is empty, this tool expects non-empty files." + ValueError, "Parameter 'blah': The selected dataset is empty, this tool expects non-empty files." ): p.validate(empty_hda) @@ -299,7 +299,7 @@ def test_DatasetEmptyValidator(self): """ ) with self.assertRaisesRegex( - ValueError, "Parameter blah: The selected dataset is non-empty, this tool expects empty files." + ValueError, "Parameter 'blah': The selected dataset is non-empty, this tool expects empty files." ): p.validate(full_hda) p.validate(empty_hda) @@ -329,7 +329,7 @@ def test_DatasetExtraFilesPathEmptyValidator(self): p.validate(has_extra_hda) with self.assertRaisesRegex( ValueError, - "Parameter blah: The selected dataset's extra_files_path directory is empty or does not exist, this tool expects non-empty extra_files_path directories associated with the selected input.", + "Parameter 'blah': The selected dataset's extra_files_path directory is empty or does not exist, this tool expects non-empty extra_files_path directories associated with the selected input.", ): p.validate(has_no_extra_hda) @@ -342,7 +342,7 @@ def test_DatasetExtraFilesPathEmptyValidator(self): with self.assertRaisesRegex( ValueError, - "Parameter blah: The selected dataset's extra_files_path directory is non-empty or does exist, this tool expects empty extra_files_path directories associated with the selected input.", + "Parameter 'blah': The selected dataset's extra_files_path directory is non-empty or does exist, this tool expects empty extra_files_path directories associated with the selected input.", ): p.validate(has_extra_hda) p.validate(has_no_extra_hda) @@ -376,7 +376,7 @@ def test_MetadataValidator(self): p = self._parameter_for(xml=param_xml.format(check="strandCol", skip="")) with self.assertRaisesRegex( ValueError, - "Parameter blah: Metadata 'strandCol' missing, click the pencil icon in the history item to edit / save the metadata attributes", + "Parameter 'blah': Metadata 'strandCol' missing, click the pencil icon in the history item to edit / save the metadata attributes", ): p.validate(hda) @@ -385,7 +385,7 @@ def test_MetadataValidator(self): p = self._parameter_for(xml=param_xml.format(check="", skip="dbkey,comment_lines,column_names,nameCol")) with self.assertRaisesRegex( ValueError, - "Parameter blah: Metadata 'strandCol' missing, click the pencil icon in the history item to edit / save the metadata attributes", + "Parameter 'blah': Metadata 'strandCol' missing, click the pencil icon in the history item to edit / save the metadata attributes", ): p.validate(hda) @@ -398,7 +398,7 @@ def test_MetadataValidator(self): p = self._parameter_for(xml=param_xml_negate.format(check="nameCol", skip="")) with self.assertRaisesRegex( ValueError, - "Parameter blah: At least one of the checked metadata 'nameCol' is set, click the pencil icon in the history item to edit / save the metadata attributes", + "Parameter 'blah': At least one of the checked metadata 'nameCol' is set, click the pencil icon in the history item to edit / save the metadata attributes", ): p.validate(hda) @@ -409,7 +409,7 @@ def test_MetadataValidator(self): ) with self.assertRaisesRegex( ValueError, - "Parameter blah: At least one of the non skipped metadata 'dbkey,comment_lines,column_names,strandCol' is set, click the pencil icon in the history item to edit / save the metadata attributes", + "Parameter 'blah': At least one of the non skipped metadata 'dbkey,comment_lines,column_names,strandCol' is set, click the pencil icon in the history item to edit / save the metadata attributes", ): p.validate(hda) @@ -437,7 +437,7 @@ def test_UnspecifiedBuildValidator(self): p.validate(has_dbkey_hda) with self.assertRaisesRegex( ValueError, - "Parameter blah: Unspecified genome build, click the pencil icon in the history item to set the genome build", + "Parameter 'blah': Unspecified genome build, click the pencil icon in the history item to set the genome build", ): p.validate(has_no_dbkey_hda) @@ -449,7 +449,7 @@ def test_UnspecifiedBuildValidator(self): ) with self.assertRaisesRegex( ValueError, - "Parameter blah: Specified genome build, click the pencil icon in the history item to remove the genome build", + "Parameter 'blah': Specified genome build, click the pencil icon in the history item to remove the genome build", ): p.validate(has_dbkey_hda) p.validate(has_no_dbkey_hda)