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

@@ -24,7 +24,7 @@ from fh_pydantic_form.ui_style import (
24
24
  SpacingTheme,
25
25
  SpacingValue,
26
26
  spacing,
27
- COMPACT_EXTRA_CSS,
27
+ spacing_many,
28
28
  )
29
29
  from fh_pydantic_form.defaults import (
30
30
  default_dict_for_model,
@@ -119,7 +119,7 @@ __all__ = [
119
119
  "SpacingTheme",
120
120
  "SpacingValue",
121
121
  "spacing",
122
- "COMPACT_EXTRA_CSS",
122
+ "spacing_many",
123
123
  "default_dict_for_model",
124
124
  "default_for_annotation",
125
125
  ]
@@ -0,0 +1,12 @@
1
+ """
2
+ Shared constants and sentinel values used across the library.
3
+ """
4
+
5
+
6
+ class _Unset:
7
+ """Sentinel class to indicate an unset value."""
8
+
9
+ pass
10
+
11
+
12
+ _UNSET = _Unset()
@@ -2,11 +2,16 @@ from __future__ import annotations
2
2
 
3
3
  import datetime as _dt
4
4
  from enum import Enum
5
- from typing import Any, get_args, get_origin, Literal
5
+ from typing import Any, Literal, get_args, get_origin
6
6
 
7
7
  from pydantic import BaseModel
8
8
 
9
- from .type_helpers import _UNSET, get_default, _is_optional_type
9
+ from fh_pydantic_form.constants import _UNSET
10
+ from fh_pydantic_form.type_helpers import (
11
+ _is_optional_type,
12
+ _is_skip_json_schema_field,
13
+ get_default,
14
+ )
10
15
 
11
16
 
12
17
  def _today():
@@ -122,6 +127,23 @@ def default_dict_for_model(model_cls: type[BaseModel]) -> dict[str, Any]:
122
127
  continue
123
128
  # --------------------------------------------------------------------
124
129
 
130
+ # Check if this is a SkipJsonSchema field - if so, always get its default
131
+ if _is_skip_json_schema_field(field):
132
+ default_val = get_default(field)
133
+ if default_val is not _UNSET:
134
+ # Handle BaseModel defaults by converting to dict
135
+ if hasattr(default_val, "model_dump"):
136
+ out[name] = default_val.model_dump()
137
+ # Convert enum instances to their values
138
+ elif isinstance(default_val, Enum):
139
+ out[name] = default_val.value
140
+ else:
141
+ out[name] = default_val
142
+ else:
143
+ # No default for SkipJsonSchema field - use smart default
144
+ out[name] = default_for_annotation(field.annotation)
145
+ continue
146
+
125
147
  # 1. Check for model-supplied default or factory
126
148
  default_val = get_default(field) # returns _UNSET if no default
127
149
  if default_val is not _UNSET:
@@ -15,9 +15,9 @@ from fastcore.xml import FT
15
15
  from pydantic import ValidationError
16
16
  from pydantic.fields import FieldInfo
17
17
 
18
+ from fh_pydantic_form.constants import _UNSET
18
19
  from fh_pydantic_form.registry import FieldRendererRegistry
19
20
  from fh_pydantic_form.type_helpers import (
20
- _UNSET,
21
21
  _get_underlying_type_if_optional,
22
22
  _is_optional_type,
23
23
  get_default,
@@ -27,6 +27,7 @@ from fh_pydantic_form.ui_style import (
27
27
  SpacingValue,
28
28
  _normalize_spacing,
29
29
  spacing,
30
+ spacing_many,
30
31
  )
31
32
 
32
33
  logger = logging.getLogger(__name__)
@@ -252,6 +253,14 @@ class StringFieldRenderer(BaseFieldRenderer):
252
253
  if self.is_optional:
253
254
  placeholder_text += " (Optional)"
254
255
 
256
+ input_cls_parts = ["w-full"]
257
+ input_spacing_cls = spacing_many(
258
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
259
+ self.spacing,
260
+ )
261
+ if input_spacing_cls:
262
+ input_cls_parts.append(input_spacing_cls)
263
+
255
264
  input_attrs = {
256
265
  "value": self.value or "",
257
266
  "id": self.field_name,
@@ -259,10 +268,7 @@ class StringFieldRenderer(BaseFieldRenderer):
259
268
  "type": "text",
260
269
  "placeholder": placeholder_text,
261
270
  "required": is_field_required,
262
- "cls": _merge_cls(
263
- "w-full",
264
- f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
265
- ),
271
+ "cls": " ".join(input_cls_parts),
266
272
  }
267
273
 
268
274
  # Only add the disabled attribute if the field should actually be disabled
@@ -290,6 +296,14 @@ class NumberFieldRenderer(BaseFieldRenderer):
290
296
  if self.is_optional:
291
297
  placeholder_text += " (Optional)"
292
298
 
299
+ input_cls_parts = ["w-full"]
300
+ input_spacing_cls = spacing_many(
301
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
302
+ self.spacing,
303
+ )
304
+ if input_spacing_cls:
305
+ input_cls_parts.append(input_spacing_cls)
306
+
293
307
  input_attrs = {
294
308
  "value": str(self.value) if self.value is not None else "",
295
309
  "id": self.field_name,
@@ -297,10 +311,7 @@ class NumberFieldRenderer(BaseFieldRenderer):
297
311
  "type": "number",
298
312
  "placeholder": placeholder_text,
299
313
  "required": is_field_required,
300
- "cls": _merge_cls(
301
- "w-full",
302
- f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
303
- ),
314
+ "cls": " ".join(input_cls_parts),
304
315
  "step": "any"
305
316
  if self.field_info.annotation is float
306
317
  or get_origin(self.field_info.annotation) is float
@@ -386,6 +397,14 @@ class DateFieldRenderer(BaseFieldRenderer):
386
397
  if self.is_optional:
387
398
  placeholder_text += " (Optional)"
388
399
 
400
+ input_cls_parts = ["w-full"]
401
+ input_spacing_cls = spacing_many(
402
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
403
+ self.spacing,
404
+ )
405
+ if input_spacing_cls:
406
+ input_cls_parts.append(input_spacing_cls)
407
+
389
408
  input_attrs = {
390
409
  "value": formatted_value,
391
410
  "id": self.field_name,
@@ -393,10 +412,7 @@ class DateFieldRenderer(BaseFieldRenderer):
393
412
  "type": "date",
394
413
  "placeholder": placeholder_text,
395
414
  "required": is_field_required,
396
- "cls": _merge_cls(
397
- "w-full",
398
- f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
399
- ),
415
+ "cls": " ".join(input_cls_parts),
400
416
  }
401
417
 
402
418
  # Only add the disabled attribute if the field should actually be disabled
@@ -433,6 +449,14 @@ class TimeFieldRenderer(BaseFieldRenderer):
433
449
  if self.is_optional:
434
450
  placeholder_text += " (Optional)"
435
451
 
452
+ input_cls_parts = ["w-full"]
453
+ input_spacing_cls = spacing_many(
454
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
455
+ self.spacing,
456
+ )
457
+ if input_spacing_cls:
458
+ input_cls_parts.append(input_spacing_cls)
459
+
436
460
  input_attrs = {
437
461
  "value": formatted_value,
438
462
  "id": self.field_name,
@@ -440,10 +464,7 @@ class TimeFieldRenderer(BaseFieldRenderer):
440
464
  "type": "time",
441
465
  "placeholder": placeholder_text,
442
466
  "required": is_field_required,
443
- "cls": _merge_cls(
444
- "w-full",
445
- f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
446
- ),
467
+ "cls": " ".join(input_cls_parts),
447
468
  }
448
469
 
449
470
  # Only add the disabled attribute if the field should actually be disabled
@@ -503,18 +524,22 @@ class LiteralFieldRenderer(BaseFieldRenderer):
503
524
  placeholder_text += " (Optional)"
504
525
 
505
526
  # Prepare attributes dictionary
527
+ select_cls_parts = ["w-full"]
528
+ select_spacing_cls = spacing_many(
529
+ ["input_size", "input_padding", "input_line_height", "input_font_size"],
530
+ self.spacing,
531
+ )
532
+ if select_spacing_cls:
533
+ select_cls_parts.append(select_spacing_cls)
534
+
506
535
  select_attrs = {
507
536
  "id": self.field_name,
508
537
  "name": self.field_name,
509
538
  "required": is_field_required,
510
539
  "placeholder": placeholder_text,
511
- "cls": _merge_cls(
512
- "w-full",
513
- f"{spacing('input_size', self.spacing)} {spacing('input_padding', self.spacing)}".strip(),
514
- ),
540
+ "cls": " ".join(select_cls_parts),
515
541
  }
516
542
 
517
- # Only add the disabled attribute if the field should actually be disabled
518
543
  if self.disabled:
519
544
  select_attrs["disabled"] = True
520
545
 
@@ -667,12 +692,15 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
667
692
  )
668
693
 
669
694
  # 5. Wrap the single AccordionItem in an Accordion container
695
+ accordion_cls = spacing_many(
696
+ ["accordion_divider", "accordion_content"], self.spacing
697
+ )
670
698
  accordion_container = mui.Accordion(
671
699
  accordion_item, # The single item to include
672
700
  id=accordion_id, # ID for the accordion container (ul)
673
701
  multiple=True, # Allow multiple open (though only one exists)
674
702
  collapsible=True, # Allow toggling
675
- cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)} w-full".strip(),
703
+ cls=f"{accordion_cls} w-full".strip(),
676
704
  )
