Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement regex validators for text workflow parameter #18781

Draft
wants to merge 12 commits into
base: dev
Choose a base branch
from
18 changes: 17 additions & 1 deletion client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
/**
Expand Down Expand Up @@ -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: {
/**
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Workflow/Run/WorkflowRun.vue
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ defineExpose({
Workflow submission failed: {{ submissionError }}
</BAlert>
<WorkflowRunFormSimple
v-else-if="fromVariant === 'simple'"
v-if="fromVariant === 'simple'"
:model="workflowModel"
:target-history="simpleFormTargetHistory"
:use-job-cache="simpleFormUseJobCache"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type ReasonToLevel = {
when_not_boolean: "error";
unexpected_failure: "error";
workflow_output_not_found: "warning";
workflow_parameter_invalid: "error";
};

const level: ReasonToLevel = {
Expand All @@ -34,6 +35,7 @@ const level: ReasonToLevel = {
when_not_boolean: "error",
unexpected_failure: "error",
workflow_output_not_found: "warning",
workflow_parameter_invalid: "error",
};

const levelClasses = {
Expand Down Expand Up @@ -165,6 +167,10 @@ const infoString = computed(() => {
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;
}
Expand Down
15 changes: 15 additions & 0 deletions lib/galaxy/schema/invocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand All @@ -223,6 +232,7 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound(
InvocationFailureWhenNotBoolean = GenericInvocationFailureWhenNotBoolean[int]
InvocationUnexpectedFailure = GenericInvocationUnexpectedFailure[int]
InvocationWarningWorkflowOutputNotFound = GenericInvocationEvaluationWarningWorkflowOutputNotFound[int]
InvocationFailureWorkflowParameterInvalid = GenericInvocationFailureWorkflowParameterInvalid[int]

InvocationMessageUnion = Union[
InvocationCancellationReviewFailed,
Expand All @@ -236,6 +246,7 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound(
InvocationFailureWhenNotBoolean,
InvocationUnexpectedFailure,
InvocationWarningWorkflowOutputNotFound,
InvocationFailureWorkflowParameterInvalid,
]

InvocationCancellationReviewFailedResponseModel = GenericInvocationCancellationReviewFailed[EncodedDatabaseIdField]
Expand All @@ -253,6 +264,9 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound(
InvocationWarningWorkflowOutputNotFoundResponseModel = GenericInvocationEvaluationWarningWorkflowOutputNotFound[
EncodedDatabaseIdField
]
InvocationFailureWorkflowParameterInvalidResponseModel = GenericInvocationFailureWorkflowParameterInvalid[
EncodedDatabaseIdField
]

_InvocationMessageResponseUnion = Annotated[
Union[
Expand All @@ -267,6 +281,7 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound(
InvocationFailureWhenNotBooleanResponseModel,
InvocationUnexpectedFailureResponseModel,
InvocationWarningWorkflowOutputNotFoundResponseModel,
InvocationFailureWorkflowParameterInvalidResponseModel,
],
Field(discriminator="reason"),
]
Expand Down
29 changes: 28 additions & 1 deletion lib/galaxy/tool_util/parser/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
15 changes: 15 additions & 0 deletions lib/galaxy/tool_util/parser/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 8 additions & 58 deletions lib/galaxy/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@
DataCollectionToolParameter,
DataToolParameter,
HiddenToolParameter,
ImplicitConversionRequired,
SelectTagParameter,
SelectToolParameter,
ToolParameter,
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/galaxy/tools/parameters/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
74 changes: 74 additions & 0 deletions lib/galaxy/tools/parameters/populate_model.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading