From 08533b381c48f58f3a8fdd2941e7f9d3b35eaa23 Mon Sep 17 00:00:00 2001 From: mrivar Date: Thu, 11 Jun 2026 14:48:55 +0200 Subject: [PATCH 1/5] feat: Conditional parameters (HEXA-1687) --- openhexa/sdk/pipelines/parameter/decorator.py | 31 ++++++++++++ openhexa/sdk/pipelines/pipeline.py | 23 +++++++++ openhexa/sdk/pipelines/runtime.py | 1 + tests/test_ast.py | 36 +++++++++++++ tests/test_parameter.py | 50 +++++++++++++++++++ tests/test_pipeline.py | 35 +++++++++++++ 6 files changed, 176 insertions(+) diff --git a/openhexa/sdk/pipelines/parameter/decorator.py b/openhexa/sdk/pipelines/parameter/decorator.py index 01050fb4..2f240aa9 100644 --- a/openhexa/sdk/pipelines/parameter/decorator.py +++ b/openhexa/sdk/pipelines/parameter/decorator.py @@ -51,6 +51,7 @@ def __init__( required: bool = True, multiple: bool = False, directory: str | None = None, + disables: typing.Sequence[str] | None = None, ): validate_pipeline_parameter_code(code) self.code = code @@ -92,6 +93,7 @@ def __init__( self.widget = widget self.connection = connection self.directory = directory + self.disables = list(disables) if disables else None self._validate_default(default, multiple) self.default = default @@ -117,6 +119,7 @@ def to_dict(self) -> dict[str, typing.Any]: "required": self.required, "multiple": self.multiple, "directory": self.directory, + "disables": self.disables, } if isinstance(self.choices, ChoicesFromFile): d["choices_from_file"] = self.choices.to_dict() @@ -207,6 +210,28 @@ def validate_parameters(parameters: list[Parameter]): supported_connection_types = {DHIS2ConnectionType, IASOConnectionType} connection_parameters = {p.code for p in parameters if type(p.type) in supported_connection_types} + parameters_by_code = {p.code: p for p in parameters} + controllers = {p.code for p in parameters if p.disables} + for parameter in parameters: + if not parameter.disables: + continue + if not isinstance(parameter.type, Boolean): + raise InvalidParameterError( + f"Only boolean parameters can use 'disables'. Parameter '{parameter.code}' is of type {parameter.type}." + ) + for target_code in parameter.disables: + if target_code == parameter.code: + raise InvalidParameterError(f"Parameter '{parameter.code}' cannot disable itself.") + if target_code not in parameters_by_code: + raise InvalidParameterError( + f"Parameter '{parameter.code}' disables a non-existing parameter '{target_code}'." + ) + if target_code in controllers: + raise InvalidParameterError( + f"Parameter '{parameter.code}' disables '{target_code}', which is itself a disabling " + f"parameter. Chaining disabling parameters is not supported." + ) + for parameter in parameters: if parameter.connection and parameter.connection not in connection_parameters: raise InvalidParameterError( @@ -251,6 +276,7 @@ def parameter( required: bool = True, multiple: bool = False, directory: str | None = None, + disables: typing.Sequence[str] | None = None, ): """Decorate a pipeline function by attaching a parameter to it.. @@ -282,6 +308,10 @@ def parameter( values of the chosen type) directory : str, optional An optional parameter to force file selection to specific directory (only used for parameter type File). If the directory does not exist, it will be ignored. + disables : sequence of str, optional + An optional list of parameter codes to disable when this (boolean) parameter is set to ``True``. Disabled + parameters are hidden/greyed out in the run form, their required check is skipped, and they are omitted from + the run config (the pipeline function receives their default value). Only boolean parameters can use this. Returns ------- @@ -305,6 +335,7 @@ def decorator(fun): connection=connection, multiple=multiple, directory=directory, + disables=disables, ), ) diff --git a/openhexa/sdk/pipelines/pipeline.py b/openhexa/sdk/pipelines/pipeline.py index 2a316619..e78f0a77 100644 --- a/openhexa/sdk/pipelines/pipeline.py +++ b/openhexa/sdk/pipelines/pipeline.py @@ -123,9 +123,16 @@ def _validate_config(self, config: dict[str, typing.Any]) -> dict[str, typing.An ParameterValueError If the config contains invalid keys or parameter validation fails. """ + disabled_codes = self._get_disabled_codes(config) + validated_config = {} for parameter in self.parameters: value = config.pop(parameter.code, None) + if parameter.code in disabled_codes: + # Parameter is disabled by an active controller: ignore the (possibly dummy or missing) + # value, skip required/type validation, and fall back to its default. + validated_config[parameter.code] = parameter.default + continue validated_value = parameter.validate(value) validated_config[parameter.code] = validated_value @@ -134,6 +141,22 @@ def _validate_config(self, config: dict[str, typing.Any]) -> dict[str, typing.An return validated_config + def _get_disabled_codes(self, config: dict[str, typing.Any]) -> set[str]: + """Return the codes of parameters disabled by an active controller in the given config. + + A controller is a boolean parameter declaring ``disables=[...]``. It is "active" when its effective + value (from the config, falling back to its default) is truthy. A parameter is disabled if any active + controller lists it. + """ + disabled_codes: set[str] = set() + for parameter in self.parameters: + if not parameter.disables: + continue + effective_value = config.get(parameter.code, parameter.default) + if effective_value: + disabled_codes.update(parameter.disables) + return disabled_codes + def _execute_tasks(self, pool): """Execute all tasks using the provided multiprocessing pool. diff --git a/openhexa/sdk/pipelines/runtime.py b/openhexa/sdk/pipelines/runtime.py index e23888a6..d7f028f4 100644 --- a/openhexa/sdk/pipelines/runtime.py +++ b/openhexa/sdk/pipelines/runtime.py @@ -316,6 +316,7 @@ def get_pipeline(pipeline_path: Path) -> Pipeline: Argument("required", [ast.Constant], default_value=True), Argument("multiple", [ast.Constant], default_value=False), Argument("directory", [ast.Constant]), + Argument("disables", [ast.List]), ), ) diff --git a/tests/test_ast.py b/tests/test_ast.py index 703d36ec..398009b2 100644 --- a/tests/test_ast.py +++ b/tests/test_ast.py @@ -154,6 +154,7 @@ def test_pipeline_with_int_param(self): "help": "Param help", "required": True, "directory": None, + "disables": None, } ], "timeout": None, @@ -161,6 +162,29 @@ def test_pipeline_with_int_param(self): }, ) + def test_pipeline_with_disables_param(self): + """The @parameter decorator's 'disables' list is parsed from the pipeline code.""" + with tempfile.TemporaryDirectory() as tmpdirname: + with open(f"{tmpdirname}/pipeline.py", "w") as f: + f.write( + "\n".join( + [ + "from openhexa.sdk.pipelines import pipeline, parameter", + "", + "@parameter('run_report_only', type=bool, default=False, disables=['data_input'])", + "@parameter('data_input', type=str)", + "@pipeline('Test pipeline')", + "def test_pipeline():", + " pass", + "", + ] + ) + ) + pipeline = get_pipeline(tmpdirname) + params = {p["code"]: p for p in pipeline.to_dict()["parameters"]} + self.assertEqual(params["run_report_only"]["disables"], ["data_input"]) + self.assertIsNone(params["data_input"]["disables"]) + def test_pipeline_with_multiple_param(self): """The file contains a @pipeline decorator and a @parameter decorator with multiple=True.""" with tempfile.TemporaryDirectory() as tmpdirname: @@ -198,6 +222,7 @@ def test_pipeline_with_multiple_param(self): "help": "Param help", "required": True, "directory": None, + "disables": None, } ], "timeout": None, @@ -243,6 +268,7 @@ def test_pipeline_with_dataset(self): "help": "Dataset", "required": False, "directory": None, + "disables": None, } ], "timeout": None, @@ -287,6 +313,7 @@ def test_pipeline_with_choices(self): "help": "Param help", "required": True, "directory": None, + "disables": None, } ], "timeout": None, @@ -359,6 +386,7 @@ def test_pipeline_with_bool(self): "help": "Param help", "required": True, "directory": None, + "disables": None, } ], "timeout": None, @@ -404,6 +432,7 @@ def test_pipeline_with_multiple_parameters(self): "help": "Param help", "required": True, "directory": None, + "disables": None, }, { "choices": ["a", "b"], @@ -417,6 +446,7 @@ def test_pipeline_with_multiple_parameters(self): "help": "Param help 2", "required": True, "directory": None, + "disables": None, }, ], "timeout": None, @@ -484,6 +514,7 @@ def test_pipeline_with_connection_parameter_for_dhis2(self): "help": None, "required": True, "directory": None, + "disables": None, }, { "code": "data_element_ids", @@ -497,6 +528,7 @@ def test_pipeline_with_connection_parameter_for_dhis2(self): "help": None, "required": True, "directory": None, + "disables": None, }, ], "timeout": None, @@ -546,6 +578,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "help": None, "required": True, "directory": None, + "disables": None, }, { "code": "org_units", @@ -559,6 +592,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "help": None, "required": True, "directory": None, + "disables": None, }, { "code": "projects", @@ -572,6 +606,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "help": None, "required": True, "directory": None, + "disables": None, }, { "code": "forms", @@ -585,6 +620,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "help": None, "required": True, "directory": None, + "disables": None, }, ], "timeout": None, diff --git a/tests/test_parameter.py b/tests/test_parameter.py index ea405112..e9886658 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -36,6 +36,7 @@ SecretType, StringType, parameter, + validate_parameters, ) from openhexa.utils import stringcase @@ -422,3 +423,52 @@ def a_function(): assert function_parameters[1].default == ["yo"] assert function_parameters[1].required is False assert function_parameters[1].multiple is True + + +def test_parameter_disables_serialization(): + """The 'disables' option is normalized to a list and serialized in to_dict.""" + no_disables = Parameter("plain", type=str) + assert no_disables.disables is None + assert no_disables.to_dict()["disables"] is None + + controller = Parameter("run_report_only", type=bool, disables=["data_input", "year"]) + assert controller.disables == ["data_input", "year"] + assert controller.to_dict()["disables"] == ["data_input", "year"] + + +def test_validate_parameters_disables_ok(): + """A valid disabling setup passes validation.""" + controller = Parameter("run_report_only", type=bool, default=False, disables=["data_input"]) + data_input = Parameter("data_input", type=str, required=True) + validate_parameters([controller, data_input]) + + +def test_validate_parameters_disables_must_be_boolean(): + """Only boolean parameters can use 'disables'.""" + controller = Parameter("mode", type=str, disables=["data_input"]) + data_input = Parameter("data_input", type=str) + with pytest.raises(InvalidParameterError): + validate_parameters([controller, data_input]) + + +def test_validate_parameters_disables_unknown_target(): + """Disabling a non-existing parameter raises.""" + controller = Parameter("run_report_only", type=bool, disables=["does_not_exist"]) + with pytest.raises(InvalidParameterError): + validate_parameters([controller]) + + +def test_validate_parameters_disables_self_reference(): + """A parameter cannot disable itself.""" + controller = Parameter("run_report_only", type=bool, disables=["run_report_only"]) + with pytest.raises(InvalidParameterError): + validate_parameters([controller]) + + +def test_validate_parameters_disables_no_chaining(): + """A disabling parameter cannot disable another disabling parameter.""" + controller_a = Parameter("toggle_a", type=bool, disables=["toggle_b"]) + controller_b = Parameter("toggle_b", type=bool, disables=["plain_c"]) + plain_c = Parameter("plain_c", type=str) + with pytest.raises(InvalidParameterError): + validate_parameters([controller_a, controller_b, plain_c]) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index bbff0fc4..c3360761 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -52,6 +52,41 @@ def test_pipeline_run_extra_config(): pipeline.run({"arg1": "ok", "arg2": "extra"}) +def test_pipeline_run_disabled_required_parameter_skipped(): + """A required parameter disabled by an active controller is skipped and receives its default.""" + pipeline_func = Mock() + controller = Parameter("run_report_only", type=bool, default=False, disables=["data_input"]) + data_input = Parameter("data_input", type=str, required=True) + pipeline = Pipeline("pipeline", pipeline_func, [controller, data_input]) + + pipeline.run({"run_report_only": True}) + + pipeline_func.assert_called_once_with(run_report_only=True, data_input=None) + + +def test_pipeline_run_disabled_parameter_value_ignored(): + """A dummy value provided for a disabled parameter is ignored in favor of its default.""" + pipeline_func = Mock() + controller = Parameter("run_report_only", type=bool, default=False, disables=["year"]) + year = Parameter("year", type=int, required=True, default=2024) + pipeline = Pipeline("pipeline", pipeline_func, [controller, year]) + + pipeline.run({"run_report_only": True, "year": 1}) + + pipeline_func.assert_called_once_with(run_report_only=True, year=2024) + + +def test_pipeline_run_inactive_controller_still_validates(): + """When the controller is not active, disabled parameters are still validated as usual.""" + pipeline_func = Mock() + controller = Parameter("run_report_only", type=bool, default=False, disables=["data_input"]) + data_input = Parameter("data_input", type=str, required=True) + pipeline = Pipeline("pipeline", pipeline_func, [controller, data_input]) + + with pytest.raises(ParameterValueError): + pipeline.run({"run_report_only": False}) + + @patch.dict( os.environ, {"HEXA_SERVER_URL": "https://test.openhexa.org"}, From 8462ee3856dad090cb2680d149324743bc56c8b8 Mon Sep 17 00:00:00 2001 From: mrivar Date: Thu, 11 Jun 2026 15:10:55 +0200 Subject: [PATCH 2/5] move check to param init --- openhexa/sdk/pipelines/parameter/decorator.py | 8 ++++---- tests/test_parameter.py | 8 +++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/openhexa/sdk/pipelines/parameter/decorator.py b/openhexa/sdk/pipelines/parameter/decorator.py index 2f240aa9..b2c9ac87 100644 --- a/openhexa/sdk/pipelines/parameter/decorator.py +++ b/openhexa/sdk/pipelines/parameter/decorator.py @@ -94,6 +94,10 @@ def __init__( self.connection = connection self.directory = directory self.disables = list(disables) if disables else None + if self.disables and not isinstance(self.type, Boolean): + raise InvalidParameterError( + f"Only boolean parameters can use 'disables'. Parameter '{self.code}' is of type {self.type}." + ) self._validate_default(default, multiple) self.default = default @@ -215,10 +219,6 @@ def validate_parameters(parameters: list[Parameter]): for parameter in parameters: if not parameter.disables: continue - if not isinstance(parameter.type, Boolean): - raise InvalidParameterError( - f"Only boolean parameters can use 'disables'. Parameter '{parameter.code}' is of type {parameter.type}." - ) for target_code in parameter.disables: if target_code == parameter.code: raise InvalidParameterError(f"Parameter '{parameter.code}' cannot disable itself.") diff --git a/tests/test_parameter.py b/tests/test_parameter.py index e9886658..cb102148 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -443,12 +443,10 @@ def test_validate_parameters_disables_ok(): validate_parameters([controller, data_input]) -def test_validate_parameters_disables_must_be_boolean(): - """Only boolean parameters can use 'disables'.""" - controller = Parameter("mode", type=str, disables=["data_input"]) - data_input = Parameter("data_input", type=str) +def test_disables_must_be_boolean(): + """Only boolean parameters can use 'disables' — rejected at construction time.""" with pytest.raises(InvalidParameterError): - validate_parameters([controller, data_input]) + Parameter("mode", type=str, disables=["data_input"]) def test_validate_parameters_disables_unknown_target(): From 2494e782f03c9cf5b95a71c3551504dea98df240 Mon Sep 17 00:00:00 2001 From: mrivar Date: Thu, 11 Jun 2026 15:36:52 +0200 Subject: [PATCH 3/5] add option to let user decide if disables when true or false --- openhexa/sdk/pipelines/parameter/decorator.py | 21 +++++++++-- openhexa/sdk/pipelines/pipeline.py | 6 +-- openhexa/sdk/pipelines/runtime.py | 1 + tests/test_ast.py | 37 +++++++++++++++++++ tests/test_parameter.py | 13 +++++++ tests/test_pipeline.py | 26 +++++++++++++ 6 files changed, 97 insertions(+), 7 deletions(-) diff --git a/openhexa/sdk/pipelines/parameter/decorator.py b/openhexa/sdk/pipelines/parameter/decorator.py index b2c9ac87..7a4ca605 100644 --- a/openhexa/sdk/pipelines/parameter/decorator.py +++ b/openhexa/sdk/pipelines/parameter/decorator.py @@ -52,6 +52,7 @@ def __init__( multiple: bool = False, directory: str | None = None, disables: typing.Sequence[str] | None = None, + disable_when: bool = True, ): validate_pipeline_parameter_code(code) self.code = code @@ -93,11 +94,16 @@ def __init__( self.widget = widget self.connection = connection self.directory = directory - self.disables = list(disables) if disables else None + self.disables = list(dict.fromkeys(disables)) if disables else None + self.disable_when = disable_when if self.disables and not isinstance(self.type, Boolean): raise InvalidParameterError( f"Only boolean parameters can use 'disables'. Parameter '{self.code}' is of type {self.type}." ) + if not isinstance(self.disable_when, bool): + raise InvalidParameterError( + f"'disable_when' must be a boolean for parameter '{self.code}' (got {disable_when!r})." + ) self._validate_default(default, multiple) self.default = default @@ -124,6 +130,7 @@ def to_dict(self) -> dict[str, typing.Any]: "multiple": self.multiple, "directory": self.directory, "disables": self.disables, + "disable_when": self.disable_when, } if isinstance(self.choices, ChoicesFromFile): d["choices_from_file"] = self.choices.to_dict() @@ -277,6 +284,7 @@ def parameter( multiple: bool = False, directory: str | None = None, disables: typing.Sequence[str] | None = None, + disable_when: bool = True, ): """Decorate a pipeline function by attaching a parameter to it.. @@ -309,9 +317,13 @@ def parameter( directory : str, optional An optional parameter to force file selection to specific directory (only used for parameter type File). If the directory does not exist, it will be ignored. disables : sequence of str, optional - An optional list of parameter codes to disable when this (boolean) parameter is set to ``True``. Disabled - parameters are hidden/greyed out in the run form, their required check is skipped, and they are omitted from - the run config (the pipeline function receives their default value). Only boolean parameters can use this. + An optional list of parameter codes to disable when this (boolean) parameter's value matches ``disable_when``. + Disabled parameters are hidden/greyed out in the run form, their required check is skipped, and they are + omitted from the run config (the pipeline function receives their default value). Only boolean parameters can + use this. + disable_when : bool, default=True + The boolean value of this parameter that triggers the disabling of the parameters listed in ``disables``. + Use ``disable_when=False`` for an "enable" toggle (the listed parameters are disabled while it is unticked). Returns ------- @@ -336,6 +348,7 @@ def decorator(fun): multiple=multiple, directory=directory, disables=disables, + disable_when=disable_when, ), ) diff --git a/openhexa/sdk/pipelines/pipeline.py b/openhexa/sdk/pipelines/pipeline.py index e78f0a77..f3f7b537 100644 --- a/openhexa/sdk/pipelines/pipeline.py +++ b/openhexa/sdk/pipelines/pipeline.py @@ -145,15 +145,15 @@ def _get_disabled_codes(self, config: dict[str, typing.Any]) -> set[str]: """Return the codes of parameters disabled by an active controller in the given config. A controller is a boolean parameter declaring ``disables=[...]``. It is "active" when its effective - value (from the config, falling back to its default) is truthy. A parameter is disabled if any active - controller lists it. + value (from the config, falling back to its default) equals its ``disable_when`` (``True`` by default). + A parameter is disabled if any active controller lists it. """ disabled_codes: set[str] = set() for parameter in self.parameters: if not parameter.disables: continue effective_value = config.get(parameter.code, parameter.default) - if effective_value: + if bool(effective_value) == parameter.disable_when: disabled_codes.update(parameter.disables) return disabled_codes diff --git a/openhexa/sdk/pipelines/runtime.py b/openhexa/sdk/pipelines/runtime.py index d7f028f4..410fd0b6 100644 --- a/openhexa/sdk/pipelines/runtime.py +++ b/openhexa/sdk/pipelines/runtime.py @@ -317,6 +317,7 @@ def get_pipeline(pipeline_path: Path) -> Pipeline: Argument("multiple", [ast.Constant], default_value=False), Argument("directory", [ast.Constant]), Argument("disables", [ast.List]), + Argument("disable_when", [ast.Constant], default_value=True), ), ) diff --git a/tests/test_ast.py b/tests/test_ast.py index 398009b2..94184be0 100644 --- a/tests/test_ast.py +++ b/tests/test_ast.py @@ -155,6 +155,7 @@ def test_pipeline_with_int_param(self): "required": True, "directory": None, "disables": None, + "disable_when": True, } ], "timeout": None, @@ -183,8 +184,32 @@ def test_pipeline_with_disables_param(self): pipeline = get_pipeline(tmpdirname) params = {p["code"]: p for p in pipeline.to_dict()["parameters"]} self.assertEqual(params["run_report_only"]["disables"], ["data_input"]) + self.assertEqual(params["run_report_only"]["disable_when"], True) self.assertIsNone(params["data_input"]["disables"]) + def test_pipeline_with_disable_when_false(self): + """The @parameter decorator's 'disable_when' is parsed from the pipeline code.""" + with tempfile.TemporaryDirectory() as tmpdirname: + with open(f"{tmpdirname}/pipeline.py", "w") as f: + f.write( + "\n".join( + [ + "from openhexa.sdk.pipelines import pipeline, parameter", + "", + "@parameter('enable_advanced', type=bool, default=False, disables=['tuning'], disable_when=False)", + "@parameter('tuning', type=str)", + "@pipeline('Test pipeline')", + "def test_pipeline():", + " pass", + "", + ] + ) + ) + pipeline = get_pipeline(tmpdirname) + params = {p["code"]: p for p in pipeline.to_dict()["parameters"]} + self.assertEqual(params["enable_advanced"]["disables"], ["tuning"]) + self.assertEqual(params["enable_advanced"]["disable_when"], False) + def test_pipeline_with_multiple_param(self): """The file contains a @pipeline decorator and a @parameter decorator with multiple=True.""" with tempfile.TemporaryDirectory() as tmpdirname: @@ -223,6 +248,7 @@ def test_pipeline_with_multiple_param(self): "required": True, "directory": None, "disables": None, + "disable_when": True, } ], "timeout": None, @@ -269,6 +295,7 @@ def test_pipeline_with_dataset(self): "required": False, "directory": None, "disables": None, + "disable_when": True, } ], "timeout": None, @@ -314,6 +341,7 @@ def test_pipeline_with_choices(self): "required": True, "directory": None, "disables": None, + "disable_when": True, } ], "timeout": None, @@ -387,6 +415,7 @@ def test_pipeline_with_bool(self): "required": True, "directory": None, "disables": None, + "disable_when": True, } ], "timeout": None, @@ -433,6 +462,7 @@ def test_pipeline_with_multiple_parameters(self): "required": True, "directory": None, "disables": None, + "disable_when": True, }, { "choices": ["a", "b"], @@ -447,6 +477,7 @@ def test_pipeline_with_multiple_parameters(self): "required": True, "directory": None, "disables": None, + "disable_when": True, }, ], "timeout": None, @@ -515,6 +546,7 @@ def test_pipeline_with_connection_parameter_for_dhis2(self): "required": True, "directory": None, "disables": None, + "disable_when": True, }, { "code": "data_element_ids", @@ -529,6 +561,7 @@ def test_pipeline_with_connection_parameter_for_dhis2(self): "required": True, "directory": None, "disables": None, + "disable_when": True, }, ], "timeout": None, @@ -579,6 +612,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "required": True, "directory": None, "disables": None, + "disable_when": True, }, { "code": "org_units", @@ -593,6 +627,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "required": True, "directory": None, "disables": None, + "disable_when": True, }, { "code": "projects", @@ -607,6 +642,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "required": True, "directory": None, "disables": None, + "disable_when": True, }, { "code": "forms", @@ -621,6 +657,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "required": True, "directory": None, "disables": None, + "disable_when": True, }, ], "timeout": None, diff --git a/tests/test_parameter.py b/tests/test_parameter.py index cb102148..b908fca9 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -434,6 +434,19 @@ def test_parameter_disables_serialization(): controller = Parameter("run_report_only", type=bool, disables=["data_input", "year"]) assert controller.disables == ["data_input", "year"] assert controller.to_dict()["disables"] == ["data_input", "year"] + assert controller.to_dict()["disable_when"] is True + + +def test_parameter_disables_dedup_preserves_order(): + """Duplicate disables targets are removed while keeping declaration order.""" + controller = Parameter("toggle", type=bool, disables=["b", "a", "b", "a"]) + assert controller.disables == ["b", "a"] + + +def test_disable_when_must_be_boolean(): + """'disable_when' must be a boolean — rejected at construction time.""" + with pytest.raises(InvalidParameterError): + Parameter("toggle", type=bool, disables=["x_param"], disable_when="yes") def test_validate_parameters_disables_ok(): diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index c3360761..f586e652 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -87,6 +87,32 @@ def test_pipeline_run_inactive_controller_still_validates(): pipeline.run({"run_report_only": False}) +def test_pipeline_run_disable_when_false_disables_while_off(): + """With disable_when=False, listed params are disabled while the controller is off (default).""" + pipeline_func = Mock() + controller = Parameter("enable_advanced", type=bool, default=False, disables=["tuning"], disable_when=False) + tuning = Parameter("tuning", type=str, required=True) + pipeline = Pipeline("pipeline", pipeline_func, [controller, tuning]) + + pipeline.run({"enable_advanced": False}) + + pipeline_func.assert_called_once_with(enable_advanced=False, tuning=None) + + +def test_pipeline_run_disable_when_false_validates_while_on(): + """With disable_when=False, listed params are required again once the controller is on.""" + pipeline_func = Mock() + controller = Parameter("enable_advanced", type=bool, default=False, disables=["tuning"], disable_when=False) + tuning = Parameter("tuning", type=str, required=True) + pipeline = Pipeline("pipeline", pipeline_func, [controller, tuning]) + + with pytest.raises(ParameterValueError): + pipeline.run({"enable_advanced": True}) + + pipeline.run({"enable_advanced": True, "tuning": "fast"}) + pipeline_func.assert_called_once_with(enable_advanced=True, tuning="fast") + + @patch.dict( os.environ, {"HEXA_SERVER_URL": "https://test.openhexa.org"}, From e6fb57ce9ba8f6ad36a3dd7ce8c16220cbd85cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Riva?= Date: Fri, 12 Jun 2026 15:06:44 +0200 Subject: [PATCH 4/5] Rename 'disable_when' to 'disableWhen' for consistency --- openhexa/sdk/pipelines/parameter/decorator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhexa/sdk/pipelines/parameter/decorator.py b/openhexa/sdk/pipelines/parameter/decorator.py index 7a4ca605..92ba4d81 100644 --- a/openhexa/sdk/pipelines/parameter/decorator.py +++ b/openhexa/sdk/pipelines/parameter/decorator.py @@ -130,7 +130,7 @@ def to_dict(self) -> dict[str, typing.Any]: "multiple": self.multiple, "directory": self.directory, "disables": self.disables, - "disable_when": self.disable_when, + "disableWhen": self.disable_when, } if isinstance(self.choices, ChoicesFromFile): d["choices_from_file"] = self.choices.to_dict() From c0861b50fb79c68353f88fc957c8f377ed16725c Mon Sep 17 00:00:00 2001 From: mrivar Date: Fri, 12 Jun 2026 16:28:24 +0200 Subject: [PATCH 5/5] fix tests --- tests/test_ast.py | 30 +++++++++++++++--------------- tests/test_parameter.py | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/test_ast.py b/tests/test_ast.py index 94184be0..b433b1ed 100644 --- a/tests/test_ast.py +++ b/tests/test_ast.py @@ -155,7 +155,7 @@ def test_pipeline_with_int_param(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, } ], "timeout": None, @@ -184,7 +184,7 @@ def test_pipeline_with_disables_param(self): pipeline = get_pipeline(tmpdirname) params = {p["code"]: p for p in pipeline.to_dict()["parameters"]} self.assertEqual(params["run_report_only"]["disables"], ["data_input"]) - self.assertEqual(params["run_report_only"]["disable_when"], True) + self.assertEqual(params["run_report_only"]["disableWhen"], True) self.assertIsNone(params["data_input"]["disables"]) def test_pipeline_with_disable_when_false(self): @@ -208,7 +208,7 @@ def test_pipeline_with_disable_when_false(self): pipeline = get_pipeline(tmpdirname) params = {p["code"]: p for p in pipeline.to_dict()["parameters"]} self.assertEqual(params["enable_advanced"]["disables"], ["tuning"]) - self.assertEqual(params["enable_advanced"]["disable_when"], False) + self.assertEqual(params["enable_advanced"]["disableWhen"], False) def test_pipeline_with_multiple_param(self): """The file contains a @pipeline decorator and a @parameter decorator with multiple=True.""" @@ -248,7 +248,7 @@ def test_pipeline_with_multiple_param(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, } ], "timeout": None, @@ -295,7 +295,7 @@ def test_pipeline_with_dataset(self): "required": False, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, } ], "timeout": None, @@ -341,7 +341,7 @@ def test_pipeline_with_choices(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, } ], "timeout": None, @@ -415,7 +415,7 @@ def test_pipeline_with_bool(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, } ], "timeout": None, @@ -462,7 +462,7 @@ def test_pipeline_with_multiple_parameters(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, }, { "choices": ["a", "b"], @@ -477,7 +477,7 @@ def test_pipeline_with_multiple_parameters(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, }, ], "timeout": None, @@ -546,7 +546,7 @@ def test_pipeline_with_connection_parameter_for_dhis2(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, }, { "code": "data_element_ids", @@ -561,7 +561,7 @@ def test_pipeline_with_connection_parameter_for_dhis2(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, }, ], "timeout": None, @@ -612,7 +612,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, }, { "code": "org_units", @@ -627,7 +627,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, }, { "code": "projects", @@ -642,7 +642,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, }, { "code": "forms", @@ -657,7 +657,7 @@ def test_pipeline_with_connection_parameter_for_iaso(self): "required": True, "directory": None, "disables": None, - "disable_when": True, + "disableWhen": True, }, ], "timeout": None, diff --git a/tests/test_parameter.py b/tests/test_parameter.py index b908fca9..5de6c2ed 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -434,7 +434,7 @@ def test_parameter_disables_serialization(): controller = Parameter("run_report_only", type=bool, disables=["data_input", "year"]) assert controller.disables == ["data_input", "year"] assert controller.to_dict()["disables"] == ["data_input", "year"] - assert controller.to_dict()["disable_when"] is True + assert controller.to_dict()["disableWhen"] is True def test_parameter_disables_dedup_preserves_order():