fh-pydantic-form 0.2.3__py3-none-any.whl → 0.2.4__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,12 @@
1
+ """
2
+ Shared constants and sentinel values used across the library.
3
+ """
4
+
5
+
6
+ class _Unset:
7
+ """Sentinel class to indicate an unset value."""
8
+
9
+ pass
10
+
11
+
12
+ _UNSET = _Unset()
@@ -2,11 +2,16 @@ from __future__ import annotations
2
2
 
3
3
  import datetime as _dt
4
4
  from enum import Enum
5
- from typing import Any, get_args, get_origin, Literal
5
+ from typing import Any, Literal, get_args, get_origin
6
6
 
7
7
  from pydantic import BaseModel
8
8
 
9
- from .type_helpers import _UNSET, get_default, _is_optional_type
9
+ from fh_pydantic_form.constants import _UNSET
10
+ from fh_pydantic_form.type_helpers import (
11
+ _is_optional_type,
12
+ _is_skip_json_schema_field,
13
+ get_default,
14
+ )
10
15
 
11
16
 
12
17
  def _today():
@@ -122,6 +127,23 @@ def default_dict_for_model(model_cls: type[BaseModel]) -> dict[str, Any]:
122
127
  continue
123
128
  # --------------------------------------------------------------------
124
129
 
130
+ # Check if this is a SkipJsonSchema field - if so, always get its default
131
+ if _is_skip_json_schema_field(field):
132
+ default_val = get_default(field)
133
+ if default_val is not _UNSET:
134
+ # Handle BaseModel defaults by converting to dict
135
+ if hasattr(default_val, "model_dump"):
136
+ out[name] = default_val.model_dump()
137
+ # Convert enum instances to their values
138
+ elif isinstance(default_val, Enum):
139
+ out[name] = default_val.value
140
+ else:
141
+ out[name] = default_val
142
+ else:
143
+ # No default for SkipJsonSchema field - use smart default
144
+ out[name] = default_for_annotation(field.annotation)
145
+ continue
146
+
125
147
  # 1. Check for model-supplied default or factory
126
148
  default_val = get_default(field) # returns _UNSET if no default
127
149
  if default_val is not _UNSET:
@@ -15,9 +15,9 @@ from fastcore.xml import FT
15
15
  from pydantic import ValidationError
16
16
  from pydantic.fields import FieldInfo
17
17
 
18
+ from fh_pydantic_form.constants import _UNSET
18
19
  from fh_pydantic_form.registry import FieldRendererRegistry
