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 workflow parameter validators. #19092

Merged
merged 14 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11941,6 +11941,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 @@ -12022,7 +12037,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
11 changes: 10 additions & 1 deletion client/src/components/Form/FormElement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import FormSelection from "./Elements/FormSelection.vue";
import FormTags from "./Elements/FormTags.vue";
import FormText from "./Elements/FormText.vue";
import FormUpload from "./Elements/FormUpload.vue";
import FormElementHelpMarkdown from "./FormElementHelpMarkdown.vue";

interface FormElementProps {
id?: string;
Expand All @@ -35,6 +36,7 @@ interface FormElementProps {
title?: string;
refreshOnChange?: boolean;
help?: string;
helpFormat?: string;
error?: string;
warning?: string;
disabled?: boolean;
Expand Down Expand Up @@ -63,6 +65,7 @@ const props = withDefaults(defineProps<FormElementProps>(), {
connectedDisableText: "Add connection to module.",
connectedEnableIcon: "fa fa-times",
connectedDisableIcon: "fa fa-arrows-alt-h",
helpFormat: "html",
workflowBuildingMode: false,
});

Expand Down Expand Up @@ -337,7 +340,13 @@ function onAlert(value: string | undefined) {
</div>

<div v-if="showPreview" class="ui-form-preview pt-1 pl-2 mt-1">{{ previewText }}</div>
<span v-if="Boolean(helpText)" class="ui-form-info form-text text-muted" v-html="helpText" />
<span
v-if="Boolean(helpText) && helpFormat != 'markdown'"
class="ui-form-info form-text text-muted"
v-html="helpText" />
<span v-else-if="Boolean(helpText)" class="ui-form-info form-text text-muted"
><FormElementHelpMarkdown :content="helpText"
/></span>
</div>
</template>

Expand Down
56 changes: 56 additions & 0 deletions client/src/components/Form/FormElementHelpMarkdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";

import { markup } from "@/components/ObjectStore/configurationMarkdown";
import { getAppRoot } from "@/onload/loadConfig";

import HelpPopover from "@/components/Help/HelpPopover.vue";

const props = defineProps<{
content: string;
}>();

const markdownHtml = computed(() => markup(props.content ?? "", false));

const helpHtml = ref<HTMLDivElement>();

interface InternalTypeReference {
element: HTMLElement;
term: string;
}

const internalHelpReferences = ref<InternalTypeReference[]>([]);

function setupPopovers() {
internalHelpReferences.value.length = 0;
if (helpHtml.value) {
const links = helpHtml.value.getElementsByTagName("a");
Array.from(links).forEach((link) => {
if (link.href.startsWith("gxhelp://")) {
const uri = link.href.substr("gxhelp://".length);
internalHelpReferences.value.push({ element: link, term: uri });
link.href = `${getAppRoot()}help/terms/${uri}`;
link.style.color = "inherit";
link.style.textDecorationLine = "underline";
link.style.textDecorationStyle = "dashed";
}
});
}
}

onMounted(setupPopovers);
</script>

<template>
<span>
<!-- Disable v-html warning because we allow markdown generated HTML
in various places in the Galaxy interface. Raw HTML is not allowed
here because admin = false in the call to markup.
-->
<!-- eslint-disable-next-line vue/no-v-html -->
<div ref="helpHtml" v-html="markdownHtml" />
<span v-for="(value, i) in internalHelpReferences" :key="i">
<HelpPopover :target="value.element" :term="value.term" />
</span>
</span>
</template>
2 changes: 2 additions & 0 deletions client/src/components/Form/FormInputs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
v-model="input.test_param.value"
:type="input.test_param.type"
:help="input.test_param.help"
:help-format="input.test_param.help_format"
:refresh-on-change="false"
:disabled="sustainConditionals"
:attributes="input.test_param"
Expand Down Expand Up @@ -51,6 +52,7 @@
:error="input.error"
:warning="input.warning"
:help="input.help"
:help-format="input.help_format"
:refresh-on-change="input.refresh_on_change"
:attributes="input.attributes || input"
:collapsed-enable-text="collapsedEnableText"
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Help/HelpPopover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ defineProps<Props>();
</script>

<template>
<BPopover :target="target" triggers="hover" placement="bottom">
<BPopover v-if="target" :target="target" triggers="hover" placement="bottom">
<HelpTerm :term="term" />
</BPopover>
</template>
5 changes: 5 additions & 0 deletions client/src/components/Help/terms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ unix:
debug what is wrong with the execution of an application.

More information on stack traces can be found on [Wikipedia](https://en.wikipedia.org/wiki/Stack_trace).
programming:
python:
regex: |
Regular expressions are patterns used to match character combinations in strings. This input accepts Python-style regular expressions, find more information about these in [this Python for Biologists tutorial](https://pythonforbiologists.com/tutorial/regex.html).

The website [regex101](https://regex101.com/) is a useful playground to explore regular expressions (be sure to enable "Python" as your flavor) and language models such as ChatGPT can help interactively build up and explain regular expressions from natural language prompts or examples.
galaxy:
collections:
flatList: |
Expand Down
12 changes: 5 additions & 7 deletions client/src/components/RuleBuilder/RegularExpressionInput.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div>
<label v-b-tooltip.hover for="regular_expression" :title="title">{{ label }}</label>
<span v-b-popover.html="popoverContent" :title="popoverTitle" class="fa fa-question"></span>
<label ref="helpTarget" v-b-tooltip.hover for="regular_expression">{{ label }}</label>
<HelpPopover :target="$refs.helpTarget" term="programming.python.regex" />
<input
v-b-tooltip.hover.left
:title="title"
Expand All @@ -16,7 +16,10 @@
<script>
import _l from "utils/localization";

import HelpPopover from "@/components/Help/HelpPopover.vue";

export default {
components: { HelpPopover },
props: {
target: {
required: true,
Expand All @@ -32,11 +35,6 @@ export default {
popoverTitle() {
return _l("Regular Expressions");
},
popoverContent() {
return _l(
`Regular expressions are patterns used to match character combinations in strings. This input accepts Python-style regular expressions, find more information about these in <a href="https://pythonforbiologists.com/tutorial/regex.html">this Python for Biologists tutorial</a>.`
);
},
},
};
</script>
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 @@ -199,7 +199,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 @@ -90,6 +90,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 @@ -225,6 +226,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 @@ -236,6 +245,7 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound(
InvocationFailureWhenNotBoolean = GenericInvocationFailureWhenNotBoolean[int]
InvocationUnexpectedFailure = GenericInvocationUnexpectedFailure[int]
InvocationWarningWorkflowOutputNotFound = GenericInvocationEvaluationWarningWorkflowOutputNotFound[int]
InvocationFailureWorkflowParameterInvalid = GenericInvocationFailureWorkflowParameterInvalid[int]

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

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

_InvocationMessageResponseUnion = Annotated[
Union[
Expand All @@ -280,6 +294,7 @@ class GenericInvocationEvaluationWarningWorkflowOutputNotFound(
InvocationFailureWhenNotBooleanResponseModel,
InvocationUnexpectedFailureResponseModel,
InvocationWarningWorkflowOutputNotFoundResponseModel,
InvocationFailureWorkflowParameterInvalidResponseModel,
],
Field(discriminator="reason"),
]
Expand Down
9 changes: 7 additions & 2 deletions lib/galaxy/tool_util/parser/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,22 @@ def get_tool_source_from_representation(tool_format, tool_representation):
raise Exception(f"Unknown tool representation format [{tool_format}].")


def get_input_source(content):
def get_input_source(content, trusted: bool = True):
"""Wrap dicts or XML elements as InputSource if needed.

If the supplied content is already an InputSource object,
it is simply returned. This allow Galaxy to uniformly
consume using the tool input source interface.

Setting trusted to false indicates that no dynamic code should be
executed - no eval. This should be used for user-defined tools (in
the future) and for workflow inputs.
"""
if not isinstance(content, InputSource):
if isinstance(content, dict):
content = YamlInputSource(content)
content = YamlInputSource(content, trusted=trusted)
else:
assert trusted # trust is not implemented for XML inputs
content = XmlInputSource(content)
return content

Expand Down
26 changes: 25 additions & 1 deletion lib/galaxy/tool_util/parser/parameter_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import (
Any,
cast,
Dict,
List,
Optional,
Sequence,
Expand All @@ -14,6 +15,7 @@
Field,
model_validator,
PrivateAttr,
TypeAdapter,
)
from typing_extensions import (
Annotated,
Expand Down Expand Up @@ -96,6 +98,9 @@ class ParameterValidatorModel(StrictModel):
implicit: bool = False
_static: bool = PrivateAttr(False)
_deprecated: bool = PrivateAttr(False)
# validators must be explicitly set as 'safe' to operate as user-defined workflow parameters or to be used
# within future user-defined tool parameters
_safe: bool = PrivateAttr(False)

@model_validator(mode="after")
def set_default_message(self) -> Self:
Expand Down Expand Up @@ -163,6 +168,7 @@ class RegexParameterValidatorModel(StaticValidatorModel):
type: Literal["regex"] = "regex"
negate: Negate = NEGATE_DEFAULT
expression: Annotated[str, ValidationArgument("Regular expression to validate against.", xml_body=True)]
_safe: bool = PrivateAttr(True)

@property
def default_message(self) -> str:
Expand All @@ -189,6 +195,7 @@ class InRangeParameterValidatorModel(StaticValidatorModel):
exclude_min: bool = False
exclude_max: bool = False
negate: Negate = NEGATE_DEFAULT
_safe: bool = PrivateAttr(True)

def statically_validate(self, value: Any):
if isinstance(value, (int, float)):
Expand All @@ -211,7 +218,9 @@ def default_message(self) -> str:
op1 = "<"
if self.exclude_max:
op2 = "<"
range_description_str = f"({self.min} {op1} value {op2} {self.max})"
min_str = str(self.min) if self.min is not None else "-infinity"
max_str = str(self.max) if self.max is not None else "+infinity"
range_description_str = f"({min_str} {op1} value {op2} {max_str})"
return f"Value ('%s') must {'not ' if self.negate else ''}fulfill {range_description_str}"


Expand All @@ -220,6 +229,7 @@ class LengthParameterValidatorModel(StaticValidatorModel):
min: Optional[int] = None
max: Optional[int] = None
negate: Negate = NEGATE_DEFAULT
_safe: bool = PrivateAttr(True)

def statically_validate(self, value: Any):
if isinstance(value, str):
Expand Down Expand Up @@ -458,6 +468,20 @@ def default_message(self) -> str:
]


DiscriminatedAnyValidatorModel = TypeAdapter(AnyValidatorModel) # type:ignore[var-annotated]


def parse_dict_validators(validator_dicts: List[Dict[str, Any]], trusted: bool) -> List[AnyValidatorModel]:
validator_models = []
for validator_dict in validator_dicts:
validator = DiscriminatedAnyValidatorModel.validate_python(validator_dict)
if not trusted:
# Don't risk instantiating unsafe validators for user-defined code
assert validator._safe
validator_models.append(validator)
return validator_models


def parse_xml_validators(input_elem: Element) -> List[AnyValidatorModel]:
validator_els: List[Element] = input_elem.findall("validator") or []
models = []
Expand Down
Loading
Loading