fh-pydantic-form 0.1.3__py3-none-any.whl → 0.2.1__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.
Potentially problematic release.
This version of fh-pydantic-form might be problematic. Click here for more details.
- fh_pydantic_form/__init__.py +38 -3
- fh_pydantic_form/defaults.py +160 -0
- fh_pydantic_form/field_renderers.py +565 -215
- fh_pydantic_form/form_parser.py +151 -43
- fh_pydantic_form/form_renderer.py +321 -161
- fh_pydantic_form/list_path.py +145 -0
- fh_pydantic_form/type_helpers.py +108 -1
- fh_pydantic_form/ui_style.py +134 -0
- fh_pydantic_form-0.2.1.dist-info/METADATA +675 -0
- fh_pydantic_form-0.2.1.dist-info/RECORD +14 -0
- fh_pydantic_form-0.1.3.dist-info/METADATA +0 -327
- fh_pydantic_form-0.1.3.dist-info/RECORD +0 -11
- {fh_pydantic_form-0.1.3.dist-info → fh_pydantic_form-0.2.1.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.1.3.dist-info → fh_pydantic_form-0.2.1.dist-info}/licenses/LICENSE +0 -0
fh_pydantic_form/form_parser.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from enum import Enum
|
|
2
3
|
from typing import (
|
|
3
4
|
Any,
|
|
4
5
|
Dict,
|
|
@@ -10,6 +11,7 @@ from typing import (
|
|
|
10
11
|
|
|
11
12
|
from fh_pydantic_form.type_helpers import (
|
|
12
13
|
_get_underlying_type_if_optional,
|
|
14
|
+
_is_enum_type,
|
|
13
15
|
_is_literal_type,
|
|
14
16
|
_is_optional_type,
|
|
15
17
|
)
|
|
@@ -49,6 +51,7 @@ def _parse_non_list_fields(
|
|
|
49
51
|
model_class,
|
|
50
52
|
list_field_defs: Dict[str, Dict[str, Any]],
|
|
51
53
|
base_prefix: str = "",
|
|
54
|
+
exclude_fields: Optional[List[str]] = None,
|
|
52
55
|
) -> Dict[str, Any]:
|
|
53
56
|
"""
|
|
54
57
|
Parses non-list fields from form data based on the model definition.
|
|
@@ -58,16 +61,22 @@ def _parse_non_list_fields(
|
|
|
58
61
|
model_class: The Pydantic model class defining the structure
|
|
59
62
|
list_field_defs: Dictionary of list field definitions (to skip)
|
|
60
63
|
base_prefix: Prefix to use when looking up field names in form_data
|
|
64
|
+
exclude_fields: Optional list of field names to exclude from parsing
|
|
61
65
|
|
|
62
66
|
Returns:
|
|
63
67
|
Dictionary with parsed non-list fields
|
|
64
68
|
"""
|
|
65
69
|
result: Dict[str, Any] = {}
|
|
70
|
+
exclude_fields = exclude_fields or []
|
|
66
71
|
|
|
67
72
|
for field_name, field_info in model_class.model_fields.items():
|
|
68
73
|
if field_name in list_field_defs:
|
|
69
74
|
continue # Skip list fields, handled separately
|
|
70
75
|
|
|
76
|
+
# Skip excluded fields - they will be handled by default injection later
|
|
77
|
+
if field_name in exclude_fields:
|
|
78
|
+
continue
|
|
79
|
+
|
|
71
80
|
# Create full key with prefix
|
|
72
81
|
full_key = f"{base_prefix}{field_name}"
|
|
73
82
|
|
|
@@ -84,6 +93,10 @@ def _parse_non_list_fields(
|
|
|
84
93
|
elif _is_literal_type(annotation):
|
|
85
94
|
result[field_name] = _parse_literal_field(full_key, form_data, field_info)
|
|
86
95
|
|
|
96
|
+
# Handle Enum fields (including Optional[Enum])
|
|
97
|
+
elif _is_enum_type(annotation):
|
|
98
|
+
result[field_name] = _parse_enum_field(full_key, form_data, field_info)
|
|
99
|
+
|
|
87
100
|
# Handle nested model fields (including Optional[NestedModel])
|
|
88
101
|
elif (
|
|
89
102
|
isinstance(annotation, type)
|
|
@@ -157,6 +170,49 @@ def _parse_literal_field(field_name: str, form_data: Dict[str, Any], field_info)
|
|
|
157
170
|
return value
|
|
158
171
|
|
|
159
172
|
|
|
173
|
+
def _parse_enum_field(field_name: str, form_data: Dict[str, Any], field_info) -> Any:
|
|
174
|
+
"""
|
|
175
|
+
Parse an Enum field, converting empty string OR '-- None --' to None for optional fields.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
field_name: Name of the field to parse
|
|
179
|
+
form_data: Dictionary containing form field data
|
|
180
|
+
field_info: FieldInfo object to check for optionality
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The parsed value or None for empty/None values with optional fields
|
|
184
|
+
"""
|
|
185
|
+
value = form_data.get(field_name)
|
|
186
|
+
|
|
187
|
+
# Check if the field is Optional[Enum]
|
|
188
|
+
if _is_optional_type(field_info.annotation):
|
|
189
|
+
# If the submitted value is the empty string OR the display text for None, treat it as None
|
|
190
|
+
if value == "" or value == "-- None --":
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
enum_cls = _get_underlying_type_if_optional(field_info.annotation)
|
|
194
|
+
if isinstance(enum_cls, type) and issubclass(enum_cls, Enum) and value is not None:
|
|
195
|
+
try:
|
|
196
|
+
first = next(iter(enum_cls))
|
|
197
|
+
# Handle integer enums - convert string to int
|
|
198
|
+
if isinstance(first.value, int):
|
|
199
|
+
try:
|
|
200
|
+
value = int(value)
|
|
201
|
+
except (TypeError, ValueError):
|
|
202
|
+
# leave it as-is; pydantic will raise if really invalid
|
|
203
|
+
pass
|
|
204
|
+
# Handle string enums - keep the value as-is, let Pydantic handle validation
|
|
205
|
+
elif isinstance(first.value, str):
|
|
206
|
+
# Keep the submitted value unchanged for string enums
|
|
207
|
+
pass
|
|
208
|
+
except StopIteration:
|
|
209
|
+
# Empty enum, leave value as-is
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
# Return the actual submitted value for Pydantic validation
|
|
213
|
+
return value
|
|
214
|
+
|
|
215
|
+
|
|
160
216
|
def _parse_simple_field(
|
|
161
217
|
field_name: str, form_data: Dict[str, Any], field_info=None
|
|
162
218
|
) -> Any:
|
|
@@ -219,7 +275,9 @@ def _parse_nested_model_field(
|
|
|
219
275
|
break
|
|
220
276
|
|
|
221
277
|
if found_any_subfield:
|
|
222
|
-
#
|
|
278
|
+
# ------------------------------------------------------------------
|
|
279
|
+
# 1. Process each **non-list** field in the nested model
|
|
280
|
+
# ------------------------------------------------------------------
|
|
223
281
|
for sub_field_name, sub_field_info in nested_model_class.model_fields.items():
|
|
224
282
|
sub_key = f"{current_prefix}{sub_field_name}"
|
|
225
283
|
annotation = getattr(sub_field_info, "annotation", None)
|
|
@@ -255,6 +313,22 @@ def _parse_nested_model_field(
|
|
|
255
313
|
elif is_optional:
|
|
256
314
|
nested_data[sub_field_name] = None
|
|
257
315
|
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# 2. Handle **list fields** inside this nested model (e.g. Address.tags)
|
|
318
|
+
# Re-use the generic helpers so behaviour matches top-level lists.
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
nested_list_defs = _identify_list_fields(nested_model_class)
|
|
321
|
+
if nested_list_defs:
|
|
322
|
+
list_results = _parse_list_fields(
|
|
323
|
+
form_data,
|
|
324
|
+
nested_list_defs,
|
|
325
|
+
current_prefix, # ← prefix for this nested model
|
|
326
|
+
)
|
|
327
|
+
# Merge without clobbering keys already set in step 1
|
|
328
|
+
for lf_name, lf_val in list_results.items():
|
|
329
|
+
if lf_name not in nested_data:
|
|
330
|
+
nested_data[lf_name] = lf_val
|
|
331
|
+
|
|
258
332
|
return nested_data
|
|
259
333
|
|
|
260
334
|
# No data found for this nested model
|
|
@@ -275,13 +349,25 @@ def _parse_nested_model_field(
|
|
|
275
349
|
default_value = None
|
|
276
350
|
default_applied = False
|
|
277
351
|
|
|
278
|
-
|
|
352
|
+
# Import PydanticUndefined to check for it specifically
|
|
353
|
+
try:
|
|
354
|
+
from pydantic_core import PydanticUndefined
|
|
355
|
+
except ImportError:
|
|
356
|
+
# Fallback for older pydantic versions
|
|
357
|
+
from pydantic.fields import PydanticUndefined
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
hasattr(field_info, "default")
|
|
361
|
+
and field_info.default is not None
|
|
362
|
+
and field_info.default is not PydanticUndefined
|
|
363
|
+
):
|
|
279
364
|
default_value = field_info.default
|
|
280
365
|
default_applied = True
|
|
281
366
|
logger.debug(f"Nested field {field_name} using default value.")
|
|
282
367
|
elif (
|
|
283
368
|
hasattr(field_info, "default_factory")
|
|
284
369
|
and field_info.default_factory is not None
|
|
370
|
+
and field_info.default_factory is not PydanticUndefined
|
|
285
371
|
):
|
|
286
372
|
try:
|
|
287
373
|
default_value = field_info.default_factory()
|
|
@@ -374,49 +460,38 @@ def _parse_list_fields(
|
|
|
374
460
|
|
|
375
461
|
items = []
|
|
376
462
|
for idx_str in ordered_indices:
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
# If this list stores *BaseModel* items, completely re-parse the item
|
|
465
|
+
# so that any inner lists (e.g. tags inside Address) become real lists
|
|
466
|
+
# instead of a bunch of 'tags_0', 'tags_new_xxx' flat entries.
|
|
467
|
+
# ------------------------------------------------------------------
|
|
468
|
+
if field_def["is_model_type"]:
|
|
469
|
+
item_prefix = f"{base_prefix}{field_name}_{idx_str}_"
|
|
470
|
+
parsed_item = _parse_model_list_item(form_data, item_type, item_prefix)
|
|
471
|
+
items.append(parsed_item)
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
# ───────── simple (non-model) items – keep existing logic ──────────
|
|
377
475
|
item_data = list_items_temp[field_name][idx_str]
|
|
378
476
|
|
|
379
|
-
#
|
|
380
|
-
if
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
):
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
base_type = _get_underlying_type_if_optional(annotation)
|
|
398
|
-
if base_type is bool:
|
|
399
|
-
item_data[subfield_name] = True
|
|
400
|
-
|
|
401
|
-
# Handle missing boolean fields in model list items
|
|
402
|
-
if field_def["is_model_type"] and hasattr(item_type, "model_fields"):
|
|
403
|
-
# Iterate through all model fields to find missing boolean fields
|
|
404
|
-
for (
|
|
405
|
-
model_field_name,
|
|
406
|
-
model_field_info,
|
|
407
|
-
) in item_type.model_fields.items():
|
|
408
|
-
annotation = getattr(model_field_info, "annotation", None)
|
|
409
|
-
base_type = _get_underlying_type_if_optional(annotation)
|
|
410
|
-
is_bool_type = base_type is bool
|
|
411
|
-
|
|
412
|
-
# If it's a boolean field and not in the item_data, set it to False
|
|
413
|
-
if is_bool_type and model_field_name not in item_data:
|
|
414
|
-
logger.info(
|
|
415
|
-
f"Setting missing boolean '{model_field_name}' to False for item in list '{field_name}'"
|
|
416
|
-
)
|
|
417
|
-
item_data[model_field_name] = False
|
|
418
|
-
|
|
419
|
-
# For model types, keep as dict for now
|
|
477
|
+
# Convert string to int for integer-valued enums in simple lists
|
|
478
|
+
if (
|
|
479
|
+
isinstance(item_type, type)
|
|
480
|
+
and issubclass(item_type, Enum)
|
|
481
|
+
and isinstance(item_data, str)
|
|
482
|
+
):
|
|
483
|
+
try:
|
|
484
|
+
first = next(iter(item_type))
|
|
485
|
+
if isinstance(first.value, int):
|
|
486
|
+
try:
|
|
487
|
+
item_data = int(item_data)
|
|
488
|
+
except (TypeError, ValueError):
|
|
489
|
+
# leave it as-is; pydantic will raise if really invalid
|
|
490
|
+
pass
|
|
491
|
+
except StopIteration:
|
|
492
|
+
# Empty enum, leave item_data as-is
|
|
493
|
+
pass
|
|
494
|
+
|
|
420
495
|
items.append(item_data)
|
|
421
496
|
|
|
422
497
|
if items: # Only add if items were found
|
|
@@ -440,6 +515,39 @@ def _parse_list_fields(
|
|
|
440
515
|
return final_lists
|
|
441
516
|
|
|
442
517
|
|
|
518
|
+
def _parse_model_list_item(
|
|
519
|
+
form_data: Dict[str, Any],
|
|
520
|
+
item_type,
|
|
521
|
+
item_prefix: str,
|
|
522
|
+
) -> Dict[str, Any]:
|
|
523
|
+
"""
|
|
524
|
+
Fully parse a single BaseModel list item – including its own nested lists.
|
|
525
|
+
|
|
526
|
+
Re-uses the existing non-list and list helpers so we don't duplicate logic.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
form_data: Dictionary containing form field data
|
|
530
|
+
item_type: The BaseModel class for this list item
|
|
531
|
+
item_prefix: Prefix for this specific list item (e.g., "main_form_compact_other_addresses_0_")
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Dictionary with fully parsed item data including nested lists
|
|
535
|
+
"""
|
|
536
|
+
nested_list_defs = _identify_list_fields(item_type)
|
|
537
|
+
# 1. Parse scalars & nested models
|
|
538
|
+
result = _parse_non_list_fields(
|
|
539
|
+
form_data,
|
|
540
|
+
item_type,
|
|
541
|
+
nested_list_defs,
|
|
542
|
+
base_prefix=item_prefix,
|
|
543
|
+
)
|
|
544
|
+
# 2. Parse inner lists
|
|
545
|
+
result.update(
|
|
546
|
+
_parse_list_fields(form_data, nested_list_defs, base_prefix=item_prefix)
|
|
547
|
+
)
|
|
548
|
+
return result
|
|
549
|
+
|
|
550
|
+
|
|
443
551
|
def _parse_list_item_key(
|
|
444
552
|
key: str, list_field_defs: Dict[str, Dict[str, Any]], base_prefix: str = ""
|
|
445
553
|
) -> Optional[Tuple[str, str, Optional[str], bool]]:
|