fh-pydantic-form 0.3.6__py3-none-any.whl → 0.3.8__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.

@@ -54,6 +54,8 @@ def _parse_non_list_fields(
54
54
  list_field_defs: Dict[str, Dict[str, Any]],
55
55
  base_prefix: str = "",
56
56
  exclude_fields: Optional[List[str]] = None,
57
+ keep_skip_json_pathset: Optional[set[str]] = None,
58
+ current_field_path: Optional[List[str]] = None,
57
59
  ) -> Dict[str, Any]:
58
60
  """
59
61
  Parses non-list fields from form data based on the model definition.
@@ -64,12 +66,30 @@ def _parse_non_list_fields(
64
66
  list_field_defs: Dictionary of list field definitions (to skip)
65
67
  base_prefix: Prefix to use when looking up field names in form_data
66
68
  exclude_fields: Optional list of field names to exclude from parsing
69
+ keep_skip_json_pathset: Optional set of normalized paths for SkipJsonSchema fields to keep
67
70
 
68
71
  Returns:
69
72
  Dictionary with parsed non-list fields
70
73
  """
71
74
  result: Dict[str, Any] = {}
72
75
  exclude_fields = exclude_fields or []
76
+ keep_skip_json_pathset = keep_skip_json_pathset or set()
77
+
78
+ # Helper function to check if a SkipJsonSchema field should be kept
79
+ def _should_keep_skip_field(path_segments: List[str]) -> bool:
80
+ from fh_pydantic_form.type_helpers import normalize_path_segments
81
+
82
+ normalized = normalize_path_segments(path_segments)
83
+ return bool(normalized) and normalized in keep_skip_json_pathset
84
+
85
+ # Calculate the current path context for fields at this level
86
+ # For top-level parsing, this will be empty
87
+ # For nested parsing, this will contain the nested path segments
88
+ current_path_segments: List[str] = []
89
+ if current_field_path is not None:
90
+ # Use explicitly passed field path
91
+ current_path_segments = current_field_path
92
+ # For top-level parsing (base_prefix is just form name), current_path_segments remains empty
73
93
 
74
94
  for field_name, field_info in model_class.model_fields.items():
75
95
  if field_name in list_field_defs:
