fh-pydantic-form 0.3.9__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.
- fh_pydantic_form/__init__.py +143 -0
- fh_pydantic_form/color_utils.py +598 -0
- fh_pydantic_form/comparison_form.py +1637 -0
- fh_pydantic_form/constants.py +12 -0
- fh_pydantic_form/defaults.py +188 -0
- fh_pydantic_form/field_renderers.py +2330 -0
- fh_pydantic_form/form_parser.py +756 -0
- fh_pydantic_form/form_renderer.py +1004 -0
- fh_pydantic_form/list_path.py +145 -0
- fh_pydantic_form/py.typed +0 -0
- fh_pydantic_form/registry.py +142 -0
- fh_pydantic_form/type_helpers.py +266 -0
- fh_pydantic_form/ui_style.py +115 -0
- fh_pydantic_form-0.3.9.dist-info/METADATA +1168 -0
- fh_pydantic_form-0.3.9.dist-info/RECORD +17 -0
- fh_pydantic_form-0.3.9.dist-info/WHEEL +4 -0
- fh_pydantic_form-0.3.9.dist-info/licenses/LICENSE +13 -0
|
@@ -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
|
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from logging import getLogger
|
|
2
|
+
from typing import (
|
|
3
|
+
Any,
|
|
4
|
+
ClassVar,
|
|
5
|
+
Dict,
|
|
6
|
+
List,
|
|
7
|
+
Optional,
|
|
8
|
+
Tuple,
|
|
9
|
+
Type,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from pydantic.fields import FieldInfo
|
|
13
|
+
|
|
14
|
+
from fh_pydantic_form.type_helpers import _get_underlying_type_if_optional
|
|
15
|
+
|
|
16
|
+
logger = getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FieldRendererRegistry:
|
|
20
|
+
"""
|
|
21
|
+
Registry for field renderers with support for type and predicate-based registration
|
|
22
|
+
|
|
23
|
+
This registry manages:
|
|
24
|
+
- Type-specific renderers (e.g., for str, int, bool)
|
|
25
|
+
- Type-name-specific renderers (by class name)
|
|
26
|
+
- Predicate-based renderers (e.g., for Literal fields)
|
|
27
|
+
- List item renderers for specialized list item rendering
|
|
28
|
+
|
|
29
|
+
It uses a singleton pattern to ensure consistent registration across the app.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_instance = None # Add class attribute to hold the single instance
|
|
33
|
+
|
|
34
|
+
# Use ClassVar for all registry storage
|
|
35
|
+
_type_renderers: ClassVar[Dict[Type, Any]] = {}
|
|
36
|
+
_type_name_renderers: ClassVar[Dict[str, Any]] = {}
|
|
37
|
+
_predicate_renderers: ClassVar[List[Tuple[Any, Any]]] = []
|
|
38
|
+
_list_item_renderers: ClassVar[Dict[Type, Any]] = {}
|
|
39
|
+
|
|
40
|
+
def __new__(cls, *args, **kwargs):
|
|
41
|
+
if cls._instance is None:
|
|
42
|
+
cls._instance = super().__new__(cls)
|
|
43
|
+
return cls._instance
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def register_type_renderer(cls, field_type: Type, renderer_cls: Any) -> None:
|
|
47
|
+
"""Register a renderer for a field type"""
|
|
48
|
+
cls._type_renderers[field_type] = renderer_cls
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def register_type_name_renderer(
|
|
52
|
+
cls, field_type_name: str, renderer_cls: Any
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Register a renderer for a specific field type name"""
|
|
55
|
+
cls._type_name_renderers[field_type_name] = renderer_cls
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def register_type_renderer_with_predicate(cls, predicate_func, renderer_cls):
|
|
59
|
+
"""
|
|
60
|
+
Register a renderer with a predicate function
|
|
61
|
+
|
|
62
|
+
The predicate function should accept a field_info parameter and return
|
|
63
|
+
True if the renderer should be used for that field.
|
|
64
|
+
"""
|
|
65
|
+
cls._predicate_renderers.append((predicate_func, renderer_cls))
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def register_list_item_renderer(cls, item_type: Type, renderer_cls: Any) -> None:
|
|
69
|
+
"""Register a renderer for list items of a specific type"""
|
|
70
|
+
cls._list_item_renderers[item_type] = renderer_cls
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def get_renderer(cls, field_name: str, field_info: FieldInfo) -> Any:
|
|
74
|
+
"""
|
|
75
|
+
Get the appropriate renderer for a field
|
|
76
|
+
|
|
77
|
+
The selection algorithm:
|
|
78
|
+
1. Check exact type matches
|
|
79
|
+
2. Check predicate renderers (for special cases like Literal fields)
|
|
80
|
+
3. Check for subclass relationships
|
|
81
|
+
4. Fall back to string renderer
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
field_name: The name of the field being rendered
|
|
85
|
+
field_info: The FieldInfo for the field
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A renderer class appropriate for the field
|
|
89
|
+
"""
|
|
90
|
+
# Get the field type (unwrap Optional if present)
|
|
91
|
+
original_annotation = field_info.annotation
|
|
92
|
+
field_type = _get_underlying_type_if_optional(original_annotation)
|
|
93
|
+
|
|
94
|
+
# 1. Check exact type matches first
|
|
95
|
+
if field_type in cls._type_renderers:
|
|
96
|
+
return cls._type_renderers[field_type]
|
|
97
|
+
|
|
98
|
+
# 2. Check predicates second
|
|
99
|
+
for predicate, renderer in cls._predicate_renderers:
|
|
100
|
+
if predicate(field_info):
|
|
101
|
+
return renderer
|
|
102
|
+
|
|
103
|
+
# 3. Check for subclass relationships
|
|
104
|
+
if isinstance(field_type, type):
|
|
105
|
+
for typ, renderer in cls._type_renderers.items():
|
|
106
|
+
try:
|
|
107
|
+
if isinstance(typ, type) and issubclass(field_type, typ):
|
|
108
|
+
return renderer
|
|
109
|
+
except TypeError:
|
|
110
|
+
# Handle non-class types
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
# 4. Fall back to string renderer
|
|
114
|
+
from_imports = globals()
|
|
115
|
+
return from_imports.get("StringFieldRenderer", None)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def get_list_item_renderer(cls, item_type: Type) -> Optional[Any]:
|
|
119
|
+
"""
|
|
120
|
+
Get renderer for summarizing list items of a given type
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
item_type: The type of the list items
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A renderer class for list items, or None if none is registered
|
|
127
|
+
"""
|
|
128
|
+
# Check for exact type match
|
|
129
|
+
if item_type in cls._list_item_renderers:
|
|
130
|
+
return cls._list_item_renderers[item_type]
|
|
131
|
+
|
|
132
|
+
# Check for subclass matches
|
|
133
|
+
for registered_type, renderer in cls._list_item_renderers.items():
|
|
134
|
+
try:
|
|
135
|
+
if isinstance(registered_type, type) and issubclass(
|
|
136
|
+
item_type, registered_type
|
|
137
|
+
):
|
|
138
|
+
return renderer
|
|
139
|
+
except TypeError:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
return None
|
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
"normalize_path_segments",
|
|
9
|
+
"MetricEntry",
|
|
10
|
+
"MetricsDict",
|
|
11
|
+
"DecorationScope",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from types import UnionType
|
|
18
|
+
from typing import (
|
|
19
|
+
Annotated,
|
|
20
|
+
Any,
|
|
21
|
+
Dict,
|
|
22
|
+
List,
|
|
23
|
+
Literal,
|
|
24
|
+
TypedDict,
|
|
25
|
+
Union,
|
|
26
|
+
get_args,
|
|
27
|
+
get_origin,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from fh_pydantic_form.constants import _UNSET
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DecorationScope(str, Enum):
|
|
36
|
+
"""Controls which metric decorations are applied to an element"""
|
|
37
|
+
|
|
38
|
+
BORDER = "border"
|
|
39
|
+
BULLET = "bullet"
|
|
40
|
+
BOTH = "both"
|
|
41
|
+
|
|
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
|
+
|
|
55
|
+
def _is_skip_json_schema_field(annotation_or_field_info: Any) -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Check if a field annotation or field_info indicates it should be skipped in JSON schema.
|
|
58
|
+
|
|
59
|
+
This handles the pattern where SkipJsonSchema is used with typing.Annotated:
|
|
60
|
+
- Annotated[str, SkipJsonSchema()]
|
|
61
|
+
- SkipJsonSchema[str] (which internally uses Annotated)
|
|
62
|
+
- Field metadata containing SkipJsonSchema (Pydantic 2 behavior)
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
annotation_or_field_info: The field annotation or field_info to check
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True if the field should be skipped in JSON schema
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
from pydantic.json_schema import SkipJsonSchema
|
|
72
|
+
|
|
73
|
+
skip_json_schema_cls = SkipJsonSchema
|
|
74
|
+
except ImportError: # very old Pydantic
|
|
75
|
+
skip_json_schema_cls = None
|
|
76
|
+
|
|
77
|
+
if skip_json_schema_cls is None:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# Check if it's a field_info object with metadata
|
|
81
|
+
if hasattr(annotation_or_field_info, "metadata"):
|
|
82
|
+
metadata = getattr(annotation_or_field_info, "metadata", [])
|
|
83
|
+
if metadata:
|
|
84
|
+
for item in metadata:
|
|
85
|
+
if (
|
|
86
|
+
item is skip_json_schema_cls
|
|
87
|
+
or isinstance(item, skip_json_schema_cls)
|
|
88
|
+
or (
|
|
89
|
+
hasattr(item, "__class__")
|
|
90
|
+
and item.__class__.__name__ == "SkipJsonSchema"
|
|
91
|
+
)
|
|
92
|
+
):
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
# Fall back to checking annotation (for backward compatibility)
|
|
96
|
+
annotation = annotation_or_field_info
|
|
97
|
+
if hasattr(annotation_or_field_info, "annotation"):
|
|
98
|
+
annotation = getattr(annotation_or_field_info, "annotation")
|
|
99
|
+
|
|
100
|
+
# 1. Direct or generic alias
|
|
101
|
+
if (
|
|
102
|
+
annotation is skip_json_schema_cls
|
|
103
|
+
or getattr(annotation, "__origin__", None) is skip_json_schema_cls
|
|
104
|
+
):
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
# 2. Something like Annotated[T, SkipJsonSchema()]
|
|
108
|
+
if get_origin(annotation) is Annotated:
|
|
109
|
+
for meta in get_args(annotation)[1:]:
|
|
110
|
+
meta_class = getattr(meta, "__class__", None)
|
|
111
|
+
if (
|
|
112
|
+
meta is skip_json_schema_cls # plain class
|
|
113
|
+
or isinstance(meta, skip_json_schema_cls) # instance
|
|
114
|
+
or (meta_class is not None and meta_class.__name__ == "SkipJsonSchema")
|
|
115
|
+
):
|
|
116
|
+
return True
|
|
117
|
+
|
|
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))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Metrics types for field-level annotations
|
|
129
|
+
class MetricEntry(TypedDict, total=False):
|
|
130
|
+
"""Metrics for annotating field values with scores, colors, and comments"""
|
|
131
|
+
|
|
132
|
+
metric: float | int | str # Metric value (0-1 score, count, or label)
|
|
133
|
+
color: str # CSS-compatible color string
|
|
134
|
+
comment: str # Free-form text for tooltips/hover
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Type alias for metrics mapping
|
|
138
|
+
MetricsDict = Dict[
|
|
139
|
+
str, MetricEntry
|
|
140
|
+
] # Keys are dot-paths like "address.street" or "tags[0]"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_optional_type(annotation: Any) -> bool:
|
|
144
|
+
"""
|
|
145
|
+
Check if an annotation is Optional[T] (Union[T, None]).
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
annotation: The type annotation to check
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
True if the annotation is Optional[T], False otherwise
|
|
152
|
+
"""
|
|
153
|
+
origin = get_origin(annotation)
|
|
154
|
+
if origin in (Union, UnionType):
|
|
155
|
+
args = get_args(annotation)
|
|
156
|
+
# Check if NoneType is one of the args and there are exactly two args
|
|
157
|
+
return len(args) == 2 and type(None) in args
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _get_underlying_type_if_optional(annotation: Any) -> Any:
|
|
162
|
+
"""
|
|
163
|
+
Extract the type T from Optional[T], otherwise return the original annotation.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
annotation: The type annotation, potentially Optional[T]
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The underlying type if Optional, otherwise the original annotation
|
|
170
|
+
"""
|
|
171
|
+
if _is_optional_type(annotation):
|
|
172
|
+
args = get_args(annotation)
|
|
173
|
+
# Return the non-None type
|
|
174
|
+
return args[0] if args[1] is type(None) else args[1]
|
|
175
|
+
return annotation
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _is_literal_type(annotation: Any) -> bool:
|
|
179
|
+
"""Check if the underlying type of an annotation is Literal."""
|
|
180
|
+
underlying_type = _get_underlying_type_if_optional(annotation)
|
|
181
|
+
return get_origin(underlying_type) is Literal
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _is_enum_type(annotation: Any) -> bool:
|
|
185
|
+
"""Check if the underlying type of an annotation is Enum."""
|
|
186
|
+
underlying_type = _get_underlying_type_if_optional(annotation)
|
|
187
|
+
return isinstance(underlying_type, type) and issubclass(underlying_type, Enum)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_default(field_info: Any) -> Any:
|
|
191
|
+
"""
|
|
192
|
+
Extract the default value from a Pydantic field definition.
|
|
193
|
+
|
|
194
|
+
Handles both literal defaults and default_factory functions.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
field_info: The Pydantic FieldInfo object
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The default value if available, or _UNSET sentinel if no default exists
|
|
201
|
+
"""
|
|
202
|
+
# Check for literal default value (including None, but not Undefined)
|
|
203
|
+
if hasattr(field_info, "default") and not _is_pydantic_undefined(
|
|
204
|
+
field_info.default
|
|
205
|
+
):
|
|
206
|
+
return field_info.default
|
|
207
|
+
|
|
208
|
+
# Check for default_factory
|
|
209
|
+
default_factory = getattr(field_info, "default_factory", None)
|
|
210
|
+
if default_factory is not None and callable(default_factory):
|
|
211
|
+
try:
|
|
212
|
+
return default_factory()
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
logger.warning(f"default_factory failed for field: {exc}")
|
|
215
|
+
# Don't raise - return sentinel to indicate no usable default
|
|
216
|
+
|
|
217
|
+
return _UNSET
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _is_pydantic_undefined(value: Any) -> bool:
|
|
221
|
+
"""
|
|
222
|
+
Check if a value is Pydantic's Undefined sentinel.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
value: The value to check
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
True if the value represents Pydantic's undefined default
|
|
229
|
+
"""
|
|
230
|
+
# Check if value is None first (common case)
|
|
231
|
+
if value is None:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
# Check for various Pydantic undefined markers
|
|
235
|
+
if hasattr(value, "__class__"):
|
|
236
|
+
class_name = value.__class__.__name__
|
|
237
|
+
if class_name in ("Undefined", "PydanticUndefined"):
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
# Check string representation as fallback
|
|
241
|
+
str_repr = str(value)
|
|
242
|
+
if str_repr in ("PydanticUndefined", "<class 'pydantic_core.PydanticUndefined'>"):
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
# Check for pydantic.fields.Undefined (older versions)
|
|
246
|
+
try:
|
|
247
|
+
from pydantic import fields
|
|
248
|
+
|
|
249
|
+
if hasattr(fields, "Undefined") and value is fields.Undefined:
|
|
250
|
+
return True
|
|
251
|
+
except ImportError:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
# Check for pydantic_core.PydanticUndefined (newer versions)
|
|
255
|
+
try:
|
|
256
|
+
import pydantic_core
|
|
257
|
+
|
|
258
|
+
if (
|
|
259
|
+
hasattr(pydantic_core, "PydanticUndefined")
|
|
260
|
+
and value is pydantic_core.PydanticUndefined
|
|
261
|
+
):
|
|
262
|
+
return True
|
|
263
|
+
except ImportError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
return False
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from enum import Enum, auto
|
|
2
|
+
from typing import Dict, Literal, Union
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SpacingTheme(Enum):
|
|
6
|
+
NORMAL = auto()
|
|
7
|
+
COMPACT = auto()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Type alias for spacing values - supports both literal strings and enum values
|
|
11
|
+
SpacingValue = Union[Literal["normal", "compact"], SpacingTheme]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalize_spacing(spacing_value: SpacingValue) -> SpacingTheme:
|
|
15
|
+
"""Convert literal string or enum spacing value to SpacingTheme enum."""
|
|
16
|
+
if isinstance(spacing_value, str):
|
|
17
|
+
if spacing_value == "compact":
|
|
18
|
+
return SpacingTheme.COMPACT
|
|
19
|
+
elif spacing_value == "normal":
|
|
20
|
+
return SpacingTheme.NORMAL
|
|
21
|
+
else:
|
|
22
|
+
# This case shouldn't happen with proper Literal typing, but included for runtime safety
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"Invalid spacing value: {spacing_value}. Must be 'compact', 'normal', or SpacingTheme enum"
|
|
25
|
+
)
|
|
26
|
+
elif isinstance(spacing_value, SpacingTheme):
|
|
27
|
+
return spacing_value
|
|
28
|
+
else:
|
|
29
|
+
raise TypeError(
|
|
30
|
+
f"spacing must be Literal['normal', 'compact'] or SpacingTheme, got {type(spacing_value)}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SPACING_MAP: Dict[SpacingTheme, Dict[str, str]] = {
|
|
35
|
+
SpacingTheme.NORMAL: {
|
|
36
|
+
"outer_margin": "mb-4",
|
|
37
|
+
"outer_margin_sm": "mb-2",
|
|
38
|
+
"inner_gap": "space-y-3",
|
|
39
|
+
"inner_gap_small": "space-y-2",
|
|
40
|
+
"stack_gap": "space-y-3",
|
|
41
|
+
"padding": "p-4",
|
|
42
|
+
"padding_sm": "p-3",
|
|
43
|
+
"padding_card": "px-4 py-3",
|
|
44
|
+
"card_border": "border",
|
|
45
|
+
"card_border_thin": "",
|
|
46
|
+
"section_divider": "border-t border-gray-200",
|
|
47
|
+
"metric_badge_gap": "ml-2",
|
|
48
|
+
"accordion_divider": "uk-accordion-divider",
|
|
49
|
+
"accordion_title_pad": "",
|
|
50
|
+
"accordion_content_pad": "",
|
|
51
|
+
"accordion_item_margin": "uk-margin-small-bottom",
|
|
52
|
+
"label_gap": "mb-1",
|
|
53
|
+
"card_body_pad": "px-4 py-3",
|
|
54
|
+
"accordion_content": "",
|
|
55
|
+
"input_size": "",
|
|
56
|
+
"input_padding": "",
|
|
57
|
+
"input_line_height": "",
|
|
58
|
+
"input_font_size": "",
|
|
59
|
+
"horizontal_gap": "gap-3",
|
|
60
|
+
"label_align": "items-start",
|
|
61
|
+
},
|
|
62
|
+
SpacingTheme.COMPACT: {
|
|
63
|
+
"outer_margin": "mb-0",
|
|
64
|
+
"outer_margin_sm": "mb-0",
|
|
65
|
+
"inner_gap": "space-y-1",
|
|
66
|
+
"inner_gap_small": "space-y-0.5",
|
|
67
|
+
"stack_gap": "space-y-1",
|
|
68
|
+
"padding": "p-1",
|
|
69
|
+
"padding_sm": "p-0.5",
|
|
70
|
+
"padding_card": "px-2 py-1",
|
|
71
|
+
"card_border": "",
|
|
72
|
+
"card_border_thin": "",
|
|
73
|
+
"section_divider": "",
|
|
74
|
+
"metric_badge_gap": "ml-1",
|
|
75
|
+
"accordion_divider": "",
|
|
76
|
+
"accordion_title_pad": "py-1",
|
|
77
|
+
"accordion_content_pad": "py-1",
|
|
78
|
+
"accordion_item_margin": "mb-0",
|
|
79
|
+
"label_gap": "mb-0",
|
|
80
|
+
"card_body_pad": "px-2 py-0.5",
|
|
81
|
+
"accordion_content": "uk-padding-remove-vertical",
|
|
82
|
+
"input_size": "uk-form-small",
|
|
83
|
+
"input_padding": "py-0.5 px-1",
|
|
84
|
+
"input_line_height": "leading-tight",
|
|
85
|
+
"input_font_size": "text-sm",
|
|
86
|
+
"horizontal_gap": "gap-2",
|
|
87
|
+
"label_align": "items-start",
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def spacing(token: str, spacing: SpacingValue) -> str:
|
|
93
|
+
"""Return a Tailwind utility class for the given semantic token."""
|
|
94
|
+
theme = _normalize_spacing(spacing)
|
|
95
|
+
return SPACING_MAP[theme][token]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def spacing_many(tokens: list[str], spacing: SpacingValue) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Return combined Tailwind utility classes for multiple semantic tokens.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
tokens: List of spacing token names
|
|
104
|
+
spacing: Spacing theme to use
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
String of space-separated CSS classes
|
|
108
|
+
"""
|
|
109
|
+
theme = _normalize_spacing(spacing)
|
|
110
|
+
classes = []
|
|
111
|
+
for token in tokens:
|
|
112
|
+
class_value = SPACING_MAP[theme].get(token, "")
|
|
113
|
+
if class_value: # Only add non-empty class values
|
|
114
|
+
classes.append(class_value)
|
|
115
|
+
return " ".join(classes)
|