fh-pydantic-form 0.1.2__py3-none-any.whl → 0.2.0__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 +339 -134
- fh_pydantic_form/form_parser.py +75 -0
- fh_pydantic_form/form_renderer.py +238 -66
- fh_pydantic_form/type_helpers.py +108 -1
- fh_pydantic_form/ui_style.py +123 -0
- fh_pydantic_form-0.2.0.dist-info/METADATA +685 -0
- fh_pydantic_form-0.2.0.dist-info/RECORD +13 -0
- fh_pydantic_form-0.1.2.dist-info/METADATA +0 -327
- fh_pydantic_form-0.1.2.dist-info/RECORD +0 -11
- {fh_pydantic_form-0.1.2.dist-info → fh_pydantic_form-0.2.0.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.1.2.dist-info → fh_pydantic_form-0.2.0.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:
|
|
@@ -416,6 +472,25 @@ def _parse_list_fields(
|
|
|
416
472
|
)
|
|
417
473
|
item_data[model_field_name] = False
|
|
418
474
|
|
|
475
|
+
# Convert string to int for integer-valued enums in simple lists
|
|
476
|
+
if (
|
|
477
|
+
not field_def["is_model_type"]
|
|
478
|
+
and isinstance(item_type, type)
|
|
479
|
+
and issubclass(item_type, Enum)
|
|
480
|
+
and isinstance(item_data, str)
|
|
481
|
+
):
|
|
482
|
+
try:
|
|
483
|
+
first = next(iter(item_type))
|
|
484
|
+
if isinstance(first.value, int):
|
|
485
|
+
try:
|
|
486
|
+
item_data = int(item_data)
|
|
487
|
+
except (TypeError, ValueError):
|
|
488
|
+
# leave it as-is; pydantic will raise if really invalid
|
|
489
|
+
pass
|
|
490
|
+
except StopIteration:
|
|
491
|
+
# Empty enum, leave item_data as-is
|
|
492
|
+
pass
|
|
493
|
+
|
|
419
494
|
# For model types, keep as dict for now
|
|
420
495
|
items.append(item_data)
|
|
421
496
|
|
|
@@ -9,6 +9,7 @@ from typing import (
|
|
|
9
9
|
Tuple,
|
|
10
10
|
Type,
|
|
11
11
|
TypeVar,
|
|
12
|
+
Union,
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
import fasthtml.common as fh
|
|
@@ -16,6 +17,7 @@ import monsterui.all as mui
|
|
|
16
17
|
from fastcore.xml import FT
|
|
17
18
|
from pydantic import BaseModel
|
|
18
19
|
|
|
20
|
+
from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
|
|
19
21
|
from fh_pydantic_form.field_renderers import (
|
|
20
22
|
BaseFieldRenderer,
|
|
21
23
|
ListFieldRenderer,
|
|
@@ -27,6 +29,13 @@ from fh_pydantic_form.form_parser import (
|
|
|
27
29
|
_parse_non_list_fields,
|
|
28
30
|
)
|
|
29
31
|
from fh_pydantic_form.registry import FieldRendererRegistry
|
|
32
|
+
from fh_pydantic_form.type_helpers import _UNSET, get_default
|
|
33
|
+
from fh_pydantic_form.ui_style import (
|
|
34
|
+
SpacingTheme,
|
|
35
|
+
SpacingValue,
|
|
36
|
+
_normalize_spacing,
|
|
37
|
+
spacing,
|
|
38
|
+
)
|
|
30
39
|
|
|
31
40
|
logger = logging.getLogger(__name__)
|
|
32
41
|
|
|
@@ -181,7 +190,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
181
190
|
|
|
182
191
|
class PydanticForm(Generic[ModelType]):
|
|
183
192
|
"""
|
|
184
|
-
Renders a form from a Pydantic model class
|
|
193
|
+
Renders a form from a Pydantic model class with robust schema drift handling
|
|
194
|
+
|
|
195
|
+
Accepts initial values as either BaseModel instances or dictionaries.
|
|
196
|
+
Gracefully handles missing fields and schema mismatches by rendering
|
|
197
|
+
available fields and skipping problematic ones.
|
|
185
198
|
|
|
186
199
|
This class handles:
|
|
187
200
|
- Finding appropriate renderers for each field
|
|
@@ -194,15 +207,29 @@ class PydanticForm(Generic[ModelType]):
|
|
|
194
207
|
- validating request data against the model
|
|
195
208
|
"""
|
|
196
209
|
|
|
210
|
+
def _compact_wrapper(self, inner: FT) -> FT:
|
|
211
|
+
"""
|
|
212
|
+
Wrap inner markup with a '.compact-form' div and inject one <style>
|
|
213
|
+
block when compact theme is used.
|
|
214
|
+
"""
|
|
215
|
+
if self.spacing == SpacingTheme.COMPACT:
|
|
216
|
+
from fh_pydantic_form.ui_style import COMPACT_EXTRA_CSS
|
|
217
|
+
|
|
218
|
+
return fh.Div(COMPACT_EXTRA_CSS, inner, cls="compact-form")
|
|
219
|
+
else:
|
|
220
|
+
return inner
|
|
221
|
+
|
|
197
222
|
def __init__(
|
|
198
223
|
self,
|
|
199
224
|
form_name: str,
|
|
200
225
|
model_class: Type[ModelType],
|
|
201
|
-
initial_values: Optional[ModelType] = None,
|
|
226
|
+
initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None,
|
|
202
227
|
custom_renderers: Optional[List[Tuple[Type, Type[BaseFieldRenderer]]]] = None,
|
|
203
228
|
disabled: bool = False,
|
|
204
229
|
disabled_fields: Optional[List[str]] = None,
|
|
205
230
|
label_colors: Optional[Dict[str, str]] = None,
|
|
231
|
+
exclude_fields: Optional[List[str]] = None,
|
|
232
|
+
spacing: SpacingValue = SpacingTheme.NORMAL,
|
|
206
233
|
):
|
|
207
234
|
"""
|
|
208
235
|
Initialize the form renderer
|
|
@@ -210,22 +237,56 @@ class PydanticForm(Generic[ModelType]):
|
|
|
210
237
|
Args:
|
|
211
238
|
form_name: Unique name for this form
|
|
212
239
|
model_class: The Pydantic model class to render
|
|
213
|
-
initial_values:
|
|
240
|
+
initial_values: Initial values as BaseModel instance or dict.
|
|
241
|
+
Missing fields will not be auto-filled with defaults.
|
|
242
|
+
Supports robust handling of schema drift.
|
|
214
243
|
custom_renderers: Optional list of tuples (field_type, renderer_cls) to register
|
|
215
244
|
disabled: Whether all form inputs should be disabled
|
|
216
245
|
disabled_fields: Optional list of top-level field names to disable specifically
|
|
217
246
|
label_colors: Optional dictionary mapping field names to label colors (CSS color values)
|
|
247
|
+
exclude_fields: Optional list of top-level field names to exclude from the form
|
|
248
|
+
spacing: Spacing theme to use for form layout ("normal", "compact", or SpacingTheme enum)
|
|
218
249
|
"""
|
|
219
250
|
self.name = form_name
|
|
220
251
|
self.model_class = model_class
|
|
221
|
-
|
|
222
|
-
self.
|
|
252
|
+
|
|
253
|
+
self.initial_values_dict: Dict[str, Any] = {}
|
|
254
|
+
|
|
255
|
+
# Store initial values as dict for robustness to schema drift
|
|
256
|
+
if initial_values is None:
|
|
257
|
+
self.initial_values_dict = {}
|
|
258
|
+
elif isinstance(initial_values, dict):
|
|
259
|
+
self.initial_values_dict = initial_values.copy()
|
|
260
|
+
elif hasattr(initial_values, "model_dump"):
|
|
261
|
+
self.initial_values_dict = initial_values.model_dump()
|
|
262
|
+
else:
|
|
263
|
+
# Fallback - attempt dict conversion
|
|
264
|
+
try:
|
|
265
|
+
temp_dict = dict(initial_values)
|
|
266
|
+
model_field_names = set(self.model_class.model_fields.keys())
|
|
267
|
+
# Only accept if all keys are in the model's field names
|
|
268
|
+
if not isinstance(temp_dict, dict) or not set(
|
|
269
|
+
temp_dict.keys()
|
|
270
|
+
).issubset(model_field_names):
|
|
271
|
+
raise ValueError("Converted to dict with keys not in model fields")
|
|
272
|
+
self.initial_values_dict = temp_dict
|
|
273
|
+
except (TypeError, ValueError):
|
|
274
|
+
logger.warning(
|
|
275
|
+
"Could not convert initial_values to dict, using empty dict"
|
|
276
|
+
)
|
|
277
|
+
self.initial_values_dict = {}
|
|
278
|
+
|
|
279
|
+
# Use copy for rendering to avoid mutations
|
|
280
|
+
self.values_dict: Dict[str, Any] = self.initial_values_dict.copy()
|
|
281
|
+
|
|
223
282
|
self.base_prefix = f"{form_name}_"
|
|
224
283
|
self.disabled = disabled
|
|
225
284
|
self.disabled_fields = (
|
|
226
285
|
disabled_fields or []
|
|
227
286
|
) # Store as list for easier checking
|
|
228
287
|
self.label_colors = label_colors or {} # Store label colors mapping
|
|
288
|
+
self.exclude_fields = exclude_fields or [] # Store excluded fields list
|
|
289
|
+
self.spacing = _normalize_spacing(spacing) # Store normalized spacing
|
|
229
290
|
|
|
230
291
|
# Register custom renderers with the global registry if provided
|
|
231
292
|
if custom_renderers:
|
|
@@ -247,11 +308,22 @@ class PydanticForm(Generic[ModelType]):
|
|
|
247
308
|
)
|
|
248
309
|
|
|
249
310
|
for field_name, field_info in self.model_class.model_fields.items():
|
|
250
|
-
#
|
|
311
|
+
# Skip excluded fields
|
|
312
|
+
if field_name in self.exclude_fields:
|
|
313
|
+
logger.debug(f"Skipping excluded field: {field_name}")
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# Only use what was explicitly provided in initial values
|
|
251
317
|
initial_value = (
|
|
252
318
|
self.values_dict.get(field_name) if self.values_dict else None
|
|
253
319
|
)
|
|
254
320
|
|
|
321
|
+
# Only use model defaults if field was not provided at all
|
|
322
|
+
# (not if it was provided as None/empty)
|
|
323
|
+
field_was_provided = (
|
|
324
|
+
field_name in self.values_dict if self.values_dict else False
|
|
325
|
+
)
|
|
326
|
+
|
|
255
327
|
# Log the initial value type and a summary for debugging
|
|
256
328
|
if initial_value is not None:
|
|
257
329
|
value_type = type(initial_value).__name__
|
|
@@ -259,26 +331,39 @@ class PydanticForm(Generic[ModelType]):
|
|
|
259
331
|
value_size = f"size={len(initial_value)}"
|
|
260
332
|
else:
|
|
261
333
|
value_size = ""
|
|
262
|
-
logger.debug(
|
|
334
|
+
logger.debug(
|
|
335
|
+
f"Field '{field_name}': {value_type} {value_size} (provided: {field_was_provided})"
|
|
336
|
+
)
|
|
263
337
|
else:
|
|
264
338
|
logger.debug(
|
|
265
|
-
f"Field '{field_name}': None (
|
|
339
|
+
f"Field '{field_name}': None (provided: {field_was_provided})"
|
|
266
340
|
)
|
|
267
341
|
|
|
268
|
-
#
|
|
269
|
-
if
|
|
342
|
+
# Only use defaults if field was not provided at all
|
|
343
|
+
if not field_was_provided:
|
|
344
|
+
# Field not provided - use model defaults
|
|
270
345
|
if field_info.default is not None:
|
|
271
346
|
initial_value = field_info.default
|
|
272
347
|
logger.debug(f" - Using default value for '{field_name}'")
|
|
273
348
|
elif getattr(field_info, "default_factory", None) is not None:
|
|
274
349
|
try:
|
|
275
|
-
|
|
276
|
-
|
|
350
|
+
default_factory = field_info.default_factory
|
|
351
|
+
if callable(default_factory):
|
|
352
|
+
initial_value = default_factory()
|
|
353
|
+
logger.debug(
|
|
354
|
+
f" - Using default_factory for '{field_name}'"
|
|
355
|
+
)
|
|
356
|
+
else:
|
|
357
|
+
initial_value = None
|
|
358
|
+
logger.warning(
|
|
359
|
+
f" - default_factory for '{field_name}' is not callable"
|
|
360
|
+
)
|
|
277
361
|
except Exception as e:
|
|
278
362
|
initial_value = None
|
|
279
363
|
logger.warning(
|
|
280
364
|
f" - Error in default_factory for '{field_name}': {e}"
|
|
281
365
|
)
|
|
366
|
+
# If field was provided (even as None), respect that value
|
|
282
367
|
|
|
283
368
|
# Get renderer from global registry
|
|
284
369
|
renderer_cls = registry.get_renderer(field_name, field_info)
|
|
@@ -307,21 +392,28 @@ class PydanticForm(Generic[ModelType]):
|
|
|
307
392
|
prefix=self.base_prefix,
|
|
308
393
|
disabled=is_field_disabled, # Pass the calculated disabled state
|
|
309
394
|
label_color=label_color, # Pass the label color if specified
|
|
395
|
+
spacing=self.spacing, # Pass the spacing
|
|
310
396
|
)
|
|
311
397
|
|
|
312
398
|
rendered_field = renderer.render()
|
|
313
399
|
form_inputs.append(rendered_field)
|
|
314
400
|
|
|
315
401
|
# Create container for inputs, ensuring items stretch to full width
|
|
316
|
-
inputs_container = mui.DivVStacked(
|
|
402
|
+
inputs_container = mui.DivVStacked(
|
|
403
|
+
*form_inputs,
|
|
404
|
+
cls=f"{spacing('stack_gap', self.spacing)} items-stretch",
|
|
405
|
+
)
|
|
317
406
|
|
|
318
407
|
# Define the ID for the wrapper div - this is what the HTMX request targets
|
|
319
408
|
form_content_wrapper_id = f"{self.name}-inputs-wrapper"
|
|
320
409
|
logger.debug(f"Creating form inputs wrapper with ID: {form_content_wrapper_id}")
|
|
321
410
|
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
411
|
+
# Create the wrapper div and apply compact styling if needed
|
|
412
|
+
wrapped = self._compact_wrapper(
|
|
413
|
+
fh.Div(inputs_container, id=form_content_wrapper_id)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return wrapped
|
|
325
417
|
|
|
326
418
|
# ---- Form Renderer Methods (continued) ----
|
|
327
419
|
|
|
@@ -351,9 +443,9 @@ class PydanticForm(Generic[ModelType]):
|
|
|
351
443
|
f"Error parsing form data for refresh on form '{self.name}': {e}",
|
|
352
444
|
exc_info=True,
|
|
353
445
|
)
|
|
354
|
-
# Fallback: Use original initial
|
|
446
|
+
# Fallback: Use original initial values dict if available, otherwise empty dict
|
|
355
447
|
parsed_data = (
|
|
356
|
-
self.
|
|
448
|
+
self.initial_values_dict.copy() if self.initial_values_dict else {}
|
|
357
449
|
)
|
|
358
450
|
alert_ft = mui.Alert(
|
|
359
451
|
f"Warning: Could not fully process current form values for refresh. Display might not be fully updated. Error: {str(e)}",
|
|
@@ -365,6 +457,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
365
457
|
form_name=self.name,
|
|
366
458
|
model_class=self.model_class,
|
|
367
459
|
# No initial_data needed here, we set values_dict below
|
|
460
|
+
spacing=self.spacing,
|
|
368
461
|
)
|
|
369
462
|
# Set the values based on the parsed (or fallback) data
|
|
370
463
|
temp_renderer.values_dict = parsed_data
|
|
@@ -395,27 +488,28 @@ class PydanticForm(Generic[ModelType]):
|
|
|
395
488
|
Returns:
|
|
396
489
|
HTML response with reset form inputs
|
|
397
490
|
"""
|
|
398
|
-
logger.info(
|
|
399
|
-
f"Resetting form '{self.name}' to initial values. Initial model: {self.initial_data_model}"
|
|
400
|
-
)
|
|
491
|
+
logger.info(f"Resetting form '{self.name}' to initial values")
|
|
401
492
|
|
|
402
|
-
# Create
|
|
493
|
+
# Create temporary renderer with original initial dict
|
|
403
494
|
temp_renderer = PydanticForm(
|
|
404
495
|
form_name=self.name,
|
|
405
496
|
model_class=self.model_class,
|
|
406
|
-
initial_values=self.
|
|
497
|
+
initial_values=self.initial_values_dict, # Use dict instead of BaseModel
|
|
498
|
+
custom_renderers=getattr(self, "custom_renderers", None),
|
|
499
|
+
disabled=self.disabled,
|
|
500
|
+
disabled_fields=self.disabled_fields,
|
|
501
|
+
label_colors=self.label_colors,
|
|
502
|
+
exclude_fields=self.exclude_fields,
|
|
503
|
+
spacing=self.spacing,
|
|
407
504
|
)
|
|
408
505
|
|
|
409
|
-
# Render inputs with the initial data
|
|
410
506
|
reset_inputs_component = temp_renderer.render_inputs()
|
|
411
507
|
|
|
412
508
|
if reset_inputs_component is None:
|
|
413
509
|
logger.error(f"Reset for form '{self.name}' failed to render inputs.")
|
|
414
510
|
return mui.Alert("Error resetting form.", cls=mui.AlertT.error)
|
|
415
511
|
|
|
416
|
-
logger.info(
|
|
417
|
-
f"Reset form '{self.name}' successful. Component: {reset_inputs_component}"
|
|
418
|
-
)
|
|
512
|
+
logger.info(f"Reset form '{self.name}' successful")
|
|
419
513
|
return reset_inputs_component
|
|
420
514
|
|
|
421
515
|
def parse(self, form_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -434,20 +528,93 @@ class PydanticForm(Generic[ModelType]):
|
|
|
434
528
|
|
|
435
529
|
list_field_defs = _identify_list_fields(self.model_class)
|
|
436
530
|
|
|
437
|
-
#
|
|
531
|
+
# Filter out excluded fields from list field definitions
|
|
532
|
+
filtered_list_field_defs = {
|
|
533
|
+
field_name: field_def
|
|
534
|
+
for field_name, field_def in list_field_defs.items()
|
|
535
|
+
if field_name not in self.exclude_fields
|
|
536
|
+
}
|
|
438
537
|
|
|
538
|
+
# Parse non-list fields first - pass the base_prefix and exclude_fields
|
|
439
539
|
result = _parse_non_list_fields(
|
|
440
|
-
form_dict,
|
|
540
|
+
form_dict,
|
|
541
|
+
self.model_class,
|
|
542
|
+
list_field_defs,
|
|
543
|
+
self.base_prefix,
|
|
544
|
+
self.exclude_fields,
|
|
441
545
|
)
|
|
442
546
|
|
|
443
547
|
# Parse list fields based on keys present in form_dict - pass the base_prefix
|
|
444
|
-
|
|
548
|
+
# Use filtered list field definitions to skip excluded list fields
|
|
549
|
+
list_results = _parse_list_fields(
|
|
550
|
+
form_dict, filtered_list_field_defs, self.base_prefix
|
|
551
|
+
)
|
|
445
552
|
|
|
446
553
|
# Merge list results into the main result
|
|
447
554
|
result.update(list_results)
|
|
448
555
|
|
|
556
|
+
# Inject defaults for excluded fields before returning
|
|
557
|
+
self._inject_default_values_for_excluded(result)
|
|
558
|
+
|
|
449
559
|
return result
|
|
450
560
|
|
|
561
|
+
def _inject_default_values_for_excluded(
|
|
562
|
+
self, data: Dict[str, Any]
|
|
563
|
+
) -> Dict[str, Any]:
|
|
564
|
+
"""
|
|
565
|
+
Ensures that every field listed in self.exclude_fields is present in data
|
|
566
|
+
if the model defines a default or default_factory, or if initial_values were provided.
|
|
567
|
+
|
|
568
|
+
Priority order:
|
|
569
|
+
1. initial_values (if provided during form creation)
|
|
570
|
+
2. model defaults/default_factory
|
|
571
|
+
|
|
572
|
+
Operates top-level only (exclude_fields spec is top-level names).
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
data: Dictionary to modify in-place
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
The same dictionary instance for method chaining
|
|
579
|
+
"""
|
|
580
|
+
for field_name in self.exclude_fields:
|
|
581
|
+
# Skip if already present (e.g., user provided initial_values)
|
|
582
|
+
if field_name in data:
|
|
583
|
+
continue
|
|
584
|
+
|
|
585
|
+
# First priority: check if initial_values_dict has this field
|
|
586
|
+
if field_name in self.initial_values_dict:
|
|
587
|
+
initial_val = self.initial_values_dict[field_name]
|
|
588
|
+
# If the initial value is a BaseModel, convert to dict for consistency
|
|
589
|
+
if hasattr(initial_val, "model_dump"):
|
|
590
|
+
initial_val = initial_val.model_dump()
|
|
591
|
+
data[field_name] = initial_val
|
|
592
|
+
logger.debug(
|
|
593
|
+
f"Injected initial value for excluded field '{field_name}'"
|
|
594
|
+
)
|
|
595
|
+
continue
|
|
596
|
+
|
|
597
|
+
# Second priority: use model defaults
|
|
598
|
+
field_info = self.model_class.model_fields.get(field_name)
|
|
599
|
+
if field_info is None:
|
|
600
|
+
logger.warning(f"exclude_fields contains unknown field '{field_name}'")
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
default_val = get_default(field_info)
|
|
604
|
+
if default_val is not _UNSET:
|
|
605
|
+
# If the default is a BaseModel, convert to dict for consistency
|
|
606
|
+
if hasattr(default_val, "model_dump"):
|
|
607
|
+
default_val = default_val.model_dump()
|
|
608
|
+
data[field_name] = default_val
|
|
609
|
+
logger.debug(
|
|
610
|
+
f"Injected model default value for excluded field '{field_name}'"
|
|
611
|
+
)
|
|
612
|
+
else:
|
|
613
|
+
# No default → leave missing; validation will surface error
|
|
614
|
+
logger.debug(f"No default found for excluded field '{field_name}'")
|
|
615
|
+
|
|
616
|
+
return data
|
|
617
|
+
|
|
451
618
|
def register_routes(self, app):
|
|
452
619
|
"""
|
|
453
620
|
Register HTMX routes for list manipulation and form refresh
|
|
@@ -521,40 +688,29 @@ class PydanticForm(Generic[ModelType]):
|
|
|
521
688
|
cls=mui.AlertT.error,
|
|
522
689
|
)
|
|
523
690
|
|
|
524
|
-
# Create a default item
|
|
691
|
+
# Create a default item using smart defaults
|
|
692
|
+
default_item_dict: Dict[str, Any] | str | Any | None = None
|
|
525
693
|
try:
|
|
526
|
-
|
|
527
|
-
if item_type:
|
|
528
|
-
# For Pydantic models, try to use model_construct for default values
|
|
529
|
-
if hasattr(item_type, "model_construct"):
|
|
530
|
-
try:
|
|
531
|
-
default_item = item_type.model_construct()
|
|
532
|
-
except Exception as e:
|
|
533
|
-
return fh.Li(
|
|
534
|
-
mui.Alert(
|
|
535
|
-
f"Error creating model instance: {str(e)}",
|
|
536
|
-
cls=mui.AlertT.error,
|
|
537
|
-
),
|
|
538
|
-
cls="mb-2",
|
|
539
|
-
)
|
|
540
|
-
# Handle simple types with appropriate defaults
|
|
541
|
-
elif item_type is str:
|
|
542
|
-
default_item = ""
|
|
543
|
-
elif item_type is int:
|
|
544
|
-
default_item = 0
|
|
545
|
-
elif item_type is float:
|
|
546
|
-
default_item = 0.0
|
|
547
|
-
elif item_type is bool:
|
|
548
|
-
default_item = False
|
|
549
|
-
else:
|
|
550
|
-
default_item = None
|
|
551
|
-
else:
|
|
552
|
-
# Case where item_type itself was None (should ideally be caught earlier)
|
|
553
|
-
default_item = None
|
|
694
|
+
if not item_type:
|
|
554
695
|
logger.warning(
|
|
555
696
|
f"item_type was None when trying to create default for {field_name}"
|
|
556
697
|
)
|
|
698
|
+
default_item_dict = ""
|
|
699
|
+
elif hasattr(item_type, "model_fields"):
|
|
700
|
+
# For Pydantic models, use smart default generation
|
|
701
|
+
default_item_dict = default_dict_for_model(item_type)
|
|
702
|
+
else:
|
|
703
|
+
# For simple types, use annotation-based defaults
|
|
704
|
+
default_item_dict = default_for_annotation(item_type)
|
|
705
|
+
|
|
706
|
+
# Final fallback for exotic cases
|
|
707
|
+
if default_item_dict is None:
|
|
708
|
+
default_item_dict = ""
|
|
709
|
+
|
|
557
710
|
except Exception as e:
|
|
711
|
+
logger.error(
|
|
712
|
+
f"Error creating default item for {field_name}: {e}", exc_info=True
|
|
713
|
+
)
|
|
558
714
|
return fh.Li(
|
|
559
715
|
mui.Alert(
|
|
560
716
|
f"Error creating default item: {str(e)}", cls=mui.AlertT.error
|
|
@@ -565,17 +721,33 @@ class PydanticForm(Generic[ModelType]):
|
|
|
565
721
|
# Generate a unique placeholder index
|
|
566
722
|
placeholder_idx = f"new_{int(pytime.time() * 1000)}"
|
|
567
723
|
|
|
568
|
-
# Create a list renderer
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
724
|
+
# Create a list renderer
|
|
725
|
+
if field_info is not None:
|
|
726
|
+
list_renderer = ListFieldRenderer(
|
|
727
|
+
field_name=field_name,
|
|
728
|
+
field_info=field_info,
|
|
729
|
+
value=[], # Empty list, we only need to render one item
|
|
730
|
+
prefix=self.base_prefix, # Use the form's base prefix
|
|
731
|
+
)
|
|
732
|
+
else:
|
|
733
|
+
logger.error(f"field_info is None for field {field_name}")
|
|
734
|
+
return mui.Alert(
|
|
735
|
+
f"Field info not found for {field_name}",
|
|
736
|
+
cls=mui.AlertT.error,
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
# The default_item_dict is already in the correct format (dict for models, primitive for simple types)
|
|
740
|
+
item_data_for_renderer = default_item_dict
|
|
741
|
+
logger.debug(
|
|
742
|
+
f"Add item: Using smart default for renderer: {item_data_for_renderer}"
|
|
574
743
|
)
|
|
575
744
|
|
|
576
745
|
# Render the new item card, set is_open=True to make it expanded by default
|
|
577
746
|
new_item_card = list_renderer._render_item_card(
|
|
578
|
-
|
|
747
|
+
item_data_for_renderer, # Pass the dictionary or simple value
|
|
748
|
+
placeholder_idx,
|
|
749
|
+
item_type,
|
|
750
|
+
is_open=True,
|
|
579
751
|
)
|
|
580
752
|
|
|
581
753
|
return new_item_card
|