fh-pydantic-form 0.3.5__tar.gz → 0.3.7__tar.gz

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.

Files changed (22) hide show
  1. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/.github/workflows/build.yaml +1 -0
  2. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/PKG-INFO +80 -2
  3. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/README.md +79 -1
  4. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/RELEASE_NOTES.md +45 -0
  5. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/pyproject.toml +1 -1
  6. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/field_renderers.py +67 -24
  7. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/form_parser.py +82 -12
  8. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/form_renderer.py +92 -29
  9. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/type_helpers.py +22 -2
  10. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/.github/workflows/publish.yaml +0 -0
  11. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/.gitignore +0 -0
  12. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/.pre-commit-config.yaml +0 -0
  13. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/LICENSE +0 -0
  14. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/__init__.py +0 -0
  15. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/color_utils.py +0 -0
  16. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/comparison_form.py +0 -0
  17. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/constants.py +0 -0
  18. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/defaults.py +0 -0
  19. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/list_path.py +0 -0
  20. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/py.typed +0 -0
  21. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/registry.py +0 -0
  22. {fh_pydantic_form-0.3.5 → fh_pydantic_form-0.3.7}/src/fh_pydantic_form/ui_style.py +0 -0
@@ -27,6 +27,7 @@ jobs:
27
27
  uses: astral-sh/setup-uv@v5
28
28
  with:
29
29
  enable-cache: true
30
+ cache-dependency-glob: "uv.lock"
30
31
 
31
32
  - name: "Set up Python"
32
33
  uses: actions/setup-python@v5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-pydantic-form
3
- Version: 0.3.5
3
+ Version: 0.3.7
4
4
  Summary: a library to turn any pydantic BaseModel object into a fasthtml/monsterui input form
5
5
  Project-URL: Homepage, https://github.com/Marcura/fh-pydantic-form
6
6
  Project-URL: Repository, https://github.com/Marcura/fh-pydantic-form
