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.
@@ -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