codeforms 0.1.0__tar.gz → 0.2.0__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.
Files changed (33) hide show
  1. {codeforms-0.1.0 → codeforms-0.2.0}/.github/workflows/tests.yml +4 -1
  2. {codeforms-0.1.0 → codeforms-0.2.0}/.gitignore +5 -2
  3. codeforms-0.2.0/.python-version +1 -0
  4. {codeforms-0.1.0 → codeforms-0.2.0}/PKG-INFO +65 -3
  5. {codeforms-0.1.0 → codeforms-0.2.0}/README.md +63 -1
  6. codeforms-0.2.0/examples/conditional_visibility.py +103 -0
  7. codeforms-0.2.0/examples/custom_fields.py +184 -0
  8. codeforms-0.2.0/examples/dependent_options.py +73 -0
  9. codeforms-0.2.0/examples/wizard_form.py +129 -0
  10. {codeforms-0.1.0 → codeforms-0.2.0}/pyproject.toml +2 -2
  11. codeforms-0.2.0/src/codeforms/__init__.py +86 -0
  12. {codeforms-0.1.0 → codeforms-0.2.0}/src/codeforms/export.py +81 -12
  13. {codeforms-0.1.0 → codeforms-0.2.0}/src/codeforms/fields.py +102 -23
  14. {codeforms-0.1.0 → codeforms-0.2.0}/src/codeforms/forms.py +315 -17
  15. {codeforms-0.1.0 → codeforms-0.2.0}/src/codeforms/i18n.py +18 -0
  16. codeforms-0.2.0/src/codeforms/registry.py +194 -0
  17. codeforms-0.2.0/tests/test_dependent_options.py +118 -0
  18. codeforms-0.2.0/tests/test_dynamic_visibility.py +243 -0
  19. codeforms-0.2.0/tests/test_phase2_compat.py +311 -0
  20. codeforms-0.2.0/tests/test_registry.py +409 -0
  21. codeforms-0.2.0/tests/test_wizard_steps.py +382 -0
  22. codeforms-0.2.0/uv.lock +416 -0
  23. codeforms-0.1.0/.python-version +0 -1
  24. codeforms-0.1.0/src/codeforms/__init__.py +0 -30
  25. codeforms-0.1.0/uv.lock +0 -232
  26. {codeforms-0.1.0 → codeforms-0.2.0}/.github/workflows/python-publish.yml +0 -0
  27. {codeforms-0.1.0 → codeforms-0.2.0}/LICENSE +0 -0
  28. {codeforms-0.1.0 → codeforms-0.2.0}/examples/basic_usage.py +0 -0
  29. {codeforms-0.1.0 → codeforms-0.2.0}/examples/i18n_usage.py +0 -0
  30. {codeforms-0.1.0 → codeforms-0.2.0}/tests/__init__.py +0 -0
  31. {codeforms-0.1.0 → codeforms-0.2.0}/tests/conftest.py +0 -0
  32. {codeforms-0.1.0 → codeforms-0.2.0}/tests/test_basic_usage.py +0 -0
  33. {codeforms-0.1.0 → codeforms-0.2.0}/tests/test_i18n.py +0 -0
@@ -9,6 +9,9 @@ on:
9
9
  jobs:
10
10
  test:
11
11
  runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12"]
12
15
 
13
16
  steps:
14
17
  - name: Checkout
@@ -17,7 +20,7 @@ jobs:
17
20
  - name: Set up Python
18
21
  uses: actions/setup-python@v5
19
22
  with:
20
- python-version: "3.12"
23
+ python-version: ${{ matrix.python-version }}
21
24
 
22
25
  - name: Install dependencies
23
26
  run: |
@@ -8,13 +8,16 @@ wheels/
8
8
  # Virtual environments
9
9
  .venv
10
10
 
11
+ # Private
11
12
  todo
13
+ info
12
14
 
13
15
  # Agents
14
16
  CLAUDE.md
15
17
  .claude
16
18
 
17
- # Caches
19
+ # Caches and temps
18
20
  .uv-cache
19
21
  .pytest_cache
20
- __pycache__/
22
+ __pycache__/
23
+ .tmp
@@ -0,0 +1 @@
1
+ 3.11
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeforms
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python library for creating, validating, and rendering web forms using Pydantic
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
7
- Requires-Python: >=3.12
7
+ Requires-Python: >=3.9
8
8
  Requires-Dist: pydantic[email]>=2.0
9
9
  Provides-Extra: dev
