fh-pydantic-form 0.3.9__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.
- fh_pydantic_form/__init__.py +143 -0
- fh_pydantic_form/color_utils.py +598 -0
- fh_pydantic_form/comparison_form.py +1637 -0
- fh_pydantic_form/constants.py +12 -0
- fh_pydantic_form/defaults.py +188 -0
- fh_pydantic_form/field_renderers.py +2330 -0
- fh_pydantic_form/form_parser.py +756 -0
- fh_pydantic_form/form_renderer.py +1004 -0
- fh_pydantic_form/list_path.py +145 -0
- fh_pydantic_form/py.typed +0 -0
- fh_pydantic_form/registry.py +142 -0
- fh_pydantic_form/type_helpers.py +266 -0
- fh_pydantic_form/ui_style.py +115 -0
- fh_pydantic_form-0.3.9.dist-info/METADATA +1168 -0
- fh_pydantic_form-0.3.9.dist-info/RECORD +17 -0
- fh_pydantic_form-0.3.9.dist-info/WHEEL +4 -0
- fh_pydantic_form-0.3.9.dist-info/licenses/LICENSE +13 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import (
|
|
4
|
+
Any,
|
|
5
|
+
Dict,
|
|
6
|
+
List,
|
|
7
|
+
Optional,
|
|
8
|
+
Tuple,
|
|
9
|
+
Union,
|
|
10
|
+
get_args,
|
|
11
|
+
get_origin,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from fh_pydantic_form.type_helpers import (
|
|
15
|
+
_get_underlying_type_if_optional,
|
|
16
|
+
_is_enum_type,
|
|
17
|
+
_is_literal_type,
|
|
18
|
+
_is_optional_type,
|
|
19
|
+
_is_skip_json_schema_field,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _identify_list_fields(model_class) -> Dict[str, Dict[str, Any]]:
|
|
26
|
+
"""
|
|
27
|
+
Identifies list fields in a model and their item types.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
model_class: The Pydantic model class to analyze
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Dictionary mapping field names to their metadata
|
|
34
|
+
"""
|
|
35
|
+
list_fields = {}
|
|
36
|
+
for field_name, field_info in model_class.model_fields.items():
|
|
37
|
+
annotation = getattr(field_info, "annotation", None)
|
|
38
|
+
if annotation is not None:
|
|
39
|
+
# Handle Optional[List[...]] by unwrapping the Optional
|
|
40
|
+
base_ann = _get_underlying_type_if_optional(annotation)
|
|
41
|
+
if get_origin(base_ann) is list:
|
|
42
|
+
item_type = get_args(base_ann)[0]
|
|
43
|
+
list_fields[field_name] = {
|
|
44
|
+
"item_type": item_type,
|
|
45
|
+
"is_model_type": hasattr(item_type, "model_fields"),
|
|
46
|
+
"field_info": field_info, # Store for later use if needed
|
|
47
|
+
}
|
|
48
|
+
return list_fields
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_non_list_fields(
|
|
52
|
+
form_data: Dict[str, Any],
|
|
53
|
+
model_class,
|
|
54
|
+
list_field_defs: Dict[str, Dict[str, Any]],
|
|
55
|
+
base_prefix: str = "",
|
|
56
|
+
exclude_fields: Optional[List[str]] = None,
|
|
57
|
+
keep_skip_json_pathset: Optional[set[str]] = None,
|
|
58
|
+
current_field_path: Optional[List[str]] = None,
|
|
59
|
+
) -> Dict[str, Any]:
|
|
60
|
+
"""
|
|
61
|
+
Parses non-list fields from form data based on the model definition.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
form_data: Dictionary containing form field data
|
|
65
|
+
model_class: The Pydantic model class defining the structure
|
|
66
|
+
list_field_defs: Dictionary of list field definitions (to skip)
|
|
67
|
+
base_prefix: Prefix to use when looking up field names in form_data
|
|
68
|
+
exclude_fields: Optional list of field names to exclude from parsing
|
|
69
|
+
keep_skip_json_pathset: Optional set of normalized paths for SkipJsonSchema fields to keep
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Dictionary with parsed non-list fields
|
|
73
|
+
"""
|
|
74
|
+
result: Dict[str, Any] = {}
|
|
75
|
+
exclude_fields = exclude_fields or []
|
|
76
|
+
keep_skip_json_pathset = keep_skip_json_pathset or set()
|
|
77
|
+
|
|
78
|
+
# Helper function to check if a SkipJsonSchema field should be kept
|
|
79
|
+
def _should_keep_skip_field(path_segments: List[str]) -> bool:
|
|
80
|
+
from fh_pydantic_form.type_helpers import normalize_path_segments
|
|
81
|
+
|
|
82
|
+
normalized = normalize_path_segments(path_segments)
|
|
83
|
+
return bool(normalized) and normalized in keep_skip_json_pathset
|
|
84
|
+
|
|
85
|
+
# Calculate the current path context for fields at this level
|
|
86
|
+
# For top-level parsing, this will be empty
|
|
87
|
+
# For nested parsing, this will contain the nested path segments
|
|
88
|
+
current_path_segments: List[str] = []
|
|
89
|
+
if current_field_path is not None:
|
|
90
|
+
# Use explicitly passed field path
|
|
91
|
+
current_path_segments = current_field_path
|
|
92
|
+
# For top-level parsing (base_prefix is just form name), current_path_segments remains empty
|
|
93
|
+
|
|
94
|
+
for field_name, field_info in model_class.model_fields.items():
|
|
95
|
+
if field_name in list_field_defs:
|
|
96
|
+
continue # Skip list fields, handled separately
|
|
97
|
+
|
|
98
|
+
# Skip excluded fields - they will be handled by default injection later
|
|
99
|
+
if field_name in exclude_fields:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Skip SkipJsonSchema fields unless they're explicitly kept
|
|
103
|
+
if _is_skip_json_schema_field(field_info):
|
|
104
|
+
field_path_segments = current_path_segments + [field_name]
|
|
105
|
+
if not _should_keep_skip_field(field_path_segments):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Create full key with prefix
|
|
109
|
+
full_key = f"{base_prefix}{field_name}"
|
|
110
|
+
|
|
111
|
+
annotation = getattr(field_info, "annotation", None)
|
|
112
|
+
|
|
113
|
+
# Handle boolean fields (including Optional[bool])
|
|
114
|
+
if annotation is bool or (
|
|
115
|
+
_is_optional_type(annotation)
|
|
116
|
+
and _get_underlying_type_if_optional(annotation) is bool
|
|
117
|
+
):
|
|
118
|
+
result[field_name] = _parse_boolean_field(full_key, form_data)
|
|
119
|
+
|
|
120
|
+
# Handle Literal fields (including Optional[Literal[...]])
|
|
121
|
+
elif _is_literal_type(annotation):
|
|
122
|
+
if full_key in form_data: # User sent it
|
|
123
|
+
result[field_name] = _parse_literal_field(
|
|
124
|
+
full_key, form_data, field_info
|
|
125
|
+
)
|
|
126
|
+
elif _is_optional_type(annotation): # Optional but omitted
|
|
127
|
+
result[field_name] = None
|
|
128
|
+
# otherwise leave the key out – defaults will be injected later
|
|
129
|
+
|
|
130
|
+
# Handle Enum fields (including Optional[Enum])
|
|
131
|
+
elif _is_enum_type(annotation):
|
|
132
|
+
if full_key in form_data: # User sent it
|
|
133
|
+
result[field_name] = _parse_enum_field(full_key, form_data, field_info)
|
|
134
|
+
elif _is_optional_type(annotation): # Optional but omitted
|
|
135
|
+
result[field_name] = None
|
|
136
|
+
# otherwise leave the key out – defaults will be injected later
|
|
137
|
+
|
|
138
|
+
# Handle nested model fields (including Optional[NestedModel])
|
|
139
|
+
elif (
|
|
140
|
+
isinstance(annotation, type)
|
|
141
|
+
and hasattr(annotation, "model_fields")
|
|
142
|
+
or (
|
|
143
|
+
_is_optional_type(annotation)
|
|
144
|
+
and isinstance(_get_underlying_type_if_optional(annotation), type)
|
|
145
|
+
and hasattr(
|
|
146
|
+
_get_underlying_type_if_optional(annotation), "model_fields"
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
):
|
|
150
|
+
# Get the nested model class (unwrap Optional if needed)
|
|
151
|
+
nested_model_class = _get_underlying_type_if_optional(annotation)
|
|
152
|
+
|
|
153
|
+
# Parse the nested model - pass the base_prefix, exclude_fields, and keep paths
|
|
154
|
+
nested_field_path = current_path_segments + [field_name]
|
|
155
|
+
nested_value = _parse_nested_model_field(
|
|
156
|
+
field_name,
|
|
157
|
+
form_data,
|
|
158
|
+
nested_model_class,
|
|
159
|
+
field_info,
|
|
160
|
+
base_prefix,
|
|
161
|
+
exclude_fields,
|
|
162
|
+
keep_skip_json_pathset,
|
|
163
|
+
nested_field_path,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Only assign if we got a non-None value or the field is not optional
|
|
167
|
+
if nested_value is not None:
|
|
168
|
+
result[field_name] = nested_value
|
|
169
|
+
elif _is_optional_type(annotation):
|
|
170
|
+
# Explicitly set None for optional nested models
|
|
171
|
+
result[field_name] = None
|
|
172
|
+
|
|
173
|
+
# Handle simple fields
|
|
174
|
+
else:
|
|
175
|
+
if full_key in form_data: # User sent it
|
|
176
|
+
result[field_name] = _parse_simple_field(
|
|
177
|
+
full_key, form_data, field_info
|
|
178
|
+
)
|
|
179
|
+
elif _is_optional_type(annotation): # Optional but omitted
|
|
180
|
+
result[field_name] = None
|
|
181
|
+
# otherwise leave the key out – defaults will be injected later
|
|
182
|
+
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_boolean_field(field_name: str, form_data: Dict[str, Any]) -> bool:
|
|
187
|
+
"""
|
|
188
|
+
Parse a boolean field from form data.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
field_name: Name of the field to parse
|
|
192
|
+
form_data: Dictionary containing form field data
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Boolean value - True if field name exists in form_data, False otherwise
|
|
196
|
+
"""
|
|
197
|
+
return field_name in form_data
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _parse_literal_field(field_name: str, form_data: Dict[str, Any], field_info) -> Any:
|
|
201
|
+
"""
|
|
202
|
+
Parse a Literal field, converting empty string OR '-- None --' to None for optional fields.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
field_name: Name of the field to parse
|
|
206
|
+
form_data: Dictionary containing form field data
|
|
207
|
+
field_info: FieldInfo object to check for optionality
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
The parsed value or None for empty/None values with optional fields
|
|
211
|
+
"""
|
|
212
|
+
value = form_data.get(field_name)
|
|
213
|
+
|
|
214
|
+
# Check if the field is Optional[Literal[...]]
|
|
215
|
+
if _is_optional_type(field_info.annotation):
|
|
216
|
+
# If the submitted value is the empty string OR the display text for None, treat it as None
|
|
217
|
+
if value == "" or value == "-- None --":
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
# Return the actual submitted value (string) for Pydantic validation
|
|
221
|
+
return value
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _parse_enum_field(field_name: str, form_data: Dict[str, Any], field_info) -> Any:
|
|
225
|
+
"""
|
|
226
|
+
Parse an Enum field, converting empty string OR '-- None --' to None for optional fields.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
field_name: Name of the field to parse
|
|
230
|
+
form_data: Dictionary containing form field data
|
|
231
|
+
field_info: FieldInfo object to check for optionality
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The parsed value or None for empty/None values with optional fields
|
|
235
|
+
"""
|
|
236
|
+
value = form_data.get(field_name)
|
|
237
|
+
|
|
238
|
+
# Check if the field is Optional[Enum]
|
|
239
|
+
if _is_optional_type(field_info.annotation):
|
|
240
|
+
# If the submitted value is the empty string OR the display text for None, treat it as None
|
|
241
|
+
if value == "" or value == "-- None --":
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
enum_cls = _get_underlying_type_if_optional(field_info.annotation)
|
|
245
|
+
if isinstance(enum_cls, type) and issubclass(enum_cls, Enum) and value is not None:
|
|
246
|
+
try:
|
|
247
|
+
first = next(iter(enum_cls))
|
|
248
|
+
# Handle integer enums - convert string to int
|
|
249
|
+
if isinstance(first.value, int):
|
|
250
|
+
try:
|
|
251
|
+
value = int(value)
|
|
252
|
+
except (TypeError, ValueError):
|
|
253
|
+
# leave it as-is; pydantic will raise if really invalid
|
|
254
|
+
pass
|
|
255
|
+
# Handle string enums - keep the value as-is, let Pydantic handle validation
|
|
256
|
+
elif isinstance(first.value, str):
|
|
257
|
+
# Keep the submitted value unchanged for string enums
|
|
258
|
+
pass
|
|
259
|
+
except StopIteration:
|
|
260
|
+
# Empty enum, leave value as-is
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
# Return the actual submitted value for Pydantic validation
|
|
264
|
+
return value
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _parse_simple_field(
|
|
268
|
+
field_name: str, form_data: Dict[str, Any], field_info=None
|
|
269
|
+
) -> Any:
|
|
270
|
+
"""
|
|
271
|
+
Parse a simple field (string, number, etc.) from form data.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
field_name: Name of the field to parse
|
|
275
|
+
form_data: Dictionary containing form field data
|
|
276
|
+
field_info: Optional FieldInfo object to check for optionality
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Value of the field or None if not found
|
|
280
|
+
"""
|
|
281
|
+
if field_name in form_data:
|
|
282
|
+
value = form_data[field_name]
|
|
283
|
+
|
|
284
|
+
# Handle empty strings for optional fields
|
|
285
|
+
if value == "" and field_info and _is_optional_type(field_info.annotation):
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
return value
|
|
289
|
+
|
|
290
|
+
# If field is optional and not in form_data, return None
|
|
291
|
+
if field_info and _is_optional_type(field_info.annotation):
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _parse_nested_model_field(
|
|
298
|
+
field_name: str,
|
|
299
|
+
form_data: Dict[str, Any],
|
|
300
|
+
nested_model_class,
|
|
301
|
+
field_info,
|
|
302
|
+
parent_prefix: str = "",
|
|
303
|
+
exclude_fields: Optional[List[str]] = None,
|
|
304
|
+
keep_skip_json_pathset: Optional[set[str]] = None,
|
|
305
|
+
current_field_path: Optional[List[str]] = None,
|
|
306
|
+
) -> Optional[Dict[str, Any]]:
|
|
307
|
+
"""
|
|
308
|
+
Parse a nested Pydantic model field from form data.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
field_name: Name of the field to parse
|
|
312
|
+
form_data: Dictionary containing form field data
|
|
313
|
+
nested_model_class: The nested model class
|
|
314
|
+
field_info: The field info from the parent model
|
|
315
|
+
parent_prefix: Prefix from parent form/model to use when constructing keys
|
|
316
|
+
exclude_fields: Optional list of field names to exclude from parsing
|
|
317
|
+
keep_skip_json_pathset: Optional set of normalized paths for SkipJsonSchema fields to keep
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Dictionary with nested model structure or None/default if no data found
|
|
321
|
+
"""
|
|
322
|
+
# Construct the full prefix for this nested model's fields
|
|
323
|
+
current_prefix = f"{parent_prefix}{field_name}_"
|
|
324
|
+
nested_data: Dict[str, Optional[Any]] = {}
|
|
325
|
+
found_any_subfield = False
|
|
326
|
+
|
|
327
|
+
# Check if any keys match this prefix
|
|
328
|
+
for key in form_data:
|
|
329
|
+
if key.startswith(current_prefix):
|
|
330
|
+
found_any_subfield = True
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
if found_any_subfield:
|
|
334
|
+
# Helper function to check if a SkipJsonSchema field should be kept
|
|
335
|
+
def _should_keep_skip_field_nested(path_segments: List[str]) -> bool:
|
|
336
|
+
from fh_pydantic_form.type_helpers import normalize_path_segments
|
|
337
|
+
|
|
338
|
+
normalized = normalize_path_segments(path_segments)
|
|
339
|
+
return bool(normalized) and normalized in (keep_skip_json_pathset or set())
|
|
340
|
+
|
|
341
|
+
# Use the passed field path for calculating nested paths
|
|
342
|
+
nested_path_segments: List[str] = current_field_path or []
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
# 1. Process each **non-list** field in the nested model
|
|
346
|
+
# ------------------------------------------------------------------
|
|
347
|
+
for sub_field_name, sub_field_info in nested_model_class.model_fields.items():
|
|
348
|
+
sub_key = f"{current_prefix}{sub_field_name}"
|
|
349
|
+
annotation = getattr(sub_field_info, "annotation", None)
|
|
350
|
+
|
|
351
|
+
# Skip SkipJsonSchema fields unless they're explicitly kept
|
|
352
|
+
if _is_skip_json_schema_field(sub_field_info):
|
|
353
|
+
sub_field_path_segments = nested_path_segments + [sub_field_name]
|
|
354
|
+
if not _should_keep_skip_field_nested(sub_field_path_segments):
|
|
355
|
+
logger.debug(
|
|
356
|
+
f"Skipping SkipJsonSchema field in nested model during parsing: {sub_field_name}"
|
|
357
|
+
)
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
# Handle based on field type, with Optional unwrapping
|
|
361
|
+
is_optional = _is_optional_type(annotation)
|
|
362
|
+
base_type = _get_underlying_type_if_optional(annotation)
|
|
363
|
+
|
|
364
|
+
# Handle boolean fields (including Optional[bool])
|
|
365
|
+
if annotation is bool or (is_optional and base_type is bool):
|
|
366
|
+
nested_data[sub_field_name] = _parse_boolean_field(sub_key, form_data)
|
|
367
|
+
|
|
368
|
+
# Handle nested model fields (including Optional[NestedModel])
|
|
369
|
+
elif isinstance(base_type, type) and hasattr(base_type, "model_fields"):
|
|
370
|
+
# Pass the current_prefix and keep paths to the recursive call
|
|
371
|
+
sub_field_path = nested_path_segments + [sub_field_name]
|
|
372
|
+
sub_value = _parse_nested_model_field(
|
|
373
|
+
sub_field_name,
|
|
374
|
+
form_data,
|
|
375
|
+
base_type,
|
|
376
|
+
sub_field_info,
|
|
377
|
+
current_prefix,
|
|
378
|
+
exclude_fields,
|
|
379
|
+
keep_skip_json_pathset,
|
|
380
|
+
sub_field_path,
|
|
381
|
+
)
|
|
382
|
+
if sub_value is not None:
|
|
383
|
+
nested_data[sub_field_name] = sub_value
|
|
384
|
+
elif is_optional:
|
|
385
|
+
nested_data[sub_field_name] = None
|
|
386
|
+
|
|
387
|
+
# Handle simple fields, including empty string to None conversion for Optional fields
|
|
388
|
+
elif sub_key in form_data:
|
|
389
|
+
value = form_data[sub_key]
|
|
390
|
+
if value == "" and is_optional:
|
|
391
|
+
nested_data[sub_field_name] = None
|
|
392
|
+
else:
|
|
393
|
+
nested_data[sub_field_name] = value
|
|
394
|
+
|
|
395
|
+
# Handle missing optional fields
|
|
396
|
+
elif is_optional:
|
|
397
|
+
nested_data[sub_field_name] = None
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
# 2. Handle **list fields** inside this nested model (e.g. Address.tags)
|
|
401
|
+
# Re-use the generic helpers so behaviour matches top-level lists.
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
nested_list_defs = _identify_list_fields(nested_model_class)
|
|
404
|
+
if nested_list_defs:
|
|
405
|
+
list_results = _parse_list_fields(
|
|
406
|
+
form_data,
|
|
407
|
+
nested_list_defs,
|
|
408
|
+
current_prefix, # ← prefix for this nested model
|
|
409
|
+
exclude_fields, # Pass through exclude_fields
|
|
410
|
+
keep_skip_json_pathset,
|
|
411
|
+
)
|
|
412
|
+
# Merge without clobbering keys already set in step 1
|
|
413
|
+
for lf_name, lf_val in list_results.items():
|
|
414
|
+
if lf_name not in nested_data:
|
|
415
|
+
nested_data[lf_name] = lf_val
|
|
416
|
+
|
|
417
|
+
return nested_data
|
|
418
|
+
|
|
419
|
+
# No data found for this nested model
|
|
420
|
+
logger.debug(
|
|
421
|
+
f"No form data found for nested model field: {field_name} with prefix: {current_prefix}"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
is_field_optional = _is_optional_type(field_info.annotation)
|
|
425
|
+
|
|
426
|
+
# If the field is optional, return None
|
|
427
|
+
if is_field_optional:
|
|
428
|
+
logger.debug(
|
|
429
|
+
f"Nested field {field_name} is optional and no data found, returning None."
|
|
430
|
+
)
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
# If not optional, try to use default or default_factory
|
|
434
|
+
default_value = None
|
|
435
|
+
default_applied = False
|
|
436
|
+
|
|
437
|
+
# Import PydanticUndefined to check for it specifically
|
|
438
|
+
try:
|
|
439
|
+
from pydantic_core import PydanticUndefined
|
|
440
|
+
except ImportError:
|
|
441
|
+
# Fallback for older pydantic versions
|
|
442
|
+
from pydantic.fields import PydanticUndefined
|
|
443
|
+
|
|
444
|
+
if (
|
|
445
|
+
hasattr(field_info, "default")
|
|
446
|
+
and field_info.default is not None
|
|
447
|
+
and field_info.default is not PydanticUndefined
|
|
448
|
+
):
|
|
449
|
+
default_value = field_info.default
|
|
450
|
+
default_applied = True
|
|
451
|
+
elif (
|
|
452
|
+
hasattr(field_info, "default_factory")
|
|
453
|
+
and field_info.default_factory is not None
|
|
454
|
+
and field_info.default_factory is not PydanticUndefined
|
|
455
|
+
):
|
|
456
|
+
try:
|
|
457
|
+
default_value = field_info.default_factory()
|
|
458
|
+
default_applied = True
|
|
459
|
+
except Exception as e:
|
|
460
|
+
logger.warning(
|
|
461
|
+
f"Error creating default for {field_name} using default_factory: {e}"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
if default_applied:
|
|
465
|
+
if default_value is not None and hasattr(default_value, "model_dump"):
|
|
466
|
+
return default_value.model_dump()
|
|
467
|
+
elif isinstance(default_value, dict):
|
|
468
|
+
return default_value
|
|
469
|
+
else:
|
|
470
|
+
# Handle cases where default might not be a model/dict (unlikely for nested model)
|
|
471
|
+
logger.warning(
|
|
472
|
+
f"Default value for nested field {field_name} is not a model or dict: {type(default_value)}"
|
|
473
|
+
)
|
|
474
|
+
# Don't return PydanticUndefined or other non-dict values directly
|
|
475
|
+
# Fall through to empty dict return instead
|
|
476
|
+
|
|
477
|
+
# If not optional, no data found, and no default applicable, always return an empty dict
|
|
478
|
+
# This ensures the test_parse_nested_model_field passes and allows Pydantic to validate
|
|
479
|
+
# if the nested model can be created from empty data
|
|
480
|
+
logger.debug(
|
|
481
|
+
f"Nested field {field_name} is required, no data/default found, returning empty dict {{}}."
|
|
482
|
+
)
|
|
483
|
+
return {}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _parse_list_fields(
|
|
487
|
+
form_data: Dict[str, Any],
|
|
488
|
+
list_field_defs: Dict[str, Dict[str, Any]],
|
|
489
|
+
base_prefix: str = "",
|
|
490
|
+
exclude_fields: Optional[List[str]] = None,
|
|
491
|
+
keep_skip_json_pathset: Optional[set[str]] = None,
|
|
492
|
+
) -> Dict[str, Optional[List[Any]]]:
|
|
493
|
+
"""
|
|
494
|
+
Parse list fields from form data by analyzing keys and reconstructing ordered lists.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
form_data: Dictionary containing form field data
|
|
498
|
+
list_field_defs: Dictionary of list field definitions
|
|
499
|
+
base_prefix: Prefix to use when looking up field names in form_data
|
|
500
|
+
exclude_fields: Optional list of field names to exclude from parsing
|
|
501
|
+
keep_skip_json_pathset: Optional set of normalized paths for SkipJsonSchema fields to keep
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
Dictionary with parsed list fields
|
|
505
|
+
"""
|
|
506
|
+
exclude_fields = exclude_fields or []
|
|
507
|
+
|
|
508
|
+
# Skip if no list fields defined
|
|
509
|
+
if not list_field_defs:
|
|
510
|
+
return {}
|
|
511
|
+
|
|
512
|
+
# Temporary storage: { list_field_name: { idx_str: item_data } }
|
|
513
|
+
list_items_temp: Dict[str, Dict[str, Union[Dict[str, Any], Any]]] = {
|
|
514
|
+
field_name: {} for field_name in list_field_defs
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# Order tracking: { list_field_name: [idx_str1, idx_str2, ...] }
|
|
518
|
+
list_item_indices_ordered: Dict[str, List[str]] = {
|
|
519
|
+
field_name: [] for field_name in list_field_defs
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
# Process all form keys that might belong to list fields
|
|
523
|
+
for key, value in form_data.items():
|
|
524
|
+
parse_result = _parse_list_item_key(key, list_field_defs, base_prefix)
|
|
525
|
+
if not parse_result:
|
|
526
|
+
continue # Key doesn't belong to a known list field
|
|
527
|
+
|
|
528
|
+
field_name, idx_str, subfield, is_simple_list = parse_result
|
|
529
|
+
|
|
530
|
+
# Track order if seeing this index for the first time for this field
|
|
531
|
+
if idx_str not in list_items_temp[field_name]:
|
|
532
|
+
list_item_indices_ordered[field_name].append(idx_str)
|
|
533
|
+
# Initialize storage for this item index
|
|
534
|
+
list_items_temp[field_name][idx_str] = {} if not is_simple_list else None
|
|
535
|
+
|
|
536
|
+
# Store the value
|
|
537
|
+
if is_simple_list:
|
|
538
|
+
list_items_temp[field_name][idx_str] = value
|
|
539
|
+
else:
|
|
540
|
+
# It's a model list item, store subfield value
|
|
541
|
+
if subfield: # Should always have a subfield for model list items
|
|
542
|
+
list_items_temp[field_name][idx_str][subfield] = value
|
|
543
|
+
|
|
544
|
+
# Build final lists based on tracked order
|
|
545
|
+
final_lists: Dict[str, Optional[List[Any]]] = {}
|
|
546
|
+
for field_name, ordered_indices in list_item_indices_ordered.items():
|
|
547
|
+
field_def = list_field_defs[field_name]
|
|
548
|
+
item_type = field_def["item_type"]
|
|
549
|
+
|
|
550
|
+
items = []
|
|
551
|
+
for idx_str in ordered_indices:
|
|
552
|
+
# ------------------------------------------------------------------
|
|
553
|
+
# If this list stores *BaseModel* items, completely re-parse the item
|
|
554
|
+
# so that any inner lists (e.g. tags inside Address) become real lists
|
|
555
|
+
# instead of a bunch of 'tags_0', 'tags_new_xxx' flat entries.
|
|
556
|
+
# ------------------------------------------------------------------
|
|
557
|
+
if field_def["is_model_type"]:
|
|
558
|
+
item_prefix = f"{base_prefix}{field_name}_{idx_str}_"
|
|
559
|
+
# For list items, the field path is the list field name (without index)
|
|
560
|
+
item_field_path = [field_name]
|
|
561
|
+
parsed_item = _parse_model_list_item(
|
|
562
|
+
form_data,
|
|
563
|
+
item_type,
|
|
564
|
+
item_prefix,
|
|
565
|
+
keep_skip_json_pathset,
|
|
566
|
+
item_field_path,
|
|
567
|
+
)
|
|
568
|
+
items.append(parsed_item)
|
|
569
|
+
continue
|
|
570
|
+
|
|
571
|
+
# ───────── simple (non-model) items – keep existing logic ──────────
|
|
572
|
+
item_data = list_items_temp[field_name][idx_str]
|
|
573
|
+
|
|
574
|
+
# Convert string to int for integer-valued enums in simple lists
|
|
575
|
+
if (
|
|
576
|
+
isinstance(item_type, type)
|
|
577
|
+
and issubclass(item_type, Enum)
|
|
578
|
+
and isinstance(item_data, str)
|
|
579
|
+
):
|
|
580
|
+
try:
|
|
581
|
+
first = next(iter(item_type))
|
|
582
|
+
if isinstance(first.value, int):
|
|
583
|
+
try:
|
|
584
|
+
item_data = int(item_data)
|
|
585
|
+
except (TypeError, ValueError):
|
|
586
|
+
# leave it as-is; pydantic will raise if really invalid
|
|
587
|
+
pass
|
|
588
|
+
except StopIteration:
|
|
589
|
+
# Empty enum, leave item_data as-is
|
|
590
|
+
pass
|
|
591
|
+
|
|
592
|
+
items.append(item_data)
|
|
593
|
+
|
|
594
|
+
if items: # Only add if items were found
|
|
595
|
+
final_lists[field_name] = items
|
|
596
|
+
|
|
597
|
+
# Ensure every rendered list field appears in final_lists
|
|
598
|
+
for field_name, field_def in list_field_defs.items():
|
|
599
|
+
# Skip list fields the UI never showed (those in exclude_fields)
|
|
600
|
+
if field_name in exclude_fields:
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
# When user supplied ≥1 item we already captured it
|
|
604
|
+
if field_name in final_lists:
|
|
605
|
+
continue
|
|
606
|
+
|
|
607
|
+
# User submitted form with zero items → honour intent with None for Optional[List]
|
|
608
|
+
field_info = field_def["field_info"]
|
|
609
|
+
if _is_optional_type(field_info.annotation):
|
|
610
|
+
final_lists[field_name] = None # Use None for empty Optional[List]
|
|
611
|
+
else:
|
|
612
|
+
final_lists[field_name] = [] # Regular empty list for required fields
|
|
613
|
+
|
|
614
|
+
return final_lists
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _parse_model_list_item(
|
|
618
|
+
form_data: Dict[str, Any],
|
|
619
|
+
item_type,
|
|
620
|
+
item_prefix: str,
|
|
621
|
+
keep_skip_json_pathset: Optional[set[str]] = None,
|
|
622
|
+
current_field_path: Optional[List[str]] = None,
|
|
623
|
+
) -> Dict[str, Any]:
|
|
624
|
+
"""
|
|
625
|
+
Fully parse a single BaseModel list item – including its own nested lists.
|
|
626
|
+
|
|
627
|
+
Re-uses the existing non-list and list helpers so we don't duplicate logic.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
form_data: Dictionary containing form field data
|
|
631
|
+
item_type: The BaseModel class for this list item
|
|
632
|
+
item_prefix: Prefix for this specific list item (e.g., "main_form_compact_other_addresses_0_")
|
|
633
|
+
keep_skip_json_pathset: Optional set of normalized paths for SkipJsonSchema fields to keep
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
Dictionary with fully parsed item data including nested lists
|
|
637
|
+
"""
|
|
638
|
+
nested_list_defs = _identify_list_fields(item_type)
|
|
639
|
+
# 1. Parse scalars & nested models
|
|
640
|
+
result = _parse_non_list_fields(
|
|
641
|
+
form_data,
|
|
642
|
+
item_type,
|
|
643
|
+
nested_list_defs,
|
|
644
|
+
base_prefix=item_prefix,
|
|
645
|
+
exclude_fields=[],
|
|
646
|
+
keep_skip_json_pathset=keep_skip_json_pathset,
|
|
647
|
+
current_field_path=current_field_path,
|
|
648
|
+
)
|
|
649
|
+
# 2. Parse inner lists
|
|
650
|
+
result.update(
|
|
651
|
+
_parse_list_fields(
|
|
652
|
+
form_data,
|
|
653
|
+
nested_list_defs,
|
|
654
|
+
base_prefix=item_prefix,
|
|
655
|
+
exclude_fields=[],
|
|
656
|
+
keep_skip_json_pathset=keep_skip_json_pathset,
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
return result
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _parse_list_item_key(
|
|
663
|
+
key: str, list_field_defs: Dict[str, Dict[str, Any]], base_prefix: str = ""
|
|
664
|
+
) -> Optional[Tuple[str, str, Optional[str], bool]]:
|
|
665
|
+
"""
|
|
666
|
+
Parse a form key that might represent a list item.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
key: Form field key to parse
|
|
670
|
+
list_field_defs: Dictionary of list field definitions
|
|
671
|
+
base_prefix: Prefix to use when looking up field names in form_data
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
Tuple of (field_name, idx_str, subfield, is_simple_list) if key is for a list item,
|
|
675
|
+
None otherwise
|
|
676
|
+
"""
|
|
677
|
+
# Check if key starts with any of our list field names with underscore
|
|
678
|
+
for field_name, field_def in list_field_defs.items():
|
|
679
|
+
full_prefix = f"{base_prefix}{field_name}_"
|
|
680
|
+
if key.startswith(full_prefix):
|
|
681
|
+
remaining = key[len(full_prefix) :]
|
|
682
|
+
is_model_type = field_def["is_model_type"]
|
|
683
|
+
|
|
684
|
+
# Handle key format based on whether it's a model list or simple list
|
|
685
|
+
if is_model_type:
|
|
686
|
+
# Complex model field: field_name_idx_subfield
|
|
687
|
+
try:
|
|
688
|
+
if "_" not in remaining:
|
|
689
|
+
# Invalid format for model list item
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
# Special handling for "new_" prefix
|
|
693
|
+
if remaining.startswith("new_"):
|
|
694
|
+
# Format is "new_timestamp_subfield"
|
|
695
|
+
parts = remaining.split("_")
|
|
696
|
+
if len(parts) >= 3: # "new", "timestamp", "subfield"
|
|
697
|
+
idx_str = f"{parts[0]}_{parts[1]}" # "new_timestamp"
|
|
698
|
+
subfield = "_".join(
|
|
699
|
+
parts[2:]
|
|
700
|
+
) # "subfield" (or "subfield_with_underscores")
|
|
701
|
+
|
|
702
|
+
# Validate timestamp part is numeric
|
|
703
|
+
timestamp_part = parts[1]
|
|
704
|
+
if not timestamp_part.isdigit():
|
|
705
|
+
continue
|
|
706
|
+
|
|
707
|
+
return (
|
|
708
|
+
field_name,
|
|
709
|
+
idx_str,
|
|
710
|
+
subfield,
|
|
711
|
+
False,
|
|
712
|
+
) # Not a simple list
|
|
713
|
+
else:
|
|
714
|
+
continue
|
|
715
|
+
else:
|
|
716
|
+
# Regular numeric index format: "123_subfield"
|
|
717
|
+
idx_part, subfield = remaining.split("_", 1)
|
|
718
|
+
|
|
719
|
+
# Validate index is numeric
|
|
720
|
+
if not idx_part.isdigit():
|
|
721
|
+
continue
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
field_name,
|
|
725
|
+
idx_part,
|
|
726
|
+
subfield,
|
|
727
|
+
False,
|
|
728
|
+
) # Not a simple list
|
|
729
|
+
|
|
730
|
+
except Exception:
|
|
731
|
+
continue
|
|
732
|
+
else:
|
|
733
|
+
# Simple list: field_name_idx
|
|
734
|
+
try:
|
|
735
|
+
# For simple types, the entire remaining part is the index
|
|
736
|
+
idx_str = remaining
|
|
737
|
+
|
|
738
|
+
# Validate index format - either numeric or "new_timestamp"
|
|
739
|
+
if idx_str.isdigit():
|
|
740
|
+
# Regular numeric index
|
|
741
|
+
pass
|
|
742
|
+
elif idx_str.startswith("new_"):
|
|
743
|
+
# New item with timestamp - validate timestamp part is numeric
|
|
744
|
+
timestamp_part = idx_str[4:] # Skip "new_" prefix
|
|
745
|
+
if not timestamp_part.isdigit():
|
|
746
|
+
continue
|
|
747
|
+
else:
|
|
748
|
+
continue
|
|
749
|
+
|
|
750
|
+
return field_name, idx_str, None, True # Simple list
|
|
751
|
+
|
|
752
|
+
except Exception:
|
|
753
|
+
continue
|
|
754
|
+
|
|
755
|
+
# Not a list field key
|
|
756
|
+
return None
|