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.

@@ -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 _is_skip_json_schema_field, get_default
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
- # Fallback: Use original initial values dict if available, otherwise empty dict
485
- parsed_data = (
486
- self.initial_values_dict.copy() if self.initial_values_dict else {}
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: Could not fully process current form values for refresh. Display might not be fully updated. Error: {str(e)}",
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
@@ -5,19 +5,39 @@ __all__ = [
5
5
  "_is_literal_type",
6
6
  "_is_enum_type",
7
7
  "_is_skip_json_schema_field",
8
- "default_for_annotation",
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 Annotated, Any, Literal, Union, get_args, get_origin
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
@@ -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",