Skip to content

Commit 3d5b011

Browse files
committed
feature: dynamic choices using FileChoices - first iteration
1 parent 0c295d5 commit 3d5b011

5 files changed

Lines changed: 291 additions & 16 deletions

File tree

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 FileChoices
89
from .decorator import FunctionWithParameter, Parameter, parameter, validate_parameters
910
from .types import (
1011
Boolean,
@@ -56,6 +57,8 @@
5657
"SecretType",
5758
# Registry
5859
"TYPES_BY_PYTHON_TYPE",
60+
# Dynamic choices
61+
"FileChoices",
5962
# Widgets
6063
"DHIS2Widget",
6164
"IASOWidget",
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Dynamic choices classes for pipeline parameters."""
2+
3+
from openhexa.sdk.pipelines.exceptions import InvalidParameterError
4+
5+
_SUPPORTED_FORMATS = {"csv", "json", "yaml", "yml"}
6+
7+
8+
class FileChoices:
9+
"""Descriptor for choices loaded dynamically from a file in the workspace file system.
10+
11+
The file format is inferred from the path extension (.csv, .json, .yaml, .yml).
12+
For CSV files with a single column, that column is used automatically.
13+
For CSV/JSON/YAML files with multiple columns/keys, `column` must be specified.
14+
15+
Parameters
16+
----------
17+
path : str
18+
Path to the file in the workspace file system (e.g. "data/districts.csv").
19+
column : str, optional
20+
Column name (CSV) or key (JSON/YAML) to use as choice values.
21+
Required when the file has more than one column/key.
22+
"""
23+
24+
def __init__(self, path: str, column: str | None = None):
25+
self.path = path
26+
self.column = column
27+
self.format = self._detect_format(path)
28+
self.validate_spec()
29+
30+
def _detect_format(self, path: str) -> str:
31+
if not path or not isinstance(path, str):
32+
raise InvalidParameterError("FileChoices path must be a non-empty string.")
33+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
34+
if ext not in _SUPPORTED_FORMATS:
35+
raise InvalidParameterError(
36+
f"Cannot determine file format from path '{path}'. "
37+
f"Supported extensions: {', '.join(sorted(_SUPPORTED_FORMATS))}."
38+
)
39+
return "yaml" if ext == "yml" else ext
40+
41+
def validate_spec(self):
42+
if not self.path or not isinstance(self.path, str):
43+
raise InvalidParameterError("FileChoices path must be a non-empty string.")
44+
if self.column is not None and not isinstance(self.column, str):
45+
raise InvalidParameterError("FileChoices column must be a string.")
46+
47+
def to_dict(self) -> dict:
48+
return {
49+
"format": self.format,
50+
"path": self.path,
51+
"column": self.column,
52+
}

openhexa/sdk/pipelines/parameter/decorator.py

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

18+
from .choices import FileChoices
1819
from .types import Boolean, DHIS2ConnectionType, IASOConnectionType, Secret, TYPES_BY_PYTHON_TYPE
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 | FileChoices | None = None,
4647
help: str | None = None,
4748
default: typing.Any | None = None,
4849
widget: DHIS2Widget | IASOWidget | None = None,
@@ -66,14 +67,19 @@ 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, FileChoices):
71+
# validate_spec() already ran in FileChoices.__init__; nothing more to check here
72+
pass
73+
else:
74+
if len(choices) == 0:
75+
raise InvalidParameterError("Choices, if provided, cannot be empty.")
76+
try:
77+
for choice in choices:
78+
self.type.validate(choice)
79+
except ParameterValueError:
80+
raise InvalidParameterError(
81+
f"The provided choices are not valid for the {self.type} parameter type."
82+
)
7783
self.choices = choices
7884

7985
self.name = name
@@ -100,11 +106,11 @@ def validate(self, value: typing.Any) -> typing.Any:
100106

101107
def to_dict(self) -> dict[str, typing.Any]:
102108
"""Return a dictionary representation of the Parameter instance."""
103-
return {
109+
d = {
104110
"code": self.code,
105111
"type": self.type.spec_type,
106112
"name": self.name,
107-
"choices": self.choices,
113+
"choices": None if isinstance(self.choices, FileChoices) else self.choices,
108114
"help": self.help,
109115
"default": self.default,
110116
"widget": self.widget.value if self.widget else None,
@@ -113,6 +119,9 @@ def to_dict(self) -> dict[str, typing.Any]:
113119
"multiple": self.multiple,
114120
"directory": self.directory,
115121
}
122+
if isinstance(self.choices, FileChoices):
123+
d["file_choices"] = self.choices.to_dict()
124+
return d
116125

117126
def _validate_single(self, value: typing.Any):
118127
# Normalize empty values to None and handles default
@@ -129,7 +138,7 @@ def _validate_single(self, value: typing.Any):
129138
return None
130139

131140
pre_validated = self.type.validate(normalized_value)
132-
if self.choices is not None and pre_validated not in self.choices:
141+
if self.choices is not None and not isinstance(self.choices, FileChoices) and pre_validated not in self.choices:
133142
raise ParameterValueError(f"The provided value for {self.code} is not included in the provided choices.")
134143

135144
return pre_validated
@@ -152,7 +161,11 @@ def _validate_multiple(self, value: typing.Any):
152161
raise ParameterValueError(f"{self.code} is required")
153162

154163
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):
164+
if (
165+
self.choices is not None
166+
and not isinstance(self.choices, FileChoices)
167+
and any(v not in self.choices for v in pre_validated)
168+
):
156169
raise ParameterValueError(
157170
f"One of the provided values for {self.code} is not included in the provided choices."
158171
)
@@ -174,7 +187,7 @@ def _validate_default(self, default: typing.Any, multiple: bool):
174187
except ParameterValueError:
175188
raise InvalidParameterError(f"The default value for {self.code} is not valid.")
176189

177-
if self.choices is not None:
190+
if self.choices is not None and not isinstance(self.choices, FileChoices):
178191
if isinstance(default, list):
179192
if not all(d in self.choices for d in default):
180193
raise InvalidParameterError(
@@ -227,7 +240,7 @@ def parameter(
227240
| File
228241
],
229242
name: str | None = None,
230-
choices: typing.Sequence | None = None,
243+
choices: typing.Sequence | FileChoices | None = None,
231244
help: str | None = None,
232245
widget: DHIS2Widget | IASOWidget | None = None,
233246
connection: str | None = None,

openhexa/sdk/pipelines/runtime.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from openhexa.sdk.pipelines.parameter import (
1818
TYPES_BY_PYTHON_TYPE,
1919
DHIS2Widget,
20+
FileChoices,
2021
IASOWidget,
2122
Parameter,
2223
validate_parameters,
@@ -172,6 +173,21 @@ def _get_decorator_arg_value(decorator: ast.Call, arg: Argument, index: int) ->
172173
return (keyword.value.id, True)
173174
elif isinstance(keyword.value, ast.List):
174175
return ([el.value for el in keyword.value.elts], True)
176+
elif isinstance(keyword.value, ast.Call):
177+
func = keyword.value.func
178+
func_name = func.id if isinstance(func, ast.Name) else None
179+
if func_name != "FileChoices":
180+
raise ValueError(f"Unsupported call in choices argument: {func_name}")
181+
# Extract positional arg (path) and keyword args (column, format override)
182+
pos_args = [a.value for a in keyword.value.args if isinstance(a, ast.Constant)]
183+
kw_args = {
184+
kw.arg: kw.value.value
185+
for kw in keyword.value.keywords
186+
if isinstance(kw.value, ast.Constant)
187+
}
188+
if pos_args:
189+
kw_args.setdefault("path", pos_args[0])
190+
return (FileChoices(**kw_args), True)
175191
elif isinstance(keyword.value, ast.Attribute):
176192
if keyword.value.attr in DHIS2Widget.__members__:
177193
return getattr(DHIS2Widget, keyword.value.attr), True
@@ -287,7 +303,7 @@ def get_pipeline(pipeline_path: Path) -> Pipeline:
287303
Argument("code", [ast.Constant]),
288304
Argument("type", [ast.Name]),
289305
Argument("name", [ast.Constant]),
290-
Argument("choices", [ast.List]),
306+
Argument("choices", [ast.List, ast.Call]),
291307
Argument("help", [ast.Constant]),
292308
Argument("default", [ast.Constant, ast.List]),
293309
Argument("widget", [ast.Attribute]),

0 commit comments

Comments
 (0)