@@ -79,9 +99,11 @@ def _parse_non_list_fields(
79
99
  if field_name in exclude_fields:
80
100
  continue
81
101
 
82
- # Skip SkipJsonSchema fields - they should not be parsed from form data
102
+ # Skip SkipJsonSchema fields unless they're explicitly kept
83
103
  if _is_skip_json_schema_field(field_info):
84
- continue
104
+ field_path_segments = current_path_segments + [field_name]
105
+ if not _should_keep_skip_field(field_path_segments):
106
+ continue
85
107
 
86
108
  # Create full key with prefix
87
109
  full_key = f"{base_prefix}{field_name}"
@@ -128,7 +150,8 @@ def _parse_non_list_fields(
128
150
  # Get the nested model class (unwrap Optional if needed)
129
151
  nested_model_class = _get_underlying_type_if_optional(annotation)
130
152
 
131
- # Parse the nested model - pass the base_prefix and exclude_fields
153
+ # Parse the nested model - pass the base_prefix, exclude_fields, and keep paths
154
+ nested_field_path = current_path_segments + [field_name]
132
155
  nested_value = _parse_nested_model_field(
133
156
  field_name,
134
157
  form_data,
@@ -136,6 +159,8 @@ def _parse_non_list_fields(
136
159
  field_info,
137
160
  base_prefix,
138
161
  exclude_fields,
162
+ keep_skip_json_pathset,
163
+ nested_field_path,
139
164
  )
140
165
 
141
166
  # Only assign if we got a non-None value or the field is not optional
@@ -276,6 +301,8 @@ def _parse_nested_model_field(
276
301
  field_info,
277
302
  parent_prefix: str = "",
278
303
  exclude_fields: Optional[List[str]] = None,
304
+ keep_skip_json_pathset: Optional[set[str]] = None,
305
+ current_field_path: Optional[List[str]] = None,
279
306
  ) -> Optional[Dict[str, Any]]:
280
307
  """
281
308
  Parse a nested Pydantic model field from form data.
@@ -286,6 +313,8 @@ def _parse_nested_model_field(
286
313
  nested_model_class: The nested model class
287
314
  field_info: The field info from the parent model
288
315
  parent_prefix: Prefix from parent form/model to use when constructing keys
316
+ exclude_fields: Optional list of field names to exclude from parsing
317
+ keep_skip_json_pathset: Optional set of normalized paths for SkipJsonSchema fields to keep
289
318
 
290
319
  Returns:
291
320
  Dictionary with nested model structure or None/default if no data found
@@ -302,6 +331,16 @@ def _parse_nested_model_field(
302
331
  break
303
332
 
304
333
  if found_any_subfield:
334
+ # Helper function to check if a SkipJsonSchema field should be kept
335
+ def _should_keep_skip_field_nested(path_segments: List[str]) -> bool:
336
+ from fh_pydantic_form.type_helpers import normalize_path_segments
337
+
338
+ normalized = normalize_path_segments(path_segments)
339
+ return bool(normalized) and normalized in (keep_skip_json_pathset or set())
340
+
341
+ # Use the passed field path for calculating nested paths
342
+ nested_path_segments: List[str] = current_field_path or []
343
+
305
344
  # ------------------------------------------------------------------
306
345
  # 1. Process each **non-list** field in the nested model
307
346
  # ------------------------------------------------------------------
@@ -309,12 +348,14 @@ def _parse_nested_model_field(
309
348
  sub_key = f"{current_prefix}{sub_field_name}"
310
349
  annotation = getattr(sub_field_info, "annotation", None)
311
350
 
312
- # Skip SkipJsonSchema fields - they should not be parsed from form data
351
+ # Skip SkipJsonSchema fields unless they're explicitly kept
313
352
  if _is_skip_json_schema_field(sub_field_info):
314
- logger.debug(
315
- f"Skipping SkipJsonSchema field in nested model during parsing: {sub_field_name}"
316
- )
317
- continue
353
+ sub_field_path_segments = nested_path_segments + [sub_field_name]
354
+ if not _should_keep_skip_field_nested(sub_field_path_segments):
355
+ logger.debug(
356
+ f"Skipping SkipJsonSchema field in nested model during parsing: {sub_field_name}"
357
+ )
358
+ continue
318
359
 
319
360
  # Handle based on field type, with Optional unwrapping
320
361
  is_optional = _is_optional_type(annotation)
@@ -326,9 +367,17 @@ def _parse_nested_model_field(
326
367
 
327
368
  # Handle nested model fields (including Optional[NestedModel])
328
369
  elif isinstance(base_type, type) and hasattr(base_type, "model_fields"):
329
- # Pass the current_prefix to the recursive call
370
+ # Pass the current_prefix and keep paths to the recursive call
371
+ sub_field_path = nested_path_segments + [sub_field_name]
330
372
  sub_value = _parse_nested_model_field(
331
- sub_field_name, form_data, base_type, sub_field_info, current_prefix
373
+ sub_field_name,
374
+ form_data,
375
+ base_type,
376
+ sub_field_info,
377
+ current_prefix,
378
+ exclude_fields,
379
+ keep_skip_json_pathset,
380
+ sub_field_path,
332
381
  )
333
382
  if sub_value is not None:
334
383
  nested_data[sub_field_name] = sub_value
@@ -358,6 +407,7 @@ def _parse_nested_model_field(
358
407
  nested_list_defs,
359
408
  current_prefix, # ← prefix for this nested model
360
409
  exclude_fields, # Pass through exclude_fields
410
+ keep_skip_json_pathset,
361
411
  )
362
412
  # Merge without clobbering keys already set in step 1
363
413
  for lf_name, lf_val in list_results.items():
@@ -438,6 +488,7 @@ def _parse_list_fields(
438
488
  list_field_defs: Dict[str, Dict[str, Any]],
439
489
  base_prefix: str = "",
440
490
  exclude_fields: Optional[List[str]] = None,
491
+ keep_skip_json_pathset: Optional[set[str]] = None,
441
492
  ) -> Dict[str, Optional[List[Any]]]:
442
493
  """
443
494
  Parse list fields from form data by analyzing keys and reconstructing ordered lists.
@@ -447,6 +498,7 @@ def _parse_list_fields(
447
498
  list_field_defs: Dictionary of list field definitions
448
499
  base_prefix: Prefix to use when looking up field names in form_data
449
500
  exclude_fields: Optional list of field names to exclude from parsing
501
+ keep_skip_json_pathset: Optional set of normalized paths for SkipJsonSchema fields to keep
450
502
 
451
503
  Returns:
452
504
  Dictionary with parsed list fields
@@ -504,7 +556,15 @@ def _parse_list_fields(
504
556
  # ------------------------------------------------------------------
505
557
  if field_def["is_model_type"]:
506
558
  item_prefix = f"{base_prefix}{field_name}_{idx_str}_"
507
- parsed_item = _parse_model_list_item(form_data, item_type, item_prefix)
559
+ # For list items, the field path is the list field name (without index)
560
+ item_field_path = [field_name]
561
+ parsed_item = _parse_model_list_item(
562
+ form_data,
563
+ item_type,
564
+ item_prefix,
565
+ keep_skip_json_pathset,
566
+ item_field_path,
567
+ )
508
568
  items.append(parsed_item)
509
569
  continue
510
570
 
@@ -558,6 +618,8 @@ def _parse_model_list_item(
558
618
  form_data: Dict[str, Any],
559
619
  item_type,
560
620
  item_prefix: str,
621
+ keep_skip_json_pathset: Optional[set[str]] = None,
622
+ current_field_path: Optional[List[str]] = None,
561
623
  ) -> Dict[str, Any]:
562
624
  """
563
625
  Fully parse a single BaseModel list item – including its own nested lists.
@@ -568,6 +630,7 @@ def _parse_model_list_item(
568
630
  form_data: Dictionary containing form field data
569
631
  item_type: The BaseModel class for this list item
570
632
  item_prefix: Prefix for this specific list item (e.g., "main_form_compact_other_addresses_0_")
633
+ keep_skip_json_pathset: Optional set of normalized paths for SkipJsonSchema fields to keep
571
634
 
572
635
  Returns:
573
636
  Dictionary with fully parsed item data including nested lists
@@ -579,11 +642,18 @@ def _parse_model_list_item(
579
642
  item_type,
580
643
  nested_list_defs,
581
644
  base_prefix=item_prefix,
645
+ exclude_fields=[],
646
+ keep_skip_json_pathset=keep_skip_json_pathset,
647
+ current_field_path=current_field_path,
582
648
  )
583
649
  # 2. Parse inner lists
584
650
  result.update(
585
651
  _parse_list_fields(
586
- form_data, nested_list_defs, base_prefix=item_prefix, exclude_fields=[]
652
+ form_data,
653
+ nested_list_defs,
654
+ base_prefix=item_prefix,
655
+ exclude_fields=[],
656
+ keep_skip_json_pathset=keep_skip_json_pathset,
587
657
  )
588
658
  )
589
659
  return result
@@ -34,6 +34,7 @@ from fh_pydantic_form.registry import FieldRendererRegistry
34
34
  from fh_pydantic_form.type_helpers import (
35
35
  _is_skip_json_schema_field,
36
36
  get_default,
37
+ normalize_path_segments,
37
38
  )
38
39
  from fh_pydantic_form.ui_style import (
39
40
  SpacingTheme,
@@ -48,6 +49,21 @@ logger = logging.getLogger(__name__)
48
49
  ModelType = TypeVar("ModelType", bound=BaseModel)
49
50
 
50
51
 
52
+ def _compile_keep_paths(paths: Optional[List[str]]) -> set[str]:
53
+ """Normalize and compile keep paths for fast membership tests."""
54
+ if not paths:
55
+ return set()
56
+
57
+ compiled: set[str] = set()
58
+ for raw_path in paths:
59
+ if not raw_path:
60
+ continue
61
+ normalized = raw_path.strip()
62
+ if normalized:
63
+ compiled.add(normalized)
64
+ return compiled
65
+
66
+
51
67
  def list_manipulation_js():
52
68
  return fh.Script("""
53
69
  function moveItem(buttonElement, direction) {
@@ -199,18 +215,46 @@ window.restoreAccordionState = function(containerId) {
199
215
  }
200
216
  };
201
217
 
202
- // Save all accordion states in the form
218
+ // Save all accordion states in the form (both lists and nested BaseModels)
203
219
  window.saveAllAccordionStates = function() {
220
+ // Save list container states
204
221
  document.querySelectorAll('[id$="_items_container"]').forEach(container => {
205
222
  window.saveAccordionState(container.id);
206
223
  });
224
+
225
+ // Save all UIkit accordion item states (nested BaseModels, etc.)
226
+ document.querySelectorAll('.uk-accordion > li').forEach(item => {
227
+ if (item.id) {
228
+ const isOpen = item.classList.contains('uk-open');
229
+ sessionStorage.setItem('accordion_state_' + item.id, isOpen ? 'open' : 'closed');
230
+ }
231
+ });
207
232
  };
208
233
 
209
- // Restore all accordion states in the form
234
+ // Restore all accordion states in the form (both lists and nested BaseModels)
210
235
  window.restoreAllAccordionStates = function() {
236
+ // Restore list container states
211
237
  document.querySelectorAll('[id$="_items_container"]').forEach(container => {
212
238
  window.restoreAccordionState(container.id);
213
239
  });
240
+
241
+ // Use requestAnimationFrame to ensure DOM has fully updated after swap
242
+ requestAnimationFrame(() => {
243
+ setTimeout(() => {
244
+ // Restore ALL UIkit accordion item states in the entire document (not just swapped area)
245
+ document.querySelectorAll('.uk-accordion > li').forEach(item => {
246
+ if (item.id) {
247
+ const savedState = sessionStorage.getItem('accordion_state_' + item.id);
248
+
249
+ if (savedState === 'open' && !item.classList.contains('uk-open')) {
250
+ item.classList.add('uk-open');
251
+ } else if (savedState === 'closed' && item.classList.contains('uk-open')) {
252
+ item.classList.remove('uk-open');
253
+ }
254
+ }
255
+ });
256
+ }, 150);
257
+ });
214
258
  };
215
259
 
216
260
  // Wait for the DOM to be fully loaded before initializing
@@ -219,8 +263,8 @@ document.addEventListener('DOMContentLoaded', () => {
219
263
  document.querySelectorAll('[id$="_items_container"]').forEach(container => {
220
264
  updateMoveButtons(container);
221
265
  });
222
-
223
- // Now it's safe to attach the HTMX event listener to document.body
266
+
267
+ // Attach HTMX event listener to document.body for list operations
224
268
  document.body.addEventListener('htmx:afterSwap', function(event) {
225
269
  // Check if this is an insert (afterend swap)
226
270
  const targetElement = event.detail.target;
@@ -281,6 +325,7 @@ class PydanticForm(Generic[ModelType]):
281
325
  disabled_fields: Optional[List[str]] = None,
282
326
  label_colors: Optional[Dict[str, str]] = None,
283
327
  exclude_fields: Optional[List[str]] = None,
328
+ keep_skip_json_fields: Optional[List[str]] = None,
284
329
  spacing: SpacingValue = SpacingTheme.NORMAL,
285
330
  metrics_dict: Optional[Dict[str, Any]] = None,
286
331
  ):
@@ -298,6 +343,7 @@ class PydanticForm(Generic[ModelType]):
298
343
  disabled_fields: Optional list of top-level field names to disable specifically
299
344
  label_colors: Optional dictionary mapping field names to label colors (CSS color values)
300
345
  exclude_fields: Optional list of top-level field names to exclude from the form
346
+ keep_skip_json_fields: Optional list of dot-paths for SkipJsonSchema fields to force-keep
301
347
  spacing: Spacing theme to use for form layout ("normal", "compact", or SpacingTheme enum)
302
348
  metrics_dict: Optional metrics dictionary for field-level visual feedback
303
349
  """
@@ -342,6 +388,8 @@ class PydanticForm(Generic[ModelType]):
342
388
  self.exclude_fields = exclude_fields or [] # Store excluded fields list
343
389
  self.spacing = _normalize_spacing(spacing) # Store normalized spacing
344
390
  self.metrics_dict = metrics_dict or {} # Store metrics dictionary
391
+ self.keep_skip_json_fields = keep_skip_json_fields or []
392
+ self._keep_skip_json_pathset = _compile_keep_paths(self.keep_skip_json_fields)
345
393
 
346
394
  # Register custom renderers with the global registry if provided
347
395
  if custom_renderers:
@@ -363,6 +411,15 @@ class PydanticForm(Generic[ModelType]):
363
411
  wrapper_cls = "fhpf-wrapper w-full flex-1"
364
412
  return fh.Div(inner, cls=wrapper_cls)
365
413
 
414
+ def _normalized_dot_path(self, path_segments: List[str]) -> str:
415
+ """Normalize path segments by dropping indices and joining with dots."""
416
+ return normalize_path_segments(path_segments)
417
+
418
+ def _is_kept_skip_field(self, full_path: List[str]) -> bool:
419
+ """Return True if a SkipJsonSchema field should be kept based on keep list."""
420
+ normalized = self._normalized_dot_path(full_path)
421
+ return bool(normalized) and normalized in self._keep_skip_json_pathset
422
+
366
423
  def reset_state(self) -> None:
367
424
  """
368
425
  Restore the live state of the form to its immutable baseline.
@@ -400,6 +457,7 @@ class PydanticForm(Generic[ModelType]):
400
457
  disabled_fields=self.disabled_fields,
401
458
  label_colors=self.label_colors,
402
459
  exclude_fields=self.exclude_fields,
460
+ keep_skip_json_fields=self.keep_skip_json_fields,
403
461
  spacing=self.spacing,
404
462
  metrics_dict=metrics_dict
405
463
  if metrics_dict is not None
@@ -423,8 +481,10 @@ class PydanticForm(Generic[ModelType]):
423
481
  if field_name in self.exclude_fields:
424
482
  continue
425
483
 
426
- # Skip SkipJsonSchema fields (they should not be rendered in the form)
427
- if _is_skip_json_schema_field(field_info):
484
+ # Skip SkipJsonSchema fields unless explicitly kept
485
+ if _is_skip_json_schema_field(field_info) and not self._is_kept_skip_field(
486
+ [field_name]
487
+ ):
428
488
  continue
429
489
 
430
490
  # Only use what was explicitly provided in initial values
@@ -440,24 +500,14 @@ class PydanticForm(Generic[ModelType]):
440
500
 
441
501
  # Only use defaults if field was not provided at all
442
502
  if not field_was_provided:
443
- # Field not provided - use model defaults
444
- if field_info.default is not None:
445
- initial_value = field_info.default
446
- elif getattr(field_info, "default_factory", None) is not None:
447
- try:
448
- default_factory = field_info.default_factory
449
- if callable(default_factory):
450
- initial_value = default_factory() # type: ignore[call-arg]
451
- else:
452
- initial_value = None
453
- logger.warning(
454
- f" - default_factory for '{field_name}' is not callable"
455
- )
456
- except Exception as e:
457
- initial_value = None
458
- logger.warning(
459
- f" - Error in default_factory for '{field_name}': {e}"
460
- )
503
+ # Field not provided - use model defaults in order of priority
504
+ # 1. Try explicit field default
505
+ default_val = get_default(field_info)
506
+ if default_val is not _UNSET:
507
+ initial_value = default_val
508
+ else:
509
+ # 2. Fall back to smart defaults for the type
510
+ initial_value = default_for_annotation(field_info.annotation)
461
511
  # If field was provided (even as None), respect that value
462
512
 
463
513
  # Get renderer from global registry
@@ -488,6 +538,7 @@ class PydanticForm(Generic[ModelType]):
488
538
  field_path=[field_name], # Set top-level field path
489
539
  form_name=self.name, # Pass form name
490
540
  metrics_dict=self.metrics_dict, # Pass the metrics dict
541
+ keep_skip_json_pathset=self._keep_skip_json_pathset,
491
542
  )
492
543
 
493
544
  rendered_field = renderer.render()
@@ -654,19 +705,25 @@ class PydanticForm(Generic[ModelType]):
654
705
  if field_name not in self.exclude_fields
655
706
  }
656
707
 
657
- # Parse non-list fields first - pass the base_prefix and exclude_fields
708
+ # Parse non-list fields first - pass the base_prefix, exclude_fields, and keep paths
658
709
  result = _parse_non_list_fields(
659
710
  form_dict,
660
711
  self.model_class,
661
712
  list_field_defs,
662
713
  self.base_prefix,
663
714
  self.exclude_fields,
715
+ self._keep_skip_json_pathset,
716
+ None, # Top-level parsing, no field path
664
717
  )
665
718
 
666
- # Parse list fields based on keys present in form_dict - pass the base_prefix
719
+ # Parse list fields based on keys present in form_dict - pass the base_prefix and keep paths
667
720
  # Use filtered list field definitions to skip excluded list fields
668
721
  list_results = _parse_list_fields(
669
- form_dict, filtered_list_field_defs, self.base_prefix
722
+ form_dict,
723
+ filtered_list_field_defs,
724
+ self.base_prefix,
725
+ self.exclude_fields,
726
+ self._keep_skip_json_pathset,
670
727
  )
671
728
 
672
729
  # Merge list results into the main result
@@ -679,13 +736,14 @@ class PydanticForm(Generic[ModelType]):
679
736
 
680
737
  def _inject_missing_defaults(self, data: Dict[str, Any]) -> Dict[str, Any]:
681
738
  """
682
- Ensures all model fields with defaults are present in data if missing.
683
- Handles excluded fields, SkipJsonSchema fields, and any other fields
684
- not rendered in the form.
739
+ Ensure missing fields are filled following precedence:
740
+ 1) form value (already in `data`)
741
+ 2) initial_values
742
+ 3) model/default_factory
743
+ 4) sensible default (for SkipJsonSchema fields only)
685
744
 
686
- Priority order:
687
- 1. initial_values (if provided during form creation)
688
- 2. model defaults/default_factory
745
+ For required fields without defaults or initial_values, they are left missing
746
+ so that Pydantic validation can properly surface the error.
689
747
 
690
748
  Args:
691
749
  data: Dictionary to modify in-place
@@ -693,22 +751,20 @@ class PydanticForm(Generic[ModelType]):
693
751
  Returns:
694
752
  The same dictionary instance for method chaining
695
753
  """
696
- # Process ALL model fields, not just excluded ones
697
754
  for field_name, field_info in self.model_class.model_fields.items():
698
- # Skip if already present in parsed data
755
+ # 1) Respect any value already parsed from the form (top priority)
699
756
  if field_name in data:
700
757
  continue
701
758
 
702
- # First priority: check if initial_values_dict has this field
759
+ # 2) Prefer initial_values for ANY missing field (including hidden SkipJsonSchema fields)
703
760
  if field_name in self.initial_values_dict:
704
761
  initial_val = self.initial_values_dict[field_name]
705
- # If the initial value is a BaseModel, convert to dict for consistency
706
762
  if hasattr(initial_val, "model_dump"):
707
763
  initial_val = initial_val.model_dump()
708
764
  data[field_name] = initial_val
709
765
  continue
710
766
 
711
- # Second priority: use model defaults
767
+ # 3) Use model/default_factory if available
712
768
  default_val = get_default(field_info)
713
769
  if default_val is not _UNSET:
714
770
  # If the default is a BaseModel, convert to dict for consistency
@@ -716,12 +772,11 @@ class PydanticForm(Generic[ModelType]):
716
772
  default_val = default_val.model_dump()
717
773
  data[field_name] = default_val
718
774
  else:
719
- # Check if this is a SkipJsonSchema field
775
+ # 4) For SkipJsonSchema fields without defaults, provide sensible defaults
776
+ # For regular required fields, leave them missing so validation catches them
720
777
  if _is_skip_json_schema_field(field_info):
721
- pass # Skip fields don't need defaults
722
- else:
723
- # No default → leave missing; validation will surface error
724
- pass
778
+ data[field_name] = default_for_annotation(field_info.annotation)
779
+ # else: leave missing, let validation fail
725
780
 
726
781
  return data
727
782
 
@@ -808,6 +863,7 @@ class PydanticForm(Generic[ModelType]):
808
863
  field_path=segments, # Pass the full path segments
809
864
  form_name=self.name, # Pass the explicit form name
810
865
  metrics_dict=self.metrics_dict, # Pass the metrics dict
866
+ keep_skip_json_pathset=self._keep_skip_json_pathset,
811
867
  )
812
868
 
813
869
  # Generate a unique placeholder index
@@ -5,6 +5,7 @@ __all__ = [
5
5
  "_is_literal_type",
6
6
  "_is_enum_type",
7
7
  "_is_skip_json_schema_field",
8
+ "normalize_path_segments",
8
9
  "MetricEntry",
9
10
  "MetricsDict",
10
11
  "DecorationScope",
@@ -18,6 +19,7 @@ from typing import (
18
19
  Annotated,
19
20
  Any,
20
21
  Dict,
22
+ List,
21
23
  Literal,
22
24
  TypedDict,
23
25
  Union,
@@ -38,6 +40,18 @@ class DecorationScope(str, Enum):
38
40
  BOTH = "both"
39
41
 
40
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
+
41
55
  def _is_skip_json_schema_field(annotation_or_field_info: Any) -> bool:
42
56
  """
43
57
  Check if a field annotation or field_info indicates it should be skipped in JSON schema.
@@ -101,8 +115,14 @@ def _is_skip_json_schema_field(annotation_or_field_info: Any) -> bool:
101
115
  ):
102
116
  return True
103
117
 
104
- # 3. Fallback – cheap but effective
105
- return "SkipJsonSchema" in repr(annotation)
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))
106
126
 
107
127
 
108
128
  # Metrics types for field-level annotations