Skip to content

Commit d15af9c

Browse files
committed
Speed up
1 parent db3f12b commit d15af9c

12 files changed

Lines changed: 640 additions & 313 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ dist/
1717
pip-wheel-metadata
1818
coverage.xml
1919

20+
profile_results.prof

profile_formidable.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""
2+
Profiling script for formidable.
3+
4+
Exercises the main code paths:
5+
1. Form initialization (field discovery, cloning)
6+
2. Request data parsing (flat dict -> nested)
7+
3. Field setting & validation
8+
4. NestedForms (multiple sub-forms)
9+
5. SlugField (unicode normalization)
10+
6. HTML rendering
11+
12+
Run:
13+
uv run python profile_formidable.py
14+
15+
Outputs:
16+
- Console: top 40 cumulative-time entries
17+
- profile_results.prof: full cProfile dump (for snakeviz, etc.)
18+
"""
19+
20+
import cProfile
21+
import pstats
22+
23+
import formidable as f
24+
25+
26+
# -- Define realistic forms --------------------------------------------------
27+
28+
class AddressForm(f.Form):
29+
street = f.TextField()
30+
city = f.TextField()
31+
zip_code = f.TextField(required=False)
32+
country = f.TextField()
33+
34+
35+
class ContactForm(f.Form):
36+
name = f.TextField()
37+
email = f.EmailField()
38+
phone = f.TextField(required=False)
39+
age = f.IntegerField(required=False)
40+
website = f.URLField(required=False)
41+
slug = f.SlugField(required=False)
42+
subscribe = f.BooleanField(required=False)
43+
notes = f.TextField(required=False)
44+
45+
address = f.FormField(AddressForm)
46+
addresses = f.NestedForms(AddressForm, min_items=1, max_items=10)
47+
48+
49+
# -- Build test data ----------------------------------------------------------
50+
51+
def make_flat_reqdata(n_addresses=5):
52+
"""Simulate flat request data as it would arrive from an HTML form."""
53+
data = {
54+
"name": "José García-López",
55+
"email": "jose@example.com",
56+
"phone": "+1-555-0123",
57+
"age": "42",
58+
"website": "https://example.com/~jose",
59+
"slug": "Héllo Wörld! Ça va très bien, merci αβγδ",
60+
"subscribe": "on",
61+
"notes": "Some notes here",
62+
# FormField (single nested)
63+
"address[street]": "123 Main St",
64+
"address[city]": "Springfield",
65+
"address[zip_code]": "62704",
66+
"address[country]": "US",
67+
}
68+
# NestedForms (multiple nested)
69+
for i in range(n_addresses):
70+
prefix = f"addresses[{i}]"
71+
data[f"{prefix}[street]"] = f"{100 + i} Oak Avenue"
72+
data[f"{prefix}[city]"] = f"City {i}"
73+
data[f"{prefix}[zip_code]"] = f"{10000 + i}"
74+
data[f"{prefix}[country]"] = "US"
75+
76+
return data
77+
78+
79+
# -- Workloads ----------------------------------------------------------------
80+
81+
def workload_init_only(iterations=1000):
82+
"""Measure form class instantiation (no data)."""
83+
for _ in range(iterations):
84+
ContactForm()
85+
86+
87+
def workload_parse_set_validate(iterations=500):
88+
"""Measure parse + set + validate cycle."""
89+
data = make_flat_reqdata(n_addresses=5)
90+
for _ in range(iterations):
91+
form = ContactForm(data)
92+
form.is_valid
93+
94+
95+
def workload_large_nested(iterations=100):
96+
"""Measure with many nested forms."""
97+
data = make_flat_reqdata(n_addresses=50)
98+
for _ in range(iterations):
99+
form = ContactForm(data)
100+
form.is_valid
101+
102+
103+
def workload_render_html(iterations=500):
104+
"""Measure HTML rendering helpers."""
105+
data = make_flat_reqdata(n_addresses=3)
106+
form = ContactForm(data)
107+
for _ in range(iterations):
108+
for field in form:
109+
field.label("Label")
110+
field.text_input()
111+
field.error_tag()
112+
113+
114+
def workload_slug(iterations=2000):
115+
"""Measure slug field processing."""
116+
from formidable.fields.slug import slugify
117+
texts = [
118+
"Héllo Wörld! Ça va très bien",
119+
"αβγδεζηθικλμνξοπρστυφχψω",
120+
"The Quick Brown Fox Jumps Over The Lazy Dog 123!@#$%",
121+
"ñoño año español café résumé naïve",
122+
"ა ბ გ დ ე ვ ზ თ ი კ ლ მ ნ ო პ",
123+
]
124+
for _ in range(iterations):
125+
for text in texts:
126+
slugify(text)
127+
128+
129+
def workload_parser(iterations=2000):
130+
"""Measure raw parser performance."""
131+
from formidable.parser import parse
132+
data = make_flat_reqdata(n_addresses=20)
133+
for _ in range(iterations):
134+
parse(data)
135+
136+
137+
def run_all():
138+
"""Run all workloads together for a combined profile."""
139+
workload_init_only()
140+
workload_parse_set_validate()
141+
workload_large_nested()
142+
workload_render_html()
143+
workload_slug()
144+
workload_parser()
145+
146+
147+
# -- Main --------------------------------------------------------------------
148+
149+
if __name__ == "__main__":
150+
print("Profiling formidable...")
151+
print("=" * 70)
152+
153+
profiler = cProfile.Profile()
154+
profiler.enable()
155+
run_all()
156+
profiler.disable()
157+
158+
# Save for visualization tools (snakeviz, pyprof2calltree, etc.)
159+
profiler.dump_stats("profile_results.prof")
160+
161+
# Print summary
162+
stats = pstats.Stats(profiler)
163+
stats.strip_dirs()
164+
stats.sort_stats("cumulative")
165+
166+
print("\n TOP 40 BY CUMULATIVE TIME")
167+
print("=" * 70)
168+
stats.print_stats(40)
169+
170+
print("\n TOP 30 BY TOTAL (SELF) TIME")
171+
print("=" * 70)
172+
stats.sort_stats("tottime")
173+
stats.print_stats(30)
174+
175+
print(f"\nFull profile saved to: profile_results.prof")
176+
print("Visualize with: uv run snakeviz profile_results.prof")

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ requires = ["setuptools"]
44

55
[project]
66
name = "formidable"
7-
version = "0.15.1"
7+
version = "0.16.0"
88
description = "Small but powerful library for rendering and processing web forms"
99
authors = [{ name = "Juan-Pablo Scaletti", email = "juanpablo@jpscaletti.com" }]
1010
license = "MIT"
@@ -23,7 +23,6 @@ requires-python = ">=3.12,<4"
2323
dependencies = [
2424
"idna>=3.10",
2525
"markupsafe>=3.0.3",
26-
"writeadoc",
2726
]
2827

2928
[project.optional-dependencies]

src/formidable/fields/base.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
Formidable | Copyright (c) 2025 Juan-Pablo Scaletti
33
"""
44