677
705
 
678
706
  return accordion_container
@@ -987,12 +1015,15 @@ class ListFieldRenderer(BaseFieldRenderer):
987
1015
  container_id = self._container_id()
988
1016
 
989
1017
  # Use mui.Accordion component
1018
+ accordion_cls = spacing_many(
1019
+ ["inner_gap_small", "accordion_content", "accordion_divider"], self.spacing
1020
+ )
990
1021
  accordion = mui.Accordion(
991
1022
  *item_elements,
992
1023
  id=container_id,
993
1024
  multiple=True, # Allow multiple items to be open at once
994
1025
  collapsible=True, # Make it collapsible
995
- cls=f"{spacing('inner_gap_small', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(), # Add space between items and accordion content styling
1026
+ cls=accordion_cls.strip(), # Add space between items and accordion content styling
996
1027
  )
997
1028
 
998
1029
  # Empty state message if no items
@@ -1344,10 +1375,19 @@ class ListFieldRenderer(BaseFieldRenderer):
1344
1375
  )
1345
1376
  li_attrs = {"id": full_card_id}
1346
1377
 
1347
- # Build card classes conditionally based on spacing theme
1348
- card_cls = "uk-card uk-margin-small-bottom"
1378
+ # Build card classes using spacing tokens
1379
+ card_cls_parts = ["uk-card"]
1349
1380
  if self.spacing == SpacingTheme.NORMAL:
