Skip to content

Commit b619636

Browse files
author
Juan Pablo Manson
committed
- Refactor code structure for improved readability and maintainability
- Support for Python 3.9+ - **Custom field type registry**: Allow registering custom field types without modifying the hardcoded `Union` in `Form.content`. Provide a `register_field_type()` API.
1 parent db1fae1 commit b619636

13 files changed

Lines changed: 1083 additions & 53 deletions

File tree

.github/workflows/tests.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ on:
99
jobs:
1010
test:
1111
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.11", "3.12"]
1215

1316
steps:
1417
- name: Checkout
@@ -17,7 +20,7 @@ jobs:
1720
- name: Set up Python
1821
uses: actions/setup-python@v5
1922
with:
20-
python-version: "3.12"
23+
python-version: ${{ matrix.python-version }}
2124

2225
- name: Install dependencies
2326
run: |

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ todo
1414
CLAUDE.md
1515
.claude
1616

17-
# Caches
17+
# Caches and temps
1818
.uv-cache
1919
.pytest_cache
20-
__pycache__/
20+
__pycache__/
21+
.tmp

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.12
1+
3.11

README.md

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Or with [uv](https://docs.astral.sh/uv/):
1414
uv add codeforms
1515
```
1616

17-
Requires Python 3.12+.
17+
Requires Python 3.9+.
1818

1919
## Quick Start
2020

@@ -246,6 +246,68 @@ print(result["errors"][0]["message"]) # "El campo name es requerido"
246246

247247
See [`examples/i18n_usage.py`](examples/i18n_usage.py) for a full working example.
248248

249+
## Custom Field Types
250+
251+
You can create your own field types by subclassing `FormFieldBase` and registering them with `register_field_type()`. Custom fields integrate seamlessly with forms, JSON serialization, validation, and HTML export.
252+
253+
### Defining a Custom Field
254+
255+
```python
256+
from codeforms import FormFieldBase, register_field_type
257+
258+
class PhoneField(FormFieldBase):
259+
field_type: str = "phone" # unique string identifier
260+
country_code: str = "+1"
261+
262+
class RatingField(FormFieldBase):
263+
field_type: str = "rating"
264+
min_rating: int = 1
265+
max_rating: int = 5
266+
267+
register_field_type(PhoneField)
268+
register_field_type(RatingField)
269+
```
270+
271+
### Using Custom Fields in Forms
272+
273+
```python
274+
from codeforms import Form, TextField
275+
276+
form = Form(
277+
name="feedback",
278+
fields=[
279+
TextField(name="name", label="Name", required=True),
280+
PhoneField(name="phone", label="Phone", country_code="+54"),
281+
RatingField(name="score", label="Score", max_rating=10),
282+
],
283+
)
284+
```
285+
286+
### JSON Roundtrip
287+
288+
Custom fields serialize and deserialize automatically (as long as the field type is registered before deserialization):
289+
290+
```python
291+
import json
292+
293+
json_str = form.to_json()
294+
restored = Form.loads(json_str)
295+
296+
assert isinstance(restored.fields[1], PhoneField)
297+
assert restored.fields[1].country_code == "+54"
298+
```
299+
300+
### Listing Registered Types
301+
302+
```python
303+
from codeforms import get_registered_field_types
304+
305+
for name, classes in sorted(get_registered_field_types().items()):
306+
print(f"{name}: {[c.__name__ for c in classes]}")
307+
```
308+
309+
See [`examples/custom_fields.py`](examples/custom_fields.py) for a full working example.
310+
249311
## License
250312

251313
MIT

examples/custom_fields.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""
2+
Custom field types example for codeforms.
3+
4+
Demonstrates how to create and register custom field types,
5+
use them in forms, and perform JSON roundtrip serialization.
6+
"""
7+
8+
import json
9+
from typing import Optional
10+
11+
from pydantic import field_validator
12+
13+
from codeforms import (
14+
Form,
15+
FormFieldBase,
16+
FieldGroup,
17+
TextField,
18+
EmailField,
19+
register_field_type,
20+
get_registered_field_types,
21+
)
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# 1. Define custom field types
26+
# ---------------------------------------------------------------------------
27+
28+
class PhoneField(FormFieldBase):
29+
"""A phone number field with an optional country code."""
30+
field_type: str = "phone"
31+
country_code: str = "+1"
32+
placeholder: Optional[str] = "e.g. +1-555-0100"
33+
34+
35+
class RatingField(FormFieldBase):
36+
"""A numeric rating field with configurable range."""
37+
field_type: str = "rating"
38+
min_rating: int = 1
39+
max_rating: int = 5
40+
41+
@field_validator("max_rating")
42+
@classmethod
43+
def max_above_min(cls, v, info):
44+
min_r = info.data.get("min_rating", 1)
45+
if v <= min_r:
46+
raise ValueError("max_rating must be greater than min_rating")
47+
return v
48+
49+
50+
class ColorField(FormFieldBase):
51+
"""A colour picker field."""
52+
field_type: str = "color"
53+
color_format: str = "hex" # hex | rgb | hsl
54+
55+
56+
# ---------------------------------------------------------------------------
57+
# 2. Register them
58+
# ---------------------------------------------------------------------------
59+
60+
register_field_type(PhoneField)
61+
register_field_type(RatingField)
62+
register_field_type(ColorField)
63+
64+
65+
def show_registered_types():
66+
"""Print all registered field types."""
67+
print("=" * 60)
68+
print("Registered field types")
69+
print("=" * 60)
70+
for key, classes in sorted(get_registered_field_types().items()):
71+
names = ", ".join(c.__name__ for c in classes)
72+
print(f" {key:12s}{names}")
73+
print()
74+
75+
76+
def create_form_with_custom_fields():
77+
"""Build a form that mixes built-in and custom field types."""
78+
print("=" * 60)
79+
print("Form with custom fields")
80+
print("=" * 60)
81+
82+
form = Form(
83+
name="event_feedback",
84+
content=[
85+
FieldGroup(
86+
title="Contact",
87+
fields=[
88+
TextField(name="name", label="Full name", required=True),
89+
EmailField(name="email", label="Email"),
90+
PhoneField(name="phone", label="Phone", country_code="+54"),
91+
],
92+
),
93+
FieldGroup(
94+
title="Feedback",
95+
fields=[
96+
RatingField(
97+
name="overall_rating",
98+
label="Overall rating",
99+
max_rating=10,
100+
),
101+
ColorField(
102+
name="fav_color",
103+
label="Favourite colour",
104+
color_format="rgb",
105+
),
106+
],
107+
),
108+
],
109+
)
110+
111+
print(f"Form: {form.name}")
112+
print(f"Total fields: {len(form.fields)}")
113+
for f in form.fields:
114+
print(f" - {f.name} ({f.field_type_value})")
115+
print()
116+
return form
117+
118+
119+
def json_roundtrip(form: Form):
120+
"""Serialize a form to JSON and back, preserving custom field data."""
121+
print("=" * 60)
122+
print("JSON roundtrip")
123+
print("=" * 60)
124+
125+
json_str = form.to_json()
126+
print("Serialized JSON (pretty):")
127+
print(json.dumps(json.loads(json_str), indent=2))
128+
print()
129+
130+
restored = Form.loads(json_str)
131+
print("Restored form fields:")
132+
for f in restored.fields:
133+
extra = ""
134+
if isinstance(f, PhoneField):
135+
extra = f" (country_code={f.country_code})"
136+
elif isinstance(f, RatingField):
137+
extra = f" (max_rating={f.max_rating})"
138+
elif isinstance(f, ColorField):
139+
extra = f" (color_format={f.color_format})"
140+
print(f" - {f.name}: {type(f).__name__}{extra}")
141+
print()
142+
143+
144+
def validate_custom_form(form: Form):
145+
"""Validate user data against a form with custom fields."""
146+
print("=" * 60)
147+
print("Data validation")
148+
print("=" * 60)
149+
150+
data = {
151+
"name": "Juan",
152+
"email": "juan@example.com",
153+
"phone": "+54-11-5555-0100",
154+
"overall_rating": "8",
155+
"fav_color": "#3498db",
156+
}
157+
result = form.validate_data(data)
158+
print(f"Input: {data}")
159+
print(f"Result: success={result['success']}")
160+
if result.get("errors"):
161+
print(f"Errors: {result['errors']}")
162+
print()
163+
164+
165+
def export_html(form: Form):
166+
"""Export the form to plain HTML."""
167+
print("=" * 60)
168+
print("HTML export")
169+
print("=" * 60)
170+
export = form.export("html")
171+
print(export["output"][:500], "...")
172+
print()
173+
174+
175+
# ---------------------------------------------------------------------------
176+
# Run all examples
177+
# ---------------------------------------------------------------------------
178+
179+
if __name__ == "__main__":
180+
show_registered_types()
181+
form = create_form_with_custom_fields()
182+
json_roundtrip(form)
183+
validate_custom_form(form)
184+
export_html(form)

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
[project]
22
name = "codeforms"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
description = "Python library for creating, validating, and rendering web forms using Pydantic"
55
readme = "README.md"
6-
requires-python = ">=3.12"
6+
requires-python = ">=3.9"
77
license = "MIT"
88
dependencies = [
99
"pydantic[email]>=2.0",

src/codeforms/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@
2828
register_locale,
2929
get_messages,
3030
)
31+
from codeforms.registry import (
32+
register_field_type,
33+
get_registered_field_types,
34+
)

src/codeforms/export.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,14 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
205205
skip_label = ['hidden']
206206

207207
label_html = ''
208-
if field.field_type.value not in skip_label:
208+
if field.field_type_value not in skip_label:
209209
label_class = "form-label" if is_bootstrap else ""
210210
label_html = f'<label class="{label_class}" for="{field.id}">{field.label}</label>'
211211

212212
help_html = f'<small class="{help_text_class}">{field.help_text}</small>' if field.help_text else ""
213213

214214
# Manejar campos SELECT de manera especial
215-
if field.field_type.value == 'select':
215+
if field.field_type_value == 'select':
216216
select_attrs = {
217217
"id": str(field.id),
218218
"name": field.name,
@@ -240,7 +240,7 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
240240
input_html = f'<select {attrs_str}>{options_html}</select>'
241241

242242
# Manejar campos RADIO de manera especial
243-
elif field.field_type.value == 'radio':
243+
elif field.field_type_value == 'radio':
244244
radio_html_parts = []
245245
if hasattr(field, 'options'):
246246
for option in field.options:
@@ -265,7 +265,7 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
265265
input_html = '<div class="radio-group">' + ''.join(radio_html_parts) + '</div>'
266266

267267
# Manejar campos CHECKBOX con opciones múltiples
268-
elif field.field_type.value == 'checkbox' and hasattr(field, 'options'):
268+
elif field.field_type_value == 'checkbox' and hasattr(field, 'options'):
269269
checkbox_html_parts = []
270270
for option in field.options:
271271
checkbox_attrs = {
@@ -293,7 +293,7 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
293293
attributes = {
294294
"id": str(field.id),
295295
"name": field.name,
296-
"type": field.field_type.value,
296+
"type": field.field_type_value,
297297
"class": f"{base_input_class} {field.css_classes or ''}".strip(),
298298
"placeholder": field.placeholder or "",
299299
}
@@ -308,7 +308,7 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
308308
attributes["value"] = str(field.default_value)
309309

310310
# Para checkbox simple, manejar el atributo checked
311-
if field.field_type.value == 'checkbox' and hasattr(field, 'checked') and field.checked:
311+
if field.field_type_value == 'checkbox' and hasattr(field, 'checked') and field.checked:
312312
attributes["checked"] = "checked"
313313

314314
# Agregar atributos personalizados

0 commit comments

Comments
 (0)