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.

@@ -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
- # Process each field in the nested model
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
- if hasattr(field_info, "default") and field_info.default is not None:
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
- # Handle empty strings for optional fields inside models
380
- if isinstance(item_data, dict):
381
- # Check each subfield for optional type and empty string
382
- for subfield_name, subfield_value in list(item_data.items()):
383
- # Get the corresponding field_info from the item_type
384
- if (
385
- hasattr(item_type, "model_fields")
386
- and subfield_name in item_type.model_fields
387
- ):
388
- subfield_info = item_type.model_fields[subfield_name]
389
- # Convert empty strings to None for optional fields
390
- if subfield_value == "" and _is_optional_type(
391
- subfield_info.annotation
392
- ):
393
- item_data[subfield_name] = None
394
- # Convert 'on' to True for boolean fields
395
- elif subfield_value == "on":
396
- annotation = getattr(subfield_info, "annotation", None)
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]]: