fh-pydantic-form 0.1.2__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.

@@ -0,0 +1,537 @@
1
+ import logging
2
+ from typing import (
3
+ Any,
4
+ Dict,
5
+ List,
6
+ Optional,
7
+ Tuple,
8
+ Union,
9
+ )
10
+
11
+ from fh_pydantic_form.type_helpers import (
12
+ _get_underlying_type_if_optional,
13
+ _is_literal_type,
14
+ _is_optional_type,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _identify_list_fields(model_class) -> Dict[str, Dict[str, Any]]:
21
+ """
22
+ Identifies list fields in a model and their item types.
23
+
24
+ Args:
25
+ model_class: The Pydantic model class to analyze
26
+
27
+ Returns:
28
+ Dictionary mapping field names to their metadata
29
+ """
30
+ list_fields = {}
31
+ for field_name, field_info in model_class.model_fields.items():
32
+ annotation = getattr(field_info, "annotation", None)
33
+ if (
34
+ annotation is not None
35
+ and hasattr(annotation, "__origin__")
36
+ and annotation.__origin__ is list
37
+ ):
38
+ item_type = annotation.__args__[0]
39
+ list_fields[field_name] = {
40
+ "item_type": item_type,
41
+ "is_model_type": hasattr(item_type, "model_fields"),
42
+ "field_info": field_info, # Store for later use if needed
43
+ }
44
+ return list_fields
45
+
46
+
47
+ def _parse_non_list_fields(
48
+ form_data: Dict[str, Any],
49
+ model_class,
50
+ list_field_defs: Dict[str, Dict[str, Any]],
51
+ base_prefix: str = "",
52
+ ) -> Dict[str, Any]:
53
+ """
54
+ Parses non-list fields from form data based on the model definition.
55
+
56
+ Args:
57
+ form_data: Dictionary containing form field data
58
+ model_class: The Pydantic model class defining the structure
59
+ list_field_defs: Dictionary of list field definitions (to skip)
60
+ base_prefix: Prefix to use when looking up field names in form_data
61
+
62
+ Returns:
63
+ Dictionary with parsed non-list fields
64
+ """
65
+ result: Dict[str, Any] = {}
66
+
67
+ for field_name, field_info in model_class.model_fields.items():
68
+ if field_name in list_field_defs:
69
+ continue # Skip list fields, handled separately
70
+
71
+ # Create full key with prefix
72
+ full_key = f"{base_prefix}{field_name}"
73
+
74
+ annotation = getattr(field_info, "annotation", None)
75
+
76
+ # Handle boolean fields (including Optional[bool])
77
+ if annotation is bool or (
78
+ _is_optional_type(annotation)
79
+ and _get_underlying_type_if_optional(annotation) is bool
80
+ ):
81
+ result[field_name] = _parse_boolean_field(full_key, form_data)
82
+
83
+ # Handle Literal fields (including Optional[Literal[...]])
84
+ elif _is_literal_type(annotation):
85
+ result[field_name] = _parse_literal_field(full_key, form_data, field_info)
86
+
87
+ # Handle nested model fields (including Optional[NestedModel])
88
+ elif (
89
+ isinstance(annotation, type)
90
+ and hasattr(annotation, "model_fields")
91
+ or (
92
+ _is_optional_type(annotation)
93
+ and isinstance(_get_underlying_type_if_optional(annotation), type)
94
+ and hasattr(
95
+ _get_underlying_type_if_optional(annotation), "model_fields"
96
+ )
97
+ )
98
+ ):
99
+ # Get the nested model class (unwrap Optional if needed)
100
+ nested_model_class = _get_underlying_type_if_optional(annotation)
101
+
102
+ # Parse the nested model - pass the base_prefix
103
+ nested_value = _parse_nested_model_field(
104
+ field_name, form_data, nested_model_class, field_info, base_prefix
105
+ )
106
+
107
+ # Only assign if we got a non-None value or the field is not optional
108
+ if nested_value is not None:
109
+ result[field_name] = nested_value
110
+ elif _is_optional_type(annotation):
111
+ # Explicitly set None for optional nested models
112
+ result[field_name] = None
113
+
114
+ # Handle simple fields
115
+ else:
116
+ # Use updated _parse_simple_field that handles optionality
117
+ result[field_name] = _parse_simple_field(full_key, form_data, field_info)
118
+
119
+ return result
120
+
121
+
122
+ def _parse_boolean_field(field_name: str, form_data: Dict[str, Any]) -> bool:
123
+ """
124
+ Parse a boolean field from form data.
125
+
126
+ Args:
127
+ field_name: Name of the field to parse
128
+ form_data: Dictionary containing form field data
129
+
130
+ Returns:
131
+ Boolean value - True if field name exists in form_data, False otherwise
132
+ """
133
+ return field_name in form_data
134
+
135
+
136
+ def _parse_literal_field(field_name: str, form_data: Dict[str, Any], field_info) -> Any:
137
+ """
138
+ Parse a Literal field, converting empty string OR '-- None --' to None for optional fields.
139
+
140
+ Args:
141
+ field_name: Name of the field to parse
142
+ form_data: Dictionary containing form field data
143
+ field_info: FieldInfo object to check for optionality
144
+
145
+ Returns:
146
+ The parsed value or None for empty/None values with optional fields
147
+ """
148
+ value = form_data.get(field_name)
149
+
150
+ # Check if the field is Optional[Literal[...]]
151
+ if _is_optional_type(field_info.annotation):
152
+ # If the submitted value is the empty string OR the display text for None, treat it as None
153
+ if value == "" or value == "-- None --":
154
+ return None
155
+
156
+ # Return the actual submitted value (string) for Pydantic validation
157
+ return value
158
+
159
+
160
+ def _parse_simple_field(
161
+ field_name: str, form_data: Dict[str, Any], field_info=None
162
+ ) -> Any:
163
+ """
164
+ Parse a simple field (string, number, etc.) from form data.
165
+
166
+ Args:
167
+ field_name: Name of the field to parse
168
+ form_data: Dictionary containing form field data
169
+ field_info: Optional FieldInfo object to check for optionality
170
+
171
+ Returns:
172
+ Value of the field or None if not found
173
+ """
174
+ if field_name in form_data:
175
+ value = form_data[field_name]
176
+
177
+ # Handle empty strings for optional fields
178
+ if value == "" and field_info and _is_optional_type(field_info.annotation):
179
+ return None
180
+
181
+ return value
182
+
183
+ # If field is optional and not in form_data, return None
184
+ if field_info and _is_optional_type(field_info.annotation):
185
+ return None
186
+
187
+ return None
188
+
189
+
190
+ def _parse_nested_model_field(
191
+ field_name: str,
192
+ form_data: Dict[str, Any],
193
+ nested_model_class,
194
+ field_info,
195
+ parent_prefix: str = "",
196
+ ) -> Optional[Dict[str, Any]]:
197
+ """
198
+ Parse a nested Pydantic model field from form data.
199
+
200
+ Args:
201
+ field_name: Name of the field to parse
202
+ form_data: Dictionary containing form field data
203
+ nested_model_class: The nested model class
204
+ field_info: The field info from the parent model
205
+ parent_prefix: Prefix from parent form/model to use when constructing keys
206
+
207
+ Returns:
208
+ Dictionary with nested model structure or None/default if no data found
209
+ """
210
+ # Construct the full prefix for this nested model's fields
211
+ current_prefix = f"{parent_prefix}{field_name}_"
212
+ nested_data: Dict[str, Optional[Any]] = {}
213
+ found_any_subfield = False
214
+
215
+ # Check if any keys match this prefix
216
+ for key in form_data:
217
+ if key.startswith(current_prefix):
218
+ found_any_subfield = True
219
+ break
220
+
221
+ if found_any_subfield:
222
+ # Process each field in the nested model
223
+ for sub_field_name, sub_field_info in nested_model_class.model_fields.items():
224
+ sub_key = f"{current_prefix}{sub_field_name}"
225
+ annotation = getattr(sub_field_info, "annotation", None)
226
+
227
+ # Handle based on field type, with Optional unwrapping
228
+ is_optional = _is_optional_type(annotation)
229
+ base_type = _get_underlying_type_if_optional(annotation)
230
+
231
+ # Handle boolean fields (including Optional[bool])
232
+ if annotation is bool or (is_optional and base_type is bool):
233
+ nested_data[sub_field_name] = _parse_boolean_field(sub_key, form_data)
234
+
235
+ # Handle nested model fields (including Optional[NestedModel])
236
+ elif isinstance(base_type, type) and hasattr(base_type, "model_fields"):
237
+ # Pass the current_prefix to the recursive call
238
+ sub_value = _parse_nested_model_field(
239
+ sub_field_name, form_data, base_type, sub_field_info, current_prefix
240
+ )
241
+ if sub_value is not None:
242
+ nested_data[sub_field_name] = sub_value
243
+ elif is_optional:
244
+ nested_data[sub_field_name] = None
245
+
246
+ # Handle simple fields, including empty string to None conversion for Optional fields
247
+ elif sub_key in form_data:
248
+ value = form_data[sub_key]
249
+ if value == "" and is_optional:
250
+ nested_data[sub_field_name] = None
251
+ else:
252
+ nested_data[sub_field_name] = value
253
+
254
+ # Handle missing optional fields
255
+ elif is_optional:
256
+ nested_data[sub_field_name] = None
257
+
258
+ return nested_data
259
+
260
+ # No data found for this nested model
261
+ logger.debug(
262
+ f"No form data found for nested model field: {field_name} with prefix: {current_prefix}"
263
+ )
264
+
265
+ is_field_optional = _is_optional_type(field_info.annotation)
266
+
267
+ # If the field is optional, return None
268
+ if is_field_optional:
269
+ logger.debug(
270
+ f"Nested field {field_name} is optional and no data found, returning None."
271
+ )
272
+ return None
273
+
274
+ # If not optional, try to use default or default_factory
275
+ default_value = None
276
+ default_applied = False
277
+
278
+ if hasattr(field_info, "default") and field_info.default is not None:
279
+ default_value = field_info.default
280
+ default_applied = True
281
+ logger.debug(f"Nested field {field_name} using default value.")
282
+ elif (
283
+ hasattr(field_info, "default_factory")
284
+ and field_info.default_factory is not None
285
+ ):
286
+ try:
287
+ default_value = field_info.default_factory()
288
+ default_applied = True
289
+ logger.debug(f"Nested field {field_name} using default_factory.")
290
+ except Exception as e:
291
+ logger.warning(
292
+ f"Error creating default for {field_name} using default_factory: {e}"
293
+ )
294
+
295
+ if default_applied:
296
+ if default_value is not None and hasattr(default_value, "model_dump"):
297
+ return default_value.model_dump()
298
+ elif isinstance(default_value, dict):
299
+ return default_value
300
+ else:
301
+ # Handle cases where default might not be a model/dict (unlikely for nested model)
302
+ logger.warning(
303
+ f"Default value for nested field {field_name} is not a model or dict: {type(default_value)}"
304
+ )
305
+ # Don't return PydanticUndefined or other non-dict values directly
306
+ # Fall through to empty dict return instead
307
+
308
+ # If not optional, no data found, and no default applicable, always return an empty dict
309
+ # This ensures the test_parse_nested_model_field passes and allows Pydantic to validate
310
+ # if the nested model can be created from empty data
311
+ logger.debug(
312
+ f"Nested field {field_name} is required, no data/default found, returning empty dict {{}}."
313
+ )
314
+ return {}
315
+
316
+
317
+ def _parse_list_fields(
318
+ form_data: Dict[str, Any],
319
+ list_field_defs: Dict[str, Dict[str, Any]],
320
+ base_prefix: str = "",
321
+ ) -> Dict[str, List[Any]]:
322
+ """
323
+ Parse list fields from form data by analyzing keys and reconstructing ordered lists.
324
+
325
+ Args:
326
+ form_data: Dictionary containing form field data
327
+ list_field_defs: Dictionary of list field definitions
328
+ base_prefix: Prefix to use when looking up field names in form_data
329
+
330
+ Returns:
331
+ Dictionary with parsed list fields
332
+ """
333
+ # Skip if no list fields defined
334
+ if not list_field_defs:
335
+ return {}
336
+
337
+ # Temporary storage: { list_field_name: { idx_str: item_data } }
338
+ list_items_temp: Dict[str, Dict[str, Union[Dict[str, Any], Any]]] = {
339
+ field_name: {} for field_name in list_field_defs
340
+ }
341
+
342
+ # Order tracking: { list_field_name: [idx_str1, idx_str2, ...] }
343
+ list_item_indices_ordered: Dict[str, List[str]] = {
344
+ field_name: [] for field_name in list_field_defs
345
+ }
346
+
347
+ # Process all form keys that might belong to list fields
348
+ for key, value in form_data.items():
349
+ parse_result = _parse_list_item_key(key, list_field_defs, base_prefix)
350
+ if not parse_result:
351
+ continue # Key doesn't belong to a known list field
352
+
353
+ field_name, idx_str, subfield, is_simple_list = parse_result
354
+
355
+ # Track order if seeing this index for the first time for this field
356
+ if idx_str not in list_items_temp[field_name]:
357
+ list_item_indices_ordered[field_name].append(idx_str)
358
+ # Initialize storage for this item index
359
+ list_items_temp[field_name][idx_str] = {} if not is_simple_list else None
360
+
361
+ # Store the value
362
+ if is_simple_list:
363
+ list_items_temp[field_name][idx_str] = value
364
+ else:
365
+ # It's a model list item, store subfield value
366
+ if subfield: # Should always have a subfield for model list items
367
+ list_items_temp[field_name][idx_str][subfield] = value
368
+
369
+ # Build final lists based on tracked order
370
+ final_lists = {}
371
+ for field_name, ordered_indices in list_item_indices_ordered.items():
372
+ field_def = list_field_defs[field_name]
373
+ item_type = field_def["item_type"]
374
+
375
+ items = []
376
+ for idx_str in ordered_indices:
377
+ item_data = list_items_temp[field_name][idx_str]
378
+
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
420
+ items.append(item_data)
421
+
422
+ if items: # Only add if items were found
423
+ final_lists[field_name] = items
424
+
425
+ # For any list field that didn't have form data, use its default
426
+ for field_name, field_def in list_field_defs.items():
427
+ if field_name not in final_lists:
428
+ field_info = field_def["field_info"]
429
+ if hasattr(field_info, "default") and field_info.default is not None:
430
+ final_lists[field_name] = field_info.default
431
+ elif (
432
+ hasattr(field_info, "default_factory")
433
+ and field_info.default_factory is not None
434
+ ):
435
+ try:
436
+ final_lists[field_name] = field_info.default_factory()
437
+ except Exception:
438
+ pass
439
+
440
+ return final_lists
441
+
442
+
443
+ def _parse_list_item_key(
444
+ key: str, list_field_defs: Dict[str, Dict[str, Any]], base_prefix: str = ""
445
+ ) -> Optional[Tuple[str, str, Optional[str], bool]]:
446
+ """
447
+ Parse a form key that might represent a list item.
448
+
449
+ Args:
450
+ key: Form field key to parse
451
+ list_field_defs: Dictionary of list field definitions
452
+ base_prefix: Prefix to use when looking up field names in form_data
453
+
454
+ Returns:
455
+ Tuple of (field_name, idx_str, subfield, is_simple_list) if key is for a list item,
456
+ None otherwise
457
+ """
458
+ # Check if key starts with any of our list field names with underscore
459
+ for field_name, field_def in list_field_defs.items():
460
+ full_prefix = f"{base_prefix}{field_name}_"
461
+ if key.startswith(full_prefix):
462
+ remaining = key[len(full_prefix) :]
463
+ is_model_type = field_def["is_model_type"]
464
+
465
+ # Handle key format based on whether it's a model list or simple list
466
+ if is_model_type:
467
+ # Complex model field: field_name_idx_subfield
468
+ try:
469
+ if "_" not in remaining:
470
+ # Invalid format for model list item
471
+ continue
472
+
473
+ # Special handling for "new_" prefix
474
+ if remaining.startswith("new_"):
475
+ # Format is "new_timestamp_subfield"
476
+ parts = remaining.split("_")
477
+ if len(parts) >= 3: # "new", "timestamp", "subfield"
478
+ idx_str = f"{parts[0]}_{parts[1]}" # "new_timestamp"
479
+ subfield = "_".join(
480
+ parts[2:]
481
+ ) # "subfield" (or "subfield_with_underscores")
482
+
483
+ # Validate timestamp part is numeric
484
+ timestamp_part = parts[1]
485
+ if not timestamp_part.isdigit():
486
+ continue
487
+
488
+ return (
489
+ field_name,
490
+ idx_str,
491
+ subfield,
492
+ False,
493
+ ) # Not a simple list
494
+ else:
495
+ continue
496
+ else:
497
+ # Regular numeric index format: "123_subfield"
498
+ idx_part, subfield = remaining.split("_", 1)
499
+
500
+ # Validate index is numeric
501
+ if not idx_part.isdigit():
502
+ continue
503
+
504
+ return (
505
+ field_name,
506
+ idx_part,
507
+ subfield,
508
+ False,
509
+ ) # Not a simple list
510
+
511
+ except Exception:
512
+ continue
513
+ else:
514
+ # Simple list: field_name_idx
515
+ try:
516
+ # For simple types, the entire remaining part is the index
517
+ idx_str = remaining
518
+
519
+ # Validate index format - either numeric or "new_timestamp"
520
+ if idx_str.isdigit():
521
+ # Regular numeric index
522
+ pass
523
+ elif idx_str.startswith("new_"):
524
+ # New item with timestamp - validate timestamp part is numeric
525
+ timestamp_part = idx_str[4:] # Skip "new_" prefix
526
+ if not timestamp_part.isdigit():
527
+ continue
528
+ else:
529
+ continue
530
+
531
+ return field_name, idx_str, None, True # Simple list
532
+
533
+ except Exception:
534
+ continue
535
+
536
+ # Not a list field key
537
+ return None