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

File without changes
@@ -0,0 +1,145 @@
1
+ from logging import getLogger
2
+ from typing import (
3
+ Any,
4
+ ClassVar,
5
+ Dict,
6
+ List,
7
+ Optional,
8
+ Tuple,
9
+ Type,
10
+ )
11
+
12
+ from pydantic.fields import FieldInfo
13
+
14
+ from fh_pydantic_form.type_helpers import _get_underlying_type_if_optional
15
+
16
+ logger = getLogger(__name__)
17
+
18
+
19
+ class FieldRendererRegistry:
20
+ """
21
+ Registry for field renderers with support for type and predicate-based registration
22
+
23
+ This registry manages:
24
+ - Type-specific renderers (e.g., for str, int, bool)
25
+ - Type-name-specific renderers (by class name)
26
+ - Predicate-based renderers (e.g., for Literal fields)
27
+ - List item renderers for specialized list item rendering
28
+
29
+ It uses a singleton pattern to ensure consistent registration across the app.
30
+ """
31
+
32
+ _instance = None # Add class attribute to hold the single instance
33
+
34
+ # Use ClassVar for all registry storage
35
+ _type_renderers: ClassVar[Dict[Type, Any]] = {}
36
+ _type_name_renderers: ClassVar[Dict[str, Any]] = {}
37
+ _predicate_renderers: ClassVar[List[Tuple[Any, Any]]] = []
38
+ _list_item_renderers: ClassVar[Dict[Type, Any]] = {}
39
+
40
+ def __new__(cls, *args, **kwargs):
41
+ if cls._instance is None:
42
+ logger.debug("Creating new FieldRendererRegistry singleton instance.")
43
+ cls._instance = super().__new__(cls)
44
+ else:
45
+ logger.debug("Returning existing FieldRendererRegistry singleton instance.")
46
+ return cls._instance
47
+
48
+ @classmethod
49
+ def register_type_renderer(cls, field_type: Type, renderer_cls: Any) -> None:
50
+ """Register a renderer for a field type"""
51
+ cls._type_renderers[field_type] = renderer_cls
52
+
53
+ @classmethod
54
+ def register_type_name_renderer(
55
+ cls, field_type_name: str, renderer_cls: Any
56
+ ) -> None:
57
+ """Register a renderer for a specific field type name"""
58
+ cls._type_name_renderers[field_type_name] = renderer_cls
59
+
60
+ @classmethod
61
+ def register_type_renderer_with_predicate(cls, predicate_func, renderer_cls):
62
+ """
63
+ Register a renderer with a predicate function
64
+
65
+ The predicate function should accept a field_info parameter and return
66
+ True if the renderer should be used for that field.
67
+ """
68
+ cls._predicate_renderers.append((predicate_func, renderer_cls))
69
+
70
+ @classmethod
71
+ def register_list_item_renderer(cls, item_type: Type, renderer_cls: Any) -> None:
72
+ """Register a renderer for list items of a specific type"""
73
+ cls._list_item_renderers[item_type] = renderer_cls
74
+
75
+ @classmethod
76
+ def get_renderer(cls, field_name: str, field_info: FieldInfo) -> Any:
77
+ """
78
+ Get the appropriate renderer for a field
79
+
80
+ The selection algorithm:
81
+ 1. Check exact type matches
82
+ 2. Check predicate renderers (for special cases like Literal fields)
83
+ 3. Check for subclass relationships
84
+ 4. Fall back to string renderer
85
+
86
+ Args:
87
+ field_name: The name of the field being rendered
88
+ field_info: The FieldInfo for the field
89
+
90
+ Returns:
91
+ A renderer class appropriate for the field
92
+ """
93
+ # Get the field type (unwrap Optional if present)
94
+ original_annotation = field_info.annotation
95
+ field_type = _get_underlying_type_if_optional(original_annotation)
96
+
97
+ # 1. Check exact type matches first
98
+ if field_type in cls._type_renderers:
99
+ return cls._type_renderers[field_type]
100
+
101
+ # 2. Check predicates second
102
+ for predicate, renderer in cls._predicate_renderers:
103
+ if predicate(field_info):
104
+ return renderer
105
+
106
+ # 3. Check for subclass relationships
107
+ if isinstance(field_type, type):
108
+ for typ, renderer in cls._type_renderers.items():
109
+ try:
110
+ if isinstance(typ, type) and issubclass(field_type, typ):
111
+ return renderer
112
+ except TypeError:
113
+ # Handle non-class types
114
+ continue
115
+
116
+ # 4. Fall back to string renderer
117
+ from_imports = globals()
118
+ return from_imports.get("StringFieldRenderer", None)
119
+
120
+ @classmethod
121
+ def get_list_item_renderer(cls, item_type: Type) -> Optional[Any]:
122
+ """
123
+ Get renderer for summarizing list items of a given type
124
+
125
+ Args:
126
+ item_type: The type of the list items
127
+
128
+ Returns:
129
+ A renderer class for list items, or None if none is registered
130
+ """
131
+ # Check for exact type match
132
+ if item_type in cls._list_item_renderers:
133
+ return cls._list_item_renderers[item_type]
134
+
135
+ # Check for subclass matches
136
+ for registered_type, renderer in cls._list_item_renderers.items():
137
+ try:
138
+ if isinstance(registered_type, type) and issubclass(
139
+ item_type, registered_type
140
+ ):
141
+ return renderer
142
+ except TypeError:
143
+ continue
144
+
145
+ return None
@@ -0,0 +1,42 @@
1
+ from typing import Any, Literal, Union, get_args, get_origin
2
+
3
+
4
+ def _is_optional_type(annotation: Any) -> bool:
5
+ """
6
+ Check if an annotation is Optional[T] (Union[T, None]).
7
+
8
+ Args:
9
+ annotation: The type annotation to check
10
+
11
+ Returns:
12
+ True if the annotation is Optional[T], False otherwise
13
+ """
14
+ origin = get_origin(annotation)
15
+ if origin is Union:
16
+ args = get_args(annotation)
17
+ # Check if NoneType is one of the args and there are exactly two args
18
+ return len(args) == 2 and type(None) in args
19
+ return False
20
+
21
+
22
+ def _get_underlying_type_if_optional(annotation: Any) -> Any:
23
+ """
24
+ Extract the type T from Optional[T], otherwise return the original annotation.
25
+
26
+ Args:
27
+ annotation: The type annotation, potentially Optional[T]
28
+
29
+ Returns:
30
+ The underlying type if Optional, otherwise the original annotation
31
+ """
32
+ if _is_optional_type(annotation):
33
+ args = get_args(annotation)
34
+ # Return the non-None type
35
+ return args[0] if args[1] is type(None) else args[1]
36
+ return annotation
37
+
38
+
39
+ def _is_literal_type(annotation: Any) -> bool:
40
+ """Check if the underlying type of an annotation is Literal."""
41
+ underlying_type = _get_underlying_type_if_optional(annotation)
42
+ return get_origin(underlying_type) is Literal
@@ -0,0 +1,327 @@
1
+ Metadata-Version: 2.4
2
+ Name: fh-pydantic-form
3
+ Version: 0.1.2
4
+ Summary: a library to turn any pydantic BaseModel object into a fasthtml/monsterui input form
5
+ Project-URL: Homepage, https://github.com/Marcura/fh-pydantic-form
6
+ Project-URL: Repository, https://github.com/Marcura/fh-pydantic-form
7
+ Project-URL: Documentation, https://github.com/Marcura/fh-pydantic-form
8
+ Author-email: Oege Dijk <o.dijk@marcura.com>
9
+ Maintainer-email: Oege Dijk <o.dijk@marcura.com>
10
+ License-File: LICENSE
11
+ Keywords: fasthtml,forms,monsterui,pydantic,ui,web
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: monsterui>=1.0.19
22
+ Requires-Dist: pydantic>=2.0
23
+ Requires-Dist: python-fasthtml>=0.12.12
24
+ Description-Content-Type: text/markdown
25
+
26
+ # fh-pydantic-form
27
+
28
+ **Generate HTML forms from Pydantic models for your FastHTML applications.**
29
+
30
+ `fh-pydantic-form` simplifies creating web forms for [FastHTML](https://github.com/AnswerDotAI/fasthtml) by automatically generating the necessary HTML input elements based on your Pydantic model definitions. It integrates seamlessly with and leverages [MonsterUI](https://github.com/AnswerDotAI/monsterui) components for styling.
31
+
32
+ <details >
33
+ <summary>show demo screen recording</summary>
34
+ <video src="https://private-user-images.githubusercontent.com/27999937/436237879-feabf388-22af-43e6-b054-f103b8a1b6e6.mp4" controls="controls" style="max-width: 730px;">
35
+ </video>
36
+ </details>
37
+
38
+ ## Purpose
39
+
40
+ - **Reduce Boilerplate:** Automatically render form inputs (text, number, checkbox, select, date, time, etc.) based on Pydantic field types and annotations.
41
+ - **Data Validation:** Leverage Pydantic's validation rules directly from form submissions.
42
+ - **Nested Structures:** Support for nested Pydantic models and lists of models/simple types.
43
+ - **Dynamic Lists:** Built-in HTMX endpoints and JavaScript for adding, deleting, and reordering items in lists within the form.
44
+ - **Customization:** Easily register custom renderers for specific Pydantic types or fields.
45
+
46
+ ## Installation
47
+
48
+ You can install `fh-pydantic-form` using either `pip` or `uv`.
49
+
50
+ **Using pip:**
51
+
52
+ ```bash
53
+ pip install fh-pydantic-form
54
+ ```
55
+
56
+ Using uv:
57
+ ```bash
58
+ uv add fh-pydantic-form
59
+ ```
60
+
61
+ This will also install necessary dependencies like `pydantic`, `python-fasthtml`, and `monsterui`.
62
+
63
+
64
+ # Basic Usage
65
+
66
+
67
+ ```python
68
+
69
+ # examples/simple_example.py
70
+ import fasthtml.common as fh
71
+ import monsterui.all as mui
72
+ from pydantic import BaseModel, ValidationError
73
+
74
+ # 1. Import the form renderer
75
+ from fh_pydantic_form import PydanticForm
76
+
77
+ app, rt = fh.fast_app(
78
+ hdrs=[
79
+ mui.Theme.blue.headers(),
80
+ # Add list_manipulation_js() if using list fields
81
+ # from fh_pydantic_form import list_manipulation_js
82
+ # list_manipulation_js(),
83
+ ],
84
+ pico=False, # Using MonsterUI, not PicoCSS
85
+ live=True, # Enable live reload for development
86
+ )
87
+
88
+ # 2. Define your Pydantic model
89
+ class SimpleModel(BaseModel):
90
+ """Model representing a simple form"""
91
+ name: str = "Default Name"
92
+ age: int
93
+ is_active: bool = True
94
+
95
+ # 3. Create a form renderer instance
96
+ # - 'my_form': Unique name for the form (used for prefixes and routes)
97
+ # - SimpleModel: The Pydantic model class
98
+ form_renderer = PydanticForm("my_form", SimpleModel)
99
+
100
+ # (Optional) Register list manipulation routes if your model has List fields
101
+ # form_renderer.register_routes(app)
102
+
103
+ # 4. Define routes
104
+ @rt("/")
105
+ def get():
106
+ """Display the form"""
107
+ return fh.Div(
108
+ mui.Container(
109
+ mui.Card(
110
+ mui.CardHeader("Simple Pydantic Form"),
111
+ mui.CardBody(
112
+ # Use MonsterUI Form component for structure
113
+ mui.Form(
114
+ # Render the inputs using the renderer
115
+ form_renderer.render_inputs(),
116
+ # Add standard form buttons
117
+ mui.Button("Submit", type="submit", cls=mui.ButtonT.primary),
118
+ # HTMX attributes for form submission
119
+ hx_post="/submit_form",
120
+ hx_target="#result", # Target div for response
121
+ hx_swap="innerHTML",
122
+ # Set a unique ID for the form itself for refresh/reset inclusion
123
+ id=f"{form_renderer.name}-form",
124
+ )
125
+ ),
126
+ ),
127
+ # Div to display validation results
128
+ fh.Div(id="result"),
129
+ ),
130
+ )
131
+
132
+ @rt("/submit_form")
133
+ async def post_submit_form(req):
134
+ """Handle form submission and validation"""
135
+ try:
136
+ # 5. Validate the request data against the model
137
+ validated_data: SimpleModel = await form_renderer.model_validate_request(req)
138
+
139
+ # Success: Display the validated data
140
+ return mui.Card(
141
+ mui.CardHeader(fh.H3("Validation Successful")),
142
+ mui.CardBody(
143
+ fh.Pre(
144
+ validated_data.model_dump_json(indent=2),
145
+ )
146
+ ),
147
+ cls="mt-4",
148
+ )
149
+ except ValidationError as e:
150
+ # Validation Error: Display the errors
151
+ return mui.Card(
152
+ mui.CardHeader(fh.H3("Validation Error", cls="text-red-500")),
153
+ mui.CardBody(
154
+ fh.Pre(
155
+ e.json(indent=2),
156
+ )
157
+ ),
158
+ cls="mt-4",
159
+ )
160
+
161
+ if __name__ == "__main__":
162
+ fh.serve()
163
+
164
+ ```
165
+ ## Key Features
166
+
167
+ - **Automatic Field Rendering:** Handles `str`, `int`, `float`, `bool`, `date`, `time`, `Optional`, `Literal`, nested `BaseModel`s, and `List`s out-of-the-box.
168
+ - **Sensible Defaults:** Uses appropriate HTML5 input types (`text`, `number`, `date`, `time`, `checkbox`, `select`).
169
+ - **Labels & Placeholders:** Generates labels from field names (converting snake_case to Title Case) and basic placeholders.
170
+ - **Descriptions as Tooltips:** Uses `Field(description=...)` from Pydantic to create tooltips (`uk-tooltip` via UIkit).
171
+ - **Required Fields:** Automatically adds the `required` attribute based on field definitions (considering `Optional` and defaults).
172
+ - **Disabled Fields:** Disable the whole form with `disabled=True` or disable specific fields with `disabled_fields`
173
+ - **Collapsible Nested Models:** Renders nested Pydantic models in collapsible details/summary elements for better form organization and space management.
174
+ - **List Manipulation:**
175
+ - Renders lists of simple types or models in accordion-style cards with an enhanced UI.
176
+ - Provides HTMX endpoints (registered via `register_routes`) for adding and deleting list items.
177
+ - Includes JavaScript (`list_manipulation_js()`) for client-side reordering (moving items up/down).
178
+ - **Form Refresh & Reset:**
179
+ - Provides HTMX-powered "Refresh" and "Reset" buttons (`form_renderer.refresh_button()`, `form_renderer.reset_button()`).
180
+ - Refresh updates list item summaries or other dynamic parts without full page reload.
181
+ - Reset reverts the form to its initial values.
182
+ - **Custom Renderers:** Register your own `BaseFieldRenderer` subclasses for specific Pydantic types or complex field logic using `FieldRendererRegistry` or by passing `custom_renderers` during `PydanticForm` initialization.
183
+ - **Form Data Parsing:** Includes logic (`form_renderer.parse` and `form_renderer.model_validate_request`) to correctly parse submitted form data (handling prefixes, list indices, nested structures, boolean checkboxes, etc.) back into a dictionary suitable for Pydantic validation.
184
+
185
+ ## disabled fields
186
+
187
+ You can disable the full form with `PydanticForm("my_form", FormModel, disabled=True)` or disable specific fields with `PydanticForm("my_form", FormModel, disabled_fields=["field1", "field3"])`.
188
+
189
+
190
+ ## Manipulating lists fields
191
+
192
+ When you have `BaseModels` with fields that are e.g. `List[str]` or even `List[BaseModel]` you want to be able to easily edit the list by adding, deleting and moving items. For this we need a little bit of javascript and register some additional routes:
193
+
194
+ ```python
195
+ from fh_pydantic_form import PydanticForm, list_manipulation_js
196
+
197
+ app, rt = fh.fast_app(
198
+ hdrs=[
199
+ mui.Theme.blue.headers(),
200
+ list_manipulation_js(),
201
+ ],
202
+ pico=False,
203
+ live=True,
204
+ )
205
+
206
+
207
+ class ListModel(BaseModel):
208
+ name: str = ""
209
+ tags: List[str] = Field(["tag1", "tag2"])
210
+
211
+
212
+ form_renderer = PydanticForm("list_model", ListModel)
213
+ form_renderer.register_routes(app)
214
+ ```
215
+
216
+ ## Refreshing and resetting the form
217
+
218
+ You can set the initial values of the form by passing an instantiated BaseModel:
219
+
220
+ ```python
221
+ form_renderer = PydanticForm("my_form", ListModel, initial_values=ListModel(name="John", tags=["happy", "joy"]))
222
+ ```
223
+
224
+ You can reset the form back to these initial values by adding a `form_render.reset_button()` to your UI:
225
+
226
+ ```python
227
+ mui.Form(
228
+ form_renderer.render_inputs(),
229
+ fh.Div(
230
+ mui.Button("Validate and Show JSON",cls=mui.ButtonT.primary,),
231
+ form_renderer.refresh_button(),
232
+ form_renderer.reset_button(),
233
+ ),
234
+ hx_post="/submit_form",
235
+ hx_target="#result",
236
+ hx_swap="innerHTML",
237
+ )
238
+ ```
239
+
240
+ The refresh button 🔄 refreshes the list item labels. These are rendered initially to summarize the underlying item, but do not automatically update after editing unless refreshed. You can also use the 🔄 icon next to the list field label.
241
+
242
+
243
+ ## Custom renderers
244
+
245
+ The library is extensible by adding your own input renderers for your types. This can be used to override e.g. the default BaseModelFieldRenderer for nested BaseModels, but also to register types that are not (yet) supported (but submit a PR then as well!)
246
+
247
+ You can register a renderer based on type, type str, or a predicate function:
248
+
249
+ ```python
250
+ from fh_pydantic_form import FieldRendererRegistry
251
+
252
+ from fh_pydantic_form.field_renderers import BaseFieldRenderer
253
+
254
+ class CustomDetail(BaseModel):
255
+ value: str = "Default value"
256
+ confidence: Literal["HIGH", "MEDIUM", "LOW"] = "MEDIUM"
257
+
258
+ def __str__(self) -> str:
259
+ return f"{self.value} ({self.confidence})"
260
+
261
+
262
+ class CustomDetailFieldRenderer(BaseFieldRenderer):
263
+ """display value input and dropdown side by side"""
264
+
265
+ def render_input(self):
266
+ value_input = fh.Div(
267
+ mui.Input(
268
+ value=self.value.get("value", ""),
269
+ id=f"{self.field_name}_value",
270
+ name=f"{self.field_name}_value",
271
+ placeholder=f"Enter {self.original_field_name.replace('_', ' ')} value",
272
+ cls="uk-input w-full",
273
+ ),
274
+ cls="flex-grow", # apply some custom css
275
+ )
276
+
277
+ confidence_options_ft = [
278
+ fh.Option(
279
+ opt, value=opt, selected=(opt == self.value.get("confidence", "MEDIUM"))
280
+ )
281
+ for opt in ["HIGH", "MEDIUM", "LOW"]
282
+ ]
283
+
284
+ confidence_select = mui.Select(
285
+ *confidence_options_ft,
286
+ id=f"{self.field_name}_confidence",
287
+ name=f"{self.field_name}_confidence",
288
+ cls_wrapper="w-[110px] min-w-[110px] flex-shrink-0", # apply some custom css
289
+ )
290
+
291
+ return fh.Div(
292
+ value_input,
293
+ confidence_select,
294
+ cls="flex items-start gap-2 w-full", # apply some custom css
295
+ )
296
+
297
+
298
+ # these are all equivalent. You can either register the type directly
299
+ FieldRendererRegistry.register_type_renderer(CustomDetail, CustomDetailFieldRender)
300
+ # or just by the name of the type
301
+ FieldRendererRegistry.register_type_name_renderer("CustomDetail", CustomDetailFieldRender)
302
+ # or register I predicate function
303
+ FieldRendererRegistry.register_type_renderer_with_predicate(lambda: x: isinstance(x, CustomDetail), CustomDetailFieldRender)
304
+ ```
305
+
306
+ You can also pass these directly to the `PydanticForm` with the custom_renderers argument:
307
+
308
+ ```python
309
+
310
+ form_renderer = PydanticForm(
311
+ form_name="main_form",
312
+ model_class=ComplexSchema,
313
+ initial_values=initial_values,
314
+ custom_renderers=[
315
+ (CustomDetail, CustomDetailFieldRenderer)
316
+ ], # Register Detail renderer
317
+ )
318
+ ```
319
+
320
+ ## Contributing
321
+
322
+ Contributions are welcome! Please feel free to open an issue or submit a pull request.
323
+
324
+
325
+
326
+
327
+
@@ -0,0 +1,11 @@
1
+ fh_pydantic_form/__init__.py,sha256=oifnGdBOS1XaO9Q8gcZ1SJ98Xzz9TfbZH9kvHg9_beo,3056
2
+ fh_pydantic_form/field_renderers.py,sha256=zgGEgR09Oop_-lyYvcHw52L7Uz4X26dWcxyFqUCBSN0,39461
3
+ fh_pydantic_form/form_parser.py,sha256=3EGy4YHbLskH76V0ieTG8zmQ0b02ty8bOZf9AEg89ZY,20629
4
+ fh_pydantic_form/form_renderer.py,sha256=U4njGebyysKEy88wd2oSf756nndXm1qYc0Dxm_ltmH8,27709
5
+ fh_pydantic_form/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ fh_pydantic_form/registry.py,sha256=sufK-85ST3rc3Vu0XmjjjdTqTAqgHr_ZbMGU0xRgTK8,4996
7
+ fh_pydantic_form/type_helpers.py,sha256=ZCU8m4xxFk_ofMsAwxb8CunZhsDBsrEFjpJdtncreT0,1322
8
+ fh_pydantic_form-0.1.2.dist-info/METADATA,sha256=D6xJkgYCio3dhL4m1JIwa2L26kSRIo9MrR3dPYZXAKY,12555
9
+ fh_pydantic_form-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ fh_pydantic_form-0.1.2.dist-info/licenses/LICENSE,sha256=AOi2eNK3D2aDycRHfPRiuACZ7WPBsKHTV2tTYNl7cls,577
11
+ fh_pydantic_form-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,13 @@
1
+ Copyright 2025 Marcura
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.