Skip to content

Commit a1ed63f

Browse files
authored
feat: SDK - pipeline run parameters - dynamic choices (HEXA-1620) (#385)
* refactor: parameters into more atomic files * feature: dynamic choices using FileChoices - first iteration * fix: lint * refactor: rename ChoicesFromFile * fix: lint * fix: lint dosctrings * feat: improve ast for callables * feat: add string shorthand choices * fix: lint * feat: minor improvements * feat: minor minor improvements * feat: add docstrings * recover comment * typo * refactor: parameters into more atomic files * fix: lint * fix: conflict * feat: remove auto detect format * tests: fix tests * fix: conda
1 parent cef5ad6 commit a1ed63f

8 files changed

Lines changed: 564 additions & 18 deletions

File tree

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
l lint:
2+
@echo "Executing lint in backend code (pre-commit)"
3+
pre-commit run --show-diff-on-failure --color=always --all-files

openhexa/sdk/pipelines/parameter/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from openhexa.sdk.pipelines.exceptions import InvalidParameterError, ParameterValueError
77

8+
from .choices import ChoicesFromFile
89
from .decorator import FunctionWithParameter, Parameter, parameter, validate_parameters
910
from .types import (
1011
TYPES_BY_PYTHON_TYPE,
@@ -56,6 +57,8 @@
5657
"SecretType",
5758
# Registry
5859
"TYPES_BY_PYTHON_TYPE",
60+
# Dynamic choices
61+
"ChoicesFromFile",
5962
# Widgets
6063
"DHIS2Widget",
6164
"IASOWidget",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Mixin for classes that can reconstruct themselves from an AST Call node."""
2+
3+
import ast
4+
import inspect
5+
6+
7+
class AstConstructible:
8+
"""Mixin that enables reconstruction of a class instance from an AST Call node.
9+
10+
Any class whose ``__init__`` takes only scalar (``ast.Constant``) arguments
11+
can inherit from this mixin and get ``from_ast_call`` for free. Adding or
12+
renaming ``__init__`` parameters does *not* require touching the parser.
13+
14+
To make the AST parser recognise a new subclass by name, add one entry to
15+
``_AST_CALLABLE_TYPES`` in ``runtime.py`` (and ensure the subclass module is
16+
imported there). Auto-registration via ``__init_subclass__`` would not remove
17+
that requirement — the registry entry only exists after the module is imported,
18+
so an explicit import would still be needed.
19+
"""
20+
21+
@classmethod
22+
def from_ast_call(cls, node: ast.Call) -> "AstConstructible":
23+
"""Reconstruct an instance from an AST Call node.
24+
25+
Maps positional args to ``__init__`` parameter names via
26+
``inspect.signature``, then merges keyword args, and calls ``cls``.
27+
"""
28+
param_names = list(inspect.signature(cls).parameters.keys())
29+
kwargs = {}
30+
for i, arg in enumerate(node.args):
31+
if i >= len(param_names):
32+
break
33+
if not isinstance(arg, ast.Constant):
34+
raise ValueError(
35+
f"{cls.__name__}() positional argument {i + 1} must be a literal value, not a dynamic expression."
36+
)
37+
kwargs[param_names[i]] = arg.value
38+
for kw in node.keywords:
39+
if not isinstance(kw.value, ast.Constant):
40+
raise ValueError(
41+
f"{cls.__name__}() keyword argument '{kw.arg}' must be a literal value, not a dynamic expression."
42+
)
43+
kwargs[kw.arg] = kw.value.value
44+
return cls(**kwargs)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Dynamic choices classes for pipeline parameters."""
2+
3+
from openhexa.sdk.pipelines.exceptions import InvalidParameterError
4+
5+
from .ast_constructible import AstConstructible
6+
7+
_SUPPORTED_FORMATS = {"csv", "json", "yaml", "yml"}
8+
9+
10+
class ChoicesFromFile(AstConstructible):
11+
"""Descriptor for choices loaded dynamically from a file in the workspace file system.
12+
13+
Parameters
14+
----------
15+
path : str
16+
Path to the file in the workspace file system (e.g. "data/districts.csv").
17+
column : str, optional
18+
Column name (CSV) or key (JSON/YAML) to use as choice values.
19+
Required when the file has more than one column/key.
20+
format : str, optional
21+
File format (e.g. "csv", "json", "yaml"). Sent as-is to the platform.
22+
"""
23+
24+
def __init__(self, path: str, column: str | None = None, format: str | None = None):
25+
self.path = path
26+
self.column = column
27+
self.format = format
28+
self._validate_spec()
29+
30+
def _validate_spec(self):
31+
"""Validate the path and column specification."""
32+
if not self.path or not isinstance(self.path, str):
33+
raise InvalidParameterError("ChoicesFromFile path must be a non-empty string.")
34+
if self.column is not None and not isinstance(self.column, str):
35+
raise InvalidParameterError("ChoicesFromFile column must be a string.")
36+
if self.format is not None and self.format not in _SUPPORTED_FORMATS:
37+
raise InvalidParameterError(
38+
f"ChoicesFromFile format '{self.format}' is not supported. "
39+
f"Supported formats: {', '.join(sorted(_SUPPORTED_FORMATS))}."
40+
)
41+
42+
def __repr__(self) -> str:
43+
"""Return a string representation of the ChoicesFromFile instance."""
44+
parts = [repr(self.path)]
45+
if self.column is not None:
46+
parts.append(f"column={self.column!r}")
47+
if self.format is not None:
48+
parts.append(f"format={self.format!r}")
49+
return f"ChoicesFromFile({', '.join(parts)})"
50+
51+
def __eq__(self, other: object) -> bool:
52+
"""Check equality based on path, column, and format."""
53+
if not isinstance(other, ChoicesFromFile):
54+
return NotImplemented
55+
return self.path == other.path and self.column == other.column and self.format == other.format
56+
57+
def __hash__(self) -> int:
58+
"""Return hash based on path, column, and format."""
59+
return hash((self.path, self.column, self.format))
60+
61+
def to_dict(self) -> dict:
62+
"""Return a dictionary representation of the choices spec."""
63+
return {
64+
"format": self.format,
65+
"path": self.path,
66+
"column": self.column,
67+
}

openhexa/sdk/pipelines/parameter/decorator.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
S3Connection,
1616
)
1717

18+
from .choices import ChoicesFromFile
1819
from .types import TYPES_BY_PYTHON_TYPE, Boolean, DHIS2ConnectionType, IASOConnectionType, Secret
1920
from .widgets import DHIS2Widget, IASOWidget
2021

@@ -42,7 +43,7 @@ def __init__(
4243
| File
4344
],
4445
name: str | None = None,
45-
choices: typing.Sequence | None = None,
46+
choices: typing.Sequence | ChoicesFromFile | str | None = None,
4647
help: str | None = None,
4748
default: typing.Any | None = None,
4849
widget: DHIS2Widget | IASOWidget | None = None,
@@ -66,14 +67,18 @@ def __init__(
6667
if choices is not None:
6768
if not self.type.accepts_choices:
6869
raise InvalidParameterError(f"Parameters of type {self.type} don't accept choices.")
69-
elif len(choices) == 0:
70-
raise InvalidParameterError("Choices, if provided, cannot be empty.")
71-
72-
try:
73-
for choice in choices:
74-
self.type.validate(choice)
75-
except ParameterValueError:
76-
raise InvalidParameterError(f"The provided choices are not valid for the {self.type} parameter type.")
70+
if isinstance(choices, str):
71+
choices = ChoicesFromFile(choices)
72+
elif not isinstance(choices, ChoicesFromFile):
73+
if len(choices) == 0:
74+
raise InvalidParameterError("Choices, if provided, cannot be empty.")
75+
try:
76+
for choice in choices:
77+
self.type.validate(choice)
78+
except ParameterValueError:
79+
raise InvalidParameterError(
80+
f"The provided choices are not valid for the {self.type} parameter type."
81+
)
7782
self.choices = choices
7883

7984
self.name = name
@@ -100,11 +105,11 @@ def validate(self, value: typing.Any) -> typing.Any:
100105

101106
def to_dict(self) -> dict[str, typing.Any]:
102107
"""Return a dictionary representation of the Parameter instance."""
103-
return {
108+
d = {
104109
"code": self.code,
105110
"type": self.type.spec_type,
106111
"name": self.name,
107-
"choices": self.choices,
112+
"choices": None if isinstance(self.choices, ChoicesFromFile) else self.choices,
108113
"help": self.help,
109114
"default": self.default,
110115
"widget": self.widget.value if self.widget else None,
@@ -113,6 +118,9 @@ def to_dict(self) -> dict[str, typing.Any]:
113118
"multiple": self.multiple,
114119
"directory": self.directory,
115120
}
121+
if isinstance(self.choices, ChoicesFromFile):
122+
d["choices_from_file"] = self.choices.to_dict()
123+
return d
116124

117125
def _validate_single(self, value: typing.Any):
118126
# Normalize empty values to None and handles default
@@ -129,7 +137,11 @@ def _validate_single(self, value: typing.Any):
129137
return None
130138

131139
pre_validated = self.type.validate(normalized_value)
132-
if self.choices is not None and pre_validated not in self.choices:
140+
if (
141+
self.choices is not None
142+
and not isinstance(self.choices, ChoicesFromFile)
143+
and pre_validated not in self.choices
144+
):
133145
raise ParameterValueError(f"The provided value for {self.code} is not included in the provided choices.")
134146

135147
return pre_validated
@@ -152,7 +164,11 @@ def _validate_multiple(self, value: typing.Any):
152164
raise ParameterValueError(f"{self.code} is required")
153165

154166
pre_validated = [self.type.validate(single_value) for single_value in normalized_value]
155-
if self.choices is not None and any(v not in self.choices for v in pre_validated):
167+
if (
168+
self.choices is not None
169+
and not isinstance(self.choices, ChoicesFromFile)
170+
and any(v not in self.choices for v in pre_validated)
171+
):
156172
raise ParameterValueError(
157173
f"One of the provided values for {self.code} is not included in the provided choices."
158174
)
@@ -174,7 +190,7 @@ def _validate_default(self, default: typing.Any, multiple: bool):
174190
except ParameterValueError:
175191
raise InvalidParameterError(f"The default value for {self.code} is not valid.")
176192

177-
if self.choices is not None:
193+
if self.choices is not None and not isinstance(self.choices, ChoicesFromFile):
178194
if isinstance(default, list):
179195
if not all(d in self.choices for d in default):
180196
raise InvalidParameterError(
@@ -227,7 +243,7 @@ def parameter(
227243
| File
228244
],
229245
name: str | None = None,
230-
choices: typing.Sequence | None = None,
246+
choices: typing.Sequence | ChoicesFromFile | str | None = None,
231247
help: str | None = None,
232248
widget: DHIS2Widget | IASOWidget | None = None,
233249
connection: str | None = None,
@@ -261,7 +277,7 @@ def parameter(
261277
An optional default value for the parameter (should be of the type defined by the type parameter)
262278
required : bool, default=True
263279
Whether the parameter is mandatory
264-
multiple : bool, default=True
280+
multiple : bool, default=False
265281
Whether this parameter should be provided multiple values (if True, the value must be provided as a list of
266282
values of the chosen type)
267283
directory : str, optional

openhexa/sdk/pipelines/runtime.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io
77
import os
88
import sys
9+
from collections.abc import Callable
910
from dataclasses import dataclass, field
1011
from pathlib import Path
1112
from typing import Any
@@ -16,6 +17,7 @@
1617
from openhexa.sdk.pipelines.exceptions import InvalidParameterError, PipelineNotFound
1718
from openhexa.sdk.pipelines.parameter import (
1819
TYPES_BY_PYTHON_TYPE,
20+
ChoicesFromFile,
1921
DHIS2Widget,
2022
IASOWidget,
2123
Parameter,
@@ -25,6 +27,12 @@
2527

2628
from .pipeline import Pipeline
2729

30+
# Maps AST function names to classes that support from_ast_call().
31+
# Add an entry here when introducing a new AstConstructible type.
32+
_AST_CALLABLE_TYPES: dict[str, type] = {
33+
"ChoicesFromFile": ChoicesFromFile,
34+
}
35+
2836

2937
@dataclass
3038
class Argument:
@@ -33,6 +41,7 @@ class Argument:
3341
name: str # Use str instead of string
3442
types: list[type] = field(default_factory=list)
3543
default_value: Any = None
44+
transform: Callable | None = None
3645

3746

3847
def import_pipeline(pipeline_dir_path: str) -> Pipeline:
@@ -172,6 +181,12 @@ def _get_decorator_arg_value(decorator: ast.Call, arg: Argument, index: int) ->
172181
return (keyword.value.id, True)
173182
elif isinstance(keyword.value, ast.List):
174183
return ([el.value for el in keyword.value.elts], True)
184+
elif isinstance(keyword.value, ast.Call):
185+
func = keyword.value.func
186+
func_name = func.id if isinstance(func, ast.Name) else None
187+
if func_name not in _AST_CALLABLE_TYPES:
188+
raise ValueError(f"Unsupported call in choices argument: {func_name}")
189+
return _AST_CALLABLE_TYPES[func_name].from_ast_call(keyword.value), True
175190
elif isinstance(keyword.value, ast.Attribute):
176191
if keyword.value.attr in DHIS2Widget.__members__:
177192
return getattr(DHIS2Widget, keyword.value.attr), True
@@ -201,6 +216,8 @@ def _get_decorator_spec(decorator: ast.Call, args: tuple[Argument, ...]) -> dict
201216
args_spec = {}
202217
for i, arg in enumerate(args):
203218
value, is_keyword = _get_decorator_arg_value(decorator, arg, i)
219+
if arg.transform is not None:
220+
value = arg.transform(value)
204221
args_spec[arg.name] = {"value": value, "is_keyword": is_keyword}
205222
return args_spec
206223

@@ -287,7 +304,11 @@ def get_pipeline(pipeline_path: Path) -> Pipeline:
287304
Argument("code", [ast.Constant]),
288305
Argument("type", [ast.Name]),
289306
Argument("name", [ast.Constant]),
290-
Argument("choices", [ast.List]),
307+
Argument(
308+
"choices",
309+
[ast.List, ast.Call, ast.Constant],
310+
transform=lambda v: ChoicesFromFile(v) if isinstance(v, str) else v,
311+
),
291312
Argument("help", [ast.Constant]),
292313
Argument("default", [ast.Constant, ast.List]),
293314
Argument("widget", [ast.Attribute]),

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ requires-python = ">=3.11,<3.15" # the main constraint for supported Python vers
2020
dependencies = [
2121
"urllib3<3",
2222
"multiprocess~=0.70.15",
23-
"requests>=2.31,<2.35",
23+
"requests>=2.31,<3",
2424
"PyYAML~=6.0",
2525
"click~=8.1.3",
2626
"jinja2>3,<4",

0 commit comments

Comments
 (0)