dataeval-flow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataeval_flow/__init__.py +93 -0
- dataeval_flow/__main__.py +149 -0
- dataeval_flow/_app/__init__.py +5 -0
- dataeval_flow/_app/_model/__init__.py +5 -0
- dataeval_flow/_app/_model/_coerce.py +126 -0
- dataeval_flow/_app/_model/_discover.py +171 -0
- dataeval_flow/_app/_model/_execution.py +108 -0
- dataeval_flow/_app/_model/_introspect.py +280 -0
- dataeval_flow/_app/_model/_item.py +213 -0
- dataeval_flow/_app/_model/_registry.py +255 -0
- dataeval_flow/_app/_model/_state.py +322 -0
- dataeval_flow/_app/_model/_undo.py +61 -0
- dataeval_flow/_app/_panes/__init__.py +35 -0
- dataeval_flow/_app/_panes/_config_pane.py +173 -0
- dataeval_flow/_app/_panes/_result_pane.py +125 -0
- dataeval_flow/_app/_panes/_task_pane.py +91 -0
- dataeval_flow/_app/_panes/_widgets.py +111 -0
- dataeval_flow/_app/_screens/__init__.py +25 -0
- dataeval_flow/_app/_screens/_base.py +242 -0
- dataeval_flow/_app/_screens/_detail.py +333 -0
- dataeval_flow/_app/_screens/_model.py +102 -0
- dataeval_flow/_app/_screens/_params.py +80 -0
- dataeval_flow/_app/_screens/_pathpicker.py +68 -0
- dataeval_flow/_app/_screens/_section.py +621 -0
- dataeval_flow/_app/_screens/_settings.py +183 -0
- dataeval_flow/_app/_viewmodel/__init__.py +15 -0
- dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
- dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
- dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
- dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
- dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
- dataeval_flow/_app/app.py +742 -0
- dataeval_flow/_app/cli.py +592 -0
- dataeval_flow/_logging.py +102 -0
- dataeval_flow/cache.py +1355 -0
- dataeval_flow/config/__init__.py +80 -0
- dataeval_flow/config/_loader.py +79 -0
- dataeval_flow/config/_merge.py +92 -0
- dataeval_flow/config/_models.py +115 -0
- dataeval_flow/config/_paths.py +85 -0
- dataeval_flow/config/schemas/__init__.py +112 -0
- dataeval_flow/config/schemas/_dataset.py +111 -0
- dataeval_flow/config/schemas/_extractor.py +119 -0
- dataeval_flow/config/schemas/_metadata.py +28 -0
- dataeval_flow/config/schemas/_preprocessor.py +18 -0
- dataeval_flow/config/schemas/_selection.py +100 -0
- dataeval_flow/config/schemas/_task.py +89 -0
- dataeval_flow/config/schemas/_workflow.py +135 -0
- dataeval_flow/dataset.py +635 -0
- dataeval_flow/embeddings.py +135 -0
- dataeval_flow/metadata.py +48 -0
- dataeval_flow/preprocessing.py +141 -0
- dataeval_flow/py.typed +0 -0
- dataeval_flow/runner.py +118 -0
- dataeval_flow/selection.py +50 -0
- dataeval_flow/workflow/__init__.py +328 -0
- dataeval_flow/workflow/_text_report.py +511 -0
- dataeval_flow/workflow/base.py +69 -0
- dataeval_flow/workflow/orchestrator.py +454 -0
- dataeval_flow/workflows/__init__.py +1 -0
- dataeval_flow/workflows/analysis/__init__.py +38 -0
- dataeval_flow/workflows/analysis/outputs.py +202 -0
- dataeval_flow/workflows/analysis/params.py +114 -0
- dataeval_flow/workflows/analysis/workflow.py +1313 -0
- dataeval_flow/workflows/cleaning/__init__.py +23 -0
- dataeval_flow/workflows/cleaning/outputs.py +200 -0
- dataeval_flow/workflows/cleaning/params.py +160 -0
- dataeval_flow/workflows/cleaning/report.py +304 -0
- dataeval_flow/workflows/cleaning/workflow.py +794 -0
- dataeval_flow/workflows/drift/__init__.py +1 -0
- dataeval_flow/workflows/drift/outputs.py +144 -0
- dataeval_flow/workflows/drift/params.py +332 -0
- dataeval_flow/workflows/drift/report.py +201 -0
- dataeval_flow/workflows/drift/workflow.py +647 -0
- dataeval_flow/workflows/ood/__init__.py +1 -0
- dataeval_flow/workflows/ood/outputs.py +134 -0
- dataeval_flow/workflows/ood/params.py +161 -0
- dataeval_flow/workflows/ood/report.py +311 -0
- dataeval_flow/workflows/ood/workflow.py +728 -0
- dataeval_flow/workflows/prioritization/__init__.py +1 -0
- dataeval_flow/workflows/prioritization/outputs.py +122 -0
- dataeval_flow/workflows/prioritization/params.py +124 -0
- dataeval_flow/workflows/prioritization/report.py +117 -0
- dataeval_flow/workflows/prioritization/workflow.py +587 -0
- dataeval_flow/workflows/splitting/__init__.py +25 -0
- dataeval_flow/workflows/splitting/outputs.py +101 -0
- dataeval_flow/workflows/splitting/params.py +61 -0
- dataeval_flow/workflows/splitting/report.py +485 -0
- dataeval_flow/workflows/splitting/workflow.py +371 -0
- dataeval_flow-0.1.0.dist-info/METADATA +305 -0
- dataeval_flow-0.1.0.dist-info/RECORD +94 -0
- dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
- dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
- dataeval_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Pydantic schema introspection for dynamic form generation.
|
|
2
|
+
|
|
3
|
+
Walks Pydantic model fields and produces ``FieldDescriptor`` objects that
|
|
4
|
+
the TUI can render as appropriate widgets (inputs, selects, checkboxes, etc.)
|
|
5
|
+
without hard-coding knowledge of each workflow's parameter model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import collections.abc
|
|
11
|
+
import typing
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, get_args, get_origin
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
from pydantic.fields import FieldInfo
|
|
18
|
+
from pydantic_core import PydanticUndefinedType
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FieldKind(Enum):
|
|
22
|
+
"""Widget hint for the TUI layer."""
|
|
23
|
+
|
|
24
|
+
STRING = "string"
|
|
25
|
+
INT = "int"
|
|
26
|
+
FLOAT = "float"
|
|
27
|
+
BOOL = "bool"
|
|
28
|
+
SELECT = "select"
|
|
29
|
+
MULTI_SELECT = "multi_select"
|
|
30
|
+
NESTED = "nested"
|
|
31
|
+
LIST = "list"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class FieldDescriptor:
|
|
36
|
+
"""Metadata extracted from a single Pydantic field."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
kind: FieldKind
|
|
40
|
+
description: str
|
|
41
|
+
required: bool
|
|
42
|
+
default: Any = None
|
|
43
|
+
choices: list[str] = field(default_factory=list)
|
|
44
|
+
constraints: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
nested_model: type[BaseModel] | None = None
|
|
46
|
+
item_descriptors: list[FieldDescriptor] | None = None # for lists of nested models
|
|
47
|
+
discriminator: str | None = None # for discriminated unions
|
|
48
|
+
union_variants: dict[str, type[BaseModel]] | None = None # discriminator value -> model
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _unwrap_optional(annotation: Any) -> tuple[Any, bool]:
|
|
52
|
+
"""Strip ``Optional[X]`` / ``X | None`` and return (inner_type, is_optional)."""
|
|
53
|
+
import types
|
|
54
|
+
|
|
55
|
+
origin = get_origin(annotation)
|
|
56
|
+
if origin is typing.Union or isinstance(annotation, types.UnionType):
|
|
57
|
+
args = [a for a in get_args(annotation) if a is not type(None)]
|
|
58
|
+
if len(args) == 1:
|
|
59
|
+
return args[0], True
|
|
60
|
+
return annotation, False
|
|
61
|
+
return annotation, False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_literal_values(annotation: Any) -> list[str] | None:
|
|
65
|
+
"""Return literal values if annotation is ``Literal[...]``."""
|
|
66
|
+
if get_origin(annotation) is typing.Literal:
|
|
67
|
+
return [str(v) for v in get_args(annotation)]
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_pydantic_model(tp: Any) -> bool:
|
|
72
|
+
return isinstance(tp, type) and issubclass(tp, BaseModel)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_constraints(field_info: FieldInfo) -> dict[str, Any]:
|
|
76
|
+
"""Pull numeric constraints (ge, le, gt, lt) from field metadata."""
|
|
77
|
+
constraints: dict[str, Any] = {}
|
|
78
|
+
for meta in field_info.metadata:
|
|
79
|
+
for attr in ("ge", "le", "gt", "lt", "min_length", "max_length"):
|
|
80
|
+
val = getattr(meta, attr, None)
|
|
81
|
+
if val is not None:
|
|
82
|
+
constraints[attr] = val
|
|
83
|
+
return constraints
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_discriminated_union(
|
|
87
|
+
annotation: Any,
|
|
88
|
+
field_info: FieldInfo,
|
|
89
|
+
) -> tuple[str | None, dict[str, type[BaseModel]] | None]:
|
|
90
|
+
"""Detect discriminated unions and return (discriminator_field, {value: model})."""
|
|
91
|
+
disc = field_info.discriminator
|
|
92
|
+
if disc is None:
|
|
93
|
+
return None, None
|
|
94
|
+
|
|
95
|
+
disc_field = disc if isinstance(disc, str) else None
|
|
96
|
+
if disc_field is None:
|
|
97
|
+
return None, None
|
|
98
|
+
|
|
99
|
+
# The annotation is Annotated[Union[A, B, ...], Field(discriminator=...)]
|
|
100
|
+
inner = annotation
|
|
101
|
+
if get_origin(annotation) is typing.Annotated:
|
|
102
|
+
inner = get_args(annotation)[0]
|
|
103
|
+
|
|
104
|
+
import types as _types
|
|
105
|
+
|
|
106
|
+
union_args = get_args(inner) if (get_origin(inner) is typing.Union or isinstance(inner, _types.UnionType)) else []
|
|
107
|
+
variants: dict[str, type[BaseModel]] = {}
|
|
108
|
+
for variant in union_args:
|
|
109
|
+
if _is_pydantic_model(variant):
|
|
110
|
+
disc_info = variant.model_fields.get(disc_field)
|
|
111
|
+
if disc_info and disc_info.default is not None:
|
|
112
|
+
variants[str(disc_info.default)] = variant
|
|
113
|
+
|
|
114
|
+
return disc_field, variants if variants else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_PRIMITIVE_KINDS: dict[type, FieldKind] = {
|
|
118
|
+
bool: FieldKind.BOOL,
|
|
119
|
+
int: FieldKind.INT,
|
|
120
|
+
float: FieldKind.FLOAT,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _introspect_list_field(
|
|
125
|
+
inner: Any,
|
|
126
|
+
name: str,
|
|
127
|
+
description: str,
|
|
128
|
+
required: bool,
|
|
129
|
+
default: Any,
|
|
130
|
+
constraints: dict[str, Any],
|
|
131
|
+
) -> FieldDescriptor | None:
|
|
132
|
+
"""Attempt to classify a list/Sequence annotation, returning a descriptor or None."""
|
|
133
|
+
list_args = get_args(inner)
|
|
134
|
+
if list_args:
|
|
135
|
+
list_inner = list_args[0]
|
|
136
|
+
list_literals = _extract_literal_values(list_inner)
|
|
137
|
+
if list_literals:
|
|
138
|
+
return FieldDescriptor(
|
|
139
|
+
name=name,
|
|
140
|
+
kind=FieldKind.MULTI_SELECT,
|
|
141
|
+
description=description,
|
|
142
|
+
required=required,
|
|
143
|
+
default=default,
|
|
144
|
+
choices=list_literals,
|
|
145
|
+
constraints=constraints,
|
|
146
|
+
)
|
|
147
|
+
if _is_pydantic_model(list_inner):
|
|
148
|
+
return FieldDescriptor(
|
|
149
|
+
name=name,
|
|
150
|
+
kind=FieldKind.LIST,
|
|
151
|
+
description=description,
|
|
152
|
+
required=required,
|
|
153
|
+
default=default,
|
|
154
|
+
nested_model=list_inner,
|
|
155
|
+
constraints=constraints,
|
|
156
|
+
)
|
|
157
|
+
# Check for list of discriminated union
|
|
158
|
+
if list_args and get_origin(list_args[0]) is typing.Annotated:
|
|
159
|
+
ann_args = get_args(list_args[0])
|
|
160
|
+
union_type = ann_args[0]
|
|
161
|
+
field_meta = [a for a in ann_args[1:] if isinstance(a, FieldInfo)]
|
|
162
|
+
import types as _types
|
|
163
|
+
|
|
164
|
+
if field_meta and (get_origin(union_type) is typing.Union or isinstance(union_type, _types.UnionType)):
|
|
165
|
+
d_field, d_variants = _resolve_discriminated_union(list_args[0], field_meta[0])
|
|
166
|
+
if d_field and d_variants:
|
|
167
|
+
return FieldDescriptor(
|
|
168
|
+
name=name,
|
|
169
|
+
kind=FieldKind.LIST,
|
|
170
|
+
description=description,
|
|
171
|
+
required=required,
|
|
172
|
+
default=default,
|
|
173
|
+
discriminator=d_field,
|
|
174
|
+
union_variants=d_variants,
|
|
175
|
+
constraints=constraints,
|
|
176
|
+
)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def introspect_model(model: type[BaseModel]) -> list[FieldDescriptor]:
|
|
181
|
+
"""Walk a Pydantic model and return field descriptors for each field."""
|
|
182
|
+
descriptors: list[FieldDescriptor] = []
|
|
183
|
+
|
|
184
|
+
for name, field_info in model.model_fields.items():
|
|
185
|
+
annotation = field_info.annotation
|
|
186
|
+
if annotation is None:
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
inner, is_optional = _unwrap_optional(annotation)
|
|
190
|
+
|
|
191
|
+
required = field_info.is_required()
|
|
192
|
+
raw_default = field_info.default
|
|
193
|
+
default = None if required or isinstance(raw_default, PydanticUndefinedType) else raw_default
|
|
194
|
+
description = field_info.description or ""
|
|
195
|
+
constraints = _extract_constraints(field_info)
|
|
196
|
+
|
|
197
|
+
# Check for discriminated union first
|
|
198
|
+
disc_field, union_variants = _resolve_discriminated_union(annotation, field_info)
|
|
199
|
+
if disc_field and union_variants:
|
|
200
|
+
descriptors.append(
|
|
201
|
+
FieldDescriptor(
|
|
202
|
+
name=name,
|
|
203
|
+
kind=FieldKind.NESTED,
|
|
204
|
+
description=description,
|
|
205
|
+
required=required,
|
|
206
|
+
default=default,
|
|
207
|
+
discriminator=disc_field,
|
|
208
|
+
union_variants=union_variants,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Literal -> select
|
|
214
|
+
literal_vals = _extract_literal_values(inner)
|
|
215
|
+
if literal_vals:
|
|
216
|
+
descriptors.append(
|
|
217
|
+
FieldDescriptor(
|
|
218
|
+
name=name,
|
|
219
|
+
kind=FieldKind.SELECT,
|
|
220
|
+
description=description,
|
|
221
|
+
required=required,
|
|
222
|
+
default=str(default) if default is not None else None,
|
|
223
|
+
choices=literal_vals,
|
|
224
|
+
constraints=constraints,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# list[...] / Sequence[...]
|
|
230
|
+
if get_origin(inner) in (list, collections.abc.Sequence):
|
|
231
|
+
list_desc = _introspect_list_field(inner, name, description, required, default, constraints)
|
|
232
|
+
if list_desc:
|
|
233
|
+
descriptors.append(list_desc)
|
|
234
|
+
continue
|
|
235
|
+
# Plain list
|
|
236
|
+
descriptors.append(
|
|
237
|
+
FieldDescriptor(
|
|
238
|
+
name=name,
|
|
239
|
+
kind=FieldKind.LIST,
|
|
240
|
+
description=description,
|
|
241
|
+
required=required,
|
|
242
|
+
default=default,
|
|
243
|
+
constraints=constraints,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# Nested BaseModel
|
|
249
|
+
if _is_pydantic_model(inner):
|
|
250
|
+
sub_descs = introspect_model(inner)
|
|
251
|
+
expandable_kinds = {FieldKind.BOOL, FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING, FieldKind.SELECT}
|
|
252
|
+
expandable = sub_descs and all(d.kind in expandable_kinds and not d.required for d in sub_descs)
|
|
253
|
+
descriptors.append(
|
|
254
|
+
FieldDescriptor(
|
|
255
|
+
name=name,
|
|
256
|
+
kind=FieldKind.NESTED,
|
|
257
|
+
description=description,
|
|
258
|
+
required=required,
|
|
259
|
+
default=default,
|
|
260
|
+
nested_model=inner,
|
|
261
|
+
constraints=constraints,
|
|
262
|
+
item_descriptors=sub_descs if expandable else None,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
# Primitives
|
|
268
|
+
kind = _PRIMITIVE_KINDS.get(inner, FieldKind.STRING)
|
|
269
|
+
descriptors.append(
|
|
270
|
+
FieldDescriptor(
|
|
271
|
+
name=name,
|
|
272
|
+
kind=kind,
|
|
273
|
+
description=description,
|
|
274
|
+
required=required,
|
|
275
|
+
default=default,
|
|
276
|
+
constraints=constraints,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return descriptors
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Item assembly, field collection, step-builder validation/coercion, and action constants.
|
|
2
|
+
|
|
3
|
+
Pure functions shared by both the TUI and CLI for building config item
|
|
4
|
+
dicts, collecting/coercing field values, and validating step parameters.
|
|
5
|
+
No UI or state dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from dataeval_flow._app._model._coerce import (
|
|
14
|
+
_split_type_alternatives,
|
|
15
|
+
coerce_field_value,
|
|
16
|
+
coerce_value,
|
|
17
|
+
validate_value,
|
|
18
|
+
)
|
|
19
|
+
from dataeval_flow._app._model._introspect import FieldDescriptor
|
|
20
|
+
from dataeval_flow._app._model._registry import (
|
|
21
|
+
STEP_BUILDER_SECTIONS,
|
|
22
|
+
get_discriminator_field,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"DELETE_SENTINEL",
|
|
27
|
+
"SKIP",
|
|
28
|
+
"build_item_dict",
|
|
29
|
+
"coerce_step_params",
|
|
30
|
+
"collect_field_value",
|
|
31
|
+
"collect_multi_select_value",
|
|
32
|
+
"diagnose_collect_failure",
|
|
33
|
+
"finalize_item",
|
|
34
|
+
"validate_step_params",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Sentinel indicating a field value should be omitted from the result dict.
|
|
38
|
+
SKIP = object()
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Sentinel for delete actions
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
DELETE_SENTINEL = "__DELETE__"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Item assembly helpers
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def finalize_item(section: str, item: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
"""Apply section-specific defaults to an assembled item dict.
|
|
54
|
+
|
|
55
|
+
Currently handles tasks defaulting to ``enabled=True``.
|
|
56
|
+
"""
|
|
57
|
+
if section == "tasks":
|
|
58
|
+
item.setdefault("enabled", True)
|
|
59
|
+
return item
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_item_dict(
|
|
63
|
+
section: str,
|
|
64
|
+
name: str,
|
|
65
|
+
variant_value: str | None,
|
|
66
|
+
field_values: dict[str, Any],
|
|
67
|
+
) -> dict[str, Any]:
|
|
68
|
+
"""Assemble a config item dict from its component parts.
|
|
69
|
+
|
|
70
|
+
Combines name, discriminator, and field values into a single dict,
|
|
71
|
+
then applies section-specific defaults via :func:`finalize_item`.
|
|
72
|
+
"""
|
|
73
|
+
item: dict[str, Any] = {"name": name}
|
|
74
|
+
disc_field = get_discriminator_field(section)
|
|
75
|
+
if disc_field and variant_value:
|
|
76
|
+
item[disc_field] = variant_value
|
|
77
|
+
item.update(field_values)
|
|
78
|
+
return finalize_item(section, item)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Step-builder validation and coercion (pure functions)
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_step_params(params: list[Any], values: dict[str, str | bool | None]) -> list[str]:
|
|
87
|
+
"""Validate step parameter values. Returns a list of error messages.
|
|
88
|
+
|
|
89
|
+
*params* is a list of ``ParamInfo``-like objects with ``name``,
|
|
90
|
+
``type_hint``, ``required``, and ``choices`` attributes.
|
|
91
|
+
*values* maps param names to raw string values (or ``None``).
|
|
92
|
+
Params whose name is not in *values* are skipped (widget not found).
|
|
93
|
+
"""
|
|
94
|
+
errors: list[str] = []
|
|
95
|
+
for p in params:
|
|
96
|
+
if p.name not in values:
|
|
97
|
+
continue
|
|
98
|
+
val = values.get(p.name)
|
|
99
|
+
if p.choices:
|
|
100
|
+
if p.required and not val:
|
|
101
|
+
errors.append(f"'{p.name}' is required (select a value).")
|
|
102
|
+
elif p.type_hint == "bool":
|
|
103
|
+
pass # booleans are always valid
|
|
104
|
+
else:
|
|
105
|
+
if p.required and not val:
|
|
106
|
+
errors.append(f"'{p.name}' is required.")
|
|
107
|
+
elif val:
|
|
108
|
+
val_str = str(val)
|
|
109
|
+
alternatives = _split_type_alternatives(p.type_hint)
|
|
110
|
+
if not any(validate_value(val_str, alt) for alt in alternatives):
|
|
111
|
+
expected = " or ".join(alternatives)
|
|
112
|
+
errors.append(f"'{p.name}' must be {expected} (got '{val_str}').")
|
|
113
|
+
return errors
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def coerce_step_params(params: list[Any], values: dict[str, str | bool | None]) -> dict[str, Any]:
|
|
117
|
+
"""Coerce raw string values for step parameters. Returns the result dict.
|
|
118
|
+
|
|
119
|
+
*params* is a list of ``ParamInfo``-like objects.
|
|
120
|
+
*values* maps param names to raw string values (or ``None``).
|
|
121
|
+
Params whose name is not in *values* are skipped (widget not found).
|
|
122
|
+
"""
|
|
123
|
+
result: dict[str, Any] = {}
|
|
124
|
+
for p in params:
|
|
125
|
+
if p.name not in values:
|
|
126
|
+
continue
|
|
127
|
+
val = values.get(p.name)
|
|
128
|
+
if p.choices:
|
|
129
|
+
if val:
|
|
130
|
+
result[p.name] = val
|
|
131
|
+
elif p.type_hint == "bool":
|
|
132
|
+
bool_val = values.get(p.name)
|
|
133
|
+
if isinstance(bool_val, bool):
|
|
134
|
+
default = p.default if isinstance(p.default, bool) else False
|
|
135
|
+
if bool_val != default:
|
|
136
|
+
result[p.name] = bool_val
|
|
137
|
+
else:
|
|
138
|
+
if val:
|
|
139
|
+
result[p.name] = coerce_value(str(val), p.type_hint)
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Field value collection (pure logic, no widget access)
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def collect_field_value(desc: FieldDescriptor, raw: str) -> Any:
|
|
149
|
+
"""Coerce a raw string from a widget into a typed value.
|
|
150
|
+
|
|
151
|
+
Returns :data:`SKIP` if the value is empty/should be omitted.
|
|
152
|
+
"""
|
|
153
|
+
if not raw:
|
|
154
|
+
return SKIP
|
|
155
|
+
return coerce_field_value(raw, desc)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def collect_json_value(raw: str) -> Any:
|
|
159
|
+
"""Parse a raw JSON string. Returns :data:`SKIP` if empty."""
|
|
160
|
+
if not raw:
|
|
161
|
+
return SKIP
|
|
162
|
+
try:
|
|
163
|
+
return json.loads(raw)
|
|
164
|
+
except (ValueError, TypeError):
|
|
165
|
+
return raw
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def collect_multi_select_value(
|
|
169
|
+
selected: list[str],
|
|
170
|
+
section: str,
|
|
171
|
+
field_name: str,
|
|
172
|
+
) -> Any:
|
|
173
|
+
"""Process a multi-select result. Returns :data:`SKIP` if empty.
|
|
174
|
+
|
|
175
|
+
For task sources, a single-element list is unwrapped to a plain string.
|
|
176
|
+
"""
|
|
177
|
+
if not selected:
|
|
178
|
+
return SKIP
|
|
179
|
+
if len(selected) == 1 and section == "tasks" and field_name == "sources":
|
|
180
|
+
return selected[0]
|
|
181
|
+
return selected
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def collect_bool_value(value: bool, default: Any) -> Any:
|
|
185
|
+
"""Return *value* if it differs from *default*, otherwise :data:`SKIP`."""
|
|
186
|
+
resolved_default = default if isinstance(default, bool) else False
|
|
187
|
+
return value if value != resolved_default else SKIP
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Validation error diagnosis (pure logic)
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def diagnose_collect_failure(
|
|
196
|
+
section: str,
|
|
197
|
+
name: str,
|
|
198
|
+
steps: list[dict[str, Any]],
|
|
199
|
+
descriptors: list[FieldDescriptor],
|
|
200
|
+
result: dict[str, Any] | None,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Return a human-readable error message when ``_collect_raw`` returns None."""
|
|
203
|
+
if not name:
|
|
204
|
+
return "Name is required."
|
|
205
|
+
if section in STEP_BUILDER_SECTIONS and not steps:
|
|
206
|
+
return "Add at least one step."
|
|
207
|
+
disc_field = get_discriminator_field(section)
|
|
208
|
+
if disc_field:
|
|
209
|
+
return f"{disc_field.replace('_', ' ')} is required."
|
|
210
|
+
missing = [d.name for d in descriptors if d.required and d.name not in (result or {})]
|
|
211
|
+
if missing:
|
|
212
|
+
return f"Required: {', '.join(missing)}"
|
|
213
|
+
return "Invalid input."
|