fh-pydantic-form 0.2.5__py3-none-any.whl → 0.3.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 +16 -4
- fh_pydantic_form/color_utils.py +598 -0
- fh_pydantic_form/comparison_form.py +599 -0
- fh_pydantic_form/field_renderers.py +570 -17
- fh_pydantic_form/form_renderer.py +111 -46
- fh_pydantic_form/type_helpers.py +37 -6
- fh_pydantic_form/ui_style.py +2 -0
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.0.dist-info}/METADATA +359 -6
- fh_pydantic_form-0.3.0.dist-info/RECORD +17 -0
- fh_pydantic_form-0.2.5.dist-info/RECORD +0 -15
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.0.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.2.5.dist-info → fh_pydantic_form-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -31,7 +31,10 @@ from fh_pydantic_form.form_parser import (
|
|
|
31
31
|
)
|
|
32
32
|
from fh_pydantic_form.list_path import walk_path
|
|
33
33
|
from fh_pydantic_form.registry import FieldRendererRegistry
|
|
34
|
-
from fh_pydantic_form.type_helpers import
|
|
34
|
+
from fh_pydantic_form.type_helpers import (
|
|
35
|
+
_is_skip_json_schema_field,
|
|
36
|
+
get_default,
|
|
37
|
+
)
|
|
35
38
|
from fh_pydantic_form.ui_style import (
|
|
36
39
|
SpacingTheme,
|
|
37
40
|
SpacingValue,
|
|
@@ -211,46 +214,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
211
214
|
|
|
212
215
|
# --- module-level flag (add near top of file) ---
|
|
213
216
|
|
|
214
|
-
def _compact_wrapper(self, inner: FT) -> FT:
|
|
215
|
-
"""
|
|
216
|
-
Wrap inner markup in a wrapper div.
|
|
217
|
-
"""
|
|
218
|
-
wrapper_cls = "fhpf-wrapper w-full flex-1"
|
|
219
|
-
return fh.Div(inner, cls=wrapper_cls)
|
|
220
|
-
|
|
221
|
-
def _clone_with_values(self, values: Dict[str, Any]) -> "PydanticForm":
|
|
222
|
-
"""
|
|
223
|
-
Create a copy of this renderer with the same configuration but different values.
|
|
224
|
-
|
|
225
|
-
This preserves all constructor arguments (label_colors, custom_renderers, etc.)
|
|
226
|
-
to avoid configuration drift during refresh operations.
|
|
227
|
-
|
|
228
|
-
Args:
|
|
229
|
-
values: New values dictionary to use in the cloned renderer
|
|
230
|
-
|
|
231
|
-
Returns:
|
|
232
|
-
A new PydanticForm instance with identical configuration but updated values
|
|
233
|
-
"""
|
|
234
|
-
# Get custom renderers if they were registered (not stored directly on instance)
|
|
235
|
-
# We'll rely on global registry state being preserved
|
|
236
|
-
|
|
237
|
-
clone = PydanticForm(
|
|
238
|
-
form_name=self.name,
|
|
239
|
-
model_class=self.model_class,
|
|
240
|
-
initial_values=None, # Will be set via values_dict below
|
|
241
|
-
custom_renderers=None, # Registry is global, no need to re-register
|
|
242
|
-
disabled=self.disabled,
|
|
243
|
-
disabled_fields=self.disabled_fields,
|
|
244
|
-
label_colors=self.label_colors,
|
|
245
|
-
exclude_fields=self.exclude_fields,
|
|
246
|
-
spacing=self.spacing,
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
# Set the values directly
|
|
250
|
-
clone.values_dict = values
|
|
251
|
-
|
|
252
|
-
return clone
|
|
253
|
-
|
|
254
217
|
def __init__(
|
|
255
218
|
self,
|
|
256
219
|
form_name: str,
|
|
@@ -262,6 +225,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
262
225
|
label_colors: Optional[Dict[str, str]] = None,
|
|
263
226
|
exclude_fields: Optional[List[str]] = None,
|
|
264
227
|
spacing: SpacingValue = SpacingTheme.NORMAL,
|
|
228
|
+
metrics_dict: Optional[Dict[str, Any]] = None,
|
|
265
229
|
):
|
|
266
230
|
"""
|
|
267
231
|
Initialize the form renderer
|
|
@@ -278,6 +242,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
278
242
|
label_colors: Optional dictionary mapping field names to label colors (CSS color values)
|
|
279
243
|
exclude_fields: Optional list of top-level field names to exclude from the form
|
|
280
244
|
spacing: Spacing theme to use for form layout ("normal", "compact", or SpacingTheme enum)
|
|
245
|
+
metrics_dict: Optional metrics dictionary for field-level visual feedback
|
|
281
246
|
"""
|
|
282
247
|
self.name = form_name
|
|
283
248
|
self.model_class = model_class
|
|
@@ -319,6 +284,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
319
284
|
self.label_colors = label_colors or {} # Store label colors mapping
|
|
320
285
|
self.exclude_fields = exclude_fields or [] # Store excluded fields list
|
|
321
286
|
self.spacing = _normalize_spacing(spacing) # Store normalized spacing
|
|
287
|
+
self.metrics_dict = metrics_dict or {} # Store metrics dictionary
|
|
322
288
|
|
|
323
289
|
# Register custom renderers with the global registry if provided
|
|
324
290
|
if custom_renderers:
|
|
@@ -326,6 +292,54 @@ class PydanticForm(Generic[ModelType]):
|
|
|
326
292
|
for field_type, renderer_cls in custom_renderers:
|
|
327
293
|
registry.register_type_renderer(field_type, renderer_cls)
|
|
328
294
|
|
|
295
|
+
def _compact_wrapper(self, inner: FT) -> FT:
|
|
296
|
+
"""
|
|
297
|
+
Wrap inner markup in a wrapper div.
|
|
298
|
+
"""
|
|
299
|
+
wrapper_cls = "fhpf-wrapper w-full flex-1"
|
|
300
|
+
return fh.Div(inner, cls=wrapper_cls)
|
|
301
|
+
|
|
302
|
+
def reset_state(self) -> None:
|
|
303
|
+
"""
|
|
304
|
+
Restore the live state of the form to its immutable baseline.
|
|
305
|
+
Call this *before* rendering if you truly want a factory-fresh view.
|
|
306
|
+
"""
|
|
307
|
+
self.values_dict = self.initial_values_dict.copy()
|
|
308
|
+
|
|
309
|
+
def _clone_with_values(self, values: Dict[str, Any]) -> "PydanticForm":
|
|
310
|
+
"""
|
|
311
|
+
Create a copy of this renderer with the same configuration but different values.
|
|
312
|
+
|
|
313
|
+
This preserves all constructor arguments (label_colors, custom_renderers, etc.)
|
|
314
|
+
to avoid configuration drift during refresh operations.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
values: New values dictionary to use in the cloned renderer
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
A new PydanticForm instance with identical configuration but updated values
|
|
321
|
+
"""
|
|
322
|
+
# Get custom renderers if they were registered (not stored directly on instance)
|
|
323
|
+
# We'll rely on global registry state being preserved
|
|
324
|
+
|
|
325
|
+
clone = PydanticForm(
|
|
326
|
+
form_name=self.name,
|
|
327
|
+
model_class=self.model_class,
|
|
328
|
+
initial_values=None, # Will be set via values_dict below
|
|
329
|
+
custom_renderers=None, # Registry is global, no need to re-register
|
|
330
|
+
disabled=self.disabled,
|
|
331
|
+
disabled_fields=self.disabled_fields,
|
|
332
|
+
label_colors=self.label_colors,
|
|
333
|
+
exclude_fields=self.exclude_fields,
|
|
334
|
+
spacing=self.spacing,
|
|
335
|
+
metrics_dict=self.metrics_dict,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Set the values directly
|
|
339
|
+
clone.values_dict = values
|
|
340
|
+
|
|
341
|
+
return clone
|
|
342
|
+
|
|
329
343
|
def render_inputs(self) -> FT:
|
|
330
344
|
"""
|
|
331
345
|
Render just the form inputs based on the model class (no form tag)
|
|
@@ -431,6 +445,8 @@ class PydanticForm(Generic[ModelType]):
|
|
|
431
445
|
label_color=label_color, # Pass the label color if specified
|
|
432
446
|
spacing=self.spacing, # Pass the spacing
|
|
433
447
|
field_path=[field_name], # Set top-level field path
|
|
448
|
+
form_name=self.name, # Pass form name
|
|
449
|
+
metrics_dict=self.metrics_dict, # Pass the metrics dict
|
|
434
450
|
)
|
|
435
451
|
|
|
436
452
|
rendered_field = renderer.render()
|
|
@@ -453,6 +469,34 @@ class PydanticForm(Generic[ModelType]):
|
|
|
453
469
|
|
|
454
470
|
return wrapped
|
|
455
471
|
|
|
472
|
+
def _filter_by_prefix(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
473
|
+
"""
|
|
474
|
+
Filter form data to include only keys that start with this form's base_prefix.
|
|
475
|
+
|
|
476
|
+
This prevents cross-contamination when multiple forms share the same HTML form element.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
data: Raw form data dictionary
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Filtered dictionary containing only keys with matching prefix
|
|
483
|
+
"""
|
|
484
|
+
if not self.base_prefix:
|
|
485
|
+
return data # No prefix = no filtering needed
|
|
486
|
+
|
|
487
|
+
filtered = {
|
|
488
|
+
key: value
|
|
489
|
+
for key, value in data.items()
|
|
490
|
+
if key.startswith(self.base_prefix)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
logger.debug(
|
|
494
|
+
f"Filtered form data for '{self.name}': "
|
|
495
|
+
f"{len(data)} keys -> {len(filtered)} keys"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return filtered
|
|
499
|
+
|
|
456
500
|
# ---- Form Renderer Methods (continued) ----
|
|
457
501
|
|
|
458
502
|
async def handle_refresh_request(self, req):
|
|
@@ -467,6 +511,10 @@ class PydanticForm(Generic[ModelType]):
|
|
|
467
511
|
"""
|
|
468
512
|
form_data = await req.form()
|
|
469
513
|
form_dict = dict(form_data)
|
|
514
|
+
|
|
515
|
+
# Filter to only this form's fields
|
|
516
|
+
form_dict = self._filter_by_prefix(form_dict)
|
|
517
|
+
|
|
470
518
|
logger.info(f"Refresh request for form '{self.name}'")
|
|
471
519
|
|
|
472
520
|
parsed_data = {}
|
|
@@ -481,15 +529,27 @@ class PydanticForm(Generic[ModelType]):
|
|
|
481
529
|
f"Error parsing form data for refresh on form '{self.name}': {e}",
|
|
482
530
|
exc_info=True,
|
|
483
531
|
)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
)
|
|
532
|
+
|
|
533
|
+
# Merge strategy - preserve existing values for unparseable fields
|
|
534
|
+
# Start with current values
|
|
535
|
+
parsed_data = self.values_dict.copy() if self.values_dict else {}
|
|
536
|
+
|
|
537
|
+
# Try to extract any simple fields that don't require complex parsing
|
|
538
|
+
for key, value in form_dict.items():
|
|
539
|
+
if key.startswith(self.base_prefix):
|
|
540
|
+
field_name = key[len(self.base_prefix) :]
|
|
541
|
+
# Only update simple fields to avoid corruption
|
|
542
|
+
if "_" not in field_name: # Not a nested field
|
|
543
|
+
parsed_data[field_name] = value
|
|
544
|
+
|
|
488
545
|
alert_ft = mui.Alert(
|
|
489
|
-
f"Warning:
|
|
546
|
+
f"Warning: Some fields could not be refreshed. Preserved previous values. Error: {str(e)}",
|
|
490
547
|
cls=mui.AlertT.warning + " mb-4", # Add margin bottom
|
|
491
548
|
)
|
|
492
549
|
|
|
550
|
+
# Parsed successfully (or merged best effort) – make it the new truth
|
|
551
|
+
self.values_dict = parsed_data.copy()
|
|
552
|
+
|
|
493
553
|
# Create temporary renderer with same configuration but updated values
|
|
494
554
|
temp_renderer = self._clone_with_values(parsed_data)
|
|
495
555
|
|
|
@@ -519,6 +579,9 @@ class PydanticForm(Generic[ModelType]):
|
|
|
519
579
|
Returns:
|
|
520
580
|
HTML response with reset form inputs
|
|
521
581
|
"""
|
|
582
|
+
# Rewind internal state to the immutable baseline
|
|
583
|
+
self.reset_state()
|
|
584
|
+
|
|
522
585
|
logger.info(f"Resetting form '{self.name}' to initial values")
|
|
523
586
|
|
|
524
587
|
# Create temporary renderer with original initial dict
|
|
@@ -532,6 +595,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
532
595
|
label_colors=self.label_colors,
|
|
533
596
|
exclude_fields=self.exclude_fields,
|
|
534
597
|
spacing=self.spacing,
|
|
598
|
+
metrics_dict=self.metrics_dict,
|
|
535
599
|
)
|
|
536
600
|
|
|
537
601
|
reset_inputs_component = temp_renderer.render_inputs()
|
|
@@ -739,6 +803,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
739
803
|
disabled=self.disabled,
|
|
740
804
|
field_path=segments, # Pass the full path segments
|
|
741
805
|
form_name=self.name, # Pass the explicit form name
|
|
806
|
+
metrics_dict=self.metrics_dict, # Pass the metrics dict
|
|
742
807
|
)
|
|
743
808
|
|
|
744
809
|
# Generate a unique placeholder index
|
fh_pydantic_form/type_helpers.py
CHANGED
|
@@ -5,19 +5,39 @@ __all__ = [
|
|
|
5
5
|
"_is_literal_type",
|
|
6
6
|
"_is_enum_type",
|
|
7
7
|
"_is_skip_json_schema_field",
|
|
8
|
-
"
|
|
8
|
+
"MetricEntry",
|
|
9
|
+
"MetricsDict",
|
|
10
|
+
"DecorationScope",
|
|
9
11
|
]
|
|
10
12
|
|
|
13
|
+
|
|
11
14
|
import logging
|
|
12
15
|
from enum import Enum
|
|
13
16
|
from types import UnionType
|
|
14
|
-
from typing import
|
|
17
|
+
from typing import (
|
|
18
|
+
Annotated,
|
|
19
|
+
Any,
|
|
20
|
+
Dict,
|
|
21
|
+
Literal,
|
|
22
|
+
TypedDict,
|
|
23
|
+
Union,
|
|
24
|
+
get_args,
|
|
25
|
+
get_origin,
|
|
26
|
+
)
|
|
15
27
|
|
|
16
28
|
from fh_pydantic_form.constants import _UNSET
|
|
17
29
|
|
|
18
30
|
logger = logging.getLogger(__name__)
|
|
19
31
|
|
|
20
32
|
|
|
33
|
+
class DecorationScope(str, Enum):
|
|
34
|
+
"""Controls which metric decorations are applied to an element"""
|
|
35
|
+
|
|
36
|
+
BORDER = "border"
|
|
37
|
+
BULLET = "bullet"
|
|
38
|
+
BOTH = "both"
|
|
39
|
+
|
|
40
|
+
|
|
21
41
|
def _is_skip_json_schema_field(annotation_or_field_info: Any) -> bool:
|
|
22
42
|
"""
|
|
23
43
|
Check if a field annotation or field_info indicates it should be skipped in JSON schema.
|
|
@@ -85,6 +105,21 @@ def _is_skip_json_schema_field(annotation_or_field_info: Any) -> bool:
|
|
|
85
105
|
return "SkipJsonSchema" in repr(annotation)
|
|
86
106
|
|
|
87
107
|
|
|
108
|
+
# Metrics types for field-level annotations
|
|
109
|
+
class MetricEntry(TypedDict, total=False):
|
|
110
|
+
"""Metrics for annotating field values with scores, colors, and comments"""
|
|
111
|
+
|
|
112
|
+
metric: float | int | str # Metric value (0-1 score, count, or label)
|
|
113
|
+
color: str # CSS-compatible color string
|
|
114
|
+
comment: str # Free-form text for tooltips/hover
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Type alias for metrics mapping
|
|
118
|
+
MetricsDict = Dict[
|
|
119
|
+
str, MetricEntry
|
|
120
|
+
] # Keys are dot-paths like "address.street" or "tags[0]"
|
|
121
|
+
|
|
122
|
+
|
|
88
123
|
def _is_optional_type(annotation: Any) -> bool:
|
|
89
124
|
"""
|
|
90
125
|
Check if an annotation is Optional[T] (Union[T, None]).
|
|
@@ -209,7 +244,3 @@ def _is_pydantic_undefined(value: Any) -> bool:
|
|
|
209
244
|
pass
|
|
210
245
|
|
|
211
246
|
return False
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
# Local import placed after _UNSET is defined to avoid circular-import problems
|
|
215
|
-
from .defaults import default_for_annotation # noqa: E402
|
fh_pydantic_form/ui_style.py
CHANGED
|
@@ -44,6 +44,7 @@ SPACING_MAP: Dict[SpacingTheme, Dict[str, str]] = {
|
|
|
44
44
|
"card_border": "border",
|
|
45
45
|
"card_border_thin": "",
|
|
46
46
|
"section_divider": "border-t border-gray-200",
|
|
47
|
+
"metric_badge_gap": "ml-2",
|
|
47
48
|
"accordion_divider": "uk-accordion-divider",
|
|
48
49
|
"accordion_title_pad": "",
|
|
49
50
|
"accordion_content_pad": "",
|
|
@@ -70,6 +71,7 @@ SPACING_MAP: Dict[SpacingTheme, Dict[str, str]] = {
|
|
|
70
71
|
"card_border": "",
|
|
71
72
|
"card_border_thin": "",
|
|
72
73
|
"section_divider": "",
|
|
74
|
+
"metric_badge_gap": "ml-1",
|
|
73
75
|
"accordion_divider": "",
|
|
74
76
|
"accordion_title_pad": "py-1",
|
|
75
77
|
"accordion_content_pad": "py-1",
|