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/type_helpers.py
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from types import UnionType
|
|
1
4
|
from typing import Any, Literal, Union, get_args, get_origin
|
|
2
5
|
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
# Sentinel value to indicate no default is available
|
|
9
|
+
_UNSET = object()
|
|
10
|
+
|
|
3
11
|
|
|
4
12
|
def _is_optional_type(annotation: Any) -> bool:
|
|
5
13
|
"""
|
|
@@ -12,13 +20,23 @@ def _is_optional_type(annotation: Any) -> bool:
|
|
|
12
20
|
True if the annotation is Optional[T], False otherwise
|
|
13
21
|
"""
|
|
14
22
|
origin = get_origin(annotation)
|
|
15
|
-
if origin
|
|
23
|
+
if origin in (Union, UnionType):
|
|
16
24
|
args = get_args(annotation)
|
|
17
25
|
# Check if NoneType is one of the args and there are exactly two args
|
|
18
26
|
return len(args) == 2 and type(None) in args
|
|
19
27
|
return False
|
|
20
28
|
|
|
21
29
|
|
|
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
|
+
|
|
22
40
|
def _get_underlying_type_if_optional(annotation: Any) -> Any:
|
|
23
41
|
"""
|
|
24
42
|
Extract the type T from Optional[T], otherwise return the original annotation.
|
|
@@ -40,3 +58,92 @@ def _is_literal_type(annotation: Any) -> bool:
|
|
|
40
58
|
"""Check if the underlying type of an annotation is Literal."""
|
|
41
59
|
underlying_type = _get_underlying_type_if_optional(annotation)
|
|
42
60
|
return get_origin(underlying_type) is Literal
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_enum_type(annotation: Any) -> bool:
|
|
64
|
+
"""Check if the underlying type of an annotation is Enum."""
|
|
65
|
+
underlying_type = _get_underlying_type_if_optional(annotation)
|
|
66
|
+
return isinstance(underlying_type, type) and issubclass(underlying_type, Enum)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_default(field_info: Any) -> Any:
|
|
70
|
+
"""
|
|
71
|
+
Extract the default value from a Pydantic field definition.
|
|
72
|
+
|
|
73
|
+
Handles both literal defaults and default_factory functions.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
field_info: The Pydantic FieldInfo object
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The default value if available, or _UNSET sentinel if no default exists
|
|
80
|
+
"""
|
|
81
|
+
# Check for literal default value (including None, but not Undefined)
|
|
82
|
+
if hasattr(field_info, "default") and not _is_pydantic_undefined(
|
|
83
|
+
field_info.default
|
|
84
|
+
):
|
|
85
|
+
return field_info.default
|
|
86
|
+
|
|
87
|
+
# Check for default_factory
|
|
88
|
+
default_factory = getattr(field_info, "default_factory", None)
|
|
89
|
+
if default_factory is not None and callable(default_factory):
|
|
90
|
+
try:
|
|
91
|
+
return default_factory()
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
logger.warning(f"default_factory failed for field: {exc}")
|
|
94
|
+
# Don't raise - return sentinel to indicate no usable default
|
|
95
|
+
|
|
96
|
+
return _UNSET
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_pydantic_undefined(value: Any) -> bool:
|
|
100
|
+
"""
|
|
101
|
+
Check if a value is Pydantic's Undefined sentinel.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
value: The value to check
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True if the value represents Pydantic's undefined default
|
|
108
|
+
"""
|
|
109
|
+
# Check if value is None first (common case)
|
|
110
|
+
if value is None:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
# Check for various Pydantic undefined markers
|
|
114
|
+
if hasattr(value, "__class__"):
|
|
115
|
+
class_name = value.__class__.__name__
|
|
116
|
+
if class_name in ("Undefined", "PydanticUndefined"):
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
# Check string representation as fallback
|
|
120
|
+
str_repr = str(value)
|
|
121
|
+
if str_repr in ("PydanticUndefined", "<class 'pydantic_core.PydanticUndefined'>"):
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
# Check for pydantic.fields.Undefined (older versions)
|
|
125
|
+
try:
|
|
126
|
+
from pydantic import fields
|
|
127
|
+
|
|
128
|
+
if hasattr(fields, "Undefined") and value is fields.Undefined:
|
|
129
|
+
return True
|
|
130
|
+
except ImportError:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
# Check for pydantic_core.PydanticUndefined (newer versions)
|
|
134
|
+
try:
|
|
135
|
+
import pydantic_core
|
|
136
|
+
|
|
137
|
+
if (
|
|
138
|
+
hasattr(pydantic_core, "PydanticUndefined")
|
|
139
|
+
and value is pydantic_core.PydanticUndefined
|
|
140
|
+
):
|
|
141
|
+
return True
|
|
142
|
+
except ImportError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Local import placed after _UNSET is defined to avoid circular-import problems
|
|
149
|
+
from .defaults import default_for_annotation # noqa: E402
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from enum import Enum, auto
|
|
2
|
+
from typing import Dict, Literal, Union
|
|
3
|
+
import fasthtml.common as fh
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SpacingTheme(Enum):
|
|
7
|
+
NORMAL = auto()
|
|
8
|
+
COMPACT = auto()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Type alias for spacing values - supports both literal strings and enum values
|
|
12
|
+
SpacingValue = Union[Literal["normal", "compact"], SpacingTheme]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_spacing(spacing_value: SpacingValue) -> SpacingTheme:
|
|
16
|
+
"""Convert literal string or enum spacing value to SpacingTheme enum."""
|
|
17
|
+
if isinstance(spacing_value, str):
|
|
18
|
+
if spacing_value == "compact":
|
|
19
|
+
return SpacingTheme.COMPACT
|
|
20
|
+
elif spacing_value == "normal":
|
|
21
|
+
return SpacingTheme.NORMAL
|
|
22
|
+
else:
|
|
23
|
+
# This case shouldn't happen with proper Literal typing, but included for runtime safety
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"Invalid spacing value: {spacing_value}. Must be 'compact', 'normal', or SpacingTheme enum"
|
|
26
|
+
)
|
|
27
|
+
elif isinstance(spacing_value, SpacingTheme):
|
|
28
|
+
return spacing_value
|
|
29
|
+
else:
|
|
30
|
+
raise TypeError(
|
|
31
|
+
f"spacing must be Literal['normal', 'compact'] or SpacingTheme, got {type(spacing_value)}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
SPACING_MAP: Dict[SpacingTheme, Dict[str, str]] = {
|
|
36
|
+
SpacingTheme.NORMAL: {
|
|
37
|
+
"outer_margin": "mb-4",
|
|
38
|
+
"outer_margin_sm": "mb-2",
|
|
39
|
+
"inner_gap": "space-y-3",
|
|
40
|
+
"inner_gap_small": "space-y-2",
|
|
41
|
+
"stack_gap": "space-y-3",
|
|
42
|
+
"padding": "p-4",
|
|
43
|
+
"padding_sm": "p-3",
|
|
44
|
+
"padding_card": "px-4 py-3",
|
|
45
|
+
"card_border": "border",
|
|
46
|
+
"section_divider": "border-t border-gray-200",
|
|
47
|
+
"accordion_divider": "uk-accordion-divider",
|
|
48
|
+
"label_gap": "mb-1",
|
|
49
|
+
"card_body_pad": "px-4 py-3",
|
|
50
|
+
"accordion_content": "",
|
|
51
|
+
"input_size": "",
|
|
52
|
+
"input_padding": "",
|
|
53
|
+
},
|
|
54
|
+
SpacingTheme.COMPACT: {
|
|
55
|
+
"outer_margin": "mb-0.5",
|
|
56
|
+
"outer_margin_sm": "mb-0.5",
|
|
57
|
+
"inner_gap": "",
|
|
58
|
+
"inner_gap_small": "",
|
|
59
|
+
"stack_gap": "",
|
|
60
|
+
"padding": "p-2",
|
|
61
|
+
"padding_sm": "p-1",
|
|
62
|
+
"padding_card": "px-2 py-1",
|
|
63
|
+
"card_border": "",
|
|
64
|
+
"section_divider": "",
|
|
65
|
+
"accordion_divider": "",
|
|
66
|
+
"label_gap": "mb-0",
|
|
67
|
+
"card_body_pad": "px-2 py-0.5",
|
|
68
|
+
"accordion_content": "uk-padding-remove-vertical",
|
|
69
|
+
"input_size": "uk-form-small",
|
|
70
|
+
"input_padding": "p-1",
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def spacing(token: str, spacing: SpacingValue) -> str:
|
|
76
|
+
"""Return a Tailwind utility class for the given semantic token."""
|
|
77
|
+
theme = _normalize_spacing(spacing)
|
|
78
|
+
return SPACING_MAP[theme][token]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# CSS override to kill any residual borders in compact mode
|
|
82
|
+
COMPACT_EXTRA_CSS = fh.Style("""
|
|
83
|
+
/* Aggressive margin reduction for all UIkit margin utilities */
|
|
84
|
+
.compact-form .uk-margin-small-bottom,
|
|
85
|
+
.compact-form .uk-margin,
|
|
86
|
+
.compact-form .uk-margin-bottom {
|
|
87
|
+
margin-bottom: 2px !important;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Remove borders and shrink accordion chrome */
|
|
91
|
+
.compact-form .uk-accordion > li,
|
|
92
|
+
.compact-form .uk-accordion .uk-accordion-content {
|
|
93
|
+
border: 0 !important;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Minimize accordion content padding */
|
|
97
|
+
.compact-form .uk-accordion-content {
|
|
98
|
+
padding-top: 0.25rem !important;
|
|
99
|
+
padding-bottom: 0.25rem !important;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Shrink accordion item title padding */
|
|
103
|
+
.compact-form li.uk-open > a {
|
|
104
|
+
padding-top: 0.25rem;
|
|
105
|
+
padding-bottom: 0.25rem;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Apply smaller font and reduced padding to all form inputs */
|
|
109
|
+
.compact-form input,
|
|
110
|
+
.compact-form select,
|
|
111
|
+
.compact-form textarea {
|
|
112
|
+
line-height: 1.25rem !important; /* ~20px */
|
|
113
|
+
font-size: 0.8125rem !important; /* 13px */
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Legacy overrides for specific UIkit classes */
|
|
117
|
+
.compact-form input.uk-form-small,
|
|
118
|
+
.compact-form select.uk-form-small,
|
|
119
|
+
.compact-form textarea.uk-textarea-small {
|
|
120
|
+
padding-top: 2px !important;
|
|
121
|
+
padding-bottom: 2px !important;
|
|
122
|
+
}
|
|
123
|
+
""")
|