fh-pydantic-form 0.3.6__py3-none-any.whl → 0.3.8__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/comparison_form.py +994 -5
- fh_pydantic_form/field_renderers.py +242 -55
- fh_pydantic_form/form_parser.py +82 -12
- fh_pydantic_form/form_renderer.py +99 -43
- fh_pydantic_form/type_helpers.py +22 -2
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/METADATA +114 -4
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/RECORD +9 -9
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.3.6.dist-info → fh_pydantic_form-0.3.8.dist-info}/licenses/LICENSE +0 -0
fh_pydantic_form/form_parser.py
CHANGED
|
@@ -54,6 +54,8 @@ def _parse_non_list_fields(
|
|
|
54
54
|
list_field_defs: Dict[str, Dict[str, Any]],
|
|
55
55
|
base_prefix: str = "",
|
|
56
56
|
exclude_fields: Optional[List[str]] = None,
|
|
57
|
+
keep_skip_json_pathset: Optional[set[str]] = None,
|
|
58
|
+
current_field_path: Optional[List[str]] = None,
|
|
57
59
|
) -> Dict[str, Any]:
|
|
58
60
|
"""
|
|
59
61
|
Parses non-list fields from form data based on the model definition.
|
|
@@ -64,12 +66,30 @@ def _parse_non_list_fields(
|
|
|
64
66
|
list_field_defs: Dictionary of list field definitions (to skip)
|
|
65
67
|
base_prefix: Prefix to use when looking up field names in form_data
|
|
66
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
|
|
67
70
|
|
|
68
71
|
Returns:
|
|
69
72
|
Dictionary with parsed non-list fields
|
|
70
73
|
"""
|
|
71
74
|
result: Dict[str, Any] = {}
|
|
72
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
|
|
73
93
|
|
|
74
94
|
for field_name, field_info in model_class.model_fields.items():
|
|
75
95
|
if field_name in list_field_defs:
|
|
@@ -79,9 +99,11 @@ def _parse_non_list_fields(
|
|
|
79
99
|
if field_name in exclude_fields:
|
|
80
100
|
continue
|
|
81
101
|
|
|
82
|
-
# Skip SkipJsonSchema fields
|
|
102
|
+
# Skip SkipJsonSchema fields unless they're explicitly kept
|
|
83
103
|
if _is_skip_json_schema_field(field_info):
|
|
84
|
-
|
|
104
|
+
field_path_segments = current_path_segments + [field_name]
|
|
105
|
+
if not _should_keep_skip_field(field_path_segments):
|
|
106
|
+
continue
|
|
85
107
|
|
|
86
108
|
# Create full key with prefix
|
|
87
109
|
full_key = f"{base_prefix}{field_name}"
|
|
@@ -128,7 +150,8 @@ def _parse_non_list_fields(
|
|
|
128
150
|
# Get the nested model class (unwrap Optional if needed)
|
|
129
151
|
nested_model_class = _get_underlying_type_if_optional(annotation)
|
|
130
152
|
|
|
131
|
-
# Parse the nested model - pass the base_prefix and
|
|
153
|
+
# Parse the nested model - pass the base_prefix, exclude_fields, and keep paths
|
|
154
|
+
nested_field_path = current_path_segments + [field_name]
|
|
132
155
|
nested_value = _parse_nested_model_field(
|
|
133
156
|
field_name,
|
|
134
157
|
form_data,
|
|
@@ -136,6 +159,8 @@ def _parse_non_list_fields(
|
|
|
136
159
|
field_info,
|
|
137
160
|
base_prefix,
|
|
138
161
|
exclude_fields,
|
|
162
|
+
keep_skip_json_pathset,
|
|
163
|
+
nested_field_path,
|
|
139
164
|
)
|
|
140
165
|
|
|
141
166
|
# Only assign if we got a non-None value or the field is not optional
|
|
@@ -276,6 +301,8 @@ def _parse_nested_model_field(
|
|
|
276
301
|
field_info,
|
|
277
302
|
parent_prefix: str = "",
|
|
278
303
|
exclude_fields: Optional[List[str]] = None,
|
|
304
|
+
keep_skip_json_pathset: Optional[set[str]] = None,
|
|
305
|
+
current_field_path: Optional[List[str]] = None,
|
|
279
306
|
) -> Optional[Dict[str, Any]]:
|
|
280
307
|
"""
|
|
281
308
|
Parse a nested Pydantic model field from form data.
|
|
@@ -286,6 +313,8 @@ def _parse_nested_model_field(
|
|
|
286
313
|
nested_model_class: The nested model class
|
|
287
314
|
field_info: The field info from the parent model
|
|
288
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
|
|
289
318
|
|
|
290
319
|
Returns:
|
|
291
320
|
Dictionary with nested model structure or None/default if no data found
|
|
@@ -302,6 +331,16 @@ def _parse_nested_model_field(
|
|
|
302
331
|
break
|
|
303
332
|
|
|
304
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
|
+
|
|
305
344
|
# ------------------------------------------------------------------
|
|
306
345
|
# 1. Process each **non-list** field in the nested model
|
|
307
346
|
# ------------------------------------------------------------------
|
|
@@ -309,12 +348,14 @@ def _parse_nested_model_field(
|
|
|
309
348
|
sub_key = f"{current_prefix}{sub_field_name}"
|
|
310
349
|
annotation = getattr(sub_field_info, "annotation", None)
|
|
311
350
|
|
|
312
|
-
# Skip SkipJsonSchema fields
|
|
351
|
+
# Skip SkipJsonSchema fields unless they're explicitly kept
|
|
313
352
|
if _is_skip_json_schema_field(sub_field_info):
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
318
359
|
|
|
319
360
|
# Handle based on field type, with Optional unwrapping
|
|
320
361
|
is_optional = _is_optional_type(annotation)
|
|
@@ -326,9 +367,17 @@ def _parse_nested_model_field(
|
|
|
326
367
|
|
|
327
368
|
# Handle nested model fields (including Optional[NestedModel])
|
|
328
369
|
elif isinstance(base_type, type) and hasattr(base_type, "model_fields"):
|
|
329
|
-
# Pass the current_prefix to the recursive call
|
|
370
|
+
# Pass the current_prefix and keep paths to the recursive call
|
|
371
|
+
sub_field_path = nested_path_segments + [sub_field_name]
|
|
330
372
|
sub_value = _parse_nested_model_field(
|
|
331
|
-
sub_field_name,
|
|
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,
|
|
332
381
|
)
|
|
333
382
|
if sub_value is not None:
|
|
334
383
|
nested_data[sub_field_name] = sub_value
|
|
@@ -358,6 +407,7 @@ def _parse_nested_model_field(
|
|
|
358
407
|
nested_list_defs,
|
|
359
408
|
current_prefix, # ← prefix for this nested model
|
|
360
409
|
exclude_fields, # Pass through exclude_fields
|
|
410
|
+
keep_skip_json_pathset,
|
|
361
411
|
)
|
|
362
412
|
# Merge without clobbering keys already set in step 1
|
|
363
413
|
for lf_name, lf_val in list_results.items():
|
|
@@ -438,6 +488,7 @@ def _parse_list_fields(
|
|
|
438
488
|
list_field_defs: Dict[str, Dict[str, Any]],
|
|
439
489
|
base_prefix: str = "",
|
|
440
490
|
exclude_fields: Optional[List[str]] = None,
|
|
491
|
+
keep_skip_json_pathset: Optional[set[str]] = None,
|
|
441
492
|
) -> Dict[str, Optional[List[Any]]]:
|
|
442
493
|
"""
|
|
443
494
|
Parse list fields from form data by analyzing keys and reconstructing ordered lists.
|
|
@@ -447,6 +498,7 @@ def _parse_list_fields(
|
|
|
447
498
|
list_field_defs: Dictionary of list field definitions
|
|
448
499
|
base_prefix: Prefix to use when looking up field names in form_data
|
|
449
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
|
|
450
502
|
|
|
451
503
|
Returns:
|
|
452
504
|
Dictionary with parsed list fields
|
|
@@ -504,7 +556,15 @@ def _parse_list_fields(
|
|
|
504
556
|
# ------------------------------------------------------------------
|
|
505
557
|
if field_def["is_model_type"]:
|
|
506
558
|
item_prefix = f"{base_prefix}{field_name}_{idx_str}_"
|
|
507
|
-
|
|
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
|
+
)
|
|
508
568
|
items.append(parsed_item)
|
|
509
569
|
continue
|
|
510
570
|
|
|
@@ -558,6 +618,8 @@ def _parse_model_list_item(
|
|
|
558
618
|
form_data: Dict[str, Any],
|
|
559
619
|
item_type,
|
|
560
620
|
item_prefix: str,
|
|
621
|
+
keep_skip_json_pathset: Optional[set[str]] = None,
|
|
622
|
+
current_field_path: Optional[List[str]] = None,
|
|
561
623
|
) -> Dict[str, Any]:
|
|
562
624
|
"""
|
|
563
625
|
Fully parse a single BaseModel list item – including its own nested lists.
|
|
@@ -568,6 +630,7 @@ def _parse_model_list_item(
|
|
|
568
630
|
form_data: Dictionary containing form field data
|
|
569
631
|
item_type: The BaseModel class for this list item
|
|
570
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
|
|
571
634
|
|
|
572
635
|
Returns:
|
|
573
636
|
Dictionary with fully parsed item data including nested lists
|
|
@@ -579,11 +642,18 @@ def _parse_model_list_item(
|
|
|
579
642
|
item_type,
|
|
580
643
|
nested_list_defs,
|
|
581
644
|
base_prefix=item_prefix,
|
|
645
|
+
exclude_fields=[],
|
|
646
|
+
keep_skip_json_pathset=keep_skip_json_pathset,
|
|
647
|
+
current_field_path=current_field_path,
|
|
582
648
|
)
|
|
583
649
|
# 2. Parse inner lists
|
|
584
650
|
result.update(
|
|
585
651
|
_parse_list_fields(
|
|
586
|
-
form_data,
|
|
652
|
+
form_data,
|
|
653
|
+
nested_list_defs,
|
|
654
|
+
base_prefix=item_prefix,
|
|
655
|
+
exclude_fields=[],
|
|
656
|
+
keep_skip_json_pathset=keep_skip_json_pathset,
|
|
587
657
|
)
|
|
588
658
|
)
|
|
589
659
|
return result
|
|
@@ -34,6 +34,7 @@ from fh_pydantic_form.registry import FieldRendererRegistry
|
|
|
34
34
|
from fh_pydantic_form.type_helpers import (
|
|
35
35
|
_is_skip_json_schema_field,
|
|
36
36
|
get_default,
|
|
37
|
+
normalize_path_segments,
|
|
37
38
|
)
|
|
38
39
|
from fh_pydantic_form.ui_style import (
|
|
39
40
|
SpacingTheme,
|
|
@@ -48,6 +49,21 @@ logger = logging.getLogger(__name__)
|
|
|
48
49
|
ModelType = TypeVar("ModelType", bound=BaseModel)
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
def _compile_keep_paths(paths: Optional[List[str]]) -> set[str]:
|
|
53
|
+
"""Normalize and compile keep paths for fast membership tests."""
|
|
54
|
+
if not paths:
|
|
55
|
+
return set()
|
|
56
|
+
|
|
57
|
+
compiled: set[str] = set()
|
|
58
|
+
for raw_path in paths:
|
|
59
|
+
if not raw_path:
|
|
60
|
+
continue
|
|
61
|
+
normalized = raw_path.strip()
|
|
62
|
+
if normalized:
|
|
63
|
+
compiled.add(normalized)
|
|
64
|
+
return compiled
|
|
65
|
+
|
|
66
|
+
|
|
51
67
|
def list_manipulation_js():
|
|
52
68
|
return fh.Script("""
|
|
53
69
|
function moveItem(buttonElement, direction) {
|
|
@@ -199,18 +215,46 @@ window.restoreAccordionState = function(containerId) {
|
|
|
199
215
|
}
|
|
200
216
|
};
|
|
201
217
|
|
|
202
|
-
// Save all accordion states in the form
|
|
218
|
+
// Save all accordion states in the form (both lists and nested BaseModels)
|
|
203
219
|
window.saveAllAccordionStates = function() {
|
|
220
|
+
// Save list container states
|
|
204
221
|
document.querySelectorAll('[id$="_items_container"]').forEach(container => {
|
|
205
222
|
window.saveAccordionState(container.id);
|
|
206
223
|
});
|
|
224
|
+
|
|
225
|
+
// Save all UIkit accordion item states (nested BaseModels, etc.)
|
|
226
|
+
document.querySelectorAll('.uk-accordion > li').forEach(item => {
|
|
227
|
+
if (item.id) {
|
|
228
|
+
const isOpen = item.classList.contains('uk-open');
|
|
229
|
+
sessionStorage.setItem('accordion_state_' + item.id, isOpen ? 'open' : 'closed');
|
|
230
|
+
}
|
|
231
|
+
});
|
|
207
232
|
};
|
|
208
233
|
|
|
209
|
-
// Restore all accordion states in the form
|
|
234
|
+
// Restore all accordion states in the form (both lists and nested BaseModels)
|
|
210
235
|
window.restoreAllAccordionStates = function() {
|
|
236
|
+
// Restore list container states
|
|
211
237
|
document.querySelectorAll('[id$="_items_container"]').forEach(container => {
|
|
212
238
|
window.restoreAccordionState(container.id);
|
|
213
239
|
});
|
|
240
|
+
|
|
241
|
+
// Use requestAnimationFrame to ensure DOM has fully updated after swap
|
|
242
|
+
requestAnimationFrame(() => {
|
|
243
|
+
setTimeout(() => {
|
|
244
|
+
// Restore ALL UIkit accordion item states in the entire document (not just swapped area)
|
|
245
|
+
document.querySelectorAll('.uk-accordion > li').forEach(item => {
|
|
246
|
+
if (item.id) {
|
|
247
|
+
const savedState = sessionStorage.getItem('accordion_state_' + item.id);
|
|
248
|
+
|
|
249
|
+
if (savedState === 'open' && !item.classList.contains('uk-open')) {
|
|
250
|
+
item.classList.add('uk-open');
|
|
251
|
+
} else if (savedState === 'closed' && item.classList.contains('uk-open')) {
|
|
252
|
+
item.classList.remove('uk-open');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}, 150);
|
|
257
|
+
});
|
|
214
258
|
};
|
|
215
259
|
|
|
216
260
|
// Wait for the DOM to be fully loaded before initializing
|
|
@@ -219,8 +263,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
219
263
|
document.querySelectorAll('[id$="_items_container"]').forEach(container => {
|
|
220
264
|
updateMoveButtons(container);
|
|
221
265
|
});
|
|
222
|
-
|
|
223
|
-
//
|
|
266
|
+
|
|
267
|
+
// Attach HTMX event listener to document.body for list operations
|
|
224
268
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
|
225
269
|
// Check if this is an insert (afterend swap)
|
|
226
270
|
const targetElement = event.detail.target;
|
|
@@ -281,6 +325,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
281
325
|
disabled_fields: Optional[List[str]] = None,
|
|
282
326
|
label_colors: Optional[Dict[str, str]] = None,
|
|
283
327
|
exclude_fields: Optional[List[str]] = None,
|
|
328
|
+
keep_skip_json_fields: Optional[List[str]] = None,
|
|
284
329
|
spacing: SpacingValue = SpacingTheme.NORMAL,
|
|
285
330
|
metrics_dict: Optional[Dict[str, Any]] = None,
|
|
286
331
|
):
|
|
@@ -298,6 +343,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
298
343
|
disabled_fields: Optional list of top-level field names to disable specifically
|
|
299
344
|
label_colors: Optional dictionary mapping field names to label colors (CSS color values)
|
|
300
345
|
exclude_fields: Optional list of top-level field names to exclude from the form
|
|
346
|
+
keep_skip_json_fields: Optional list of dot-paths for SkipJsonSchema fields to force-keep
|
|
301
347
|
spacing: Spacing theme to use for form layout ("normal", "compact", or SpacingTheme enum)
|
|
302
348
|
metrics_dict: Optional metrics dictionary for field-level visual feedback
|
|
303
349
|
"""
|
|
@@ -342,6 +388,8 @@ class PydanticForm(Generic[ModelType]):
|
|
|
342
388
|
self.exclude_fields = exclude_fields or [] # Store excluded fields list
|
|
343
389
|
self.spacing = _normalize_spacing(spacing) # Store normalized spacing
|
|
344
390
|
self.metrics_dict = metrics_dict or {} # Store metrics dictionary
|
|
391
|
+
self.keep_skip_json_fields = keep_skip_json_fields or []
|
|
392
|
+
self._keep_skip_json_pathset = _compile_keep_paths(self.keep_skip_json_fields)
|
|
345
393
|
|
|
346
394
|
# Register custom renderers with the global registry if provided
|
|
347
395
|
if custom_renderers:
|
|
@@ -363,6 +411,15 @@ class PydanticForm(Generic[ModelType]):
|
|
|
363
411
|
wrapper_cls = "fhpf-wrapper w-full flex-1"
|
|
364
412
|
return fh.Div(inner, cls=wrapper_cls)
|
|
365
413
|
|
|
414
|
+
def _normalized_dot_path(self, path_segments: List[str]) -> str:
|
|
415
|
+
"""Normalize path segments by dropping indices and joining with dots."""
|
|
416
|
+
return normalize_path_segments(path_segments)
|
|
417
|
+
|
|
418
|
+
def _is_kept_skip_field(self, full_path: List[str]) -> bool:
|
|
419
|
+
"""Return True if a SkipJsonSchema field should be kept based on keep list."""
|
|
420
|
+
normalized = self._normalized_dot_path(full_path)
|
|
421
|
+
return bool(normalized) and normalized in self._keep_skip_json_pathset
|
|
422
|
+
|
|
366
423
|
def reset_state(self) -> None:
|
|
367
424
|
"""
|
|
368
425
|
Restore the live state of the form to its immutable baseline.
|
|
@@ -400,6 +457,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
400
457
|
disabled_fields=self.disabled_fields,
|
|
401
458
|
label_colors=self.label_colors,
|
|
402
459
|
exclude_fields=self.exclude_fields,
|
|
460
|
+
keep_skip_json_fields=self.keep_skip_json_fields,
|
|
403
461
|
spacing=self.spacing,
|
|
404
462
|
metrics_dict=metrics_dict
|
|
405
463
|
if metrics_dict is not None
|
|
@@ -423,8 +481,10 @@ class PydanticForm(Generic[ModelType]):
|
|
|
423
481
|
if field_name in self.exclude_fields:
|
|
424
482
|
continue
|
|
425
483
|
|
|
426
|
-
# Skip SkipJsonSchema fields
|
|
427
|
-
if _is_skip_json_schema_field(field_info)
|
|
484
|
+
# Skip SkipJsonSchema fields unless explicitly kept
|
|
485
|
+
if _is_skip_json_schema_field(field_info) and not self._is_kept_skip_field(
|
|
486
|
+
[field_name]
|
|
487
|
+
):
|
|
428
488
|
continue
|
|
429
489
|
|
|
430
490
|
# Only use what was explicitly provided in initial values
|
|
@@ -440,24 +500,14 @@ class PydanticForm(Generic[ModelType]):
|
|
|
440
500
|
|
|
441
501
|
# Only use defaults if field was not provided at all
|
|
442
502
|
if not field_was_provided:
|
|
443
|
-
# Field not provided - use model defaults
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
else:
|
|
452
|
-
initial_value = None
|
|
453
|
-
logger.warning(
|
|
454
|
-
f" - default_factory for '{field_name}' is not callable"
|
|
455
|
-
)
|
|
456
|
-
except Exception as e:
|
|
457
|
-
initial_value = None
|
|
458
|
-
logger.warning(
|
|
459
|
-
f" - Error in default_factory for '{field_name}': {e}"
|
|
460
|
-
)
|
|
503
|
+
# Field not provided - use model defaults in order of priority
|
|
504
|
+
# 1. Try explicit field default
|
|
505
|
+
default_val = get_default(field_info)
|
|
506
|
+
if default_val is not _UNSET:
|
|
507
|
+
initial_value = default_val
|
|
508
|
+
else:
|
|
509
|
+
# 2. Fall back to smart defaults for the type
|
|
510
|
+
initial_value = default_for_annotation(field_info.annotation)
|
|
461
511
|
# If field was provided (even as None), respect that value
|
|
462
512
|
|
|
463
513
|
# Get renderer from global registry
|
|
@@ -488,6 +538,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
488
538
|
field_path=[field_name], # Set top-level field path
|
|
489
539
|
form_name=self.name, # Pass form name
|
|
490
540
|
metrics_dict=self.metrics_dict, # Pass the metrics dict
|
|
541
|
+
keep_skip_json_pathset=self._keep_skip_json_pathset,
|
|
491
542
|
)
|
|
492
543
|
|
|
493
544
|
rendered_field = renderer.render()
|
|
@@ -654,19 +705,25 @@ class PydanticForm(Generic[ModelType]):
|
|
|
654
705
|
if field_name not in self.exclude_fields
|
|
655
706
|
}
|
|
656
707
|
|
|
657
|
-
# Parse non-list fields first - pass the base_prefix and
|
|
708
|
+
# Parse non-list fields first - pass the base_prefix, exclude_fields, and keep paths
|
|
658
709
|
result = _parse_non_list_fields(
|
|
659
710
|
form_dict,
|
|
660
711
|
self.model_class,
|
|
661
712
|
list_field_defs,
|
|
662
713
|
self.base_prefix,
|
|
663
714
|
self.exclude_fields,
|
|
715
|
+
self._keep_skip_json_pathset,
|
|
716
|
+
None, # Top-level parsing, no field path
|
|
664
717
|
)
|
|
665
718
|
|
|
666
|
-
# Parse list fields based on keys present in form_dict - pass the base_prefix
|
|
719
|
+
# Parse list fields based on keys present in form_dict - pass the base_prefix and keep paths
|
|
667
720
|
# Use filtered list field definitions to skip excluded list fields
|
|
668
721
|
list_results = _parse_list_fields(
|
|
669
|
-
form_dict,
|
|
722
|
+
form_dict,
|
|
723
|
+
filtered_list_field_defs,
|
|
724
|
+
self.base_prefix,
|
|
725
|
+
self.exclude_fields,
|
|
726
|
+
self._keep_skip_json_pathset,
|
|
670
727
|
)
|
|
671
728
|
|
|
672
729
|
# Merge list results into the main result
|
|
@@ -679,13 +736,14 @@ class PydanticForm(Generic[ModelType]):
|
|
|
679
736
|
|
|
680
737
|
def _inject_missing_defaults(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
681
738
|
"""
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
739
|
+
Ensure missing fields are filled following precedence:
|
|
740
|
+
1) form value (already in `data`)
|
|
741
|
+
2) initial_values
|
|
742
|
+
3) model/default_factory
|
|
743
|
+
4) sensible default (for SkipJsonSchema fields only)
|
|
685
744
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
2. model defaults/default_factory
|
|
745
|
+
For required fields without defaults or initial_values, they are left missing
|
|
746
|
+
so that Pydantic validation can properly surface the error.
|
|
689
747
|
|
|
690
748
|
Args:
|
|
691
749
|
data: Dictionary to modify in-place
|
|
@@ -693,22 +751,20 @@ class PydanticForm(Generic[ModelType]):
|
|
|
693
751
|
Returns:
|
|
694
752
|
The same dictionary instance for method chaining
|
|
695
753
|
"""
|
|
696
|
-
# Process ALL model fields, not just excluded ones
|
|
697
754
|
for field_name, field_info in self.model_class.model_fields.items():
|
|
698
|
-
#
|
|
755
|
+
# 1) Respect any value already parsed from the form (top priority)
|
|
699
756
|
if field_name in data:
|
|
700
757
|
continue
|
|
701
758
|
|
|
702
|
-
#
|
|
759
|
+
# 2) Prefer initial_values for ANY missing field (including hidden SkipJsonSchema fields)
|
|
703
760
|
if field_name in self.initial_values_dict:
|
|
704
761
|
initial_val = self.initial_values_dict[field_name]
|
|
705
|
-
# If the initial value is a BaseModel, convert to dict for consistency
|
|
706
762
|
if hasattr(initial_val, "model_dump"):
|
|
707
763
|
initial_val = initial_val.model_dump()
|
|
708
764
|
data[field_name] = initial_val
|
|
709
765
|
continue
|
|
710
766
|
|
|
711
|
-
#
|
|
767
|
+
# 3) Use model/default_factory if available
|
|
712
768
|
default_val = get_default(field_info)
|
|
713
769
|
if default_val is not _UNSET:
|
|
714
770
|
# If the default is a BaseModel, convert to dict for consistency
|
|
@@ -716,12 +772,11 @@ class PydanticForm(Generic[ModelType]):
|
|
|
716
772
|
default_val = default_val.model_dump()
|
|
717
773
|
data[field_name] = default_val
|
|
718
774
|
else:
|
|
719
|
-
#
|
|
775
|
+
# 4) For SkipJsonSchema fields without defaults, provide sensible defaults
|
|
776
|
+
# For regular required fields, leave them missing so validation catches them
|
|
720
777
|
if _is_skip_json_schema_field(field_info):
|
|
721
|
-
|
|
722
|
-
else:
|
|
723
|
-
# No default → leave missing; validation will surface error
|
|
724
|
-
pass
|
|
778
|
+
data[field_name] = default_for_annotation(field_info.annotation)
|
|
779
|
+
# else: leave missing, let validation fail
|
|
725
780
|
|
|
726
781
|
return data
|
|
727
782
|
|
|
@@ -808,6 +863,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
808
863
|
field_path=segments, # Pass the full path segments
|
|
809
864
|
form_name=self.name, # Pass the explicit form name
|
|
810
865
|
metrics_dict=self.metrics_dict, # Pass the metrics dict
|
|
866
|
+
keep_skip_json_pathset=self._keep_skip_json_pathset,
|
|
811
867
|
)
|
|
812
868
|
|
|
813
869
|
# Generate a unique placeholder index
|
fh_pydantic_form/type_helpers.py
CHANGED
|
@@ -5,6 +5,7 @@ __all__ = [
|
|
|
5
5
|
"_is_literal_type",
|
|
6
6
|
"_is_enum_type",
|
|
7
7
|
"_is_skip_json_schema_field",
|
|
8
|
+
"normalize_path_segments",
|
|
8
9
|
"MetricEntry",
|
|
9
10
|
"MetricsDict",
|
|
10
11
|
"DecorationScope",
|
|
@@ -18,6 +19,7 @@ from typing import (
|
|
|
18
19
|
Annotated,
|
|
19
20
|
Any,
|
|
20
21
|
Dict,
|
|
22
|
+
List,
|
|
21
23
|
Literal,
|
|
22
24
|
TypedDict,
|
|
23
25
|
Union,
|
|
@@ -38,6 +40,18 @@ class DecorationScope(str, Enum):
|
|
|
38
40
|
BOTH = "both"
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
def normalize_path_segments(path_segments: List[str]) -> str:
|
|
44
|
+
"""Collapse path segments into a dot path ignoring list indices and placeholders."""
|
|
45
|
+
normalized: List[str] = []
|
|
46
|
+
for segment in path_segments:
|
|
47
|
+
# Coerce to string to avoid surprises from enums or numbers
|
|
48
|
+
seg_str = str(segment)
|
|
49
|
+
if seg_str.isdigit() or seg_str.startswith("new_"):
|
|
50
|
+
continue
|
|
51
|
+
normalized.append(seg_str)
|
|
52
|
+
return ".".join(normalized)
|
|
53
|
+
|
|
54
|
+
|
|
41
55
|
def _is_skip_json_schema_field(annotation_or_field_info: Any) -> bool:
|
|
42
56
|
"""
|
|
43
57
|
Check if a field annotation or field_info indicates it should be skipped in JSON schema.
|
|
@@ -101,8 +115,14 @@ def _is_skip_json_schema_field(annotation_or_field_info: Any) -> bool:
|
|
|
101
115
|
):
|
|
102
116
|
return True
|
|
103
117
|
|
|
104
|
-
# 3. Fallback – cheap but effective
|
|
105
|
-
|
|
118
|
+
# 3. Fallback – cheap but effective, but be more specific to avoid false positives
|
|
119
|
+
# Only match if SkipJsonSchema appears as a standalone word (not part of a class name)
|
|
120
|
+
repr_str = repr(annotation)
|
|
121
|
+
# Look for patterns like "SkipJsonSchema[" or "SkipJsonSchema(" or "SkipJsonSchema]"
|
|
122
|
+
# but not "SomeClassNameSkipJsonSchema"
|
|
123
|
+
import re
|
|
124
|
+
|
|
125
|
+
return bool(re.search(r"\bSkipJsonSchema\b", repr_str))
|
|
106
126
|
|
|
107
127
|
|
|
108
128
|
# Metrics types for field-level annotations
|