5+
import itertools
56
import typing as t
67
from collections.abc import Iterable
7-
from uuid import uuid4
88

99
from markupsafe import Markup
1010

@@ -15,6 +15,9 @@
1515
from ..form import Form
1616

1717

18+
_field_counter = itertools.count()
19+
20+
1821
class Field:
1922
"""
2023
Base class for all form fields.
@@ -55,7 +58,12 @@ def __init__(
5558
self.default = default
5659
self.value = self.default_value
5760
self.messages = messages if messages is not None else {}
58-
self.id = f"f{uuid4().hex}"
61+
self.id = f"f{next(_field_counter)}"
62+
63+
def __copy__(self):
64+
clone = object.__new__(self.__class__)
65+
clone.__dict__ = self.__dict__.copy()
66+
return clone
5967

6068
def __repr__(self):
6169
attrs = [
@@ -742,9 +750,7 @@ def _render_html_attrs(self, attrs: dict[str, t.Any]) -> str:
742750
html_props = []
743751
clean_attrs = {}
744752
for key, value in attrs.items():
745-
if key == "class_":
746-
key = "class"
747-
key = key.replace("_", "-")
753+
key = "class" if key == "class_" else key.replace("_", "-")
748754
if isinstance(value, bool):
749755
if value:
750756
html_props.append(key)

src/formidable/fields/formfield.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def __init__(
4040

4141
def __copy__(self):
4242
clone = object.__new__(self.__class__)
43-
clone.__dict__.update(self.__dict__)
43+
clone.__dict__ = self.__dict__.copy()
4444
clone.form = self.FormClass()
4545
return clone
4646

src/formidable/fields/nested.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def __init__(
6868

6969
def __copy__(self):
7070
clone = object.__new__(self.__class__)
71-
clone.__dict__.update(self.__dict__)
71+
clone.__dict__ = self.__dict__.copy()
7272
clone.empty_form = self.FormClass()
7373
clone.forms = []
7474
return clone

src/formidable/fields/slug.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,19 +302,29 @@
302302
}
303303

304304

305+
_rx_non_word = re.compile(r"[^\w\s-]")
306+
_rx_sep = re.compile(r"[-\s]+")
307+
308+
# Pre-built translation table for str.translate() — much faster than
309+
# iterating char-by-char with CHAR_MAP.get() in Python.
310+
_TRANSLATE_TABLE = str.maketrans(
311+
{ord(k): v for k, v in CHAR_MAP.items()}
312+
)
313+
314+
305315
def slugify(value: str) -> str:
306316
"""
307317
A very simple function to convert a string to a slug.
308318
"""
309319
value = unicodedata.normalize("NFKC", value).lower()
310320
# Replace some non-ASCII characters
311-
value = "".join(CHAR_MAP.get(c, c) for c in value)
321+
value = value.translate(_TRANSLATE_TABLE)
312322
# Remove any remaining non-ASCII characters
313323
value = value.encode("ascii", "ignore").decode("ascii")
314324
# Replace non-word characters with hyphens
315-
value = re.sub(r"[^\w\s-]", "", value)
325+
value = _rx_non_word.sub("", value)
316326
# Replace whitespace and hyphens with a single hyphen
317-
return re.sub(r"[-\s]+", "-", value).strip("-_")
327+
return _rx_sep.sub("-", value).strip("-_")
318328

319329

320330
class SlugField(TextField):

0 commit comments

Comments
 (0)