From 6756c534c1e7a7c631b15f980ffbec68c590623a Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Fri, 17 Nov 2023 16:09:41 -0500 Subject: [PATCH] Add numeric input range validation + tests --- pyproject.toml | 2 +- src/styx/boutiques/model.py | 68 +++------- src/styx/compiler/core.py | 235 ++++++++++++++++++++++++++++----- src/styx/compiler/utils.py | 6 + src/styx/pycodegen/core.py | 6 +- tests/__init__.py | 1 + tests/test_carg_building.py | 140 +++++++------------- tests/test_input_validation.py | 195 +++++++++++++++++++++++++++ tests/utils/dynmodule.py | 42 ++++++ 9 files changed, 510 insertions(+), 185 deletions(-) create mode 100644 src/styx/compiler/utils.py create mode 100644 tests/__init__.py create mode 100644 tests/test_input_validation.py create mode 100644 tests/utils/dynmodule.py diff --git a/pyproject.toml b/pyproject.toml index 25e7620..6d677f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ exclude = [ "node_modules", "venv", ] -line-length = 88 +line-length = 120 indent-width = 4 src=["src"] diff --git a/src/styx/boutiques/model.py b/src/styx/boutiques/model.py index 2eb69ea..e7ee53f 100644 --- a/src/styx/boutiques/model.py +++ b/src/styx/boutiques/model.py @@ -52,9 +52,7 @@ class ContainerImageItem1(BaseModel): extra="forbid", ) type: Type1 - url: constr(min_length=1) = Field( - ..., description="URL where the image is available." - ) + url: constr(min_length=1) = Field(..., description="URL where the image is available.") working_directory: Optional[Any] = Field(None, alias="working-directory") container_hash: Optional[Any] = Field(None, alias="container-hash") @@ -95,9 +93,7 @@ class EnvironmentVariable(BaseModel): description='The environment variable name (identifier) containing only alphanumeric characters and underscores. Example: "PROGRAM_PATH".', ) value: str = Field(..., description="The value of the environment variable.") - description: Optional[str] = Field( - None, description="Description of the environment variable." - ) + description: Optional[str] = Field(None, description="Description of the environment variable.") class Group(BaseModel): @@ -108,12 +104,8 @@ class Group(BaseModel): ..., description='A short, unique, informative identifier containing only alphanumeric characters and underscores. Typically used to generate variable names. Example: "outfile_group".', ) - name: constr(min_length=1) = Field( - ..., description="A human-readable name for the input group." - ) - description: Optional[str] = Field( - None, description="Description of the input group." - ) + name: constr(min_length=1) = Field(..., description="A human-readable name for the input group.") + description: Optional[str] = Field(None, description="Description of the input group.") members: List[constr(pattern=r"^[0-9,_,a-z,A-Z]*$", min_length=1)] = Field( ..., description="IDs of the inputs belonging to this group." ) @@ -172,9 +164,7 @@ class Inputs(Input): ..., description='A short, unique, informative identifier containing only alphanumeric characters and underscores. Typically used to generate variable names. Example: "data_file".', ) - name: constr(min_length=1) = Field( - ..., description="A human-readable input name. Example: 'Data file'." - ) + name: constr(min_length=1) = Field(..., description="A human-readable input name. Example: 'Data file'.") type: Type4 = Field(..., description="Input type.") description: Optional[str] = Field(None, description="Input description.") value_key: Optional[str] = Field( @@ -279,9 +269,7 @@ class Inputs1(Input1): ..., description='A short, unique, informative identifier containing only alphanumeric characters and underscores. Typically used to generate variable names. Example: "data_file".', ) - name: constr(min_length=1) = Field( - ..., description="A human-readable input name. Example: 'Data file'." - ) + name: constr(min_length=1) = Field(..., description="A human-readable input name. Example: 'Data file'.") type: Type4 = Field(..., description="Input type.") description: Optional[str] = Field(None, description="Input description.") value_key: Optional[str] = Field( @@ -384,9 +372,7 @@ class Test(BaseModel): class OutputFile(BaseModel): - file_template: Optional[List[str]] = Field( - None, alias="file-template", min_length=1 - ) + file_template: Optional[List[str]] = Field(None, alias="file-template", min_length=1) list: Optional[ListModel] = None @@ -410,9 +396,7 @@ class OutputFiles(OutputFile): ..., description='A short, unique, informative identifier containing only alphanumeric characters and underscores. Typically used to generate variable names. Example: "data_file"', ) - name: constr(min_length=1) = Field( - ..., description="A human-readable output name. Example: 'Data file'" - ) + name: constr(min_length=1) = Field(..., description="A human-readable output name. Example: 'Data file'") description: Optional[str] = Field(None, description="Output description.") value_key: Optional[str] = Field( None, @@ -436,9 +420,7 @@ class OutputFiles(OutputFile): description='List of file extensions that will be stripped from the input values before being substituted in the path template. Example: [".nii",".nii.gz"].', ) list: Optional[bool] = Field(None, description="True if output is a list of value.") - optional: Optional[bool] = Field( - None, description="True if output may not be produced by the tool." - ) + optional: Optional[bool] = Field(None, description="True if output may not be produced by the tool.") command_line_flag: Optional[str] = Field( None, alias="command-line-flag", @@ -470,9 +452,7 @@ class OutputFiles1(OutputFile1): ..., description='A short, unique, informative identifier containing only alphanumeric characters and underscores. Typically used to generate variable names. Example: "data_file"', ) - name: constr(min_length=1) = Field( - ..., description="A human-readable output name. Example: 'Data file'" - ) + name: constr(min_length=1) = Field(..., description="A human-readable output name. Example: 'Data file'") description: Optional[str] = Field(None, description="Output description.") value_key: Optional[str] = Field( None, @@ -496,9 +476,7 @@ class OutputFiles1(OutputFile1): description='List of file extensions that will be stripped from the input values before being substituted in the path template. Example: [".nii",".nii.gz"].', ) list: Optional[bool] = Field(None, description="True if output is a list of value.") - optional: Optional[bool] = Field( - None, description="True if output may not be produced by the tool." - ) + optional: Optional[bool] = Field(None, description="True if output may not be produced by the tool.") command_line_flag: Optional[str] = Field( None, alias="command-line-flag", @@ -561,27 +539,21 @@ class Tool(BaseModel): extra="forbid", ) name: constr(min_length=1) = Field(..., description="Tool name.") - tool_version: constr(min_length=1) = Field( - ..., alias="tool-version", description="Tool version." - ) + tool_version: constr(min_length=1) = Field(..., alias="tool-version", description="Tool version.") description: constr(min_length=1) = Field(..., description="Tool description.") deprecated_by_doi: Optional[Union[constr(min_length=1), bool]] = Field( None, alias="deprecated-by-doi", description="doi of the tool that deprecates the current one. May be set to 'true' if the current tool is deprecated but no specific tool deprecates it.", ) - author: Optional[constr(min_length=1)] = Field( - None, description="Tool author name(s)." - ) + author: Optional[constr(min_length=1)] = Field(None, description="Tool author name(s).") url: Optional[constr(min_length=1)] = Field(None, description="Tool URL.") descriptor_url: Optional[constr(min_length=1)] = Field( None, alias="descriptor-url", description="Link to the descriptor itself (e.g. the GitHub repo where it is hosted).", ) - doi: Optional[constr(min_length=1)] = Field( - None, description="DOI of the descriptor (not of the tool itself)." - ) + doi: Optional[constr(min_length=1)] = Field(None, description="DOI of the descriptor (not of the tool itself).") shell: Optional[constr(min_length=1)] = Field( None, description="Absolute path of the shell interpreter to use in the container (defaults to /bin/sh).", @@ -595,9 +567,7 @@ class Tool(BaseModel): description='A string that describes the tool command line, where input and output values are identified by "keys". At runtime, command-line keys are substituted with flags and values.', ) container_image: Optional[ContainerImage] = Field(None, alias="container-image") - schema_version: SchemaVersion = Field( - ..., alias="schema-version", description="Version of the schema used." - ) + schema_version: SchemaVersion = Field(..., alias="schema-version", description="Version of the schema used.") environment_variables: Optional[List[EnvironmentVariable]] = Field( None, alias="environment-variables", @@ -616,13 +586,9 @@ class Tool(BaseModel): alias="online-platform-urls", description="Online platform URLs from which the tool can be executed.", ) - output_files: Optional[List[Union[OutputFiles, OutputFiles1]]] = Field( - None, alias="output-files", min_length=1 - ) + output_files: Optional[List[Union[OutputFiles, OutputFiles1]]] = Field(None, alias="output-files", min_length=1) invocation_schema: Optional[Dict[str, Any]] = Field(None, alias="invocation-schema") - suggested_resources: Optional[SuggestedResources] = Field( - None, alias="suggested-resources" - ) + suggested_resources: Optional[SuggestedResources] = Field(None, alias="suggested-resources") tags: Optional[Dict[str, List[str]]] = Field( None, description="A set of key-value pairs specifying tags describing the pipeline. The tag names are open, they might be more constrained in the future.", diff --git a/src/styx/compiler/core.py b/src/styx/compiler/core.py index e7e3186..3f23373 100644 --- a/src/styx/compiler/core.py +++ b/src/styx/compiler/core.py @@ -4,8 +4,9 @@ from styx.boutiques import model as bt from styx.boutiques.utils import boutiques_split_command from styx.compiler.settings import CompilerSettings, DefsMode +from styx.compiler.utils import optional_float_to_int from styx.pycodegen.core import INDENT as PY_INDENT -from styx.pycodegen.core import PyArg, PyFunc, PyModule, collapse, indent +from styx.pycodegen.core import LineBuffer, PyArg, PyFunc, PyModule, collapse, indent from styx.pycodegen.utils import ( as_py_literal, enquote, @@ -45,8 +46,7 @@ [ '"""', "Run the command.", - "Called after all `Execution.input_file()` calls and " - "before `Execution.output_file()` calls.", + "Called after all `Execution.input_file()` calls and " "before `Execution.output_file()` calls.", '"""', "...", ] @@ -57,8 +57,7 @@ '"""', "Resolve local output files.", "Returns a host filepath.", - "Called (potentially multiple times) after " - "`Runner.run()` and before `Execution.finalize()`.", + "Called (potentially multiple times) after " "`Runner.run()` and before `Execution.finalize()`.", '"""', "...", ] @@ -82,8 +81,7 @@ [ '"""', "Runner object used to execute commands.", - "Possible examples would be `LocalRunner`, " - "`DockerRunner`, `DebugRunner`, ...", + "Possible examples would be `LocalRunner`, " "`DockerRunner`, `DebugRunner`, ...", "Used as a factory for `Execution` objects.", '"""', "def start_execution(self, tool_name: str) -> Execution[P, R]:", @@ -123,9 +121,27 @@ def __init__(self, bt_input: bt.Inputs) -> None: # type: ignore self.name = ensure_snake_case(ensure_python_symbol(bt_input.id)) self.docstring = bt_input.description self.command_line_flag = bt_input.command_line_flag - self.list_separator = ( - bt_input.list_separator if bt_input.list_separator is not None else " " - ) + self.list_separator = bt_input.list_separator if bt_input.list_separator is not None else " " + + # Validation + + self.minimum: float | int | None = None + self.minimum_exclusive: bool = False + self.maximum: float | int | None = None + self.maximum_exclusive: bool = False + self.list_minimum: int | None = None + self.list_maximum: int | None = None + + if bt_input.type == bt.Type4.Number: # type: ignore + if bt_input.minimum is not None: + self.minimum = int(bt_input.minimum) if bt_input.integer else bt_input.minimum + self.minimum_exclusive = bt_input.exclusive_minimum is True + if bt_input.maximum is not None: + self.maximum = int(bt_input.maximum) if bt_input.integer else bt_input.maximum + self.maximum_exclusive = bt_input.exclusive_maximum is True + if bt_input.list is True: + self.list_minimum = optional_float_to_int(bt_input.min_list_entries) + self.list_maximum = optional_float_to_int(bt_input.max_list_entries) # Resolve type @@ -133,22 +149,16 @@ def __init__(self, bt_input: bt.Inputs) -> None: # type: ignore bt_is_optional = bt_input.optional is True bt_is_enum = bt_input.value_choices is not None if bt_input.type == bt.Type4.String: # type: ignore - self.type = BtType( - BtPrimitive.String, bt_is_list, bt_is_optional, bt_is_enum - ) + self.type = BtType(BtPrimitive.String, bt_is_list, bt_is_optional, bt_is_enum) elif bt_input.type == bt.Type4.File: # type: ignore assert not bt_is_enum self.type = BtType(BtPrimitive.File, bt_is_list, bt_is_optional, False) elif bt_input.type == bt.Type4.Flag: # type: ignore self.type = BtType(BtPrimitive.Flag, False, True, False) elif bt_input.type == bt.Type4.Number and not bt_input.integer: # type: ignore - self.type = BtType( - BtPrimitive.Number, bt_is_list, bt_is_optional, bt_is_enum - ) + self.type = BtType(BtPrimitive.Number, bt_is_list, bt_is_optional, bt_is_enum) elif bt_input.type == bt.Type4.Number and bt_input.integer: # type: ignore - self.type = BtType( - BtPrimitive.Integer, bt_is_list, bt_is_optional, bt_is_enum - ) + self.type = BtType(BtPrimitive.Integer, bt_is_list, bt_is_optional, bt_is_enum) else: raise NotImplementedError @@ -235,7 +245,168 @@ def _make_py_expr(self) -> list[str]: return buf -def text_from_boutiques_json(tool: bt.Tool, settings: CompilerSettings) -> str: # type: ignore +def _generate_raise_value_err(obj: str, expectation: str, reality: str | None = None) -> LineBuffer: + fstr = "" + if "{" in obj or "{" in expectation or (reality is not None and "{" in reality): + fstr = "f" + + return ( + [f'raise ValueError({fstr}"{obj} must be {expectation} but was {reality}")'] + if reality is not None + else [f'raise ValueError({fstr}"{obj} must be {expectation}")'] + ) + + +def _generate_validation_expr( + buf: LineBuffer, + bt_input: BtInput, +) -> None: + val_opt = "" + if bt_input.type.is_optional: + val_opt = f"{bt_input.name} is not None and " + + # List argument length validation + if bt_input.list_minimum is not None and bt_input.list_maximum is not None: + assert bt_input.list_minimum <= bt_input.list_maximum + if bt_input.list_minimum == bt_input.list_maximum: + buf.extend( + [ + f"if {val_opt}(len({bt_input.name}) != {bt_input.list_minimum}): ", + *indent( + _generate_raise_value_err( + f"Length of '{bt_input.name}'", + f"{bt_input.list_minimum}", + f"{{len({bt_input.name})}}", + ) + ), + ] + ) + else: + buf.extend( + [ + f"if {val_opt}not ({bt_input.list_minimum} <= len({bt_input.name}) <= {bt_input.list_maximum}): ", + *indent( + _generate_raise_value_err( + f"Length of '{bt_input.name}'", + f"between {bt_input.list_minimum} and {bt_input.list_maximum}", + f"{{len({bt_input.name})}}", + ) + ), + ] + ) + elif bt_input.list_minimum is not None: + buf.extend( + [ + f"if {val_opt}not ({bt_input.list_minimum} <= len({bt_input.name})): ", + *indent( + _generate_raise_value_err( + f"Length of '{bt_input.name}'", + f"greater than {bt_input.list_minimum}", + f"{{len({bt_input.name})}}", + ) + ), + ] + ) + elif bt_input.list_maximum is not None: + buf.extend( + [ + f"if {val_opt}not (len({bt_input.name}) <= {bt_input.list_maximum}): ", + *indent( + _generate_raise_value_err( + f"Length of '{bt_input.name}'", + f"less than {bt_input.list_maximum}", + f"{{len({bt_input.name})}}", + ) + ), + ] + ) + + # Numeric argument range validation + op_min = "<" if bt_input.minimum_exclusive else "<=" + op_max = "<" if bt_input.maximum_exclusive else "<=" + if bt_input.minimum is not None and bt_input.maximum is not None: + assert bt_input.minimum <= bt_input.maximum + if bt_input.type.is_list: + buf.extend( + [ + f"if {val_opt}not ({bt_input.minimum} {op_min} min({bt_input.name}) " + f"and max({bt_input.name}) {op_max} {bt_input.maximum}): ", + *indent( + _generate_raise_value_err( + f"All elements of '{bt_input.name}'", + f"between {bt_input.minimum} {op_min} x {op_max} {bt_input.maximum}", + ) + ), + ] + ) + else: + buf.extend( + [ + f"if {val_opt}not ({bt_input.minimum} {op_min} {bt_input.name} {op_max} {bt_input.maximum}): ", + *indent( + _generate_raise_value_err( + f"'{bt_input.name}'", + f"between {bt_input.minimum} {op_min} x {op_max} {bt_input.maximum}", + f"{{{bt_input.name}}}", + ) + ), + ] + ) + elif bt_input.minimum is not None: + if bt_input.type.is_list: + buf.extend( + [ + f"if {val_opt}not ({bt_input.minimum} {op_min} min({bt_input.name})): ", + *indent( + _generate_raise_value_err( + f"All elements of '{bt_input.name}'", + f"greater than {bt_input.minimum} {op_min} x", + ) + ), + ] + ) + else: + buf.extend( + [ + f"if {val_opt}not ({bt_input.minimum} {op_min} {bt_input.name}): ", + *indent( + _generate_raise_value_err( + f"'{bt_input.name}'", + f"greater than {bt_input.minimum} {op_min} x", + f"{{{bt_input.name}}}", + ) + ), + ] + ) + elif bt_input.maximum is not None: + if bt_input.type.is_list: + buf.extend( + [ + f"if {val_opt}not (max({bt_input.name}) {op_max} {bt_input.maximum}): ", + *indent( + _generate_raise_value_err( + f"All elements of '{bt_input.name}'", + f"less than x {op_max} {bt_input.maximum}", + ) + ), + ] + ) + else: + buf.extend( + [ + f"if {val_opt}not ({bt_input.name} {op_max} {bt_input.maximum}): ", + *indent( + _generate_raise_value_err( + f"'{bt_input.name}'", + f"less than x {op_max} {bt_input.maximum}", + f"{{{bt_input.name}}}", + ) + ), + ] + ) + + +def py_from_boutiques(tool: bt.Tool, settings: CompilerSettings) -> str: # type: ignore mod = PyModule() # Python names @@ -256,19 +427,18 @@ def text_from_boutiques_json(tool: bt.Tool, settings: CompilerSettings) -> str: "cargs = []", ] - # Sort arguments by occurrence in command line + # Arguments cmd = boutiques_split_command(tool.command_line) args_lookup = {a.bt_ref: a for a in args} - pyargs = [ - PyArg( - name="runner", type="Runner[P, R]", default=None, docstring="Command runner" - ) - ] - pyargs += [ - PyArg(name=i.name, type=i.py_type, default=i.py_default, docstring=i.docstring) - for i in args - ] + pyargs = [PyArg(name="runner", type="Runner[P, R]", default=None, docstring="Command runner")] + pyargs += [PyArg(name=i.name, type=i.py_type, default=i.py_default, docstring=i.docstring) for i in args] + + # Input validation + for i in args: + _generate_validation_expr(buf_body, i) + + # Command line args building for segment in cmd: if segment in args_lookup: i = args_lookup[segment] @@ -333,8 +503,7 @@ def text_from_boutiques_json(tool: bt.Tool, settings: CompilerSettings) -> str: name=py_func_name, args=pyargs, return_type=f"{py_output_class_name}[R]", - return_descr=f"NamedTuple of outputs " - f"(described in `{py_output_class_name}`).", + return_descr=f"NamedTuple of outputs " f"(described in `{py_output_class_name}`).", docstring_body=docstring, body=buf_body, ) @@ -348,4 +517,4 @@ def text_from_boutiques_json(tool: bt.Tool, settings: CompilerSettings) -> str: def compile_descriptor(descriptor: bt.Tool, settings: CompilerSettings) -> str: # type: ignore """Compile a Boutiques descriptor to Python code.""" - return text_from_boutiques_json(descriptor, settings) + return py_from_boutiques(descriptor, settings) diff --git a/src/styx/compiler/utils.py b/src/styx/compiler/utils.py new file mode 100644 index 0000000..37f5169 --- /dev/null +++ b/src/styx/compiler/utils.py @@ -0,0 +1,6 @@ +"""Compiler utilities.""" + + +def optional_float_to_int(value: float | None) -> int | None: + """Convert an optional float to an optional int.""" + return int(value) if value is not None else None diff --git a/src/styx/pycodegen/core.py b/src/styx/pycodegen/core.py index 48e9533..03c19df 100644 --- a/src/styx/pycodegen/core.py +++ b/src/styx/pycodegen/core.py @@ -118,11 +118,7 @@ def generate(self) -> LineBuffer: ), *blank_before(self.imports), *blank_before(self.header), - *[ - line - for func in self.funcs - for line in blank_before(func.generate(), 2) - ], + *[line for func in self.funcs for line in blank_before(func.generate(), 2)], *blank_before(self.footer), ] ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d420712 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests.""" diff --git a/tests/test_carg_building.py b/tests/test_carg_building.py index 9002235..a6d7ef0 100644 --- a/tests/test_carg_building.py +++ b/tests/test_carg_building.py @@ -1,59 +1,25 @@ """Test command line argument building.""" -import importlib.util -from types import ModuleType import styx.boutiques.utils import styx.compiler.core import styx.compiler.settings import styx.runners.core - -_BT_TYPE_STRING = "String" -_BT_TYPE_NUMBER = "Number" -_BT_TYPE_FILE = "File" -_BT_TYPE_FLAG = "Flag" - - -def _dynamic_module(source_code: str, module_name: str) -> ModuleType: - """Create a dynamic module.""" - module_spec = importlib.util.spec_from_loader(module_name, loader=None) - assert module_spec is not None # mypy - module = importlib.util.module_from_spec(module_spec) - exec(source_code, module.__dict__) - # TODO: Does this module need to be unloaded somehow after use? - return module - - -def _boutiques_dummy(descriptor: dict) -> dict: - """Add required meta data placeholders to a boutiques descriptor.""" - dummy = { - "name": "dummy", - "tool-version": "1.0", - "description": "Dummy description", - "command-line": "dummy", - "schema-version": "0.5", - "container-image": {"type": "docker", "image": "dummy/dummy"}, - "inputs": [], - "output-files": [ - { - "id": "dummy_output", - "name": "Dummy output", - "path-template": "dummy_output.txt", - } - ], - } - - dummy.update(descriptor) - return dummy +from tests.utils.dynmodule import ( + BT_TYPE_FILE, + BT_TYPE_FLAG, + BT_TYPE_NUMBER, + BT_TYPE_STRING, + boutiques_dummy, + dynamic_module, +) def test_positional_string_arg() -> None: """Positional string argument.""" - settings = styx.compiler.settings.CompilerSettings( - defs_mode=styx.compiler.settings.DefsMode.IMPORT - ) + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) model = styx.boutiques.utils.boutiques_from_dict( - _boutiques_dummy( + boutiques_dummy( { "command-line": "dummy [X]", "inputs": [ @@ -61,7 +27,7 @@ def test_positional_string_arg() -> None: "id": "x", "name": "The x", "value-key": "[X]", - "type": _BT_TYPE_STRING, + "type": BT_TYPE_STRING, } ], } @@ -70,7 +36,7 @@ def test_positional_string_arg() -> None: compiled_module = styx.compiler.core.compile_descriptor(model, settings) - test_module = _dynamic_module(compiled_module, "test_module") + test_module = dynamic_module(compiled_module, "test_module") dummy_runner = styx.runners.core.DummyRunner() test_module.dummy(runner=dummy_runner, x="my_string") @@ -80,11 +46,9 @@ def test_positional_string_arg() -> None: def test_positional_number_arg() -> None: """Positional number argument.""" - settings = styx.compiler.settings.CompilerSettings( - defs_mode=styx.compiler.settings.DefsMode.IMPORT - ) + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) model = styx.boutiques.utils.boutiques_from_dict( - _boutiques_dummy( + boutiques_dummy( { "command-line": "dummy [X]", "inputs": [ @@ -92,7 +56,7 @@ def test_positional_number_arg() -> None: "id": "x", "name": "The x", "value-key": "[X]", - "type": _BT_TYPE_NUMBER, + "type": BT_TYPE_NUMBER, } ], } @@ -101,7 +65,7 @@ def test_positional_number_arg() -> None: compiled_module = styx.compiler.core.compile_descriptor(model, settings) - test_module = _dynamic_module(compiled_module, "test_module") + test_module = dynamic_module(compiled_module, "test_module") dummy_runner = styx.runners.core.DummyRunner() test_module.dummy(runner=dummy_runner, x="123") @@ -111,11 +75,9 @@ def test_positional_number_arg() -> None: def test_positional_file_arg() -> None: """Positional file argument.""" - settings = styx.compiler.settings.CompilerSettings( - defs_mode=styx.compiler.settings.DefsMode.IMPORT - ) + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) model = styx.boutiques.utils.boutiques_from_dict( - _boutiques_dummy( + boutiques_dummy( { "command-line": "dummy [X]", "inputs": [ @@ -123,7 +85,7 @@ def test_positional_file_arg() -> None: "id": "x", "name": "The x", "value-key": "[X]", - "type": _BT_TYPE_FILE, + "type": BT_TYPE_FILE, } ], } @@ -132,7 +94,7 @@ def test_positional_file_arg() -> None: compiled_module = styx.compiler.core.compile_descriptor(model, settings) - test_module = _dynamic_module(compiled_module, "test_module") + test_module = dynamic_module(compiled_module, "test_module") dummy_runner = styx.runners.core.DummyRunner() test_module.dummy(runner=dummy_runner, x="/my/file.txt") @@ -142,11 +104,9 @@ def test_positional_file_arg() -> None: def test_flag_arg() -> None: """Flag argument.""" - settings = styx.compiler.settings.CompilerSettings( - defs_mode=styx.compiler.settings.DefsMode.IMPORT - ) + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) model = styx.boutiques.utils.boutiques_from_dict( - _boutiques_dummy( + boutiques_dummy( { "command-line": "dummy [X]", "inputs": [ @@ -154,7 +114,7 @@ def test_flag_arg() -> None: "id": "x", "name": "The x", "value-key": "[X]", - "type": _BT_TYPE_FLAG, + "type": BT_TYPE_FLAG, "command-line-flag": "-x", } ], @@ -164,7 +124,7 @@ def test_flag_arg() -> None: compiled_module = styx.compiler.core.compile_descriptor(model, settings) - test_module = _dynamic_module(compiled_module, "test_module") + test_module = dynamic_module(compiled_module, "test_module") dummy_runner = styx.runners.core.DummyRunner() test_module.dummy(runner=dummy_runner, x="my_string") @@ -174,11 +134,9 @@ def test_flag_arg() -> None: def test_named_arg() -> None: """Named argument.""" - settings = styx.compiler.settings.CompilerSettings( - defs_mode=styx.compiler.settings.DefsMode.IMPORT - ) + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) model = styx.boutiques.utils.boutiques_from_dict( - _boutiques_dummy( + boutiques_dummy( { "command-line": "dummy [X]", "inputs": [ @@ -186,7 +144,7 @@ def test_named_arg() -> None: "id": "x", "name": "The x", "value-key": "[X]", - "type": _BT_TYPE_STRING, + "type": BT_TYPE_STRING, "command-line-flag": "-x", } ], @@ -196,7 +154,7 @@ def test_named_arg() -> None: compiled_module = styx.compiler.core.compile_descriptor(model, settings) - test_module = _dynamic_module(compiled_module, "test_module") + test_module = dynamic_module(compiled_module, "test_module") dummy_runner = styx.runners.core.DummyRunner() test_module.dummy(runner=dummy_runner, x="my_string") @@ -206,11 +164,9 @@ def test_named_arg() -> None: def test_list_of_strings_arg() -> None: """List of strings.""" - settings = styx.compiler.settings.CompilerSettings( - defs_mode=styx.compiler.settings.DefsMode.IMPORT - ) + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) model = styx.boutiques.utils.boutiques_from_dict( - _boutiques_dummy( + boutiques_dummy( { "command-line": "dummy [X]", "inputs": [ @@ -218,7 +174,7 @@ def test_list_of_strings_arg() -> None: "id": "x", "name": "The x", "value-key": "[X]", - "type": _BT_TYPE_STRING, + "type": BT_TYPE_STRING, "list": True, } ], @@ -228,7 +184,7 @@ def test_list_of_strings_arg() -> None: compiled_module = styx.compiler.core.compile_descriptor(model, settings) - test_module = _dynamic_module(compiled_module, "test_module") + test_module = dynamic_module(compiled_module, "test_module") dummy_runner = styx.runners.core.DummyRunner() test_module.dummy(runner=dummy_runner, x=["my_string1", "my_string2"]) @@ -238,11 +194,9 @@ def test_list_of_strings_arg() -> None: def test_list_of_numbers_arg() -> None: """List of numbers.""" - settings = styx.compiler.settings.CompilerSettings( - defs_mode=styx.compiler.settings.DefsMode.IMPORT - ) + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) model = styx.boutiques.utils.boutiques_from_dict( - _boutiques_dummy( + boutiques_dummy( { "command-line": "dummy [X]", "inputs": [ @@ -250,7 +204,7 @@ def test_list_of_numbers_arg() -> None: "id": "x", "name": "The x", "value-key": "[X]", - "type": _BT_TYPE_NUMBER, + "type": BT_TYPE_NUMBER, "list": True, } ], @@ -260,7 +214,7 @@ def test_list_of_numbers_arg() -> None: compiled_module = styx.compiler.core.compile_descriptor(model, settings) - test_module = _dynamic_module(compiled_module, "test_module") + test_module = dynamic_module(compiled_module, "test_module") dummy_runner = styx.runners.core.DummyRunner() test_module.dummy(runner=dummy_runner, x=[1, 2]) @@ -270,11 +224,9 @@ def test_list_of_numbers_arg() -> None: def test_static_args() -> None: """Static arguments.""" - settings = styx.compiler.settings.CompilerSettings( - defs_mode=styx.compiler.settings.DefsMode.IMPORT - ) + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) model = styx.boutiques.utils.boutiques_from_dict( - _boutiques_dummy( + boutiques_dummy( { "command-line": "dummy -a 1 -b 2 [X] -c 3 -d 4", "inputs": [ @@ -282,7 +234,7 @@ def test_static_args() -> None: "id": "x", "name": "The x", "value-key": "[X]", - "type": _BT_TYPE_STRING, + "type": BT_TYPE_STRING, } ], } @@ -291,7 +243,7 @@ def test_static_args() -> None: compiled_module = styx.compiler.core.compile_descriptor(model, settings) - test_module = _dynamic_module(compiled_module, "test_module") + test_module = dynamic_module(compiled_module, "test_module") dummy_runner = styx.runners.core.DummyRunner() test_module.dummy(runner=dummy_runner, x="my_string") @@ -316,11 +268,9 @@ def test_arg_order() -> None: The wrapper should respect the order of the arguments in the Boutiques descriptor input array. """ - settings = styx.compiler.settings.CompilerSettings( - defs_mode=styx.compiler.settings.DefsMode.IMPORT - ) + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) model = styx.boutiques.utils.boutiques_from_dict( - _boutiques_dummy( + boutiques_dummy( { "command-line": "[B] [A]", "inputs": [ @@ -328,13 +278,13 @@ def test_arg_order() -> None: "id": "a", "name": "The a", "value-key": "[A]", - "type": _BT_TYPE_STRING, + "type": BT_TYPE_STRING, }, { "id": "b", "name": "The b", "value-key": "[B]", - "type": _BT_TYPE_STRING, + "type": BT_TYPE_STRING, }, ], } @@ -344,7 +294,7 @@ def test_arg_order() -> None: compiled_module = styx.compiler.core.compile_descriptor(model, settings) print(compiled_module) - test_module = _dynamic_module(compiled_module, "test_module") + test_module = dynamic_module(compiled_module, "test_module") dummy_runner = styx.runners.core.DummyRunner() test_module.dummy(dummy_runner, "aaa", "bbb") diff --git a/tests/test_input_validation.py b/tests/test_input_validation.py new file mode 100644 index 0000000..32957b2 --- /dev/null +++ b/tests/test_input_validation.py @@ -0,0 +1,195 @@ +"""Input validation tests. + +Non-goals: +- Argument types. -> typing + +Goals: +- Numeric ranges of values. +- Mutually exclusive arguments. + +""" + +import styx.boutiques.utils +import styx.compiler.core +import styx.compiler.settings +import styx.runners.core +from tests.utils.dynmodule import ( + BT_TYPE_NUMBER, + boutiques_dummy, + dynamic_module, +) + + +def test_below_range_minimum_inclusive() -> None: + """Below range minimum.""" + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) + model = styx.boutiques.utils.boutiques_from_dict( + boutiques_dummy( + { + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "name": "The x", + "value-key": "[X]", + "type": BT_TYPE_NUMBER, + "minimum": 5, + "integer": True, + } + ], + } + ) + ) + + compiled_module = styx.compiler.core.compile_descriptor(model, settings) + + test_module = dynamic_module(compiled_module, "test_module") + dummy_runner = styx.runners.core.DummyRunner() + try: + test_module.dummy(runner=dummy_runner, x=4) + except ValueError as e: + assert "must be greater than" in str(e) + else: + assert False, "Expected ValueError" + + +def test_above_range_maximum_inclusive() -> None: + """Above range maximum.""" + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) + model = styx.boutiques.utils.boutiques_from_dict( + boutiques_dummy( + { + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "name": "The x", + "value-key": "[X]", + "type": BT_TYPE_NUMBER, + "maximum": 5, + "integer": True, + } + ], + } + ) + ) + + compiled_module = styx.compiler.core.compile_descriptor(model, settings) + + test_module = dynamic_module(compiled_module, "test_module") + dummy_runner = styx.runners.core.DummyRunner() + try: + test_module.dummy(runner=dummy_runner, x=6) + except ValueError as e: + assert "must be less than" in str(e) + else: + assert False, "Expected ValueError" + + +def test_above_range_maximum_exclusive() -> None: + """Above range maximum exclusive.""" + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) + model = styx.boutiques.utils.boutiques_from_dict( + boutiques_dummy( + { + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "name": "The x", + "value-key": "[X]", + "type": BT_TYPE_NUMBER, + "maximum": 5, + "integer": True, + "exclusive-maximum": True, + } + ], + } + ) + ) + + compiled_module = styx.compiler.core.compile_descriptor(model, settings) + + test_module = dynamic_module(compiled_module, "test_module") + dummy_runner = styx.runners.core.DummyRunner() + try: + test_module.dummy(runner=dummy_runner, x=5) + except ValueError as e: + assert "must be less than" in str(e) + else: + assert False, "Expected ValueError" + + +def test_below_range_minimum_exclusive() -> None: + """Below range minimum exclusive.""" + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) + model = styx.boutiques.utils.boutiques_from_dict( + boutiques_dummy( + { + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "name": "The x", + "value-key": "[X]", + "type": BT_TYPE_NUMBER, + "minimum": 5, + "integer": True, + "exclusive-minimum": True, + } + ], + } + ) + ) + + compiled_module = styx.compiler.core.compile_descriptor(model, settings) + + test_module = dynamic_module(compiled_module, "test_module") + dummy_runner = styx.runners.core.DummyRunner() + try: + test_module.dummy(runner=dummy_runner, x=5) + except ValueError as e: + assert "must be greater than" in str(e) + else: + assert False, "Expected ValueError" + + +def test_outside_range() -> None: + """Outside range.""" + settings = styx.compiler.settings.CompilerSettings(defs_mode=styx.compiler.settings.DefsMode.IMPORT) + model = styx.boutiques.utils.boutiques_from_dict( + boutiques_dummy( + { + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "name": "The x", + "value-key": "[X]", + "type": BT_TYPE_NUMBER, + "minimum": 5, + "maximum": 10, + "integer": True, + } + ], + } + ) + ) + + compiled_module = styx.compiler.core.compile_descriptor(model, settings) + + test_module = dynamic_module(compiled_module, "test_module") + dummy_runner = styx.runners.core.DummyRunner() + try: + test_module.dummy(runner=dummy_runner, x=11) + except ValueError as e: + assert "must be less than" in str(e) + else: + assert False, "Expected ValueError" + + try: + test_module.dummy(runner=dummy_runner, x=4) + except ValueError as e: + assert "must be greater than" in str(e) + else: + assert False, "Expected ValueError" diff --git a/tests/utils/dynmodule.py b/tests/utils/dynmodule.py new file mode 100644 index 0000000..604949e --- /dev/null +++ b/tests/utils/dynmodule.py @@ -0,0 +1,42 @@ +"""Dynamic boutiques module creation for testing purposes.""" + +import importlib.util +from types import ModuleType + +BT_TYPE_STRING = "String" +BT_TYPE_NUMBER = "Number" +BT_TYPE_FILE = "File" +BT_TYPE_FLAG = "Flag" + + +def dynamic_module(source_code: str, module_name: str) -> ModuleType: + """Create a dynamic module.""" + module_spec = importlib.util.spec_from_loader(module_name, loader=None) + assert module_spec is not None # mypy + module = importlib.util.module_from_spec(module_spec) + exec(source_code, module.__dict__) + # TODO: Does this module need to be unloaded somehow after use? + return module + + +def boutiques_dummy(descriptor: dict) -> dict: + """Add required meta data placeholders to a boutiques descriptor.""" + dummy = { + "name": "dummy", + "tool-version": "1.0", + "description": "Dummy description", + "command-line": "dummy", + "schema-version": "0.5", + "container-image": {"type": "docker", "image": "dummy/dummy"}, + "inputs": [], + "output-files": [ + { + "id": "dummy_output", + "name": "Dummy output", + "path-template": "dummy_output.txt", + } + ], + } + + dummy.update(descriptor) + return dummy