diff --git a/lib/galaxy/tool_util/parameters/_types.py b/lib/galaxy/tool_util/parameters/_types.py index 2b97ee16c200..9bb94d8c97c0 100644 --- a/lib/galaxy/tool_util/parameters/_types.py +++ b/lib/galaxy/tool_util/parameters/_types.py @@ -6,6 +6,7 @@ """ from typing import ( + Any, cast, List, Optional, @@ -15,6 +16,7 @@ # https://stackoverflow.com/questions/56832881/check-if-a-field-is-typing-optional from typing_extensions import ( + Annotated, get_args, get_origin, ) @@ -46,3 +48,12 @@ def cast_as_type(arg) -> Type: def is_optional(field) -> bool: return get_origin(field) is Union and type(None) in get_args(field) + + +def expand_annotation(field: Type, new_annotations: List[Any]) -> Type: + is_annotation = get_origin(field) is Annotated + if is_annotation: + args = get_args(field) # noqa: F841 + return Annotated[tuple([args[0], *args[1:], *new_annotations])] + else: + return Annotated[tuple([field, *new_annotations])] diff --git a/lib/galaxy/tool_util/parameters/factory.py b/lib/galaxy/tool_util/parameters/factory.py index e200b67aeb0e..ff67726ef35d 100644 --- a/lib/galaxy/tool_util/parameters/factory.py +++ b/lib/galaxy/tool_util/parameters/factory.py @@ -14,6 +14,11 @@ PagesSource, ToolSource, ) +from galaxy.tool_util.parser.parameter_validators import ( + InRangeParameterValidatorModel, + LengthParameterValidatorModel, + static_validators, +) from galaxy.tool_util.parser.util import parse_profile_version from galaxy.util import string_as_bool from .models import ( @@ -42,10 +47,12 @@ HiddenParameterModel, IntegerParameterModel, LabelValue, + NumberCompatiableValidators, RepeatParameterModel, RulesParameterModel, SectionParameterModel, SelectParameterModel, + TextCompatiableValidators, TextParameterModel, ToolParameterBundle, ToolParameterBundleModel, @@ -82,7 +89,12 @@ def _from_input_source_galaxy(input_source: InputSource, profile: float) -> Tool int_value = None else: raise ParameterDefinitionError() - return IntegerParameterModel(name=input_source.parse_name(), optional=optional, value=int_value) + static_validator_models = static_validators(input_source.parse_validators()) + validators: List[NumberCompatiableValidators] = [] + for static_validator in static_validator_models: + if static_validator.type == "in_range": + validators.append(cast(InRangeParameterValidatorModel, static_validator)) + return IntegerParameterModel(name=input_source.parse_name(), optional=optional, value=int_value, validators=validators) elif param_type == "boolean": nullable = input_source.parse_optional() value = input_source.get_bool_or_none("checked", None if nullable else False) @@ -93,9 +105,15 @@ def _from_input_source_galaxy(input_source: InputSource, profile: float) -> Tool ) elif param_type == "text": optional = input_source.parse_optional() + static_validator_models = static_validators(input_source.parse_validators()) + validators: List[TextCompatiableValidators] = [] + for static_validator in static_validator_models: + if static_validator.type == "length": + validators.append(cast(LengthParameterValidatorModel, static_validator)) return TextParameterModel( name=input_source.parse_name(), optional=optional, + validators=validators, ) elif param_type == "float": optional = input_source.parse_optional() @@ -107,10 +125,16 @@ def _from_input_source_galaxy(input_source: InputSource, profile: float) -> Tool float_value = None else: raise ParameterDefinitionError() + static_validator_models = static_validators(input_source.parse_validators()) + validators: List[NumberCompatiableValidators] = [] + for static_validator in static_validator_models: + if static_validator.type == "in_range": + validators.append(cast(InRangeParameterValidatorModel, static_validator)) return FloatParameterModel( name=input_source.parse_name(), optional=optional, value=float_value, + validators=validators, ) elif param_type == "hidden": optional = input_source.parse_optional() diff --git a/lib/galaxy/tool_util/parameters/models.py b/lib/galaxy/tool_util/parameters/models.py index 7a70c4d5c245..54dd2c4f16d3 100644 --- a/lib/galaxy/tool_util/parameters/models.py +++ b/lib/galaxy/tool_util/parameters/models.py @@ -24,6 +24,7 @@ Field, field_validator, HttpUrl, + PlainValidator, RootModel, StrictBool, StrictFloat, @@ -44,8 +45,14 @@ JsonTestCollectionDefDict, JsonTestDatasetDefDict, ) +from galaxy.tool_util.parser.parameter_validators import ( + InRangeParameterValidatorModel, + LengthParameterValidatorModel, + StaticValidatorModel, +) from ._types import ( cast_as_type, + expand_annotation, is_optional as is_python_type_optional, list_type, optional, @@ -179,11 +186,34 @@ class LabelValue(BaseModel): selected: bool +TextCompatiableValidators = Union[ + LengthParameterValidatorModel, +] + + +def pydantic_validator_for(validator_model: StaticValidatorModel): + + def validator(v: Any) -> Any: + validator_model.validate(v) + return v + + return PlainValidator(validator) + + +# actual typing will require generics and a typevar I think... +def static_tool_validators_to_pydantic(static_tool_param_validators: List[StaticValidatorModel]): + pydantic_validators = [] + for static_validator in static_tool_param_validators: + pydantic_validators.append(pydantic_validator_for(static_validator)) + return pydantic_validators + + class TextParameterModel(BaseGalaxyToolParameterModelDefinition): parameter_type: Literal["gx_text"] = "gx_text" area: bool = False default_value: Optional[str] = Field(default=None, alias="value") default_options: List[LabelValue] = [] + validators: List[TextCompatiableValidators] = [] @property def py_type(self) -> Type: @@ -196,6 +226,9 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam requires_value = self.request_requires_value if state_representation == "job_internal": requires_value = True + validators = static_tool_validators_to_pydantic(self.validators) + if validators: + py_type = expand_annotation(py_type, validators) return dynamic_model_information_from_py_type(self, py_type, requires_value=requires_value) @property @@ -203,12 +236,18 @@ def request_requires_value(self) -> bool: return False +NumberCompatiableValidators = Union[ + InRangeParameterValidatorModel, +] + + class IntegerParameterModel(BaseGalaxyToolParameterModelDefinition): parameter_type: Literal["gx_integer"] = "gx_integer" optional: bool value: Optional[int] = None min: Optional[int] = None max: Optional[int] = None + validators: List[NumberCompatiableValidators] = None @property def py_type(self) -> Type: @@ -223,6 +262,9 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam requires_value = True elif _is_landing_request(state_representation): requires_value = False + validators = static_tool_validators_to_pydantic(self.validators) + if validators: + py_type = expand_annotation(py_type, validators) return dynamic_model_information_from_py_type(self, py_type, requires_value=requires_value) @property @@ -235,6 +277,7 @@ class FloatParameterModel(BaseGalaxyToolParameterModelDefinition): value: Optional[float] = None min: Optional[float] = None max: Optional[float] = None + validators: List[NumberCompatiableValidators] = None @property def py_type(self) -> Type: @@ -244,6 +287,9 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam py_type = self.py_type if state_representation == "workflow_step_linked": py_type = allow_connected_value(py_type) + validators = static_tool_validators_to_pydantic(self.validators) + if validators: + py_type = expand_annotation(py_type, validators) return dynamic_model_information_from_py_type(self, py_type) @property diff --git a/test/functional/tools/parameters/gx_int_validation_range.xml b/test/functional/tools/parameters/gx_int_validation_range.xml new file mode 100644 index 000000000000..5f7862497abf --- /dev/null +++ b/test/functional/tools/parameters/gx_int_validation_range.xml @@ -0,0 +1,15 @@ + + > '$output' + ]]> + + + + + + + + + + + diff --git a/test/functional/tools/parameters/gx_text_length_validation.xml b/test/functional/tools/parameters/gx_text_length_validation.xml new file mode 100644 index 000000000000..4d5434aa6c58 --- /dev/null +++ b/test/functional/tools/parameters/gx_text_length_validation.xml @@ -0,0 +1,20 @@ + + > '$output'; +cat '$inputs' >> $inputs_json; + ]]> + + + + + + + + + + + + + + + diff --git a/test/functional/tools/parameters/gx_text_length_validation_negate.xml b/test/functional/tools/parameters/gx_text_length_validation_negate.xml new file mode 100644 index 000000000000..4b0b835fe562 --- /dev/null +++ b/test/functional/tools/parameters/gx_text_length_validation_negate.xml @@ -0,0 +1,20 @@ + + > '$output'; +cat '$inputs' >> $inputs_json; + ]]> + + + + + + + + + + + + + + + diff --git a/test/unit/tool_util/parameter_specification.yml b/test/unit/tool_util/parameter_specification.yml index a46de9e0ec83..31975ac5ba25 100644 --- a/test/unit/tool_util/parameter_specification.yml +++ b/test/unit/tool_util/parameter_specification.yml @@ -191,6 +191,16 @@ gx_int_required: &gx_int_required gx_int_required_via_empty_string: <<: *gx_int_required + +gx_int_validation_range: + request_valid: + - parameter: 1 + - parameter: 9 + request_invalid: + - parameter: -1 + - parameter: 11 + - parameter: 10 + gx_text: request_valid: &gx_text_request_valid - parameter: moocow @@ -294,6 +304,21 @@ gx_text_optional: - parameter: {} - parameter: { "moo": "cow" } +gx_text_length_validation: + request_valid: + - parameter: "mytext" + - parameter: "mytext123" + request_invalid: + - parameter: "s" # too short + - parameter: "mytext1231231231sd" # too long + +gx_text_length_validation_negate: + request_valid: + - parameter: "m" + - parameter: "mytext123mocowdowees" + request_invalid: + - parameter: "goldilocks" + gx_select: request_valid: - parameter: "--ex1"