fh-pydantic-form 0.1.3__py3-none-any.whl → 0.2.1__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,145 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ from typing import List, Tuple, Type, get_origin, get_args
4
+ from pydantic import BaseModel
5
+ from pydantic.fields import FieldInfo
6
+
7
+ from fh_pydantic_form.type_helpers import _get_underlying_type_if_optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def walk_path(
13
+ model: Type[BaseModel], segments: List[str]
14
+ ) -> Tuple[FieldInfo, List[str], Type]:
15
+ """
16
+ Resolve `segments` against `model`, stopping at the *list* field.
17
+
18
+ Args:
19
+ model: The BaseModel class to traverse
20
+ segments: Path segments like ["main_address", "tags"] or ["other_addresses", "1", "tags"]
21
+ The final segment should always be a list field name.
22
+
23
+ Returns:
24
+ Tuple of:
25
+ - list_field_info: the FieldInfo for the target list field
26
+ - html_prefix_parts: segments used to build element IDs (includes indices)
27
+ - item_type: the concrete python type of items in the list
28
+
29
+ Raises:
30
+ ValueError: if the path is invalid or doesn't lead to a list field
31
+ """
32
+ if not segments:
33
+ raise ValueError("Empty path provided")
34
+
35
+ current_model = model
36
+ html_parts = []
37
+ i = 0
38
+
39
+ # Process all segments except the last one (which should be the list field)
40
+ while i < len(segments) - 1:
41
+ segment = segments[i]
42
+
43
+ # Check if this segment is a field name
44
+ if segment in current_model.model_fields:
45
+ field_info = current_model.model_fields[segment]
46
+ field_type = _get_underlying_type_if_optional(field_info.annotation)
47
+ html_parts.append(segment)
48
+
49
+ # Check if this is a list field (we're traversing into a list element)
50
+ if get_origin(field_type) is list:
51
+ # Next segment should be an index
52
+ if i + 1 >= len(segments) - 1:
53
+ raise ValueError(f"Expected index after list field '{segment}'")
54
+
55
+ next_segment = segments[i + 1]
56
+ if not _is_index_segment(next_segment):
57
+ raise ValueError(
58
+ f"Expected index after list field '{segment}', got '{next_segment}'"
59
+ )
60
+
61
+ # Get the item type of the list
62
+ list_item_type = (
63
+ get_args(field_type)[0] if get_args(field_type) else None
64
+ )
65
+ if not list_item_type or not hasattr(list_item_type, "model_fields"):
66
+ raise ValueError(
67
+ f"List field '{segment}' does not contain BaseModel items"
68
+ )
69
+
70
+ # Add the index to html_parts and update current model
71
+ html_parts.append(next_segment)
72
+ current_model = list_item_type
73
+
74
+ # Skip the next segment (the index) since we processed it
75
+ i += 2
76
+ continue
77
+
78
+ # Check if this is a BaseModel field
79
+ elif hasattr(field_type, "model_fields"):
80
+ current_model = field_type
81
+ i += 1
82
+ else:
83
+ raise ValueError(f"Field '{segment}' is not a BaseModel or list type")
84
+
85
+ elif _is_index_segment(segment):
86
+ # This should only happen if we're processing an index that wasn't handled above
87
+ raise ValueError(
88
+ f"Unexpected index segment '{segment}' without preceding list field"
89
+ )
90
+ else:
91
+ raise ValueError(
92
+ f"Field '{segment}' not found in model {current_model.__name__}"
93
+ )
94
+
95
+ # Process the final segment (should be a list field)
96
+ final_field_name = segments[-1]
97
+ if final_field_name not in current_model.model_fields:
98
+ raise ValueError(
99
+ f"Field '{final_field_name}' not found in model {current_model.__name__}"
100
+ )
101
+
102
+ list_field_info = current_model.model_fields[final_field_name]
103
+ list_field_type = _get_underlying_type_if_optional(list_field_info.annotation)
104
+
105
+ # Verify this is actually a list field
106
+ if get_origin(list_field_type) is not list:
107
+ raise ValueError(f"Final field '{final_field_name}' is not a list type")
108
+
109
+ # Get the item type
110
+ item_type_args = get_args(list_field_type)
111
+ if not item_type_args:
112
+ raise ValueError(
113
+ f"Cannot determine item type for list field '{final_field_name}'"
114
+ )
115
+
116
+ item_type = item_type_args[0]
117
+ html_parts.append(final_field_name)
118
+
119
+ logger.debug(
120
+ f"walk_path resolved: {segments} -> field_info={list_field_info}, html_parts={html_parts}, item_type={item_type}"
121
+ )
122
+
123
+ return list_field_info, html_parts, item_type
124
+
125
+
126
+ def _is_index_segment(segment: str) -> bool:
127
+ """
128
+ Check if a segment represents an index (purely numeric or placeholder like 'new_1234').
129
+
130
+ Args:
131
+ segment: The segment to check
132
+
133
+ Returns:
134
+ True if the segment represents an index
135
+ """
136
+ # Pure numeric (like "0", "1", "2")
137
+ if segment.isdigit():
138
+ return True
139
+
140
+ # Placeholder format (like "new_1234567890")
141
+ if segment.startswith("new_") and len(segment) > 4:
142
+ timestamp_part = segment[4:]
143
+ return timestamp_part.isdigit()
144
+
145
+ return False
@@ -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 is Union:
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,134 @@
1
+ from enum import Enum, auto
2
+ from typing import Dict, Literal, Union
3
+
4
+ import fasthtml.common as fh
5
+
6
+
7
+ class SpacingTheme(Enum):
8
+ NORMAL = auto()
9
+ COMPACT = auto()
10
+
11
+
12
+ # Type alias for spacing values - supports both literal strings and enum values
13
+ SpacingValue = Union[Literal["normal", "compact"], SpacingTheme]
14
+
15
+
16
+ def _normalize_spacing(spacing_value: SpacingValue) -> SpacingTheme:
17
+ """Convert literal string or enum spacing value to SpacingTheme enum."""
18
+ if isinstance(spacing_value, str):
19
+ if spacing_value == "compact":
20
+ return SpacingTheme.COMPACT
21
+ elif spacing_value == "normal":
22
+ return SpacingTheme.NORMAL
23
+ else:
24
+ # This case shouldn't happen with proper Literal typing, but included for runtime safety
25
+ raise ValueError(
26
+ f"Invalid spacing value: {spacing_value}. Must be 'compact', 'normal', or SpacingTheme enum"
27
+ )
28
+ elif isinstance(spacing_value, SpacingTheme):
29
+ return spacing_value
30
+ else:
31
+ raise TypeError(
32
+ f"spacing must be Literal['normal', 'compact'] or SpacingTheme, got {type(spacing_value)}"
33
+ )
34
+
35
+
36
+ SPACING_MAP: Dict[SpacingTheme, Dict[str, str]] = {
37
+ SpacingTheme.NORMAL: {
38
+ "outer_margin": "mb-4",
39
+ "outer_margin_sm": "mb-2",
40
+ "inner_gap": "space-y-3",
41
+ "inner_gap_small": "space-y-2",
42
+ "stack_gap": "space-y-3",
43
+ "padding": "p-4",
44
+ "padding_sm": "p-3",
45
+ "padding_card": "px-4 py-3",
46
+ "card_border": "border",
47
+ "section_divider": "border-t border-gray-200",
48
+ "accordion_divider": "uk-accordion-divider",
49
+ "label_gap": "mb-1",
50
+ "card_body_pad": "px-4 py-3",
51
+ "accordion_content": "",
52
+ "input_size": "",
53
+ "input_padding": "",
54
+ "horizontal_gap": "gap-3",
55
+ "label_align": "items-start",
56
+ },
57
+ SpacingTheme.COMPACT: {
58
+ "outer_margin": "mb-0",
59
+ "outer_margin_sm": "mb-0",
60
+ "inner_gap": "space-y-1",
61
+ "inner_gap_small": "space-y-0.5",
62
+ "stack_gap": "space-y-1",
63
+ "padding": "p-1",
64
+ "padding_sm": "p-0.5",
65
+ "padding_card": "px-2 py-1",
66
+ "card_border": "",
67
+ "section_divider": "",
68
+ "accordion_divider": "",
69
+ "label_gap": "mb-0",
70
+ "card_body_pad": "px-2 py-0.5",
71
+ "accordion_content": "uk-padding-remove-vertical",
72
+ "input_size": "uk-form-small",
73
+ "input_padding": "p-1",
74
+ "horizontal_gap": "gap-2",
75
+ "label_align": "items-center",
76
+ },
77
+ }
78
+
79
+
80
+ def spacing(token: str, spacing: SpacingValue) -> str:
81
+ """Return a Tailwind utility class for the given semantic token."""
82
+ theme = _normalize_spacing(spacing)
83
+ return SPACING_MAP[theme][token]
84
+
85
+
86
+ # Optional minimal CSS for compact mode - affects only form inputs, not layout
87
+ # Host applications can optionally inject this once at app level if desired
88
+ COMPACT_EXTRA_CSS = fh.Style("""
89
+ /* Compact polish – applies ONLY inside .fhpf-compact ------------------- */
90
+ .fhpf-compact {
91
+
92
+ /* Accordion chrome: remove border and default 20 px gap */
93
+ .uk-accordion > li,
94
+ .uk-accordion > li + li { /* second & later items */
95
+ border-top: 0 !important;
96
+ margin-top: 0 !important;
97
+ }
98
+ .uk-accordion-title::after { /* the hair-line we still see */
99
+ border-top: 0 !important;
100
+ }
101
+
102
+ /* Tighter title and content padding */
103
+ li > a.uk-accordion-title,
104
+ .uk-accordion-content {
105
+ padding-top: 0.25rem !important;
106
+ padding-bottom: 0.25rem !important;
107
+ }
108
+
109
+ /* Remove residual card outline */
110
+ .uk-card,
111
+ .uk-card-body { border: 0 !important; }
112
+
113
+ /* Small-size inputs */
114
+ input, select, textarea {
115
+ line-height: 1.25rem !important;
116
+ font-size: 0.8125rem !important;
117
+ padding-top: 0.25rem !important;
118
+ padding-bottom: 0.25rem !important;
119
+ }
120
+
121
+ /* Legacy uk-form-small support */
122
+ input.uk-form-small,
123
+ select.uk-form-small,
124
+ textarea.uk-textarea-small {
125
+ padding-top: 2px !important;
126
+ padding-bottom: 2px !important;
127
+ }
128
+
129
+ /* Kill generic uk-margin utilities inside the form */
130
+ .uk-margin-small-bottom,
131
+ .uk-margin,
132
+ .uk-margin-bottom { margin-bottom: 2px !important; }
133
+ }
134
+ """)