1350
- card_cls += " uk-card-default"
1381
+ card_cls_parts.append("uk-card-default")
1382
+
1383
+ # Add spacing-based classes
1384
+ card_spacing_cls = spacing_many(
1385
+ ["accordion_item_margin", "card_border_thin"], self.spacing
1386
+ )
1387
+ if card_spacing_cls:
1388
+ card_cls_parts.append(card_spacing_cls)
1389
+
1390
+ card_cls = " ".join(card_cls_parts)
1351
1391
 
1352
1392
  return mui.AccordionItem(
1353
1393
  title_component, # Title as first positional argument
@@ -1370,10 +1410,19 @@ class ListFieldRenderer(BaseFieldRenderer):
1370
1410
  t = self.spacing
1371
1411
  content_wrapper = fh.Div(content_component, cls=spacing("card_body_pad", t))
1372
1412
 
1373
- # Build card classes conditionally based on spacing theme
1374
- card_cls = "uk-card uk-margin-small-bottom"
1413
+ # Build card classes using spacing tokens
1414
+ card_cls_parts = ["uk-card"]
1375
1415
  if self.spacing == SpacingTheme.NORMAL:
1376
- card_cls += " uk-card-default"
1416
+ card_cls_parts.append("uk-card-default")
1417
+
1418
+ # Add spacing-based classes
1419
+ card_spacing_cls = spacing_many(
1420
+ ["accordion_item_margin", "card_border_thin"], self.spacing
1421
+ )
1422
+ if card_spacing_cls:
1423
+ card_cls_parts.append(card_spacing_cls)
1424
+
1425
+ card_cls = " ".join(card_cls_parts)
1377
1426
 
1378
1427
  return mui.AccordionItem(
1379
1428
  title_component, # Title as first positional argument
@@ -14,6 +14,7 @@ from fh_pydantic_form.type_helpers import (
14
14
  _is_enum_type,
15
15
  _is_literal_type,
16
16
  _is_optional_type,
17
+ _is_skip_json_schema_field,
17
18
  )
18
19
 
19
20
  logger = logging.getLogger(__name__)
@@ -77,6 +78,11 @@ def _parse_non_list_fields(
77
78
  if field_name in exclude_fields:
78
79
  continue
79
80
 
81
+ # Skip SkipJsonSchema fields - they should not be parsed from form data
82
+ if _is_skip_json_schema_field(field_info):
83
+ logger.debug(f"Skipping SkipJsonSchema field during parsing: {field_name}")
84
+ continue
85
+
80
86
  # Create full key with prefix
81
87
  full_key = f"{base_prefix}{field_name}"
82
88
 
@@ -91,11 +97,21 @@ def _parse_non_list_fields(
91
97
 
92
98
  # Handle Literal fields (including Optional[Literal[...]])
93
99
  elif _is_literal_type(annotation):
94
- result[field_name] = _parse_literal_field(full_key, form_data, field_info)
100
+ if full_key in form_data: # User sent it
101
+ result[field_name] = _parse_literal_field(
102
+ full_key, form_data, field_info
103
+ )
104
+ elif _is_optional_type(annotation): # Optional but omitted
105
+ result[field_name] = None
106
+ # otherwise leave the key out – defaults will be injected later
95
107
 
96
108
  # Handle Enum fields (including Optional[Enum])
97
109
  elif _is_enum_type(annotation):
98
- result[field_name] = _parse_enum_field(full_key, form_data, field_info)
110
+ if full_key in form_data: # User sent it
111
+ result[field_name] = _parse_enum_field(full_key, form_data, field_info)
112
+ elif _is_optional_type(annotation): # Optional but omitted
113
+ result[field_name] = None
114
+ # otherwise leave the key out – defaults will be injected later
99
115
 
100
116
  # Handle nested model fields (including Optional[NestedModel])
101
117
  elif (
@@ -126,8 +142,13 @@ def _parse_non_list_fields(
126
142
 
127
143
  # Handle simple fields
128
144
  else:
129
- # Use updated _parse_simple_field that handles optionality
130
- result[field_name] = _parse_simple_field(full_key, form_data, field_info)
145
+ if full_key in form_data: # User sent it
146
+ result[field_name] = _parse_simple_field(
147
+ full_key, form_data, field_info
148
+ )
149
+ elif _is_optional_type(annotation): # Optional but omitted
150
+ result[field_name] = None
151
+ # otherwise leave the key out – defaults will be injected later
131
152
 
132
153
  return result
133
154
 
@@ -282,6 +303,13 @@ def _parse_nested_model_field(
282
303
  sub_key = f"{current_prefix}{sub_field_name}"
283
304
  annotation = getattr(sub_field_info, "annotation", None)
284
305
 
306
+ # Skip SkipJsonSchema fields - they should not be parsed from form data
307
+ if _is_skip_json_schema_field(sub_field_info):
308
+ logger.debug(
309
+ f"Skipping SkipJsonSchema field in nested model during parsing: {sub_field_name}"
310
+ )
311
+ continue
312
+
285
313
  # Handle based on field type, with Optional unwrapping
286
314
  is_optional = _is_optional_type(annotation)
287
315
  base_type = _get_underlying_type_if_optional(annotation)
@@ -497,20 +525,9 @@ def _parse_list_fields(
497
525
  if items: # Only add if items were found
498
526
  final_lists[field_name] = items
499
527
 
500
- # For any list field that didn't have form data, use its default
501
- for field_name, field_def in list_field_defs.items():
502
- if field_name not in final_lists:
503
- field_info = field_def["field_info"]
504
- if hasattr(field_info, "default") and field_info.default is not None:
505
- final_lists[field_name] = field_info.default
506
- elif (
507
- hasattr(field_info, "default_factory")
508
- and field_info.default_factory is not None
509
- ):
510
- try:
511
- final_lists[field_name] = field_info.default_factory()
512
- except Exception:
513
- pass
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
514
531
 
515
532
  return final_lists
516
533
 
@@ -17,6 +17,7 @@ import monsterui.all as mui
17
17
  from fastcore.xml import FT
18
18
  from pydantic import BaseModel
19
19
 
20
+ from fh_pydantic_form.constants import _UNSET
20
21
  from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
21
22
  from fh_pydantic_form.field_renderers import (
22
23
  BaseFieldRenderer,
@@ -30,9 +31,8 @@ from fh_pydantic_form.form_parser import (
30
31
  )
31
32
  from fh_pydantic_form.list_path import walk_path
32
33
  from fh_pydantic_form.registry import FieldRendererRegistry
33
- from fh_pydantic_form.type_helpers import _UNSET, get_default
34
+ from fh_pydantic_form.type_helpers import _is_skip_json_schema_field, get_default
34
35
  from fh_pydantic_form.ui_style import (
35
- COMPACT_EXTRA_CSS,
36
36
  SpacingTheme,
37
37
  SpacingValue,
38
38
  _normalize_spacing,
@@ -213,19 +213,10 @@ class PydanticForm(Generic[ModelType]):
213
213
 
214
214
  def _compact_wrapper(self, inner: FT) -> FT:
215
215
  """
216
- Wrap inner markup in a namespaced div.
217
- Auto-inject the compact CSS the *first* time any compact form is rendered.
216
+ Wrap inner markup in a wrapper div.
218
217
  """
219
218
  wrapper_cls = "fhpf-wrapper w-full flex-1"
220
-
221
- if self.spacing != SpacingTheme.COMPACT:
222
- return fh.Div(inner, cls=wrapper_cls)
223
-
224
- return fh.Div(
225
- COMPACT_EXTRA_CSS,
226
- fh.Div(inner, cls="fhpf-fields fhpf-compact"),
227
- cls=wrapper_cls,
228
- )
219
+ return fh.Div(inner, cls=wrapper_cls)
229
220
 
230
221
  def _clone_with_values(self, values: Dict[str, Any]) -> "PydanticForm":
231
222
  """
@@ -354,6 +345,11 @@ class PydanticForm(Generic[ModelType]):
354
345
  logger.debug(f"Skipping excluded field: {field_name}")
355
346
  continue
356
347
 
348
+ # Skip SkipJsonSchema fields (they should not be rendered in the form)
349
+ if _is_skip_json_schema_field(field_info):
350
+ logger.debug(f"Skipping SkipJsonSchema field: {field_name}")
351
+ continue
352
+
357
353
  # Only use what was explicitly provided in initial values
358
354
  initial_value = (
359
355
  self.values_dict.get(field_name) if self.values_dict else None
@@ -588,35 +584,30 @@ class PydanticForm(Generic[ModelType]):
588
584
  # Merge list results into the main result
589
585
  result.update(list_results)
590
586
 
591
- # Inject defaults for excluded fields before returning
592
- self._inject_default_values_for_excluded(result)
587
+ # Inject defaults for missing fields before returning
588
+ self._inject_missing_defaults(result)
593
589
 
594
590
  return result
595
591
 
596
- def _inject_default_values_for_excluded(
597
- self, data: Dict[str, Any]
598
- ) -> Dict[str, Any]:
592
+ def _inject_missing_defaults(self, data: Dict[str, Any]) -> Dict[str, Any]:
599
593
  """
600
- Ensures that every field listed in self.exclude_fields is present in data
601
- if the model defines a default or default_factory, or if initial_values were provided.
602
-
603
- Also ensures all model fields have appropriate defaults if missing.
594
+ Ensures all model fields with defaults are present in data if missing.
595
+ Handles excluded fields, SkipJsonSchema fields, and any other fields
596
+ not rendered in the form.
604
597
 
605
598
  Priority order:
606
599
  1. initial_values (if provided during form creation)
607
600
  2. model defaults/default_factory
608
601
 
609
- Operates top-level only (exclude_fields spec is top-level names).
610
-
611
602
  Args:
612
603
  data: Dictionary to modify in-place
613
604
 
614
605
  Returns:
615
606
  The same dictionary instance for method chaining
616
607
  """
617
- # Handle excluded fields first
618
- for field_name in self.exclude_fields:
619
- # Skip if already present (e.g., user provided initial_values)
608
+ # Process ALL model fields, not just excluded ones
609
+ for field_name, field_info in self.model_class.model_fields.items():
610
+ # Skip if already present in parsed data
620
611
  if field_name in data:
621
612
  continue
622
613
 
@@ -627,17 +618,10 @@ class PydanticForm(Generic[ModelType]):
627
618
  if hasattr(initial_val, "model_dump"):
628
619
  initial_val = initial_val.model_dump()
629
620
  data[field_name] = initial_val
630
- logger.debug(
631
- f"Injected initial value for excluded field '{field_name}'"
632
- )
621
+ logger.debug(f"Injected initial value for missing field '{field_name}'")
633
622
  continue
634
623
 
635
624
  # Second priority: use model defaults
636
- field_info = self.model_class.model_fields.get(field_name)
637
- if field_info is None:
638
- logger.warning(f"exclude_fields contains unknown field '{field_name}'")
639
- continue
640
-
641
625
  default_val = get_default(field_info)
642
626
  if default_val is not _UNSET:
643
627
  # If the default is a BaseModel, convert to dict for consistency
@@ -645,27 +629,17 @@ class PydanticForm(Generic[ModelType]):
645
629
  default_val = default_val.model_dump()
646
630
  data[field_name] = default_val
647
631
  logger.debug(
648
- f"Injected model default value for excluded field '{field_name}'"
632
+ f"Injected model default value for missing field '{field_name}'"
649
633
  )
650
634
  else:
651
- # No default leave missing; validation will surface error
652
- logger.debug(f"No default found for excluded field '{field_name}'")
653
-
654
- # Also handle any other missing fields that should have defaults
655
- for field_name, field_info in self.model_class.model_fields.items():
656
- if field_name not in data:
657
- # Try to inject defaults for missing fields
658
- if field_name in self.initial_values_dict:
659
- initial_val = self.initial_values_dict[field_name]
660
- if hasattr(initial_val, "model_dump"):
661
- initial_val = initial_val.model_dump()
662
- data[field_name] = initial_val
635
+ # Check if this is a SkipJsonSchema field
636
+ if _is_skip_json_schema_field(field_info):
637
+ logger.debug(
638
+ f"No default found for SkipJsonSchema field '{field_name}'"
639
+ )
663
640
  else:
664
- default_val = get_default(field_info)
665
- if default_val is not _UNSET:
666
- if hasattr(default_val, "model_dump"):
667
- default_val = default_val.model_dump()
668
- data[field_name] = default_val
641
+ # No default → leave missing; validation will surface error
642
+ logger.debug(f"No default found for field '{field_name}'")
669
643
 
670
644
  return data
671
645
 
@@ -1,12 +1,88 @@
1
+ # Explicit exports for public API
2
+ __all__ = [
3
+ "_is_optional_type",
4
+ "_get_underlying_type_if_optional",
5
+ "_is_literal_type",
6
+ "_is_enum_type",
7
+ "_is_skip_json_schema_field",
8
+ "default_for_annotation",
9
+ ]
10
+
1
11
  import logging
2
12
  from enum import Enum
3
13
  from types import UnionType
4
- from typing import Any, Literal, Union, get_args, get_origin
14
+ from typing import Annotated, Any, Literal, Union, get_args, get_origin
15
+
16
+ from fh_pydantic_form.constants import _UNSET
5
17
 
6
18
  logger = logging.getLogger(__name__)
7
19
 
8
- # Sentinel value to indicate no default is available
9
- _UNSET = object()
20
+
21
+ def _is_skip_json_schema_field(annotation_or_field_info: Any) -> bool:
22
+ """
23
+ Check if a field annotation or field_info indicates it should be skipped in JSON schema.
24
+
25
+ This handles the pattern where SkipJsonSchema is used with typing.Annotated:
26
+ - Annotated[str, SkipJsonSchema()]
27
+ - SkipJsonSchema[str] (which internally uses Annotated)
28
+ - Field metadata containing SkipJsonSchema (Pydantic 2 behavior)
29
+
30
+ Args:
31
+ annotation_or_field_info: The field annotation or field_info to check
32
+
33
+ Returns:
34
+ True if the field should be skipped in JSON schema
35
+ """
36
+ try:
37
+ from pydantic.json_schema import SkipJsonSchema
38
+
39
+ skip_json_schema_cls = SkipJsonSchema
40
+ except ImportError: # very old Pydantic
41
+ skip_json_schema_cls = None
42
+
43
+ if skip_json_schema_cls is None:
44
+ return False
45
+
46
+ # Check if it's a field_info object with metadata
47
+ if hasattr(annotation_or_field_info, "metadata"):
48
+ metadata = getattr(annotation_or_field_info, "metadata", [])
49
+ if metadata:
50
+ for item in metadata:
51
+ if (
52
+ item is skip_json_schema_cls
53
+ or isinstance(item, skip_json_schema_cls)
54
+ or (
55
+ hasattr(item, "__class__")
56
+ and item.__class__.__name__ == "SkipJsonSchema"
57
+ )
58
+ ):
59
+ return True
60
+
61
+ # Fall back to checking annotation (for backward compatibility)
62
+ annotation = annotation_or_field_info
63
+ if hasattr(annotation_or_field_info, "annotation"):
64
+ annotation = getattr(annotation_or_field_info, "annotation")
65
+
66
+ # 1. Direct or generic alias
67
+ if (
68
+ annotation is skip_json_schema_cls
69
+ or getattr(annotation, "__origin__", None) is skip_json_schema_cls
70
+ ):
71
+ return True
72
+
73
+ # 2. Something like Annotated[T, SkipJsonSchema()]
74
+ if get_origin(annotation) is Annotated:
75
+ for meta in get_args(annotation)[1:]:
76
+ meta_class = getattr(meta, "__class__", None)
77
+ if (
78
+ meta is skip_json_schema_cls # plain class
79
+ or isinstance(meta, skip_json_schema_cls) # instance
80
+ or (meta_class is not None and meta_class.__name__ == "SkipJsonSchema")
81
+ ):
82
+ return True
83
+
84
+ # 3. Fallback – cheap but effective
85
+ return "SkipJsonSchema" in repr(annotation)
10
86
 
11
87
 
12
88
  def _is_optional_type(annotation: Any) -> bool:
@@ -27,16 +103,6 @@ def _is_optional_type(annotation: Any) -> bool:
27
103
  return False
28
104
 
29
105
 
30
- # Explicit exports for public API
31
- __all__ = [
32
- "_is_optional_type",
33
- "_get_underlying_type_if_optional",
34
- "_is_literal_type",
35
- "_is_enum_type",
36
- "default_for_annotation",
37
- ]
38
-
39
-
40
106
  def _get_underlying_type_if_optional(annotation: Any) -> Any:
41
107
  """
42
108
  Extract the type T from Optional[T], otherwise return the original annotation.
@@ -1,8 +1,6 @@
1
1
  from enum import Enum, auto
2
2
  from typing import Dict, Literal, Union
3
3
 
4
- import fasthtml.common as fh
5
-
6
4
 
7
5
  class SpacingTheme(Enum):
8
6
  NORMAL = auto()
@@ -44,13 +42,19 @@ SPACING_MAP: Dict[SpacingTheme, Dict[str, str]] = {
44
42
  "padding_sm": "p-3",
45
43
  "padding_card": "px-4 py-3",
46
44
  "card_border": "border",
45
+ "card_border_thin": "",
47
46
  "section_divider": "border-t border-gray-200",
48
47
  "accordion_divider": "uk-accordion-divider",
48
+ "accordion_title_pad": "",
49
+ "accordion_content_pad": "",
50
+ "accordion_item_margin": "uk-margin-small-bottom",
49
51
  "label_gap": "mb-1",
50
52
  "card_body_pad": "px-4 py-3",
51
53
  "accordion_content": "",
52
54
  "input_size": "",
53
55
  "input_padding": "",
56
+ "input_line_height": "",
57
+ "input_font_size": "",
54
58
  "horizontal_gap": "gap-3",
55
59
  "label_align": "items-start",
56
60
  },
@@ -64,13 +68,19 @@ SPACING_MAP: Dict[SpacingTheme, Dict[str, str]] = {
64
68
  "padding_sm": "p-0.5",
65
69
  "padding_card": "px-2 py-1",
66
70
  "card_border": "",
71
+ "card_border_thin": "",
67
72
  "section_divider": "",
68
73
  "accordion_divider": "",
74
+ "accordion_title_pad": "py-1",
75
+ "accordion_content_pad": "py-1",
76
+ "accordion_item_margin": "mb-0",
69
77
  "label_gap": "mb-0",
70
78
  "card_body_pad": "px-2 py-0.5",
71
79
  "accordion_content": "uk-padding-remove-vertical",
72
80
  "input_size": "uk-form-small",
73
- "input_padding": "p-1",
81
+ "input_padding": "py-0.5 px-1",
82
+ "input_line_height": "leading-tight",
83
+ "input_font_size": "text-sm",
74
84
  "horizontal_gap": "gap-2",
75
85
  "label_align": "items-start",
76
86
  },
@@ -83,72 +93,21 @@ def spacing(token: str, spacing: SpacingValue) -> str:
83
93
  return SPACING_MAP[theme][token]
84
94
 
85
95
 
86
- # Optional minimal CSS for compact mode - affects only form inputs, not layout
87
- # Host applications can optionally inject this once at app level if desired
88
- COMPACT_EXTRA_CSS = fh.Style("""
89
- /* Compact polish – applies ONLY inside .fhpf-compact ------------------- */
90
- .fhpf-compact {
91
- /* Force full width and left alignment */
92
- width: 100% !important;
93
-
94
- /* Ensure all direct children are full width and left aligned */
95
- & > * {
96
- width: 100% !important;
97
- justify-content: flex-start !important;
98
- align-items: flex-start !important;
99
- }
100
-
101
- /* Target the field containers specifically */
102
- & > div > div {
103
- width: 100% !important;
104
- justify-content: flex-start !important;
105
- }
106
-
107
- /* Ensure flex containers don't center */
108
- .flex {
109
- justify-content: flex-start !important;
110
- }
111
-
112
- /* Accordion chrome: remove border and default 20 px gap */
113
- .uk-accordion > li,
114
- .uk-accordion > li + li { /* second & later items */
115
- border-top: 0 !important;
116
- margin-top: 0 !important;
117
- }
118
- .uk-accordion-title::after { /* the hair-line we still see */
119
- border-top: 0 !important;
120
- }
121
-
122
- /* Tighter title and content padding */
123
- li > a.uk-accordion-title,
124
- .uk-accordion-content {
125
- padding-top: 0.25rem !important;
126
- padding-bottom: 0.25rem !important;
127
- }
128
-
129
- /* Remove residual card outline */
130
- .uk-card,
131
- .uk-card-body { border: 0 !important; }
96
+ def spacing_many(tokens: list[str], spacing: SpacingValue) -> str:
97
+ """
98
+ Return combined Tailwind utility classes for multiple semantic tokens.
132
99
 
133
- /* Small-size inputs */
134
- input, select, textarea {
135
- line-height: 1.25rem !important;
136
- font-size: 0.8125rem !important;
137
- padding-top: 0.25rem !important;
138
- padding-bottom: 0.25rem !important;
139
- }
100
+ Args:
101
+ tokens: List of spacing token names
102
+ spacing: Spacing theme to use
140
103
 
141
- /* Legacy uk-form-small support */
142
- input.uk-form-small,
143
- select.uk-form-small,
144
- textarea.uk-textarea-small {
145
- padding-top: 2px !important;
146
- padding-bottom: 2px !important;
147
- }
148
-
149
- /* Kill generic uk-margin utilities inside the form */
150
- .uk-margin-small-bottom,
151
- .uk-margin,
152
- .uk-margin-bottom { margin-bottom: 2px !important; }
153
- }
154
- """)
104
+ Returns:
105
+ String of space-separated CSS classes
106
+ """
107
+ theme = _normalize_spacing(spacing)
108
+ classes = []
109
+ for token in tokens:
110
+ class_value = SPACING_MAP[theme].get(token, "")
111
+ if class_value: # Only add non-empty class values
112
+ classes.append(class_value)
113
+ return " ".join(classes)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-pydantic-form
3
- Version: 0.2.2
3
+ Version: 0.2.4
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
@@ -0,0 +1,15 @@
1
+ fh_pydantic_form/__init__.py,sha256=luxohu6NgZDC0nhSIyw5lJGP2A8JQ51Ge1Ga7DYDkF8,4048
2
+ fh_pydantic_form/constants.py,sha256=-N9wzkibFNn-V6cO8iWTQ7_xBvwSr2hBdq-m3apmW4M,169
3
+ fh_pydantic_form/defaults.py,sha256=Pwv46v7e43cykx4Pt01e4nw-6FBkHmPvTZK36ZTZqgA,6068
4
+ fh_pydantic_form/field_renderers.py,sha256=VYvAmLsLhQttlg97g2KGg-VNlS4ohxrPN1O906EJM6I,54984
5
+ fh_pydantic_form/form_parser.py,sha256=3p4SSLCA7wjuNe1izn5Me7x8z_Vhc8fxcUKtHEGdzrI,25375
6
+ fh_pydantic_form/form_renderer.py,sha256=cPd7NbaPOZC8cTvhEZOsy8sf5fH6FomrsR_r6KAFF54,34573
7
+ fh_pydantic_form/list_path.py,sha256=AA8bmDmaYy4rlGIvQOOZ0fP2tgcimNUB2Re5aVGnYc8,5182
8
+ fh_pydantic_form/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ fh_pydantic_form/registry.py,sha256=sufK-85ST3rc3Vu0XmjjjdTqTAqgHr_ZbMGU0xRgTK8,4996
10
+ fh_pydantic_form/type_helpers.py,sha256=FH4yl5FW1KNKvfHzs8TKQinFTC-MUgqDvRTVfPHs1LM,6815
11
+ fh_pydantic_form/ui_style.py,sha256=aIWDWbPBUAQ73nPC5AHZi5cnqA0SIp9ISWwsxFdXXdE,3776
12
+ fh_pydantic_form-0.2.4.dist-info/METADATA,sha256=wkprIEyX02VPPrijmwaTOkmw7XA0TA1P9m2LC6UKc2E,26356
13
+ fh_pydantic_form-0.2.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ fh_pydantic_form-0.2.4.dist-info/licenses/LICENSE,sha256=AOi2eNK3D2aDycRHfPRiuACZ7WPBsKHTV2tTYNl7cls,577
15
+ fh_pydantic_form-0.2.4.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- fh_pydantic_form/__init__.py,sha256=auqrMQyy6WsEeiMIdXVrjHpSuW_L7CpW2AZ1FOXb8QE,4058
2
- fh_pydantic_form/defaults.py,sha256=IzBA_soBOdXP_XAUqfFAtniDQaW6N23hiXmWJD2xq0c,5168
3
- fh_pydantic_form/field_renderers.py,sha256=D0GGqVpUUsJMIyX3tQEgNGPIQ6x8S3OEMj8qp94YDnE,53561
4
- fh_pydantic_form/form_parser.py,sha256=9jSJya4TR5q2LMGV_PK-xiAjoEhq-FYKDN27lFNn5n0,24389
5
- fh_pydantic_form/form_renderer.py,sha256=2f1n--_DD891x1-2ci4JFBVmO8yO7X_b5Ol8W5SA42E,35580
6
- fh_pydantic_form/list_path.py,sha256=AA8bmDmaYy4rlGIvQOOZ0fP2tgcimNUB2Re5aVGnYc8,5182
7
- fh_pydantic_form/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- fh_pydantic_form/registry.py,sha256=sufK-85ST3rc3Vu0XmjjjdTqTAqgHr_ZbMGU0xRgTK8,4996
9
- fh_pydantic_form/type_helpers.py,sha256=bWHOxu52yh9_79d_x5L3cfMqnZo856OsbL4sTttDoa4,4367
10
- fh_pydantic_form/ui_style.py,sha256=tJWY3MYO7XLmP0nm5x6qllEywarkoS1R-6jdxxKnYlU,4749
11
- fh_pydantic_form-0.2.2.dist-info/METADATA,sha256=FSBPbRTJ2FFWxnRPoUHsSnixeeO1L6ZdRTLuZB6q5kA,26356
12
- fh_pydantic_form-0.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
- fh_pydantic_form-0.2.2.dist-info/licenses/LICENSE,sha256=AOi2eNK3D2aDycRHfPRiuACZ7WPBsKHTV2tTYNl7cls,577
14
- fh_pydantic_form-0.2.2.dist-info/RECORD,,