diff --git a/api/docs/v2/new_protocol_api.rst b/api/docs/v2/new_protocol_api.rst index 815faebcde6..3bd6ac38658 100644 --- a/api/docs/v2/new_protocol_api.rst +++ b/api/docs/v2/new_protocol_api.rst @@ -14,7 +14,7 @@ Protocols .. autoclass:: opentrons.protocol_api.ProtocolContext :members: - :exclude-members: location_cache, cleanup, clear_commands + :exclude-members: location_cache, cleanup, clear_commands, params Instruments =========== diff --git a/api/src/opentrons/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index e9bc4356aaf..1e817c7a882 100644 --- a/api/src/opentrons/protocol_api/__init__.py +++ b/api/src/opentrons/protocol_api/__init__.py @@ -29,6 +29,8 @@ COLUMN, ALL, ) +from ._parameters import Parameters +from ._parameter_context import ParameterContext from .create_protocol_context import ( create_protocol_context, @@ -48,11 +50,13 @@ "ThermocyclerContext", "HeaterShakerContext", "MagneticBlockContext", + "ParameterContext", "Labware", "TrashBin", "WasteChute", "Well", "Liquid", + "Parameters", "COLUMN", "ALL", "OFF_DECK", diff --git a/api/src/opentrons/protocol_api/_parameter_context.py b/api/src/opentrons/protocol_api/_parameter_context.py new file mode 100644 index 00000000000..6a503f7337a --- /dev/null +++ b/api/src/opentrons/protocol_api/_parameter_context.py @@ -0,0 +1,169 @@ +"""Parameter context for python protocols.""" + +from typing import List, Optional, Union + +from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocols.parameters import parameter_definition +from opentrons.protocols.parameters.types import ParameterChoice + +from ._parameters import Parameters + +_ParameterDefinitionTypes = Union[ + parameter_definition.ParameterDefinition[int], + parameter_definition.ParameterDefinition[bool], + parameter_definition.ParameterDefinition[float], + parameter_definition.ParameterDefinition[str], +] + + +class ParameterContext: + """Public context for adding parameters to a protocol.""" + + def __init__(self, api_version: APIVersion) -> None: + """Initializes a parameter context for user-set parameters.""" + self._api_version = api_version + self._parameters: List[_ParameterDefinitionTypes] = [] + + def add_int( + self, + display_name: str, + variable_name: str, + default: int, + minimum: Optional[int] = None, + maximum: Optional[int] = None, + choices: Optional[List[ParameterChoice]] = None, + description: Optional[str] = None, + unit: Optional[str] = None, + ) -> None: + """Creates an integer parameter, settable within a given range or list of choices. + + Arguments: + display_name: The display name of the int parameter as it would show up on the frontend. + variable_name: The variable name the int parameter will be referred to in the run context. + default: The default value the int parameter will be set to. This will be used in initial analysis. + minimum: The minimum value the int parameter can be set to (inclusive). Mutually exclusive with choices. + maximum: The maximum value the int parameter can be set to (inclusive). Mutually exclusive with choices. + choices: A list of possible choices that this parameter can be set to. + Mutually exclusive with minimum and maximum. + description: A description of the parameter as it will show up on the frontend. + unit: An optional unit to be appended to the end of the integer as it shown on the frontend. + """ + self._parameters.append( + parameter_definition.create_int_parameter( + display_name=display_name, + variable_name=variable_name, + default=default, + minimum=minimum, + maximum=maximum, + choices=choices, + description=description, + unit=unit, + ) + ) + + def add_float( + self, + display_name: str, + variable_name: str, + default: float, + minimum: Optional[float] = None, + maximum: Optional[float] = None, + choices: Optional[List[ParameterChoice]] = None, + description: Optional[str] = None, + unit: Optional[str] = None, + ) -> None: + """Creates a float parameter, settable within a given range or list of choices. + + Arguments: + display_name: The display name of the float parameter as it would show up on the frontend. + variable_name: The variable name the float parameter will be referred to in the run context. + default: The default value the float parameter will be set to. This will be used in initial analysis. + minimum: The minimum value the float parameter can be set to (inclusive). Mutually exclusive with choices. + maximum: The maximum value the float parameter can be set to (inclusive). Mutually exclusive with choices. + choices: A list of possible choices that this parameter can be set to. + Mutually exclusive with minimum and maximum. + description: A description of the parameter as it will show up on the frontend. + unit: An optional unit to be appended to the end of the float as it shown on the frontend. + """ + self._parameters.append( + parameter_definition.create_float_parameter( + display_name=display_name, + variable_name=variable_name, + default=default, + minimum=minimum, + maximum=maximum, + choices=choices, + description=description, + unit=unit, + ) + ) + + def add_bool( + self, + display_name: str, + variable_name: str, + default: bool, + description: Optional[str] = None, + ) -> None: + """Creates a boolean parameter with allowable values of "On" (True) or "Off" (False). + + Arguments: + display_name: The display name of the boolean parameter as it would show up on the frontend. + variable_name: The variable name the boolean parameter will be referred to in the run context. + default: The default value the boolean parameter will be set to. This will be used in initial analysis. + description: A description of the parameter as it will show up on the frontend. + """ + self._parameters.append( + parameter_definition.create_bool_parameter( + display_name=display_name, + variable_name=variable_name, + default=default, + choices=[ + {"display_name": "On", "value": True}, + {"display_name": "Off", "value": False}, + ], + description=description, + ) + ) + + def add_str( + self, + display_name: str, + variable_name: str, + default: str, + choices: Optional[List[ParameterChoice]] = None, + description: Optional[str] = None, + ) -> None: + """Creates a string parameter, settable among given choices. + + Arguments: + display_name: The display name of the string parameter as it would show up on the frontend. + variable_name: The variable name the string parameter will be referred to in the run context. + default: The default value the string parameter will be set to. This will be used in initial analysis. + choices: A list of possible choices that this parameter can be set to. + Mutually exclusive with minimum and maximum. + description: A description of the parameter as it will show up on the frontend. + """ + self._parameters.append( + parameter_definition.create_str_parameter( + display_name=display_name, + variable_name=variable_name, + default=default, + choices=choices, + description=description, + ) + ) + + def export_parameters(self) -> Parameters: + """Exports all parameters into a protocol run usable parameters object. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + return Parameters( + parameters={ + parameter.variable_name: parameter.value + for parameter in self._parameters + } + ) diff --git a/api/src/opentrons/protocol_api/_parameters.py b/api/src/opentrons/protocol_api/_parameters.py new file mode 100644 index 00000000000..8176052111b --- /dev/null +++ b/api/src/opentrons/protocol_api/_parameters.py @@ -0,0 +1,30 @@ +from typing import Dict, Optional, Any + +from opentrons.protocols.parameters.types import AllowedTypes, ParameterNameError + + +class Parameters: + def __init__(self, parameters: Optional[Dict[str, AllowedTypes]] = None) -> None: + super().__setattr__("_values", {}) + self._values: Dict[str, AllowedTypes] = {} + if parameters is not None: + for name, value in parameters.items(): + self._initialize_parameter(name, value) + + def __setattr__(self, key: str, value: Any) -> None: + if key in self._values: + raise AttributeError(f"Cannot overwrite protocol defined parameter {key}") + super().__setattr__(key, value) + + def _initialize_parameter(self, variable_name: str, value: AllowedTypes) -> None: + if not hasattr(self, variable_name): + setattr(self, variable_name, value) + self._values[variable_name] = value + else: + raise ParameterNameError( + f"Cannot use {variable_name} as a variable name, either duplicates another" + f" parameter name, Opentrons reserved function, or Python built-in" + ) + + def get_all(self) -> Dict[str, AllowedTypes]: + return self._values diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 7a151ad4233..2dd7815c09f 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -64,6 +64,7 @@ MagneticBlockContext, ModuleContext, ) +from ._parameters import Parameters logger = logging.getLogger(__name__) @@ -167,6 +168,7 @@ def __init__( self._core.load_ot2_fixed_trash_bin() self._commands: List[str] = [] + self._params: Parameters = Parameters() self._unsubscribe_commands: Optional[Callable[[], None]] = None self.clear_commands() @@ -215,6 +217,11 @@ def bundled_data(self) -> Dict[str, bytes]: """ return self._bundled_data + @property + @requires_version(2, 18) + def params(self) -> Parameters: + return self._params + def cleanup(self) -> None: """Finalize and clean up the protocol context.""" if self._unsubscribe_commands: diff --git a/api/src/opentrons/protocols/execution/execute_python.py b/api/src/opentrons/protocols/execution/execute_python.py index cf5f3303cbe..6deab339fc8 100644 --- a/api/src/opentrons/protocols/execution/execute_python.py +++ b/api/src/opentrons/protocols/execution/execute_python.py @@ -6,9 +6,12 @@ from typing import Any, Dict from opentrons.drivers.smoothie_drivers.errors import SmoothieAlarm -from opentrons.protocol_api import ProtocolContext +from opentrons.protocol_api import ProtocolContext, ParameterContext +from opentrons.protocol_api._parameters import Parameters from opentrons.protocols.execution.errors import ExceptionInProtocolError from opentrons.protocols.types import PythonProtocol, MalformedPythonProtocolError + + from opentrons_shared_data.errors.exceptions import ExecutionCancelledError MODULE_LOG = logging.getLogger(__name__) @@ -29,6 +32,14 @@ def _runfunc_ok(run_func: Any): ) +def _add_parameters_func_ok(add_parameters_func: Any) -> None: + if not callable(add_parameters_func): + raise SyntaxError("'add_parameters' must be a function.") + sig = inspect.Signature.from_callable(add_parameters_func) + if len(sig.parameters) != 1: + raise SyntaxError("Function 'add_parameters' must take exactly one argument.") + + def _find_protocol_error(tb, proto_name): """Return the FrameInfo for the lowest frame in the traceback from the protocol. @@ -41,6 +52,34 @@ def _find_protocol_error(tb, proto_name): raise KeyError +def _raise_pretty_protocol_error(exception: Exception, filename: str) -> None: + exc_type, exc_value, tb = sys.exc_info() + try: + frame = _find_protocol_error(tb, filename) + except KeyError: + # No pretty names, just raise it + raise exception + raise ExceptionInProtocolError( + exception, tb, str(exception), frame.lineno + ) from exception + + +def _parse_and_set_parameters( + protocol: PythonProtocol, new_globs: Dict[Any, Any], filename: str +) -> Parameters: + try: + _add_parameters_func_ok(new_globs.get("add_parameters")) + except SyntaxError as se: + raise MalformedPythonProtocolError(str(se)) + parameter_context = ParameterContext(api_version=protocol.api_level) + new_globs["__param_context"] = parameter_context + try: + exec("add_parameters(__param_context)", new_globs) + except Exception as e: + _raise_pretty_protocol_error(exception=e, filename=filename) + return parameter_context.export_parameters() + + def run_python(proto: PythonProtocol, context: ProtocolContext): new_globs: Dict[Any, Any] = {} exec(proto.contents, new_globs) @@ -60,10 +99,14 @@ def run_python(proto: PythonProtocol, context: ProtocolContext): # AST filename. filename = proto.filename or "" + if new_globs.get("add_parameters"): + context._params = _parse_and_set_parameters(proto, new_globs, filename) + try: _runfunc_ok(new_globs.get("run")) except SyntaxError as se: raise MalformedPythonProtocolError(str(se)) + new_globs["__context"] = context try: exec("run(__context)", new_globs) @@ -75,10 +118,4 @@ def run_python(proto: PythonProtocol, context: ProtocolContext): # this is a protocol cancel and shouldn't have special logging raise except Exception as e: - exc_type, exc_value, tb = sys.exc_info() - try: - frame = _find_protocol_error(tb, filename) - except KeyError: - # No pretty names, just raise it - raise e - raise ExceptionInProtocolError(e, tb, str(e), frame.lineno) from e + _raise_pretty_protocol_error(exception=e, filename=filename) diff --git a/api/src/opentrons/protocols/parameters/__init__.py b/api/src/opentrons/protocols/parameters/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/src/opentrons/protocols/parameters/parameter_definition.py b/api/src/opentrons/protocols/parameters/parameter_definition.py new file mode 100644 index 00000000000..54c27b1840d --- /dev/null +++ b/api/src/opentrons/protocols/parameters/parameter_definition.py @@ -0,0 +1,189 @@ +"""Parameter definition and associated validators.""" + +from typing import Generic, Optional, List, Set, Union, get_args + +from opentrons.protocols.parameters.types import ( + ParamType, + ParameterChoice, + AllowedTypes, + ParameterDefinitionError, + ParameterValueError, +) +from opentrons.protocols.parameters import validation + + +class ParameterDefinition(Generic[ParamType]): + """The definition for a user defined parameter.""" + + def __init__( + self, + display_name: str, + variable_name: str, + parameter_type: type, + default: ParamType, + minimum: Optional[ParamType] = None, + maximum: Optional[ParamType] = None, + choices: Optional[List[ParameterChoice]] = None, + description: Optional[str] = None, + unit: Optional[str] = None, + ) -> None: + """Initializes a parameter. + + This stores the type, default values, range or list of possible values, and other information + that is defined when a parameter is created for a protocol, as well as validators for setting + a non-default value for the parameter. + + Arguments: + display_name: The display name of the parameter as it would show up on the frontend. + variable_name: The variable name the parameter will be referred to in the run context. + parameter_type: Can be bool, int, float or str. Must match the type of default and all choices or + min and max values + default: The default value the parameter is set to. This will be used in initial analysis. + minimum: The minimum value the parameter can be set to (inclusive). Mutually exclusive with choices. + maximum: The maximum value the parameter can be set to (inclusive). Mutually exclusive with choices. + choices: A sequence of possible choices that this parameter can be set to. + Mutually exclusive with minimum and maximum. + description: An optional description for the parameter. + unit: An optional suffix for float and int type parameters. + """ + self._display_name = validation.ensure_display_name(display_name) + self._variable_name = validation.ensure_variable_name(variable_name) + self._description = validation.ensure_description(description) + self._unit = validation.ensure_unit_string_length(unit) + + if parameter_type not in get_args(AllowedTypes): + raise ParameterDefinitionError( + "Parameters can only be of type int, float, str, or bool." + ) + self._type = parameter_type + + self._choices: Optional[List[ParameterChoice]] = choices + self._allowed_values: Optional[Set[AllowedTypes]] = None + + self._minimum: Optional[Union[int, float]] = None + self._maximum: Optional[Union[int, float]] = None + + validation.validate_options(default, minimum, maximum, choices, parameter_type) + if choices is not None: + self._allowed_values = {choice["value"] for choice in choices} + else: + assert isinstance(minimum, (int, float)) and isinstance( + maximum, (int, float) + ) + self._minimum = minimum + self._maximum = maximum + + self._default: ParamType = default + self.value: ParamType = default + + @property + def value(self) -> ParamType: + """The current value of the parameter.""" + return self._value + + @value.setter + def value(self, new_value: ParamType) -> None: + validation.validate_type(new_value, self._type) + if self._allowed_values is not None and new_value not in self._allowed_values: + raise ParameterValueError( + f"Parameter must be set to one of the allowed values of {self._allowed_values}." + ) + elif ( + isinstance(self._minimum, (int, float)) + and isinstance(self._maximum, (int, float)) + and isinstance(new_value, (int, float)) + and not (self._minimum <= new_value <= self._maximum) + ): + raise ParameterValueError( + f"Parameter must be between {self._minimum} and {self._maximum} inclusive." + ) + self._value = new_value + + @property + def variable_name(self) -> str: + """The in-protocol variable name of the parameter.""" + return self._variable_name + + +def create_int_parameter( + display_name: str, + variable_name: str, + default: int, + minimum: Optional[int] = None, + maximum: Optional[int] = None, + choices: Optional[List[ParameterChoice]] = None, + description: Optional[str] = None, + unit: Optional[str] = None, +) -> ParameterDefinition[int]: + """Creates an integer parameter.""" + return ParameterDefinition( + parameter_type=int, + display_name=display_name, + variable_name=variable_name, + default=default, + minimum=minimum, + maximum=maximum, + choices=choices, + description=description, + unit=unit, + ) + + +def create_float_parameter( + display_name: str, + variable_name: str, + default: float, + minimum: Optional[float] = None, + maximum: Optional[float] = None, + choices: Optional[List[ParameterChoice]] = None, + description: Optional[str] = None, + unit: Optional[str] = None, +) -> ParameterDefinition[float]: + """Creates a float parameter.""" + return ParameterDefinition( + parameter_type=float, + display_name=display_name, + variable_name=variable_name, + default=default, + minimum=minimum, + maximum=maximum, + choices=choices, + description=description, + unit=unit, + ) + + +def create_bool_parameter( + display_name: str, + variable_name: str, + default: bool, + choices: List[ParameterChoice], + description: Optional[str] = None, +) -> ParameterDefinition[bool]: + """Creates a boolean parameter.""" + return ParameterDefinition( + parameter_type=bool, + display_name=display_name, + variable_name=variable_name, + default=default, + choices=choices, + description=description, + ) + + +def create_str_parameter( + display_name: str, + variable_name: str, + default: str, + choices: Optional[List[ParameterChoice]] = None, + description: Optional[str] = None, +) -> ParameterDefinition[str]: + """Creates a string parameter.""" + return ParameterDefinition( + parameter_type=str, + display_name=display_name, + variable_name=variable_name, + default=default, + choices=choices, + description=description, + ) diff --git a/api/src/opentrons/protocols/parameters/types.py b/api/src/opentrons/protocols/parameters/types.py new file mode 100644 index 00000000000..7edf0c941d5 --- /dev/null +++ b/api/src/opentrons/protocols/parameters/types.py @@ -0,0 +1,25 @@ +from typing import TypeVar, Union, TypedDict + + +AllowedTypes = Union[str, int, float, bool] + +ParamType = TypeVar("ParamType", bound=AllowedTypes) + + +class ParameterChoice(TypedDict): + """A parameter choice containing the display name and value.""" + + display_name: str + value: AllowedTypes + + +class ParameterValueError(ValueError): + """An error raised when a parameter value is not valid.""" + + +class ParameterDefinitionError(ValueError): + """An error raised when a parameter definition value is not valid.""" + + +class ParameterNameError(ValueError): + """An error raised when a parameter name or description is not valid.""" diff --git a/api/src/opentrons/protocols/parameters/validation.py b/api/src/opentrons/protocols/parameters/validation.py new file mode 100644 index 00000000000..9b4cae7354e --- /dev/null +++ b/api/src/opentrons/protocols/parameters/validation.py @@ -0,0 +1,141 @@ +import keyword +from typing import List, Optional + +from .types import ( + ParamType, + ParameterChoice, + ParameterNameError, + ParameterValueError, + ParameterDefinitionError, +) + + +UNIT_MAX_LEN = 10 +DISPLAY_NAME_MAX_LEN = 30 +DESCRIPTION_MAX_LEN = 100 + + +def ensure_display_name(display_name: str) -> str: + """Validate display name is within the character limit.""" + if len(display_name) > DISPLAY_NAME_MAX_LEN: + raise ParameterNameError( + f"Display name {display_name} greater than {DISPLAY_NAME_MAX_LEN} characters." + ) + return display_name + + +def ensure_variable_name(variable_name: str) -> str: + """Validate variable name is a valid python variable name.""" + if not variable_name.isidentifier(): + raise ParameterNameError( + "Variable name must only contain alphanumeric characters, underscores, and cannot start with a digit." + ) + if keyword.iskeyword(variable_name): + raise ParameterNameError("Variable name cannot be a reserved Python keyword.") + return variable_name + + +def ensure_description(description: Optional[str]) -> Optional[str]: + """Validate description is within the character limit.""" + if description is not None and len(description) > DESCRIPTION_MAX_LEN: + raise ParameterNameError( + f"Description {description} greater than {DESCRIPTION_MAX_LEN} characters." + ) + return description + + +def ensure_unit_string_length(unit: Optional[str]) -> Optional[str]: + """Validate unit is within the character limit.""" + if unit is not None and len(unit) > UNIT_MAX_LEN: + raise ParameterNameError( + f"Description {unit} greater than {UNIT_MAX_LEN} characters." + ) + return unit + + +def _validate_choices( + minimum: Optional[ParamType], + maximum: Optional[ParamType], + choices: List[ParameterChoice], + parameter_type: type, +) -> None: + """Validate that min and max is not defined and all choices are properly formatted.""" + if minimum is not None or maximum is not None: + raise ParameterDefinitionError( + "If choices are provided minimum and maximum values cannot be provided." + ) + for choice in choices: + try: + display_name = choice["display_name"] + value = choice["value"] + except KeyError: + raise ParameterDefinitionError( + "All choices must be a dictionary with keys 'display_name' and 'value'." + ) + ensure_display_name(display_name) + if not isinstance(value, parameter_type): + raise ParameterDefinitionError( + f"All choices provided must match type {type(parameter_type)}" + ) + + +def _validate_min_and_max( + minimum: Optional[ParamType], + maximum: Optional[ParamType], + parameter_type: type, +) -> None: + """Validate the minium and maximum are both defined, the same type, and a valid range.""" + if minimum is not None and maximum is None: + raise ParameterDefinitionError( + "If a minimum value is provided a maximum must also be provided." + ) + elif maximum is not None and minimum is None: + raise ParameterDefinitionError( + "If a maximum value is provided a minimum must also be provided." + ) + elif maximum is not None and minimum is not None: + if isinstance(maximum, (int, float)) and isinstance(minimum, (int, float)): + if maximum <= minimum: + raise ParameterDefinitionError( + "Maximum must be greater than the minimum" + ) + + if not isinstance(minimum, parameter_type) or not isinstance( + maximum, parameter_type + ): + raise ParameterDefinitionError( + f"Minimum and maximum must match type {parameter_type}" + ) + else: + raise ParameterDefinitionError( + "Only parameters of type float or int can have a minimum and maximum" + ) + + +def validate_type(value: ParamType, parameter_type: type) -> None: + """Validate parameter value is the correct type.""" + if not isinstance(value, parameter_type): + raise ParameterValueError( + f"Default parameter value has type {type(value)} must match type {parameter_type}." + ) + + +def validate_options( + default: ParamType, + minimum: Optional[ParamType], + maximum: Optional[ParamType], + choices: Optional[List[ParameterChoice]], + parameter_type: type, +) -> None: + """Validate default values and all possible constraints for a valid parameter definition.""" + validate_type(default, parameter_type) + + if choices is None and minimum is None and maximum is None: + raise ParameterDefinitionError( + "Must provide either choices or a minimum and maximum value" + ) + + if choices is not None: + _validate_choices(minimum, maximum, choices, parameter_type) + else: + _validate_min_and_max(minimum, maximum, parameter_type) diff --git a/api/tests/opentrons/protocol_api/test_parameter_context.py b/api/tests/opentrons/protocol_api/test_parameter_context.py new file mode 100644 index 00000000000..dd4c6fb8a74 --- /dev/null +++ b/api/tests/opentrons/protocol_api/test_parameter_context.py @@ -0,0 +1,136 @@ +"""Tests for the ParameterContext public interface.""" +import inspect + +import pytest +from decoy import Decoy + +from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocol_api import ( + MAX_SUPPORTED_VERSION, +) +from opentrons.protocols.parameters import ( + parameter_definition as mock_parameter_definition, +) +from opentrons.protocol_api._parameter_context import ParameterContext + + +@pytest.fixture(autouse=True) +def _mock_parameter_definition_creates( + decoy: Decoy, monkeypatch: pytest.MonkeyPatch +) -> None: + for name, func in inspect.getmembers(mock_parameter_definition, inspect.isfunction): + monkeypatch.setattr(mock_parameter_definition, name, decoy.mock(func=func)) + + +@pytest.fixture +def api_version() -> APIVersion: + """The API version under test.""" + return MAX_SUPPORTED_VERSION + + +@pytest.fixture +def subject(api_version: APIVersion) -> ParameterContext: + """Get a ParameterContext test subject.""" + return ParameterContext(api_version=api_version) + + +def test_add_int(decoy: Decoy, subject: ParameterContext) -> None: + """It should create and add an int parameter definition.""" + param_def = decoy.mock(cls=mock_parameter_definition.ParameterDefinition) + decoy.when( + mock_parameter_definition.create_int_parameter( + display_name="abc", + variable_name="xyz", + default=123, + minimum=45, + maximum=678, + choices=[{"display_name": "foo", "value": 42}], + description="blah blah blah", + unit="foot candles", + ) + ).then_return(param_def) + subject.add_int( + display_name="abc", + variable_name="xyz", + default=123, + minimum=45, + maximum=678, + choices=[{"display_name": "foo", "value": 42}], + description="blah blah blah", + unit="foot candles", + ) + assert param_def in subject._parameters + + +def test_add_float(decoy: Decoy, subject: ParameterContext) -> None: + """It should create and add a float parameter definition.""" + param_def = decoy.mock(cls=mock_parameter_definition.ParameterDefinition) + decoy.when( + mock_parameter_definition.create_float_parameter( + display_name="abc", + variable_name="xyz", + default=12.3, + minimum=4.5, + maximum=67.8, + choices=[{"display_name": "foo", "value": 4.2}], + description="blah blah blah", + unit="lux", + ) + ).then_return(param_def) + subject.add_float( + display_name="abc", + variable_name="xyz", + default=12.3, + minimum=4.5, + maximum=67.8, + choices=[{"display_name": "foo", "value": 4.2}], + description="blah blah blah", + unit="lux", + ) + assert param_def in subject._parameters + + +def test_add_bool(decoy: Decoy, subject: ParameterContext) -> None: + """It should create and add a boolean parameter definition.""" + param_def = decoy.mock(cls=mock_parameter_definition.ParameterDefinition) + decoy.when( + mock_parameter_definition.create_bool_parameter( + display_name="cba", + variable_name="zxy", + default=False, + choices=[ + {"display_name": "On", "value": True}, + {"display_name": "Off", "value": False}, + ], + description="lorem ipsum", + ) + ).then_return(param_def) + subject.add_bool( + display_name="cba", + variable_name="zxy", + default=False, + description="lorem ipsum", + ) + assert param_def in subject._parameters + + +def test_add_string(decoy: Decoy, subject: ParameterContext) -> None: + """It should create and add a string parameter definition.""" + param_def = decoy.mock(cls=mock_parameter_definition.ParameterDefinition) + decoy.when( + mock_parameter_definition.create_str_parameter( + display_name="jkl", + variable_name="qwerty", + default="asdf", + choices=[{"display_name": "bar", "value": "aaa"}], + description="fee foo fum", + ) + ).then_return(param_def) + subject.add_str( + display_name="jkl", + variable_name="qwerty", + default="asdf", + choices=[{"display_name": "bar", "value": "aaa"}], + description="fee foo fum", + ) + assert param_def in subject._parameters diff --git a/api/tests/opentrons/protocols/parameters/__init__.py b/api/tests/opentrons/protocols/parameters/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/tests/opentrons/protocols/parameters/test_parameter_definition.py b/api/tests/opentrons/protocols/parameters/test_parameter_definition.py new file mode 100644 index 00000000000..6e93e54a97c --- /dev/null +++ b/api/tests/opentrons/protocols/parameters/test_parameter_definition.py @@ -0,0 +1,239 @@ +"""Tests for the Parameter Definitions.""" +import inspect + +import pytest +from decoy import Decoy + +from opentrons.protocols.parameters import validation as mock_validation +from opentrons.protocols.parameters.types import ParameterValueError +from opentrons.protocols.parameters.parameter_definition import ( + create_int_parameter, + create_float_parameter, + create_bool_parameter, + create_str_parameter, +) + + +@pytest.fixture(autouse=True) +def _patch_parameter_validation(decoy: Decoy, monkeypatch: pytest.MonkeyPatch) -> None: + for name, func in inspect.getmembers(mock_validation, inspect.isfunction): + monkeypatch.setattr(mock_validation, name, decoy.mock(func=func)) + + +def test_create_int_parameter_min_and_max(decoy: Decoy) -> None: + """It should create an int parameter definition with a minimum and maximum.""" + decoy.when(mock_validation.ensure_display_name("foo")).then_return("my cool name") + decoy.when(mock_validation.ensure_variable_name("bar")).then_return("my variable") + decoy.when(mock_validation.ensure_description("a b c")).then_return("1 2 3") + decoy.when(mock_validation.ensure_unit_string_length("test")).then_return("microns") + + parameter_def = create_int_parameter( + display_name="foo", + variable_name="bar", + default=42, + minimum=1, + maximum=100, + description="a b c", + unit="test", + ) + + decoy.verify( + mock_validation.validate_options(42, 1, 100, None, int), + mock_validation.validate_type(42, int), + ) + + assert parameter_def._display_name == "my cool name" + assert parameter_def.variable_name == "my variable" + assert parameter_def._description == "1 2 3" + assert parameter_def._unit == "microns" + assert parameter_def._allowed_values is None + assert parameter_def._minimum == 1 + assert parameter_def._maximum == 100 + assert parameter_def.value == 42 + + +def test_create_int_parameter_choices(decoy: Decoy) -> None: + """It should create an int parameter definition with choices.""" + decoy.when(mock_validation.ensure_display_name("foo")).then_return("my cool name") + decoy.when(mock_validation.ensure_variable_name("bar")).then_return("my variable") + decoy.when(mock_validation.ensure_description(None)).then_return("1 2 3") + decoy.when(mock_validation.ensure_unit_string_length(None)).then_return("microns") + + parameter_def = create_int_parameter( + display_name="foo", + variable_name="bar", + default=42, + choices=[{"display_name": "uhh", "value": 42}], + ) + + decoy.verify( + mock_validation.validate_options( + 42, None, None, [{"display_name": "uhh", "value": 42}], int + ), + mock_validation.validate_type(42, int), + ) + + assert parameter_def._display_name == "my cool name" + assert parameter_def.variable_name == "my variable" + assert parameter_def._description == "1 2 3" + assert parameter_def._unit == "microns" + assert parameter_def._allowed_values == {42} + assert parameter_def._minimum is None + assert parameter_def._maximum is None + assert parameter_def.value == 42 + + +def test_int_parameter_default_raises_not_in_range() -> None: + """It should raise an error if the default is not between min or max""" + with pytest.raises(ParameterValueError, match="between"): + create_int_parameter( + display_name="foo", + variable_name="bar", + default=9000, + minimum=9001, + maximum=10000, + ) + + +def test_create_float_parameter_min_and_max(decoy: Decoy) -> None: + """It should create a float parameter definition with a minimum and maximum.""" + decoy.when(mock_validation.ensure_display_name("foo")).then_return("my cool name") + decoy.when(mock_validation.ensure_variable_name("bar")).then_return("my variable") + decoy.when(mock_validation.ensure_description("a b c")).then_return("1 2 3") + decoy.when(mock_validation.ensure_unit_string_length("test")).then_return("microns") + + parameter_def = create_float_parameter( + display_name="foo", + variable_name="bar", + default=4.2, + minimum=1.0, + maximum=10.5, + description="a b c", + unit="test", + ) + + decoy.verify( + mock_validation.validate_options(4.2, 1.0, 10.5, None, float), + mock_validation.validate_type(4.2, float), + ) + + assert parameter_def._display_name == "my cool name" + assert parameter_def.variable_name == "my variable" + assert parameter_def._description == "1 2 3" + assert parameter_def._unit == "microns" + assert parameter_def._allowed_values is None + assert parameter_def._minimum == 1.0 + assert parameter_def._maximum == 10.5 + assert parameter_def.value == 4.2 + + +def test_create_float_parameter_choices(decoy: Decoy) -> None: + """It should create a float parameter definition with choices.""" + decoy.when(mock_validation.ensure_display_name("foo")).then_return("my cool name") + decoy.when(mock_validation.ensure_variable_name("bar")).then_return("my variable") + + parameter_def = create_float_parameter( + display_name="foo", + variable_name="bar", + default=4.2, + choices=[{"display_name": "urr", "value": 4.2}], + ) + + decoy.verify( + mock_validation.validate_options( + 4.2, None, None, [{"display_name": "urr", "value": 4.2}], float + ), + mock_validation.validate_type(4.2, float), + ) + + assert parameter_def._display_name == "my cool name" + assert parameter_def.variable_name == "my variable" + assert parameter_def._allowed_values == {4.2} + assert parameter_def._minimum is None + assert parameter_def._maximum is None + assert parameter_def.value == 4.2 + + +def test_float_parameter_default_raises_not_in_range() -> None: + """It should raise an error if the default is not between min or max""" + with pytest.raises(ParameterValueError, match="between"): + create_float_parameter( + display_name="foo", + variable_name="bar", + default=9000.1, + minimum=1, + maximum=9000, + ) + + +def test_create_bool_parameter(decoy: Decoy) -> None: + """It should create a boolean parameter""" + decoy.when(mock_validation.ensure_display_name("foo")).then_return("my cool name") + decoy.when(mock_validation.ensure_variable_name("bar")).then_return("my variable") + decoy.when(mock_validation.ensure_description("describe this")).then_return("1 2 3") + + parameter_def = create_bool_parameter( + display_name="foo", + variable_name="bar", + default=False, + choices=[{"display_name": "uhh", "value": False}], + description="describe this", + ) + + decoy.verify( + mock_validation.validate_options( + False, None, None, [{"display_name": "uhh", "value": False}], bool + ), + mock_validation.validate_type(False, bool), + ) + + assert parameter_def._display_name == "my cool name" + assert parameter_def.variable_name == "my variable" + assert parameter_def._description == "1 2 3" + assert parameter_def._unit is None + assert parameter_def._allowed_values == {False} + assert parameter_def._minimum is None + assert parameter_def._maximum is None + assert parameter_def.value is False + + +def test_create_str_parameter(decoy: Decoy) -> None: + """It should create a string parameter""" + decoy.when(mock_validation.ensure_display_name("foo")).then_return("my cool name") + decoy.when(mock_validation.ensure_variable_name("bar")).then_return("my variable") + decoy.when(mock_validation.ensure_description("describe this")).then_return("1 2 3") + + parameter_def = create_str_parameter( + display_name="foo", + variable_name="bar", + default="omega", + choices=[{"display_name": "alpha", "value": "omega"}], + description="describe this", + ) + + decoy.verify( + mock_validation.validate_options( + "omega", None, None, [{"display_name": "alpha", "value": "omega"}], str + ), + mock_validation.validate_type("omega", str), + ) + + assert parameter_def._display_name == "my cool name" + assert parameter_def.variable_name == "my variable" + assert parameter_def._description == "1 2 3" + assert parameter_def._unit is None + assert parameter_def._allowed_values == {"omega"} + assert parameter_def._minimum is None + assert parameter_def._maximum is None + assert parameter_def.value == "omega" + + +def test_str_parameter_default_raises_not_in_allowed_values() -> None: + """It should raise an error if the default is not between min or max""" + with pytest.raises(ParameterValueError, match="allowed values"): + create_str_parameter( + display_name="foo", + variable_name="bar", + default="waldo", + choices=[{"display_name": "where's", "value": "odlaw"}], + ) diff --git a/api/tests/opentrons/protocols/parameters/test_validation.py b/api/tests/opentrons/protocols/parameters/test_validation.py new file mode 100644 index 00000000000..cd82fe173c4 --- /dev/null +++ b/api/tests/opentrons/protocols/parameters/test_validation.py @@ -0,0 +1,180 @@ +import pytest +from typing import Optional, List + +from opentrons.protocols.parameters.types import ( + AllowedTypes, + ParameterChoice, + ParameterNameError, + ParameterValueError, + ParameterDefinitionError, +) + +from opentrons.protocols.parameters import validation as subject + + +def test_ensure_display_name() -> None: + """It should ensure the display name is within the character limit.""" + result = subject.ensure_display_name("abc") + assert result == "abc" + + +def test_ensure_display_name_raises() -> None: + """It should raise if the display name is too long.""" + with pytest.raises(ParameterNameError): + subject.ensure_display_name("Lorem ipsum dolor sit amet nam.") + + +def test_ensure_description_name() -> None: + """It should ensure the description name is within the character limit.""" + result = subject.ensure_description("123456789") + assert result == "123456789" + + +def test_ensure_description_raises() -> None: + """It should raise if the description is too long.""" + with pytest.raises(ParameterNameError): + subject.ensure_description( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + " Fusce eget elementum nunc, quis sodales sed." + ) + + +def test_ensure_unit_string_length() -> None: + """It should ensure the unit name is within the character limit.""" + result = subject.ensure_unit_string_length("ul") + assert result == "ul" + + +def test_ensure_unit_string_length_raises() -> None: + """It should raise if the unit name is too long.""" + with pytest.raises(ParameterNameError): + subject.ensure_unit_string_length("newtons per square foot") + + +@pytest.mark.parametrize( + "variable_name", + [ + "x", + "my_cool_variable", + "_secret_variable", + ], +) +def test_ensure_variable_name(variable_name: str) -> None: + """It should ensure the variable name is a valid python variable name.""" + result = subject.ensure_variable_name(variable_name) + assert result == variable_name + + +@pytest.mark.parametrize( + "variable_name", + [ + "3d_vector", + "my cool variable name", + "ca$h_money", + ], +) +def test_ensure_variable_name_raises(variable_name: str) -> None: + """It should raise if the variable name is not valid.""" + with pytest.raises(ParameterNameError, match="underscore"): + subject.ensure_variable_name(variable_name) + + +@pytest.mark.parametrize( + "variable_name", + [ + "def", + "class", + "lambda", + ], +) +def test_ensure_variable_name_raises_keyword(variable_name: str) -> None: + """It should raise if the variable name is a python keyword.""" + with pytest.raises(ParameterNameError, match="keyword"): + subject.ensure_variable_name(variable_name) + + +def test_validate_options() -> None: + """It should not raise when given valid constraints""" + subject.validate_options(123, 1, 100, None, int) + subject.validate_options( + 123, None, None, [{"display_name": "abc", "value": 456}], int + ) + subject.validate_options(12.3, 1.1, 100.9, None, float) + subject.validate_options( + 12.3, None, None, [{"display_name": "abc", "value": 45.6}], float + ) + subject.validate_options( + True, None, None, [{"display_name": "abc", "value": False}], bool + ) + subject.validate_options( + "x", None, None, [{"display_name": "abc", "value": "y"}], str + ) + + +def test_validate_options_raises_value_error() -> None: + """It should raise if the value of the default does not match the type.""" + with pytest.raises(ParameterValueError): + subject.validate_options(123, 1, 100, None, str) + + +def test_validate_options_raises_name_error() -> None: + """It should raise if the display name of a choice is too long.""" + with pytest.raises(ParameterNameError): + subject.validate_options( + "foo", + None, + None, + [{"display_name": "Lorem ipsum dolor sit amet nam.", "value": "a"}], + str, + ) + + +@pytest.mark.parametrize( + ["default", "minimum", "maximum", "choices", "parameter_type", "error_text"], + [ + (123, None, None, None, int, "provide either"), + ( + 123, + 1, + None, + [{"display_name": "abc", "value": 123}], + int, + "maximum values cannot", + ), + ( + 123, + None, + 100, + [{"display_name": "abc", "value": 123}], + int, + "maximum values cannot", + ), + (123, None, None, [{"display_name": "abc"}], int, "dictionary with keys"), + (123, None, None, [{"value": 123}], int, "dictionary with keys"), + ( + 123, + None, + None, + [{"display_name": "abc", "value": "123"}], + int, + "must match type", + ), + (123, 1, None, None, int, "maximum must also"), + (123, None, 100, None, int, "minimum must also"), + (123, 100, 1, None, int, "Maximum must be greater"), + (123, 1.1, 100, None, int, "Minimum and maximum must match type"), + (123, 1, 100.5, None, int, "Minimum and maximum must match type"), + (123, "1", "100", None, int, "Only parameters of type float or int"), + ], +) +def test_validate_options_raise_definition_error( + default: AllowedTypes, + minimum: Optional[AllowedTypes], + maximum: Optional[AllowedTypes], + choices: Optional[List[ParameterChoice]], + parameter_type: type, + error_text: str, +) -> None: + """It should raise if the parameter definition constraints are not valid.""" + with pytest.raises(ParameterDefinitionError, match=error_text): + subject.validate_options(default, minimum, maximum, choices, parameter_type)