10
10
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -26,7 +26,7 @@ Or with [uv](https://docs.astral.sh/uv/):
26
26
  uv add codeforms
27
27
  ```
28
28
 
29
- Requires Python 3.12+.
29
+ Requires Python 3.9+.
30
30
 
31
31
  ## Quick Start
32
32
 
@@ -258,6 +258,68 @@ print(result["errors"][0]["message"]) # "El campo name es requerido"
258
258
 
259
259
  See [`examples/i18n_usage.py`](examples/i18n_usage.py) for a full working example.
260
260
 
261
+ ## Custom Field Types
262
+
263
+ 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.
264
+
265
+ ### Defining a Custom Field
266
+
267
+ ```python
268
+ from codeforms import FormFieldBase, register_field_type
269
+
270
+ class PhoneField(FormFieldBase):
271
+ field_type: str = "phone" # unique string identifier
272
+ country_code: str = "+1"
273
+
274
+ class RatingField(FormFieldBase):
275
+ field_type: str = "rating"
276
+ min_rating: int = 1
277
+ max_rating: int = 5
278
+
279
+ register_field_type(PhoneField)
280
+ register_field_type(RatingField)
281
+ ```
282
+
283
+ ### Using Custom Fields in Forms
284
+
285
+ ```python
286
+ from codeforms import Form, TextField
287
+
288
+ form = Form(
289
+ name="feedback",
290
+ fields=[
291
+ TextField(name="name", label="Name", required=True),
292
+ PhoneField(name="phone", label="Phone", country_code="+54"),
293
+ RatingField(name="score", label="Score", max_rating=10),
294
+ ],
295
+ )
296
+ ```
297
+
298
+ ### JSON Roundtrip
299
+
300
+ Custom fields serialize and deserialize automatically (as long as the field type is registered before deserialization):
301
+
302
+ ```python
303
+ import json
304
+
305
+ json_str = form.to_json()
306
+ restored = Form.loads(json_str)
307
+
308
+ assert isinstance(restored.fields[1], PhoneField)
309
+ assert restored.fields[1].country_code == "+54"
310
+ ```
311
+
312
+ ### Listing Registered Types
313
+
314
+ ```python
315
+ from codeforms import get_registered_field_types
316
+
317
+ for name, classes in sorted(get_registered_field_types().items()):
318
+ print(f"{name}: {[c.__name__ for c in classes]}")
319
+ ```
320
+
321
+ See [`examples/custom_fields.py`](examples/custom_fields.py) for a full working example.
322
+
261
323
  ## License
262
324
 
263
325
  MIT
@@ -14,7 +14,7 @@ Or with [uv](https://docs.astral.sh/uv/):
14
14
  uv add codeforms
15
15
  ```
16
16
 
17
- Requires Python 3.12+.
17
+ Requires Python 3.9+.
18
18
 
19
19
  ## Quick Start
20
20
 
@@ -246,6 +246,68 @@ print(result["errors"][0]["message"]) # "El campo name es requerido"
246
246
 
247
247
  See [`examples/i18n_usage.py`](examples/i18n_usage.py) for a full working example.
248
248
 
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
+
249
311
  ## License
250
312
 
251
313
  MIT
@@ -0,0 +1,103 @@
1
+ """
2
+ Ejemplo de visibilidad condicional con visible_when.
3
+
4
+ Demuestra cómo ocultar/mostrar campos según el valor de otros campos,
5
+ y la diferencia entre validación legacy y dinámica.
6
+ """
7
+
8
+ from codeforms import (
9
+ Form,
10
+ TextField,
11
+ SelectField,
12
+ SelectOption,
13
+ VisibilityRule,
14
+ validate_form_data,
15
+ validate_form_data_dynamic,
16
+ )
17
+
18
+
19
+ def create_address_form() -> Form:
20
+ """Crea un formulario de dirección con campos condicionales."""
21
+ return Form(
22
+ name="address_form",
23
+ fields=[
24
+ SelectField(
25
+ name="country",
26
+ label="Country",
27
+ required=True,
28
+ options=[
29
+ SelectOption(value="US", label="United States"),
30
+ SelectOption(value="AR", label="Argentina"),
31
+ SelectOption(value="UK", label="United Kingdom"),
32
+ ],
33
+ ),
34
+ # Solo visible cuando country == "US"
35
+ TextField(
36
+ name="state",
37
+ label="State",
38
+ required=True,
39
+ visible_when=[
40
+ VisibilityRule(field="country", operator="equals", value="US"),
41
+ ],
42
+ ),
43
+ # Solo visible cuando country == "AR"
44
+ TextField(
45
+ name="province",
46
+ label="Province",
47
+ required=True,
48
+ visible_when=[
49
+ VisibilityRule(field="country", operator="equals", value="AR"),
50
+ ],
51
+ ),
52
+ # Solo visible cuando country == "UK"
53
+ TextField(
54
+ name="county",
55
+ label="County",
56
+ required=True,
57
+ visible_when=[
58
+ VisibilityRule(field="country", operator="equals", value="UK"),
59
+ ],
60
+ ),
61
+ # Siempre visible
62
+ TextField(
63
+ name="city",
64
+ label="City",
65
+ required=True,
66
+ ),
67
+ ],
68
+ )
69
+
70
+
71
+ if __name__ == "__main__":
72
+ form = create_address_form()
73
+ data_us = {"country": "US", "state": "California", "city": "Los Angeles"}
74
+ data_ar = {"country": "AR", "province": "Buenos Aires", "city": "CABA"}
75
+
76
+ # --- Legacy validation: ignores visible_when ---
77
+ print("=== Legacy validate_form_data (ignores visible_when) ===")
78
+ result = validate_form_data(form, data_us)
79
+ print(f"US data: success={result['success']}")
80
+ if not result["success"]:
81
+ print(f" Errors: {result['errors']}")
82
+ print(" (Province and County are required but missing — legacy doesn't know they're hidden)")
83
+
84
+ # --- Dynamic validation: respects visible_when ---
85
+ print("\n=== Dynamic validate_form_data_dynamic (respects visible_when) ===")
86
+ result = validate_form_data_dynamic(form, data_us, respect_visibility=True)
87
+ print(f"US data: success={result['success']}")
88
+ if result["success"]:
89
+ print(f" Validated data: {result['data']}")
90
+ print(" (Province and County are hidden, so not validated)")
91
+
92
+ result = validate_form_data_dynamic(form, data_ar, respect_visibility=True)
93
+ print(f"AR data: success={result['success']}")
94
+ if result["success"]:
95
+ print(f" Validated data: {result['data']}")
96
+
97
+ # --- Visible fields helper ---
98
+ print("\n=== get_visible_fields ===")
99
+ visible = form.get_visible_fields(data_us)
100
+ print(f"Visible fields for US: {[f.name for f in visible]}")
101
+
102
+ visible = form.get_visible_fields(data_ar)
103
+ print(f"Visible fields for AR: {[f.name for f in visible]}")
@@ -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)
@@ -0,0 +1,73 @@
1
+ """
2
+ Ejemplo de opciones dependientes con DependentOptionsConfig.
3
+
4
+ Demuestra cómo definir campos cuyos opciones cambian según el valor
5
+ de otro campo (por ejemplo, país → ciudades).
6
+ """
7
+
8
+ from codeforms import (
9
+ Form,
10
+ SelectField,
11
+ SelectOption,
12
+ DependentOptionsConfig,
13
+ )
14
+
15
+
16
+ def create_location_form() -> Form:
17
+ """Crea un formulario con ciudades dependientes del país seleccionado."""
18
+ return Form(
19
+ name="location_form",
20
+ fields=[
21
+ SelectField(
22
+ name="country",
23
+ label="Country",
24
+ required=True,
25
+ options=[
26
+ SelectOption(value="US", label="United States"),
27
+ SelectOption(value="AR", label="Argentina"),
28
+ ],
29
+ ),
30
+ SelectField(
31
+ name="city",
32
+ label="City",
33
+ required=True,
34
+ # Opciones estáticas (todas las ciudades posibles para HTML rendering)
35
+ options=[
36
+ SelectOption(value="nyc", label="New York City"),
37
+ SelectOption(value="la", label="Los Angeles"),
38
+ SelectOption(value="bsas", label="Buenos Aires"),
39
+ SelectOption(value="cor", label="Córdoba"),
40
+ ],
41
+ # Metadata de dependencia (para lógica dinámica en frontend o backend)
42
+ dependent_options=DependentOptionsConfig(
43
+ depends_on="country",
44
+ options_map={
45
+ "US": [
46
+ SelectOption(value="nyc", label="New York City"),
47
+ SelectOption(value="la", label="Los Angeles"),
48
+ ],
49
+ "AR": [
50
+ SelectOption(value="bsas", label="Buenos Aires"),
51
+ SelectOption(value="cor", label="Córdoba"),
52
+ ],
53
+ },
54
+ ),
55
+ ),
56
+ ],
57
+ )
58
+
59
+
60
+ if __name__ == "__main__":
61
+ form = create_location_form()
62
+
63
+ # La metadata de dependencia se serializa a JSON
64
+ import json
65
+ data = json.loads(form.model_dump_json(exclude_none=True))
66
+ city_field = data["content"][1]
67
+ print("City field dependent_options:")
68
+ print(json.dumps(city_field["dependent_options"], indent=2))
69
+
70
+ # Las opciones específicas para cada país
71
+ dep = form.fields[1].dependent_options
72
+ print(f"\nOptions for US: {[o.label for o in dep.options_map['US']]}")
73
+ print(f"Options for AR: {[o.label for o in dep.options_map['AR']]}")
@@ -0,0 +1,129 @@
1
+ """
2
+ Ejemplo de formulario multi-paso (wizard) con FormStep.
3
+
4
+ Demuestra cómo crear un formulario wizard con validación por paso
5
+ y validación global.
6
+ """
7
+
8
+ from codeforms import (
9
+ Form,
10
+ FormStep,
11
+ TextField,
12
+ EmailField,
13
+ NumberField,
14
+ SelectField,
15
+ SelectOption,
16
+ CheckboxField,
17
+ FieldGroup,
18
+ validate_form_data_dynamic,
19
+ )
20
+
21
+
22
+ def create_registration_wizard() -> Form:
23
+ """Crea un formulario wizard de registro de usuario en 3 pasos."""
24
+ return Form(
25
+ name="registration_wizard",
26
+ content=[
27
+ # Paso 1: Información personal
28
+ FormStep(
29
+ title="Personal Information",
30
+ description="Tell us about yourself",
31
+ content=[
32
+ TextField(name="first_name", label="First Name", required=True),
33
+ TextField(name="last_name", label="Last Name", required=True),
34
+ EmailField(name="email", label="Email", required=True),
35
+ ],
36
+ ),
37
+ # Paso 2: Preferencias
38
+ FormStep(
39
+ title="Preferences",
40
+ description="Choose your plan and preferences",
41
+ content=[
42
+ SelectField(
43
+ name="plan",
44
+ label="Plan",
45
+ required=True,
46
+ options=[
47
+ SelectOption(value="free", label="Free"),
48
+ SelectOption(value="pro", label="Professional"),
49
+ SelectOption(value="enterprise", label="Enterprise"),
50
+ ],
51
+ ),
52
+ NumberField(
53
+ name="team_size",
54
+ label="Team Size",
55
+ min_value=1,
56
+ max_value=1000,
57
+ ),
58
+ ],
59
+ ),
60
+ # Paso 3: Confirmación
61
+ FormStep(
62
+ title="Confirmation",
63
+ description="Review and accept the terms",
64
+ content=[
65
+ CheckboxField(
66
+ name="terms",
67
+ label="I accept the terms and conditions",
68
+ required=True,
69
+ ),
70
+ ],
71
+ validation_mode="on_submit",
72
+ ),
73
+ ],
74
+ )
75
+
76
+
77
+ if __name__ == "__main__":
78
+ form = create_registration_wizard()
79
+
80
+ # Verificar estructura
81
+ print(f"Form: {form.name}")
82
+ print(f"Steps: {len(form.get_steps())}")
83
+ print(f"Total fields: {len(form.fields)}")
84
+
85
+ for i, step in enumerate(form.get_steps()):
86
+ print(f"\n Step {i + 1}: {step.title}")
87
+ for field in step.fields:
88
+ print(f" - {field.name} ({'required' if field.required else 'optional'})")
89
+
90
+ # Validar paso 1
91
+ print("\n--- Validating Step 1 ---")
92
+ result = form.validate_step(0, {
93
+ "first_name": "John",
94
+ "last_name": "Doe",
95
+ "email": "john@example.com",
96
+ })
97
+ print(f"Step 1 valid: {result['success']}")
98
+
99
+ # Validar paso 2
100
+ print("\n--- Validating Step 2 ---")
101
+ result = form.validate_step(1, {
102
+ "plan": "pro",
103
+ "team_size": 5,
104
+ })
105
+ print(f"Step 2 valid: {result['success']}")
106
+
107
+ # Validar todos los pasos
108
+ print("\n--- Validating All Steps ---")
109
+ result = form.validate_all_steps({
110
+ "first_name": "John",
111
+ "last_name": "Doe",
112
+ "email": "john@example.com",
113
+ "plan": "pro",
114
+ "team_size": 5,
115
+ "terms": True,
116
+ })
117
+ print(f"All steps valid: {result['success']}")
118
+
119
+ # Exportar HTML
120
+ print("\n--- HTML Export ---")
121
+ export = form.export("html_bootstrap5")
122
+ print(export["output"][:300] + "...")
123
+
124
+ # JSON roundtrip
125
+ print("\n--- JSON Roundtrip ---")
126
+ json_str = form.model_dump_json()
127
+ restored = Form.model_validate_json(json_str)
128
+ print(f"Restored steps: {len(restored.get_steps())}")
129
+ print(f"Restored fields: {len(restored.fields)}")