fh-pydantic-form 0.2.4__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.

@@ -7,6 +7,8 @@ from typing import (
7
7
  Optional,
8
8
  Tuple,
9
9
  Union,
10
+ get_origin,
11
+ get_args,
10
12
  )
11
13
 
12
14
  from fh_pydantic_form.type_helpers import (
@@ -33,17 +35,16 @@ def _identify_list_fields(model_class) -> Dict[str, Dict[str, Any]]:
33
35
  list_fields = {}
34
36
  for field_name, field_info in model_class.model_fields.items():
35
37
  annotation = getattr(field_info, "annotation", None)
36
- if (
37
- annotation is not None
38
- and hasattr(annotation, "__origin__")
39
- and annotation.__origin__ is list
40
- ):
41
- item_type = annotation.__args__[0]
42
- list_fields[field_name] = {
43
- "item_type": item_type,
44
- "is_model_type": hasattr(item_type, "model_fields"),
45
- "field_info": field_info, # Store for later use if needed
46
- }
38
+ if annotation is not None:
39
+ # Handle Optional[List[...]] by unwrapping the Optional
40
+ base_ann = _get_underlying_type_if_optional(annotation)
41
+ if get_origin(base_ann) is list:
42
+ item_type = get_args(base_ann)[0]
43
+ list_fields[field_name] = {
44
+ "item_type": item_type,
45
+ "is_model_type": hasattr(item_type, "model_fields"),
46
+ "field_info": field_info, # Store for later use if needed
47
+ }
47
48
  return list_fields
48
49
 
49
50
 
@@ -128,9 +129,14 @@ def _parse_non_list_fields(
128
129
  # Get the nested model class (unwrap Optional if needed)
129
130
  nested_model_class = _get_underlying_type_if_optional(annotation)
130
131
 
131
- # Parse the nested model - pass the base_prefix
132
+ # Parse the nested model - pass the base_prefix and exclude_fields
132
133
  nested_value = _parse_nested_model_field(
133
- field_name, form_data, nested_model_class, field_info, base_prefix
134
+ field_name,
135
+ form_data,
136
+ nested_model_class,
137
+ field_info,
138
+ base_prefix,
139
+ exclude_fields,
134
140
  )
135
141
 
136
142
  # Only assign if we got a non-None value or the field is not optional
@@ -270,6 +276,7 @@ def _parse_nested_model_field(
270
276
  nested_model_class,
271
277
  field_info,
272
278
  parent_prefix: str = "",
279
+ exclude_fields: Optional[List[str]] = None,
273
280
  ) -> Optional[Dict[str, Any]]:
274
281
  """
275
282
  Parse a nested Pydantic model field from form data.
@@ -351,6 +358,7 @@ def _parse_nested_model_field(
351
358
  form_data,
352
359
  nested_list_defs,
353
360
  current_prefix, # ← prefix for this nested model
361
+ exclude_fields, # Pass through exclude_fields
354
362
  )
355
363
  # Merge without clobbering keys already set in step 1
356
364
  for lf_name, lf_val in list_results.items():
@@ -432,6 +440,7 @@ def _parse_list_fields(
432
440
  form_data: Dict[str, Any],
433
441
  list_field_defs: Dict[str, Dict[str, Any]],
434
442
  base_prefix: str = "",
443
+ exclude_fields: Optional[List[str]] = None,
435
444
  ) -> Dict[str, List[Any]]:
436
445
  """
437
446
  Parse list fields from form data by analyzing keys and reconstructing ordered lists.
@@ -440,10 +449,13 @@ def _parse_list_fields(
440
449
  form_data: Dictionary containing form field data
441
450
  list_field_defs: Dictionary of list field definitions
442
451
  base_prefix: Prefix to use when looking up field names in form_data
452
+ exclude_fields: Optional list of field names to exclude from parsing
443
453
 
444
454
  Returns:
445
455
  Dictionary with parsed list fields
446
456
  """
457
+ exclude_fields = exclude_fields or []
458
+
447
459
  # Skip if no list fields defined
448
460
  if not list_field_defs:
449
461
  return {}
@@ -525,9 +537,18 @@ def _parse_list_fields(
525
537
  if items: # Only add if items were found
526
538
  final_lists[field_name] = items
527
539
 
528
- # DON'T set defaults for missing list fields here - let _inject_missing_defaults handle all defaults
529
- # This allows the proper default injection mechanism to work for missing list fields
530
- # Only keep this section for excluded fields if needed, but don't inject defaults for all missing fields
540
+ # Ensure every rendered list field appears in final_lists
541
+ for field_name, field_def in list_field_defs.items():
542
+ # Skip list fields the UI never showed (those in exclude_fields)
543
+ if field_name in exclude_fields:
544
+ continue
545
+
546
+ # When user supplied ≥1 item we already captured it
547
+ if field_name in final_lists:
548
+ continue
549
+
550
+ # User submitted form with zero items → honour intent with empty list
551
+ final_lists[field_name] = []
531
552
 
532
553
  return final_lists
533
554
 
@@ -560,7 +581,9 @@ def _parse_model_list_item(
560
581
  )
561
582
  # 2. Parse inner lists
562
583
  result.update(
563
- _parse_list_fields(form_data, nested_list_defs, base_prefix=item_prefix)
584
+ _parse_list_fields(
585
+ form_data, nested_list_defs, base_prefix=item_prefix, exclude_fields=[]
586
+ )
564
587
  )
565
588
  return result
566
589
 
@@ -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",