@@ -480,6 +480,83 @@ form_renderer = PydanticForm(
480
480
 
481
481
  This automatic default injection means you can safely exclude fields that shouldn't be user-editable while maintaining data integrity.
482
482
 
483
+ ### SkipJsonSchema Fields
484
+
485
+ `fh-pydantic-form` provides advanced handling for `SkipJsonSchema` fields with selective visibility control. By default, fields marked with `SkipJsonSchema` are hidden from forms, but you can selectively show specific ones using the `keep_skip_json_fields` parameter.
486
+
487
+ ```python
488
+ from pydantic.json_schema import SkipJsonSchema
489
+
490
+ class DocumentModel(BaseModel):
491
+ title: str
492
+ content: str
493
+
494
+ # Hidden by default - system fields
495
+ document_id: SkipJsonSchema[str] = Field(
496
+ default_factory=lambda: f"doc_{uuid4().hex[:12]}",
497
+ description="Internal document ID"
498
+ )
499
+ created_at: SkipJsonSchema[datetime.datetime] = Field(
500
+ default_factory=datetime.datetime.now,
501
+ description="Creation timestamp"
502
+ )
503
+ version: SkipJsonSchema[int] = Field(
504
+ default=1,
505
+ description="Document version"
506
+ )
507
+
508
+ # Normal form - all SkipJsonSchema fields hidden
509
+ form_normal = PydanticForm("doc_form", DocumentModel)
510
+
511
+ # Admin form - selectively show some SkipJsonSchema fields
512
+ form_admin = PydanticForm(
513
+ "admin_form",
514
+ DocumentModel,
515
+ keep_skip_json_fields=[
516
+ "document_id", # Show document ID
517
+ "version", # Show version number
518
+ # created_at remains hidden
519
+ ]
520
+ )
521
+ ```
522
+
523
+ #### Nested SkipJsonSchema Fields
524
+
525
+ The feature supports dot notation for nested objects and list items:
526
+
527
+ ```python
528
+ class Address(BaseModel):
529
+ street: str
530
+ city: str
531
+ # Hidden system field
532
+ internal_id: SkipJsonSchema[str] = Field(default_factory=lambda: f"addr_{uuid4().hex[:8]}")
533
+
534
+ class UserModel(BaseModel):
535
+ name: str
536
+ main_address: Address
537
+ other_addresses: List[Address]
538
+
539
+ # Show specific nested SkipJsonSchema fields
540
+ form = PydanticForm(
541
+ "user_form",
542
+ UserModel,
543
+ keep_skip_json_fields=[
544
+ "main_address.internal_id", # Show main address ID
545
+ "other_addresses.internal_id", # Show all address IDs in the list
546
+ ]
547
+ )
548
+ ```
549
+
550
+ **Key Features:**
551
+ - **Hidden by default:** SkipJsonSchema fields are automatically excluded from forms
552
+ - **Selective visibility:** Use `keep_skip_json_fields` to show specific fields
553
+ - **Nested support:** Access nested fields with dot notation (`"main_address.internal_id"`)
554
+ - **List support:** Show fields in all list items (`"addresses.internal_id"`)
555
+ - **Smart defaults:** Non-kept fields use model defaults, kept fields retain initial values
556
+ - **Admin interfaces:** Perfect for admin panels or debugging where you need to see system fields
557
+
558
+ See `examples/complex_example.py` for a comprehensive demonstration of SkipJsonSchema field handling.
559
+
483
560
  ## Refreshing & Resetting
484
561
 
485
562
  Forms support dynamic refresh and reset functionality:
@@ -1007,6 +1084,7 @@ form_renderer = PydanticForm(
1007
1084
  | `disabled_fields` | `Optional[List[str]]` | `None` | List of specific field names to disable |
1008
1085
  | `label_colors` | `Optional[Dict[str, str]]` | `None` | Mapping of field names to CSS colors or Tailwind classes |
1009
1086
  | `exclude_fields` | `Optional[List[str]]` | `None` | List of field names to exclude from rendering (auto-injected on submission) |
1087
+ | `keep_skip_json_fields` | `Optional[List[str]]` | `None` | List of SkipJsonSchema field paths to selectively show (supports dot notation for nested fields) |
1010
1088
  | `spacing` | `SpacingValue` | `"normal"` | Spacing theme: `"normal"`, `"compact"`, or `SpacingTheme` enum |
1011
1089
  | `metrics_dict` | `Optional[Dict[str, Dict]]` | `None` | Field metrics for highlighting and tooltips |
1012
1090
 
@@ -1025,7 +1103,7 @@ form_renderer = PydanticForm(
1025
1103
  | Method | Purpose |
1026
1104
  |--------|---------|
1027
1105
  | `render_inputs()` | Generate the HTML form inputs (without `<form>` wrapper) |
1028
- | `with_initial_values(initial_values)` | Create a new form instance with same configuration but different initial values |
1106
+ | `with_initial_values(initial_values, metrics_dict=None)` | Create a new form instance with same configuration but different initial values |
1029
1107
  | `refresh_button(text=None, **kwargs)` | Create a refresh button component |
1030
1108
  | `reset_button(text=None, **kwargs)` | Create a reset button component |
1031
1109
  | `register_routes(app)` | Register HTMX endpoints for list manipulation |
@@ -455,6 +455,83 @@ form_renderer = PydanticForm(
455
455
 
456
456
  This automatic default injection means you can safely exclude fields that shouldn't be user-editable while maintaining data integrity.
457
457
 
458
+ ### SkipJsonSchema Fields
459
+
460
+ `fh-pydantic-form` provides advanced handling for `SkipJsonSchema` fields with selective visibility control. By default, fields marked with `SkipJsonSchema` are hidden from forms, but you can selectively show specific ones using the `keep_skip_json_fields` parameter.
461
+
462
+ ```python
463
+ from pydantic.json_schema import SkipJsonSchema
464
+
465
+ class DocumentModel(BaseModel):
466
+ title: str
467
+ content: str
468
+
469
+ # Hidden by default - system fields
470
+ document_id: SkipJsonSchema[str] = Field(
471
+ default_factory=lambda: f"doc_{uuid4().hex[:12]}",
472
+ description="Internal document ID"
473
+ )
474
+ created_at: SkipJsonSchema[datetime.datetime] = Field(
475
+ default_factory=datetime.datetime.now,
476
+ description="Creation timestamp"
477
+ )
478
+ version: SkipJsonSchema[int] = Field(
479
+ default=1,
480
+ description="Document version"
481
+ )
482
+
483
+ # Normal form - all SkipJsonSchema fields hidden
484
+ form_normal = PydanticForm("doc_form", DocumentModel)
485
+
486
+ # Admin form - selectively show some SkipJsonSchema fields
487
+ form_admin = PydanticForm(
488
+ "admin_form",
489
+ DocumentModel,
490
+ keep_skip_json_fields=[
491
+ "document_id", # Show document ID
492
+ "version", # Show version number
493
+ # created_at remains hidden
494
+ ]
495
+ )
496
+ ```
497
+
498
+ #### Nested SkipJsonSchema Fields
499
+
500
+ The feature supports dot notation for nested objects and list items:
501
+
502
+ ```python
503
+ class Address(BaseModel):
504
+ street: str
505
+ city: str
506
+ # Hidden system field
507
+ internal_id: SkipJsonSchema[str] = Field(default_factory=lambda: f"addr_{uuid4().hex[:8]}")
508
+
509
+ class UserModel(BaseModel):
510
+ name: str
511
+ main_address: Address
512
+ other_addresses: List[Address]
513
+
514
+ # Show specific nested SkipJsonSchema fields
515
+ form = PydanticForm(
516
+ "user_form",
517
+ UserModel,
518
+ keep_skip_json_fields=[
519
+ "main_address.internal_id", # Show main address ID
520
+ "other_addresses.internal_id", # Show all address IDs in the list
521
+ ]
522
+ )
523
+ ```
524
+
525
+ **Key Features:**
526
+ - **Hidden by default:** SkipJsonSchema fields are automatically excluded from forms
527
+ - **Selective visibility:** Use `keep_skip_json_fields` to show specific fields
528
+ - **Nested support:** Access nested fields with dot notation (`"main_address.internal_id"`)
529
+ - **List support:** Show fields in all list items (`"addresses.internal_id"`)
530
+ - **Smart defaults:** Non-kept fields use model defaults, kept fields retain initial values
531
+ - **Admin interfaces:** Perfect for admin panels or debugging where you need to see system fields
532
+
533
+ See `examples/complex_example.py` for a comprehensive demonstration of SkipJsonSchema field handling.
534
+
458
535
  ## Refreshing & Resetting
459
536
 
460
537
  Forms support dynamic refresh and reset functionality:
@@ -982,6 +1059,7 @@ form_renderer = PydanticForm(
982
1059
  | `disabled_fields` | `Optional[List[str]]` | `None` | List of specific field names to disable |
983
1060
  | `label_colors` | `Optional[Dict[str, str]]` | `None` | Mapping of field names to CSS colors or Tailwind classes |
984
1061
  | `exclude_fields` | `Optional[List[str]]` | `None` | List of field names to exclude from rendering (auto-injected on submission) |
1062
+ | `keep_skip_json_fields` | `Optional[List[str]]` | `None` | List of SkipJsonSchema field paths to selectively show (supports dot notation for nested fields) |
985
1063
  | `spacing` | `SpacingValue` | `"normal"` | Spacing theme: `"normal"`, `"compact"`, or `SpacingTheme` enum |
986
1064
  | `metrics_dict` | `Optional[Dict[str, Dict]]` | `None` | Field metrics for highlighting and tooltips |
987
1065
 
@@ -1000,7 +1078,7 @@ form_renderer = PydanticForm(
1000
1078
  | Method | Purpose |
1001
1079
  |--------|---------|
1002
1080
  | `render_inputs()` | Generate the HTML form inputs (without `<form>` wrapper) |
1003
- | `with_initial_values(initial_values)` | Create a new form instance with same configuration but different initial values |
1081
+ | `with_initial_values(initial_values, metrics_dict=None)` | Create a new form instance with same configuration but different initial values |
1004
1082
  | `refresh_button(text=None, **kwargs)` | Create a refresh button component |
1005
1083
  | `reset_button(text=None, **kwargs)` | Create a reset button component |
1006
1084
  | `register_routes(app)` | Register HTMX endpoints for list manipulation |
@@ -1,5 +1,50 @@
1
1
  # Release Notes
2
2
 
3
+ ## Version 0.3.7 (2025-09-19)
4
+
5
+ ### 🎉 New Features
6
+
7
+ #### SkipJsonSchema Field Support with Selective Override
8
+ - **NEW**: Added comprehensive support for fields marked with `SkipJsonSchema` annotation
9
+ - **NEW**: `keep_skip_json_fields` parameter allows selective inclusion of specific SkipJsonSchema fields
10
+ - Supports dot-notation paths for nested fields (e.g., `"addresses.internal_id"`)
11
+ - Enables fine-grained control over which internal fields are exposed in forms
12
+ - Works with complex nested structures and list fields
13
+ - **ENHANCED**: SkipJsonSchema fields are automatically excluded from form rendering by default
14
+ - **IMPROVED**: Better field introspection for complex type scenarios including optional skip fields
15
+
16
+ ### 🔧 Bug Fixes & Improvements
17
+
18
+ #### Default Values Handling
19
+ - **FIXED**: Default values for simple fields now work correctly without initial values
20
+ - **IMPROVED**: Better handling of field defaults when no initial values are provided
21
+ - **ENHANCED**: More robust form rendering for fields with default values
22
+
23
+ #### Documentation & Examples
24
+ - **UPDATED**: README.md with SkipJsonSchema handling documentation
25
+ - **ENHANCED**: Complex example updated to demonstrate SkipJsonSchema usage patterns
26
+ - **IMPROVED**: Better code documentation and examples
27
+
28
+ ### 🧪 Testing
29
+ - **NEW**: Comprehensive test coverage for SkipJsonSchema field handling
30
+ - **NEW**: Tests for default values behavior without initial values
31
+ - **IMPROVED**: Enhanced test coverage for edge cases and type introspection
32
+
33
+ ### 📊 Statistics
34
+ - **7 commits** since v0.3.6
35
+ - Focus on optional field handling and default value improvements
36
+ - Enhanced SkipJsonSchema support with comprehensive testing
37
+
38
+ **Key Highlights:**
39
+ This release significantly improves handling of optional fields, particularly those marked with `SkipJsonSchema`, and fixes important issues with default value handling when no initial values are provided.
40
+
41
+ ---
42
+
43
+ ## Version 0.3.6 (2025-07-21)
44
+
45
+ - **NEW**: can now pass new metrics_dict to `.with_initial_values()` helper method.
46
+
47
+ ---
3
48
  ## Version 0.3.5 (2025-07-17)
4
49
 
5
50
  - **NEW**: Added support for `decimal.Decimal` fields with dedicated field renderer
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fh-pydantic-form"
3
- version = "0.3.5"
3
+ version = "0.3.7"
4
4
  description = "a library to turn any pydantic BaseModel object into a fasthtml/monsterui input form"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ from typing import (
14
14
  import fasthtml.common as fh
15
15
  import monsterui.all as mui
16
16
  from fastcore.xml import FT
17
- from pydantic import ValidationError
17
+ from pydantic import BaseModel, ValidationError
18
18
  from pydantic.fields import FieldInfo
19
19
 
20
20
  from fh_pydantic_form.color_utils import (
@@ -23,6 +23,7 @@ from fh_pydantic_form.color_utils import (
23
23
  robust_color_to_rgba,
24
24
  )
25
25
  from fh_pydantic_form.constants import _UNSET
26
+ from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
26
27
  from fh_pydantic_form.registry import FieldRendererRegistry
27
28
  from fh_pydantic_form.type_helpers import (
28
29
  DecorationScope,
@@ -30,7 +31,9 @@ from fh_pydantic_form.type_helpers import (
30
31
  MetricsDict,
31
32
  _get_underlying_type_if_optional,
32
33
  _is_optional_type,
34
+ _is_skip_json_schema_field,
33
35
  get_default,
36
+ normalize_path_segments,
34
37
  )
35
38
  from fh_pydantic_form.ui_style import (
36
39
  SpacingTheme,
@@ -379,6 +382,7 @@ class BaseFieldRenderer(MetricsRendererMixin):
379
382
  metric_entry: Optional[MetricEntry] = None,
380
383
  metrics_dict: Optional[MetricsDict] = None,
381
384
  refresh_endpoint_override: Optional[str] = None,
385
+ keep_skip_json_pathset: Optional[set[str]] = None,
382
386
  **kwargs, # Accept additional kwargs for extensibility
383
387
  ):
384
388
  """
@@ -402,6 +406,14 @@ class BaseFieldRenderer(MetricsRendererMixin):
402
406
  self.field_name = f"{prefix}{field_name}" if prefix else field_name
403
407
  self.original_field_name = field_name
404
408
  self.field_info = field_info
409
+ # Normalize PydanticUndefined → None so it never renders as text
410
+ try:
411
+ from pydantic_core import PydanticUndefined
412
+
413
+ if value is PydanticUndefined:
414
+ value = None
415
+ except Exception:
416
+ pass
405
417
  self.value = value
406
418
  self.prefix = prefix
407
419
  self.field_path: List[str] = field_path or []
@@ -412,6 +424,7 @@ class BaseFieldRenderer(MetricsRendererMixin):
412
424
  self.spacing = _normalize_spacing(spacing)
413
425
  self.metrics_dict = metrics_dict
414
426
  self._refresh_endpoint_override = refresh_endpoint_override
427
+ self._keep_skip_json_pathset = keep_skip_json_pathset or set()
415
428
 
416
429
  # Initialize metric entry attribute
417
430
  self.metric_entry: Optional[MetricEntry] = None
@@ -447,6 +460,15 @@ class BaseFieldRenderer(MetricsRendererMixin):
447
460
  parts.append(segment)
448
461
  return ".".join(parts)
449
462
 
463
+ def _normalized_dot_path(self, path_segments: List[str]) -> str:
464
+ """Normalize path segments by dropping indices and joining with dots."""
465
+ return normalize_path_segments(path_segments)
466
+
467
+ def _is_kept_skip_field(self, full_path: List[str]) -> bool:
468
+ """Return True if a SkipJsonSchema field should be kept based on keep list."""
469
+ normalized = self._normalized_dot_path(full_path)
470
+ return bool(normalized) and normalized in self._keep_skip_json_pathset
471
+
450
472
  def _is_inline_color(self, color: str) -> bool:
451
473
  """
452
474
  Determine if a color should be applied as an inline style or CSS class.
@@ -1249,18 +1271,26 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
1249
1271
 
1250
1272
  # Only use defaults if field wasn't provided
1251
1273
  if not field_was_provided:
1252
- if nested_field_info.default is not None:
1253
- nested_field_value = nested_field_info.default
1254
- elif (
1255
- getattr(nested_field_info, "default_factory", None) is not None
1256
- ):
1257
- try:
1258
- nested_field_value = nested_field_info.default_factory()
1259
- except Exception as e:
1260
- logger.warning(
1261
- f"Default factory failed for {nested_field_name}: {e}"
1262
- )
1263
- nested_field_value = None
1274
+ dv = get_default(nested_field_info) # _UNSET if truly unset
1275
+ if dv is not _UNSET:
1276
+ nested_field_value = dv
1277
+ else:
1278
+ ann = nested_field_info.annotation
1279
+ base_ann = get_origin(ann) or ann
1280
+ if isinstance(base_ann, type) and issubclass(
1281
+ base_ann, BaseModel
1282
+ ):
1283
+ nested_field_value = default_dict_for_model(base_ann)
1284
+ else:
1285
+ nested_field_value = default_for_annotation(ann)
1286
+
1287
+ # Skip SkipJsonSchema fields unless explicitly kept
1288
+ if _is_skip_json_schema_field(
1289
+ nested_field_info
1290
+ ) and not self._is_kept_skip_field(
1291
+ self.field_path + [nested_field_name]
1292
+ ):
1293
+ continue
1264
1294
 
1265
1295
  # Get renderer for this nested field
1266
1296
  registry = FieldRendererRegistry() # Get singleton instance
@@ -1290,6 +1320,7 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
1290
1320
  metric_entry=None, # Let auto-lookup handle it
1291
1321
  metrics_dict=self.metrics_dict, # Pass down the metrics dict
1292
1322
  refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1323
+ keep_skip_json_pathset=self._keep_skip_json_pathset, # Propagate keep paths
1293
1324
  )
1294
1325
 
1295
1326
  nested_inputs.append(renderer.render())
@@ -1843,6 +1874,7 @@ class ListFieldRenderer(BaseFieldRenderer):
1843
1874
  metric_entry=None, # Let auto-lookup handle it
1844
1875
  metrics_dict=self.metrics_dict, # Pass down the metrics dict
1845
1876
  refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1877
+ keep_skip_json_pathset=self._keep_skip_json_pathset, # Propagate keep paths
1846
1878
  )
1847
1879
  # Add the rendered input to content elements
1848
1880
  item_content_elements.append(item_renderer.render_input())
@@ -1866,18 +1898,28 @@ class ListFieldRenderer(BaseFieldRenderer):
1866
1898
 
1867
1899
  # Use defaults only if field not provided
1868
1900
  if not field_was_provided:
1869
- if nested_field_info.default is not None:
1870
- nested_field_value = nested_field_info.default
1871
- elif (
1872
- getattr(nested_field_info, "default_factory", None)
1873
- is not None
1874
- ):
1875
- try:
1876
- nested_field_value = (
1877
- nested_field_info.default_factory()
1901
+ dv = get_default(nested_field_info)
1902
+ if dv is not _UNSET:
1903
+ nested_field_value = dv
1904
+ else:
1905
+ ann = nested_field_info.annotation
1906
+ base_ann = get_origin(ann) or ann
1907
+ if isinstance(base_ann, type) and issubclass(
1908
+ base_ann, BaseModel
1909
+ ):
1910
+ nested_field_value = default_dict_for_model(
1911
+ base_ann
1878
1912
  )
1879
- except Exception:
1880
- continue # Skip fields with problematic defaults
1913
+ else:
1914
+ nested_field_value = default_for_annotation(ann)
1915
+
1916
+ # Skip SkipJsonSchema fields unless explicitly kept
1917
+ if _is_skip_json_schema_field(
1918
+ nested_field_info
1919
+ ) and not self._is_kept_skip_field(
1920
+ self.field_path + [nested_field_name]
1921
+ ):
1922
+ continue
1881
1923
 
1882
1924
  # Get renderer and render field with error handling
1883
1925
  renderer_cls = FieldRendererRegistry().get_renderer(
@@ -1902,6 +1944,7 @@ class ListFieldRenderer(BaseFieldRenderer):
1902
1944
  metric_entry=None, # Let auto-lookup handle it
1903
1945
  metrics_dict=self.metrics_dict, # Pass down the metrics dict
1904
1946
  refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1947
+ keep_skip_json_pathset=self._keep_skip_json_pathset, # Propagate keep paths
1905
1948
  )
1906
1949
 
1907
1950
  # Add rendered field to valid fields
@@ -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) {
@@ -281,6 +297,7 @@ class PydanticForm(Generic[ModelType]):
281
297
  disabled_fields: Optional[List[str]] = None,
282
298
  label_colors: Optional[Dict[str, str]] = None,
283
299
  exclude_fields: Optional[List[str]] = None,
300
+ keep_skip_json_fields: Optional[List[str]] = None,
284
301
  spacing: SpacingValue = SpacingTheme.NORMAL,
285
302
  metrics_dict: Optional[Dict[str, Any]] = None,
286
303
  ):
@@ -298,6 +315,7 @@ class PydanticForm(Generic[ModelType]):
298
315
  disabled_fields: Optional list of top-level field names to disable specifically
299
316
  label_colors: Optional dictionary mapping field names to label colors (CSS color values)
300
317
  exclude_fields: Optional list of top-level field names to exclude from the form
318
+ keep_skip_json_fields: Optional list of dot-paths for SkipJsonSchema fields to force-keep
301
319
  spacing: Spacing theme to use for form layout ("normal", "compact", or SpacingTheme enum)
302
320
  metrics_dict: Optional metrics dictionary for field-level visual feedback
303
321
  """
@@ -342,6 +360,8 @@ class PydanticForm(Generic[ModelType]):
342
360
  self.exclude_fields = exclude_fields or [] # Store excluded fields list
343
361
  self.spacing = _normalize_spacing(spacing) # Store normalized spacing
344
362
  self.metrics_dict = metrics_dict or {} # Store metrics dictionary
363
+ self.keep_skip_json_fields = keep_skip_json_fields or []
364
+ self._keep_skip_json_pathset = _compile_keep_paths(self.keep_skip_json_fields)
345
365
 
346
366
  # Register custom renderers with the global registry if provided
347
367
  if custom_renderers:
@@ -349,6 +369,13 @@ class PydanticForm(Generic[ModelType]):
349
369
  for field_type, renderer_cls in custom_renderers:
350
370
  registry.register_type_renderer(field_type, renderer_cls)
351
371
 
372
+ @property
373
+ def form_name(self) -> str:
374
+ """
375
+ LLMs like to hallucinate this property, so might as well make it real.
376
+ """
377
+ return self.name
378
+
352
379
  def _compact_wrapper(self, inner: FT) -> FT:
353
380
  """
354
381
  Wrap inner markup in a wrapper div.
@@ -356,6 +383,15 @@ class PydanticForm(Generic[ModelType]):
356
383
  wrapper_cls = "fhpf-wrapper w-full flex-1"
357
384
  return fh.Div(inner, cls=wrapper_cls)
358
385
 
386
+ def _normalized_dot_path(self, path_segments: List[str]) -> str:
387
+ """Normalize path segments by dropping indices and joining with dots."""
388
+ return normalize_path_segments(path_segments)
389
+
390
+ def _is_kept_skip_field(self, full_path: List[str]) -> bool:
391
+ """Return True if a SkipJsonSchema field should be kept based on keep list."""
392
+ normalized = self._normalized_dot_path(full_path)
393
+ return bool(normalized) and normalized in self._keep_skip_json_pathset
394
+
359
395
  def reset_state(self) -> None:
360
396
  """
361
397
  Restore the live state of the form to its immutable baseline.
@@ -364,7 +400,9 @@ class PydanticForm(Generic[ModelType]):
364
400
  self.values_dict = self.initial_values_dict.copy()
365
401
 
366
402
  def with_initial_values(
367
- self, initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None
403
+ self,
404
+ initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None,
405
+ metrics_dict: Optional[Dict[str, Any]] = None,
368
406
  ) -> "PydanticForm":
369
407
  """
370
408
  Create a new PydanticForm instance with the same configuration but different initial values.
@@ -376,6 +414,7 @@ class PydanticForm(Generic[ModelType]):
376
414
  Args:
377
415
  initial_values: New initial values as BaseModel instance or dict.
378
416
  Same format as the constructor accepts.
417
+ metrics_dict: Optional metrics dictionary for field-level visual feedback
379
418
 
380
419
  Returns:
381
420
  A new PydanticForm instance with identical configuration but updated initial values
@@ -390,8 +429,11 @@ class PydanticForm(Generic[ModelType]):
390
429
  disabled_fields=self.disabled_fields,
391
430
  label_colors=self.label_colors,
392
431
  exclude_fields=self.exclude_fields,
432
+ keep_skip_json_fields=self.keep_skip_json_fields,
393
433
  spacing=self.spacing,
394
- metrics_dict=self.metrics_dict,
434
+ metrics_dict=metrics_dict
435
+ if metrics_dict is not None
436
+ else self.metrics_dict,
395
437
  )
396
438
 
397
439
  return clone
@@ -411,8 +453,10 @@ class PydanticForm(Generic[ModelType]):
411
453
  if field_name in self.exclude_fields:
412
454
  continue
413
455
 
414
- # Skip SkipJsonSchema fields (they should not be rendered in the form)
415
- if _is_skip_json_schema_field(field_info):
456
+ # Skip SkipJsonSchema fields unless explicitly kept
457
+ if _is_skip_json_schema_field(field_info) and not self._is_kept_skip_field(
458
+ [field_name]
459
+ ):
416
460
  continue
417
461
 
418
462
  # Only use what was explicitly provided in initial values
@@ -428,24 +472,14 @@ class PydanticForm(Generic[ModelType]):
428
472
 
429
473
  # Only use defaults if field was not provided at all
430
474
  if not field_was_provided:
431
- # Field not provided - use model defaults
432
- if field_info.default is not None:
433
- initial_value = field_info.default
434
- elif getattr(field_info, "default_factory", None) is not None:
435
- try:
436
- default_factory = field_info.default_factory
437
- if callable(default_factory):
438
- initial_value = default_factory() # type: ignore[call-arg]
439
- else:
440
- initial_value = None
441
- logger.warning(
442
- f" - default_factory for '{field_name}' is not callable"
443
- )
444
- except Exception as e:
445
- initial_value = None
446
- logger.warning(
447
- f" - Error in default_factory for '{field_name}': {e}"
448
- )
475
+ # Field not provided - use model defaults in order of priority
476
+ # 1. Try explicit field default
477
+ default_val = get_default(field_info)
478
+ if default_val is not _UNSET:
479
+ initial_value = default_val
480
+ else:
481
+ # 2. Fall back to smart defaults for the type
482
+ initial_value = default_for_annotation(field_info.annotation)
449
483
  # If field was provided (even as None), respect that value
450
484
 
451
485
  # Get renderer from global registry
@@ -476,6 +510,7 @@ class PydanticForm(Generic[ModelType]):
476
510
  field_path=[field_name], # Set top-level field path
477
511
  form_name=self.name, # Pass form name
478
512
  metrics_dict=self.metrics_dict, # Pass the metrics dict
513
+ keep_skip_json_pathset=self._keep_skip_json_pathset,
479
514
  )
480
515
 
481
516
  rendered_field = renderer.render()
@@ -642,19 +677,25 @@ class PydanticForm(Generic[ModelType]):
642
677
  if field_name not in self.exclude_fields
643
678
  }
644
679
 
645
- # Parse non-list fields first - pass the base_prefix and exclude_fields
680
+ # Parse non-list fields first - pass the base_prefix, exclude_fields, and keep paths
646
681
  result = _parse_non_list_fields(
647
682
  form_dict,
648
683
  self.model_class,
649
684
  list_field_defs,
650
685
  self.base_prefix,
651
686
  self.exclude_fields,
687
+ self._keep_skip_json_pathset,
688
+ None, # Top-level parsing, no field path
652
689
  )
653
690
 
654
- # Parse list fields based on keys present in form_dict - pass the base_prefix
691
+ # Parse list fields based on keys present in form_dict - pass the base_prefix and keep paths
655
692
  # Use filtered list field definitions to skip excluded list fields
656
693
  list_results = _parse_list_fields(
657
- form_dict, filtered_list_field_defs, self.base_prefix
694
+ form_dict,
695
+ filtered_list_field_defs,
696
+ self.base_prefix,
697
+ self.exclude_fields,
698
+ self._keep_skip_json_pathset,
658
699
  )
659
700
 
660
701
  # Merge list results into the main result
@@ -683,12 +724,33 @@ class PydanticForm(Generic[ModelType]):
683
724
  """
684
725
  # Process ALL model fields, not just excluded ones
685
726
  for field_name, field_info in self.model_class.model_fields.items():
686
- # Skip if already present in parsed data
687
- if field_name in data:
688
- continue
727
+ # Special handling for SkipJsonSchema fields
728
+ if _is_skip_json_schema_field(field_info):
729
+ # If keep_skip_json_fields was specified and this field is not kept, always use defaults
730
+ if self.keep_skip_json_fields and not self._is_kept_skip_field(
731
+ [field_name]
732
+ ):
733
+ # Remove any existing value and inject default
734
+ if field_name in data:
735
+ del data[field_name]
736
+ # Fall through to default injection logic below
737
+ elif field_name in data:
738
+ # This is either a kept SkipJsonSchema field or no keep list was specified, keep it
739
+ continue
740
+ # If it's a kept field but not in data, fall through to default injection
741
+ else:
742
+ # Skip if already present in parsed data (normal fields)
743
+ if field_name in data:
744
+ continue
689
745
 
690
746
  # First priority: check if initial_values_dict has this field
691
- if field_name in self.initial_values_dict:
747
+ # Use initial values for non-SkipJsonSchema fields, or SkipJsonSchema fields that are kept,
748
+ # or SkipJsonSchema fields when no keep_skip_json_fields list was specified
749
+ if field_name in self.initial_values_dict and (
750
+ not _is_skip_json_schema_field(field_info)
751
+ or not self.keep_skip_json_fields
752
+ or self._is_kept_skip_field([field_name])
753
+ ):
692
754
  initial_val = self.initial_values_dict[field_name]
693
755
  # If the initial value is a BaseModel, convert to dict for consistency
694
756
  if hasattr(initial_val, "model_dump"):
@@ -796,6 +858,7 @@ class PydanticForm(Generic[ModelType]):
796
858
  field_path=segments, # Pass the full path segments
797
859
  form_name=self.name, # Pass the explicit form name
798
860
  metrics_dict=self.metrics_dict, # Pass the metrics dict
861
+ keep_skip_json_pathset=self._keep_skip_json_pathset,
799
862
  )
800
863
 
801
864
  # 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