Skip to content

Commit a64a1ea

Browse files
authored
feat: Conditional parameters (HEXA-1687) (#1860)
* feat: Conditional parameters (HEXA-1687) * feat: Disable params considered for validating schedule configs * lint * feat: disable by text and memoizing * tests: tests cleanup * remove not needed sets * i18n * feat: add text for deafult disabled params setting * feat: normalize pipeline param dict values * feat: update sdk
1 parent 4e51074 commit a64a1ea

33 files changed

Lines changed: 1099 additions & 148 deletions

backend/hexa/pipelines/graphql/schema.graphql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ type PipelineParameter {
134134
choices: [Generic!] # The list of choices for the parameter.
135135
choicesFromFile: PipelineParameterChoicesFromFile # Dynamic choices loaded from a workspace file.
136136
directory: String # The name of the directory restriction (for file browser).
137+
disables: [String!]! # Codes of parameters disabled when this (boolean) parameter matches `disableWhen`.
138+
disableWhen: Boolean! # The boolean value of this parameter that triggers disabling of `disables` (default true).
137139
}
138140

139141
"""
@@ -152,6 +154,8 @@ input ParameterInput {
152154
choices: [Generic!] # The list of choices for the parameter.
153155
choicesFromFile: PipelineParameterChoicesFromFileInput # Dynamic choices loaded from a workspace file.
154156
directory: String # The name of the directory restriction (for file browser).
157+
disables: [String!] # Codes of parameters disabled when this (boolean) parameter matches `disableWhen`.
158+
disableWhen: Boolean # The boolean value of this parameter that triggers disabling of `disables` (default true).
155159
}
156160

157161
"""

backend/hexa/pipelines/models.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,31 @@ def validate_config_types(self, config: dict):
250250
if errors:
251251
raise ValueError(f"Parameters with invalid types: {', '.join(errors)}")
252252

253+
def get_disabled_parameter_codes(self, config: dict) -> set:
254+
"""Return the codes of parameters disabled by an active controller for the given config.
255+
256+
A controller is a boolean parameter declaring ``disables=[...]``. It is "active" when its effective
257+
value (from ``config``, falling back to its ``default``) equals its ``disable_when`` (``True`` by
258+
default). A parameter is disabled if any active controller lists it. This mirrors the SDK runtime and
259+
the frontend run form so schedulability matches what a run would actually validate.
260+
"""
261+
config = config or {}
262+
disabled = set()
263+
for parameter in self.parameters:
264+
disables = parameter.get("disables")
265+
if not disables:
266+
continue
267+
disable_when = parameter.get("disable_when", True)
268+
effective_value = config.get(parameter["code"], parameter.get("default"))
269+
if bool(effective_value) == disable_when:
270+
disabled.update(disables)
271+
return disabled
272+
253273
def validate_new_config(self, new_config: dict):
274+
disabled = self.get_disabled_parameter_codes(new_config)
254275
for parameter in self.parameters:
276+
if parameter["code"] in disabled:
277+
continue
255278
if (
256279
parameter.get("required")
257280
and parameter.get("default") is None
@@ -263,8 +286,10 @@ def validate_new_config(self, new_config: dict):
263286

264287
@property
265288
def is_schedulable(self):
289+
disabled = self.get_disabled_parameter_codes(self.config)
266290
return all(
267-
parameter.get("required") is False
291+
parameter["code"] in disabled
292+
or parameter.get("required") is False
268293
or parameter.get("default") is not None
269294
or self.config.get(parameter["code"]) is not None
270295
for parameter in self.parameters

backend/hexa/pipelines/schema/mutations.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import io
3+
import re
34
import tempfile
45
from pathlib import Path
56
from zipfile import ZipFile
@@ -45,13 +46,36 @@ def get_bucket_object(bucket_name, file):
4546
return storage.get_bucket_object(bucket_name, file)
4647

4748

49+
_CAMEL_CASE_RE = re.compile(r"(?<!^)(?=[A-Z])")
50+
51+
52+
def _to_snake_case(name: str) -> str:
53+
return _CAMEL_CASE_RE.sub("_", name).lower()
54+
55+
56+
def _normalize_parameter_keys(parameters: list) -> list:
57+
"""Convert top-level parameter keys to snake_case.
58+
59+
The SDK's ``to_dict()`` emits camelCase for multi-word keys (e.g. ``disableWhen``,
60+
``choicesFromFile``). On the explicit-upload path ariadne's ``convert_names_case`` normalizes
61+
those to snake_case, but this zip-parsing path stores the dicts directly — so we normalize here
62+
to keep the stored representation canonical (snake_case), which is what all readers expect.
63+
"""
64+
return [
65+
{_to_snake_case(key): value for key, value in param.items()}
66+
for param in parameters
67+
]
68+
69+
4870
def _parse_parameters_from_zipfile(zipfile_data: bytes) -> list:
4971
try:
5072
with tempfile.TemporaryDirectory() as temp_dir:
5173
with ZipFile(io.BytesIO(zipfile_data), "r") as zip_file:
5274
zip_file.extractall(temp_dir)
5375
sdk_pipeline = get_pipeline(Path(temp_dir))
54-
return [p.to_dict() for p in sdk_pipeline.parameters]
76+
return _normalize_parameter_keys(
77+
[p.to_dict() for p in sdk_pipeline.parameters]
78+
)
5579
except PipelineNotFound:
5680
return []
5781
except Exception as e:

backend/hexa/pipelines/schema/types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ def resolve_pipeline_parameter_choices_from_file(parameter, info, **kwargs):
103103
return parameter.get("choices_from_file")
104104

105105

106+
@pipeline_parameter.field("disables")
107+
def resolve_pipeline_parameter_disables(parameter, info, **kwargs):
108+
return parameter.get("disables") or []
109+
110+
111+
@pipeline_parameter.field("disableWhen")
112+
def resolve_pipeline_parameter_disable_when(parameter, info, **kwargs):
113+
return parameter.get("disable_when", True)
114+
115+
106116
@pipeline_parameter_choices_from_file.field("format")
107117
def resolve_choices_from_file_format(obj, info, **kwargs):
108118
fmt = obj.get("format")

backend/hexa/pipelines/tests/test_pipelines_scheduler.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from hexa.core.test import TestCase
77
from hexa.pipelines.models import (
88
Pipeline,
9+
PipelineDoesNotSupportParametersError,
910
PipelineRun,
1011
PipelineRunState,
1112
PipelineRunTrigger,
@@ -322,3 +323,165 @@ def test_is_schedulable_false_when_pinned_version_unschedulable(self):
322323
self.PIPELINE.scheduled_pipeline_version = None
323324
self.PIPELINE.save()
324325
unschedulable_version.delete()
326+
327+
def _disabling_version(self, config):
328+
return PipelineVersion.objects.create(
329+
pipeline=self.PIPELINE,
330+
name="disabling",
331+
parameters=[
332+
{
333+
"code": "run_report_only",
334+
"type": "bool",
335+
"required": False,
336+
"default": False,
337+
"disables": ["data_input"],
338+
},
339+
{"code": "data_input", "type": "str", "required": True},
340+
],
341+
config=config,
342+
)
343+
344+
def test_schedulable_when_required_param_disabled_by_active_controller(self):
345+
# Controller stored as on → data_input is disabled → required check waived.
346+
version = self._disabling_version({"run_report_only": True})
347+
self.assertTrue(version.is_schedulable)
348+
version.delete()
349+
350+
def test_not_schedulable_when_controller_off_and_required_param_empty(self):
351+
# Controller off (default) → data_input active and required with no value → not schedulable.
352+
version = self._disabling_version({})
353+
self.assertFalse(version.is_schedulable)
354+
version.delete()
355+
356+
def test_schedulable_with_disable_when_false_controller_off(self):
357+
version = PipelineVersion.objects.create(
358+
pipeline=self.PIPELINE,
359+
name="enable-toggle",
360+
parameters=[
361+
{
362+
"code": "enable_advanced",
363+
"type": "bool",
364+
"required": False,
365+
"default": False,
366+
"disables": ["tuning"],
367+
"disable_when": False,
368+
},
369+
{"code": "tuning", "type": "str", "required": True},
370+
],
371+
config={},
372+
)
373+
# enable_advanced off (default) matches disable_when=False → tuning disabled → schedulable.
374+
self.assertTrue(version.is_schedulable)
375+
version.delete()
376+
377+
def test_not_schedulable_with_disable_when_false_controller_on(self):
378+
version = PipelineVersion.objects.create(
379+
pipeline=self.PIPELINE,
380+
name="enable-toggle-on",
381+
parameters=[
382+
{
383+
"code": "enable_advanced",
384+
"type": "bool",
385+
"required": False,
386+
"default": False,
387+
"disables": ["tuning"],
388+
"disable_when": False,
389+
},
390+
{"code": "tuning", "type": "str", "required": True},
391+
],
392+
config={"enable_advanced": True},
393+
)
394+
# Controller on → does NOT match disable_when=False → tuning active, required, empty → not schedulable.
395+
self.assertFalse(version.is_schedulable)
396+
version.delete()
397+
398+
def test_not_schedulable_when_controller_explicitly_false_in_config(self):
399+
# Explicit False in config (not just default) keeps the required param active.
400+
version = self._disabling_version({"run_report_only": False})
401+
self.assertFalse(version.is_schedulable)
402+
version.delete()
403+
404+
def test_schedulable_unions_multiple_controllers(self):
405+
version = PipelineVersion.objects.create(
406+
pipeline=self.PIPELINE,
407+
name="multi-controller",
408+
parameters=[
409+
{
410+
"code": "toggle_a",
411+
"type": "bool",
412+
"required": False,
413+
"default": False,
414+
"disables": ["x_param"],
415+
},
416+
{
417+
"code": "toggle_b",
418+
"type": "bool",
419+
"required": False,
420+
"default": False,
421+
"disables": ["x_param", "y_param"],
422+
},
423+
{"code": "x_param", "type": "str", "required": True},
424+
{"code": "y_param", "type": "str", "required": True},
425+
],
426+
config={"toggle_a": True, "toggle_b": True},
427+
)
428+
self.assertTrue(version.is_schedulable)
429+
version.delete()
430+
431+
def test_schedulable_when_required_disabled_param_has_no_default(self):
432+
# Sanity: a disabled required param needs no default to be schedulable.
433+
version = self._disabling_version({"run_report_only": True})
434+
data_input = next(p for p in version.parameters if p["code"] == "data_input")
435+
self.assertNotIn("default", data_input)
436+
self.assertTrue(version.is_schedulable)
437+
version.delete()
438+
439+
def test_get_disabled_parameter_codes_handles_none_config(self):
440+
version = self._disabling_version({})
441+
# Controller defaults to False → no params disabled.
442+
self.assertEqual(version.get_disabled_parameter_codes(None), set())
443+
version.delete()
444+
445+
def _scheduled_disabling_version(self, current_config):
446+
"""A version that currently has `data_input` set and is disabled-capable."""
447+
return PipelineVersion.objects.create(
448+
pipeline=self.PIPELINE,
449+
name="scheduled-disabling",
450+
parameters=[
451+
{
452+
"code": "run_report_only",
453+
"type": "bool",
454+
"required": False,
455+
"default": False,
456+
"disables": ["data_input"],
457+
},
458+
{"code": "data_input", "type": "str", "required": True},
459+
],
460+
config=current_config,
461+
)
462+
463+
def test_validate_new_config_allows_dropping_disabled_required_param(self):
464+
version = self._scheduled_disabling_version(
465+
{"run_report_only": False, "data_input": "real_value"}
466+
)
467+
# New config turns the controller on → data_input disabled → dropping it is allowed.
468+
version.validate_new_config({"run_report_only": True, "data_input": None})
469+
version.delete()
470+
471+
def test_validate_new_config_rejects_dropping_active_required_param(self):
472+
version = self._scheduled_disabling_version(
473+
{"run_report_only": False, "data_input": "real_value"}
474+
)
475+
# Controller stays off → data_input active and required → dropping its value must raise.
476+
with self.assertRaises(PipelineDoesNotSupportParametersError):
477+
version.validate_new_config({"run_report_only": False, "data_input": None})
478+
version.delete()
479+
480+
def test_validate_new_config_passes_when_value_kept(self):
481+
version = self._scheduled_disabling_version(
482+
{"run_report_only": False, "data_input": "real_value"}
483+
)
484+
version.validate_new_config(
485+
{"run_report_only": False, "data_input": "still_here"}
486+
)
487+
version.delete()

0 commit comments

Comments
 (0)