19
20
  from fh_pydantic_form.type_helpers import (
20
- _UNSET,
21
21
  _get_underlying_type_if_optional,
22
22
  _is_optional_type,
23
23
  get_default,
@@ -14,6 +14,7 @@ from fh_pydantic_form.type_helpers import (
14
14
  _is_enum_type,
15
15
  _is_literal_type,
16
16
  _is_optional_type,
17
+ _is_skip_json_schema_field,
17
18
  )
18
19
 
19
20
  logger = logging.getLogger(__name__)
@@ -77,6 +78,11 @@ def _parse_non_list_fields(
77
78
  if field_name in exclude_fields:
78
79
  continue
79
80
 
81
+ # Skip SkipJsonSchema fields - they should not be parsed from form data
82
+ if _is_skip_json_schema_field(field_info):
83
+ logger.debug(f"Skipping SkipJsonSchema field during parsing: {field_name}")
84
+ continue
85
+
80
86
  # Create full key with prefix
81
87
  full_key = f"{base_prefix}{field_name}"
82
88
 
@@ -91,11 +97,21 @@ def _parse_non_list_fields(
91
97
 
92
98
  # Handle Literal fields (including Optional[Literal[...]])
93
99
  elif _is_literal_type(annotation):
94
- result[field_name] = _parse_literal_field(full_key, form_data, field_info)
100
+ if full_key in form_data: # User sent it
101
+ result[field_name] = _parse_literal_field(
102
+ full_key, form_data, field_info
103
+ )
104
+ elif _is_optional_type(annotation): # Optional but omitted
105
+ result[field_name] = None
106
+ # otherwise leave the key out – defaults will be injected later
95
107
 
96
108
  # Handle Enum fields (including Optional[Enum])
97
109
  elif _is_enum_type(annotation):
98
- result[field_name] = _parse_enum_field(full_key, form_data, field_info)
110
+ if full_key in form_data: # User sent it
111
+ result[field_name] = _parse_enum_field(full_key, form_data, field_info)
112
+ elif _is_optional_type(annotation): # Optional but omitted
113
+ result[field_name] = None
114
+ # otherwise leave the key out – defaults will be injected later
99
115
 
100
116
  # Handle nested model fields (including Optional[NestedModel])
101
117
  elif (
@@ -126,8 +142,13 @@ def _parse_non_list_fields(
126
142
 
127
143
  # Handle simple fields
128
144
  else:
129
- # Use updated _parse_simple_field that handles optionality
130
- result[field_name] = _parse_simple_field(full_key, form_data, field_info)
145
+ if full_key in form_data: # User sent it
146
+ result[field_name] = _parse_simple_field(
147
+ full_key, form_data, field_info
148
+ )
149
+ elif _is_optional_type(annotation): # Optional but omitted
150
+ result[field_name] = None
151
+ # otherwise leave the key out – defaults will be injected later
131
152
 
132
153
  return result
133
154
 
@@ -282,6 +303,13 @@ def _parse_nested_model_field(
282
303
  sub_key = f"{current_prefix}{sub_field_name}"
283
304
  annotation = getattr(sub_field_info, "annotation", None)
284
305
 
306
+ # Skip SkipJsonSchema fields - they should not be parsed from form data
307
+ if _is_skip_json_schema_field(sub_field_info):
308
+ logger.debug(
309
+ f"Skipping SkipJsonSchema field in nested model during parsing: {sub_field_name}"
310
+ )
311
+ continue
312
+
285
313
  # Handle based on field type, with Optional unwrapping
286
314
  is_optional = _is_optional_type(annotation)
287
315
  base_type = _get_underlying_type_if_optional(annotation)
@@ -497,20 +525,9 @@ def _parse_list_fields(
497
525
  if items: # Only add if items were found
498
526
  final_lists[field_name] = items
499
527
 
500
- # For any list field that didn't have form data, use its default
501
- for field_name, field_def in list_field_defs.items():
502
- if field_name not in final_lists:
503
- field_info = field_def["field_info"]
504
- if hasattr(field_info, "default") and field_info.default is not None:
505
- final_lists[field_name] = field_info.default
506
- elif (
507
- hasattr(field_info, "default_factory")
508
- and field_info.default_factory is not None
509
- ):
510
- try:
511
- final_lists[field_name] = field_info.default_factory()
512
- except Exception:
513
- pass
528
+ # DON'T set defaults for missing list fields here - let _inject_missing_defaults handle all defaults
529
+ # This allows the proper default injection mechanism to work for missing list fields
530
+ # Only keep this section for excluded fields if needed, but don't inject defaults for all missing fields
514
531
 
515
532
  return final_lists
516
533
 
@@ -17,6 +17,7 @@ import monsterui.all as mui
17
17
  from fastcore.xml import FT
18
18
  from pydantic import BaseModel
19
19
 
20
+ from fh_pydantic_form.constants import _UNSET
20
21
  from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
21
22
  from fh_pydantic_form.field_renderers import (
22
23
  BaseFieldRenderer,
@@ -30,7 +31,7 @@ from fh_pydantic_form.form_parser import (
30
31
  )
31
32
  from fh_pydantic_form.list_path import walk_path
32
33
  from fh_pydantic_form.registry import FieldRendererRegistry
33
- from fh_pydantic_form.type_helpers import _UNSET, get_default
34
+ from fh_pydantic_form.type_helpers import _is_skip_json_schema_field, get_default
34
35
  from fh_pydantic_form.ui_style import (
35
36
  SpacingTheme,
36
37
  SpacingValue,
@@ -344,6 +345,11 @@ class PydanticForm(Generic[ModelType]):
344
345
  logger.debug(f"Skipping excluded field: {field_name}")
345
346
  continue
346
347
 
348
+ # Skip SkipJsonSchema fields (they should not be rendered in the form)
349
+ if _is_skip_json_schema_field(field_info):
350
+ logger.debug(f"Skipping SkipJsonSchema field: {field_name}")
351
+ continue
352
+
347
353
  # Only use what was explicitly provided in initial values
348
354
  initial_value = (
349
355
  self.values_dict.get(field_name) if self.values_dict else None
@@ -578,35 +584,30 @@ class PydanticForm(Generic[ModelType]):
578
584
  # Merge list results into the main result
579
585
  result.update(list_results)
580
586
 
581
- # Inject defaults for excluded fields before returning
582
- self._inject_default_values_for_excluded(result)
587
+ # Inject defaults for missing fields before returning
588
+ self._inject_missing_defaults(result)
583
589
 
584
590
  return result
585
591
 
586
- def _inject_default_values_for_excluded(
587
- self, data: Dict[str, Any]
588
- ) -> Dict[str, Any]:
592
+ def _inject_missing_defaults(self, data: Dict[str, Any]) -> Dict[str, Any]:
589
593
  """
590
- Ensures that every field listed in self.exclude_fields is present in data
591
- if the model defines a default or default_factory, or if initial_values were provided.
592
-
593
- Also ensures all model fields have appropriate defaults if missing.
594
+ Ensures all model fields with defaults are present in data if missing.
595
+ Handles excluded fields, SkipJsonSchema fields, and any other fields
596
+ not rendered in the form.
594
597
 
595
598
  Priority order:
596
599
  1. initial_values (if provided during form creation)
597
600
  2. model defaults/default_factory
598
601
 
599
- Operates top-level only (exclude_fields spec is top-level names).
600
-
601
602
  Args:
602
603
  data: Dictionary to modify in-place
603
604
 
604
605
  Returns:
605
606
  The same dictionary instance for method chaining
606
607
  """
607
- # Handle excluded fields first
608
- for field_name in self.exclude_fields:
609
- # Skip if already present (e.g., user provided initial_values)
608
+ # Process ALL model fields, not just excluded ones
609
+ for field_name, field_info in self.model_class.model_fields.items():
610
+ # Skip if already present in parsed data
610
611
  if field_name in data:
611
612
  continue
612
613
 
@@ -617,17 +618,10 @@ class PydanticForm(Generic[ModelType]):
617
618
  if hasattr(initial_val, "model_dump"):
618
619
  initial_val = initial_val.model_dump()
619
620
  data[field_name] = initial_val
620
- logger.debug(
621
- f"Injected initial value for excluded field '{field_name}'"
622
- )
621
+ logger.debug(f"Injected initial value for missing field '{field_name}'")
623
622
  continue
624
623
 
625
624
  # Second priority: use model defaults
626
- field_info = self.model_class.model_fields.get(field_name)
627
- if field_info is None:
628
- logger.warning(f"exclude_fields contains unknown field '{field_name}'")
629
- continue
630
-
631
625
  default_val = get_default(field_info)
632
626
  if default_val is not _UNSET:
633
627
  # If the default is a BaseModel, convert to dict for consistency
@@ -635,27 +629,17 @@ class PydanticForm(Generic[ModelType]):
635
629
  default_val = default_val.model_dump()
636
630
  data[field_name] = default_val
637
631
  logger.debug(
638
- f"Injected model default value for excluded field '{field_name}'"
632
+ f"Injected model default value for missing field '{field_name}'"
639
633
  )
640
634
  else:
641
- # No default leave missing; validation will surface error
642
- logger.debug(f"No default found for excluded field '{field_name}'")
643
-
644
- # Also handle any other missing fields that should have defaults
645
- for field_name, field_info in self.model_class.model_fields.items():
646
- if field_name not in data:
647
- # Try to inject defaults for missing fields
648
- if field_name in self.initial_values_dict:
649
- initial_val = self.initial_values_dict[field_name]
650
- if hasattr(initial_val, "model_dump"):
651
- initial_val = initial_val.model_dump()
652
- data[field_name] = initial_val
635
+ # Check if this is a SkipJsonSchema field
636
+ if _is_skip_json_schema_field(field_info):
637
+ logger.debug(
638
+ f"No default found for SkipJsonSchema field '{field_name}'"
639
+ )
653
640
  else:
654
- default_val = get_default(field_info)
655
- if default_val is not _UNSET:
656
- if hasattr(default_val, "model_dump"):
657
- default_val = default_val.model_dump()
658
- data[field_name] = default_val
641
+ # No default → leave missing; validation will surface error
642
+ logger.debug(f"No default found for field '{field_name}'")
659
643
 
660
644
  return data
661
645
 
@@ -1,12 +1,88 @@
1
+ # Explicit exports for public API
2
+ __all__ = [
3
+ "_is_optional_type",
4
+ "_get_underlying_type_if_optional",
5
+ "_is_literal_type",
6
+ "_is_enum_type",
7
+ "_is_skip_json_schema_field",
8
+ "default_for_annotation",
9
+ ]
10
+
1
11
  import logging
2
12
  from enum import Enum
3
13
  from types import UnionType
4
- from typing import Any, Literal, Union, get_args, get_origin
14
+ from typing import Annotated, Any, Literal, Union, get_args, get_origin
15
+
16
+ from fh_pydantic_form.constants import _UNSET
5
17
 
6
18
  logger = logging.getLogger(__name__)
7
19
 
8
- # Sentinel value to indicate no default is available
9
- _UNSET = object()
20
+
21
+ def _is_skip_json_schema_field(annotation_or_field_info: Any) -> bool:
22
+ """
23
+ Check if a field annotation or field_info indicates it should be skipped in JSON schema.
24
+
25
+ This handles the pattern where SkipJsonSchema is used with typing.Annotated:
26
+ - Annotated[str, SkipJsonSchema()]
27
+ - SkipJsonSchema[str] (which internally uses Annotated)
28
+ - Field metadata containing SkipJsonSchema (Pydantic 2 behavior)
29
+
30
+ Args:
31
+ annotation_or_field_info: The field annotation or field_info to check
32
+
33
+ Returns:
34
+ True if the field should be skipped in JSON schema
35
+ """
36
+ try:
37
+ from pydantic.json_schema import SkipJsonSchema
38
+
39
+ skip_json_schema_cls = SkipJsonSchema
40
+ except ImportError: # very old Pydantic
41
+ skip_json_schema_cls = None
42
+
43
+ if skip_json_schema_cls is None:
44
+ return False
45
+
46
+ # Check if it's a field_info object with metadata
47
+ if hasattr(annotation_or_field_info, "metadata"):
48
+ metadata = getattr(annotation_or_field_info, "metadata", [])
49
+ if metadata:
50
+ for item in metadata:
51
+ if (
52
+ item is skip_json_schema_cls
53
+ or isinstance(item, skip_json_schema_cls)
54
+ or (
55
+ hasattr(item, "__class__")
56
+ and item.__class__.__name__ == "SkipJsonSchema"
57
+ )
58
+ ):
59
+ return True
60
+
61
+ # Fall back to checking annotation (for backward compatibility)
62
+ annotation = annotation_or_field_info
63
+ if hasattr(annotation_or_field_info, "annotation"):
64
+ annotation = getattr(annotation_or_field_info, "annotation")
65
+
66
+ # 1. Direct or generic alias
67
+ if (
68
+ annotation is skip_json_schema_cls
69
+ or getattr(annotation, "__origin__", None) is skip_json_schema_cls
70
+ ):
71
+ return True
72
+
73
+ # 2. Something like Annotated[T, SkipJsonSchema()]
74
+ if get_origin(annotation) is Annotated:
75
+ for meta in get_args(annotation)[1:]:
76
+ meta_class = getattr(meta, "__class__", None)
77
+ if (
78
+ meta is skip_json_schema_cls # plain class
79
+ or isinstance(meta, skip_json_schema_cls) # instance
80
+ or (meta_class is not None and meta_class.__name__ == "SkipJsonSchema")
81
+ ):
82
+ return True
83
+
84
+ # 3. Fallback – cheap but effective
85
+ return "SkipJsonSchema" in repr(annotation)
10
86
 
11
87
 
12
88
  def _is_optional_type(annotation: Any) -> bool:
@@ -27,16 +103,6 @@ def _is_optional_type(annotation: Any) -> bool:
27
103
  return False
28
104
 
29
105
 
30
- # Explicit exports for public API
31
- __all__ = [
32
- "_is_optional_type",
33
- "_get_underlying_type_if_optional",
34
- "_is_literal_type",
35
- "_is_enum_type",
36
- "default_for_annotation",
37
- ]
38
-
39
-
40
106
  def _get_underlying_type_if_optional(annotation: Any) -> Any:
41
107
  """
42
108
  Extract the type T from Optional[T], otherwise return the original annotation.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-pydantic-form
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: a library to turn any pydantic BaseModel object into a fasthtml/monsterui input form
5
5
  Project-URL: Homepage, https://github.com/Marcura/fh-pydantic-form
6
6
  Project-URL: Repository, https://github.com/Marcura/fh-pydantic-form
@@ -0,0 +1,15 @@
1
+ fh_pydantic_form/__init__.py,sha256=luxohu6NgZDC0nhSIyw5lJGP2A8JQ51Ge1Ga7DYDkF8,4048
2
+ fh_pydantic_form/constants.py,sha256=-N9wzkibFNn-V6cO8iWTQ7_xBvwSr2hBdq-m3apmW4M,169
3
+ fh_pydantic_form/defaults.py,sha256=Pwv46v7e43cykx4Pt01e4nw-6FBkHmPvTZK36ZTZqgA,6068
4
+ fh_pydantic_form/field_renderers.py,sha256=VYvAmLsLhQttlg97g2KGg-VNlS4ohxrPN1O906EJM6I,54984
5
+ fh_pydantic_form/form_parser.py,sha256=3p4SSLCA7wjuNe1izn5Me7x8z_Vhc8fxcUKtHEGdzrI,25375
6
+ fh_pydantic_form/form_renderer.py,sha256=cPd7NbaPOZC8cTvhEZOsy8sf5fH6FomrsR_r6KAFF54,34573
7
+ fh_pydantic_form/list_path.py,sha256=AA8bmDmaYy4rlGIvQOOZ0fP2tgcimNUB2Re5aVGnYc8,5182
8
+ fh_pydantic_form/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ fh_pydantic_form/registry.py,sha256=sufK-85ST3rc3Vu0XmjjjdTqTAqgHr_ZbMGU0xRgTK8,4996
10
+ fh_pydantic_form/type_helpers.py,sha256=FH4yl5FW1KNKvfHzs8TKQinFTC-MUgqDvRTVfPHs1LM,6815
11
+ fh_pydantic_form/ui_style.py,sha256=aIWDWbPBUAQ73nPC5AHZi5cnqA0SIp9ISWwsxFdXXdE,3776
12
+ fh_pydantic_form-0.2.4.dist-info/METADATA,sha256=wkprIEyX02VPPrijmwaTOkmw7XA0TA1P9m2LC6UKc2E,26356
13
+ fh_pydantic_form-0.2.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ fh_pydantic_form-0.2.4.dist-info/licenses/LICENSE,sha256=AOi2eNK3D2aDycRHfPRiuACZ7WPBsKHTV2tTYNl7cls,577
15
+ fh_pydantic_form-0.2.4.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- fh_pydantic_form/__init__.py,sha256=luxohu6NgZDC0nhSIyw5lJGP2A8JQ51Ge1Ga7DYDkF8,4048
2
- fh_pydantic_form/defaults.py,sha256=IzBA_soBOdXP_XAUqfFAtniDQaW6N23hiXmWJD2xq0c,5168
3
- fh_pydantic_form/field_renderers.py,sha256=uJRPoQhsjjRPlT-DGiodumUot0PIpifBrp0C_2OaTMo,54950
4
- fh_pydantic_form/form_parser.py,sha256=9jSJya4TR5q2LMGV_PK-xiAjoEhq-FYKDN27lFNn5n0,24389
5
- fh_pydantic_form/form_renderer.py,sha256=epBmdQtHpoazSe3uiG26inyI4XMp6w2I7-JC7F2rwoQ,35264
6
- fh_pydantic_form/list_path.py,sha256=AA8bmDmaYy4rlGIvQOOZ0fP2tgcimNUB2Re5aVGnYc8,5182
7
- fh_pydantic_form/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- fh_pydantic_form/registry.py,sha256=sufK-85ST3rc3Vu0XmjjjdTqTAqgHr_ZbMGU0xRgTK8,4996
9
- fh_pydantic_form/type_helpers.py,sha256=bWHOxu52yh9_79d_x5L3cfMqnZo856OsbL4sTttDoa4,4367
10
- fh_pydantic_form/ui_style.py,sha256=aIWDWbPBUAQ73nPC5AHZi5cnqA0SIp9ISWwsxFdXXdE,3776
11
- fh_pydantic_form-0.2.3.dist-info/METADATA,sha256=lqqppd9Y4g48AfSDkqMLaFvqI7KzDVxGVz7RQARZjr0,26356
12
- fh_pydantic_form-0.2.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
- fh_pydantic_form-0.2.3.dist-info/licenses/LICENSE,sha256=AOi2eNK3D2aDycRHfPRiuACZ7WPBsKHTV2tTYNl7cls,577
14
- fh_pydantic_form-0.2.3.dist-info/RECORD,,