hyperclass 0.0.4__tar.gz → 0.1.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. {hyperclass-0.0.4 → hyperclass-0.1.0}/PKG-INFO +121 -17
  2. {hyperclass-0.0.4 → hyperclass-0.1.0}/README.md +114 -16
  3. {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass/__init__.py +4 -2
  4. hyperclass-0.1.0/hyperclass/binding.py +116 -0
  5. hyperclass-0.1.0/hyperclass/django.py +172 -0
  6. hyperclass-0.1.0/hyperclass/flask.py +119 -0
  7. {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass/html.py +54 -31
  8. hyperclass-0.1.0/hyperclass/lite.py +229 -0
  9. hyperclass-0.1.0/hyperclass/rendering.py +60 -0
  10. hyperclass-0.1.0/hyperclass/routing.py +300 -0
  11. hyperclass-0.1.0/hyperclass/wsgi.py +37 -0
  12. {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass.egg-info/PKG-INFO +121 -17
  13. {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass.egg-info/SOURCES.txt +8 -0
  14. hyperclass-0.1.0/hyperclass.egg-info/requires.txt +12 -0
  15. {hyperclass-0.0.4 → hyperclass-0.1.0}/pyproject.toml +9 -1
  16. {hyperclass-0.0.4 → hyperclass-0.1.0}/tests/test_bookmarks.py +5 -0
  17. hyperclass-0.1.0/tests/test_django.py +97 -0
  18. hyperclass-0.1.0/tests/test_flask.py +70 -0
  19. {hyperclass-0.0.4 → hyperclass-0.1.0}/tests/test_html.py +12 -5
  20. hyperclass-0.1.0/tests/test_package.py +5 -0
  21. {hyperclass-0.0.4 → hyperclass-0.1.0}/tests/test_wsgi.py +20 -0
  22. hyperclass-0.0.4/hyperclass/wsgi.py +0 -503
  23. hyperclass-0.0.4/hyperclass.egg-info/requires.txt +0 -4
  24. hyperclass-0.0.4/tests/test_package.py +0 -5
  25. {hyperclass-0.0.4 → hyperclass-0.1.0}/LICENSE +0 -0
  26. {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass/__main__.py +0 -0
  27. {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass/css.py +0 -0
  28. {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass/htmx.py +0 -0
  29. {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass.egg-info/dependency_links.txt +0 -0
  30. {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass.egg-info/top_level.txt +0 -0
  31. {hyperclass-0.0.4 → hyperclass-0.1.0}/setup.cfg +0 -0
  32. {hyperclass-0.0.4 → hyperclass-0.1.0}/tests/test_cli.py +0 -0
  33. {hyperclass-0.0.4 → hyperclass-0.1.0}/tests/test_htmx.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperclass
3
- Version: 0.0.4
3
+ Version: 0.1.0
4
4
  Summary: Build interactive web applications as Python class hierarchies.
5
5
  Author: Grant Jenks
6
6
  License-Expression: Apache-2.0
@@ -23,7 +23,13 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Requires-Python: >=3.10
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
+ Provides-Extra: flask
27
+ Requires-Dist: Flask>=3.1; extra == "flask"
28
+ Provides-Extra: django
29
+ Requires-Dist: Django>=5.2; extra == "django"
26
30
  Provides-Extra: dev
31
+ Requires-Dist: Django>=5.2; extra == "dev"
32
+ Requires-Dist: Flask>=3.1; extra == "dev"
27
33
  Requires-Dist: pytest>=8.0; extra == "dev"
28
34
  Requires-Dist: nox>=2024.4.15; extra == "dev"
29
35
  Dynamic: license-file
@@ -37,10 +43,14 @@ Python class hierarchies.
37
43
 
38
44
  HTML elements are Python base classes. Python subclasses become CSS classes.
39
45
  Styles follow inheritance. Routes are methods. Decorated handlers are URLs.
40
- Interaction is ordinary HTTP over WSGI, with htmx 4 in the browser.
46
+ Interaction is ordinary HTTP, with htmx 4 in the browser. Run it with the tiny
47
+ built-in WSGI host, or put the same components and routes inside Flask or
48
+ Django.
41
49
 
42
50
  ~~~console
43
51
  pip install hyperclass
52
+ # or: pip install "hyperclass[flask]"
53
+ # or: pip install "hyperclass[django]"
44
54
  ~~~
45
55
 
46
56
  ## Sixty-second tour
@@ -83,7 +93,7 @@ class Guest:
83
93
  name: str
84
94
 
85
95
 
86
- class guestbook(App):
96
+ class GuestbookRoutes:
87
97
  @get("/")
88
98
  def index(self, request):
89
99
  return card(
@@ -102,6 +112,10 @@ class guestbook(App):
102
112
  return card(f"Hello, {form.name}!")
103
113
 
104
114
 
115
+ class guestbook(GuestbookRoutes, App):
116
+ pass
117
+
118
+
105
119
  app = guestbook(title="Guestbook")
106
120
  ~~~
107
121
 
@@ -114,6 +128,10 @@ python -m hyperclass myapp:app
114
128
  Then open <http://127.0.0.1:8000>. There is no JavaScript build, template
115
129
  language, ASGI dependency, or CSS file hidden elsewhere.
116
130
 
131
+ `App` is the zero-dependency host and remains the shortest way to start. The
132
+ HTML, CSS, htmx, selector, route, and form-binding APIs are shared by every
133
+ host.
134
+
117
135
  ## HTML classes are Python classes
118
136
 
119
137
  Every built-in element can be subclassed:
@@ -239,8 +257,9 @@ class primary_button(button):
239
257
  narrow = media(max_width=40 * rem, width="100%")
240
258
  ~~~
241
259
 
242
- Pages collect only the rules used by their element tree. Python inheritance and
243
- the CSS cascade cooperate instead of imitating one another.
260
+ Pages collect only the rules used by their element tree. Rules use the concrete
261
+ semantic class chain as their selector, so Python inheritance and the CSS
262
+ cascade cooperate even when new component styles arrive later.
244
263
 
245
264
  ## Classes, IDs, and names are selectors
246
265
 
@@ -296,7 +315,7 @@ class bookmarks(App):
296
315
  return bookmark_card(...)
297
316
  ~~~
298
317
 
299
- Decorated handlers retain their route metadata:
318
+ Decorated handlers retain their route metadata. Their URLs are lazy values:
300
319
 
301
320
  ~~~python
302
321
  bookmarks.toggle.url(bookmark_id=42)
@@ -311,6 +330,9 @@ hx.patch(
311
330
 
312
331
  Typed path parameters are converted before the handler runs. Query strings can
313
332
  be attached with `.url(query={...})` or the `query=` option on an htmx request.
333
+ The URL is resolved only while rendering, so a Flask mount prefix or Django URL
334
+ namespace is included automatically. Components never need to know where their
335
+ application was mounted.
314
336
 
315
337
  ## Forms bind to dataclasses
316
338
 
@@ -334,16 +356,20 @@ class bookmarks(App):
334
356
  Binding supports strings, integers, floats, booleans, optional values, and
335
357
  lists or tuples of those values. Dataclass defaults remain defaults. Invalid or
336
358
  missing required values produce a `400 Bad Request`; application validation can
337
- return a more specific `Response`.
359
+ return `(body, status)`.
360
+
361
+ The lightweight host also exposes `request.form` and `request.query`. Flask and
362
+ Django handlers receive their native request objects, so use `request.form` and
363
+ `request.args` in Flask or `request.POST` and `request.GET` in Django. Their
364
+ Werkzeug `MultiDict` and Django `QueryDict` values feed the same dataclass
365
+ binder without a request wrapper.
338
366
 
339
- The underlying values remain available as `request.form`, `request.query`,
340
- `.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
341
- Those accessors accept first-class `name.*` objects as well as strings.
367
+ ## Three hosts, one component model
342
368
 
343
- ## WSGI and htmx 4
369
+ ### Lightweight
344
370
 
345
- A Hyperclass application is a normal WSGI callable. Use the standard-library
346
- development server:
371
+ Top-level `App` is a deliberately small, dependency-free WSGI application. Use
372
+ the standard-library development server:
347
373
 
348
374
  ~~~console
349
375
  python -m hyperclass package.module:app
@@ -354,10 +380,67 @@ Production can use any WSGI server. Returning an element from an ordinary
354
380
  browser request wraps it in a complete page. Returning the same element to an
355
381
  htmx request sends only the fragment to swap.
356
382
 
383
+ The explicit import is `from hyperclass.lite import App`; top-level
384
+ `from hyperclass import App` is its convenient and backward-compatible alias.
385
+
386
+ ### Flask
387
+
388
+ Install `hyperclass[flask]`, then subclass the native Flask host:
389
+
390
+ ~~~python
391
+ from hyperclass.flask import App
392
+
393
+
394
+ class guestbook(GuestbookRoutes, App):
395
+ pass
396
+
397
+
398
+ app = guestbook(title="Guestbook")
399
+ ~~~
400
+
401
+ `hyperclass.flask.App` is a real `flask.Flask` subclass. Route handlers receive
402
+ Flask's request object, native Flask responses pass through unchanged, and
403
+ Flask extensions, middleware, test clients, and WSGI deployment continue to
404
+ work normally. Hyperclass route references use `url_for()` when rendered.
405
+
406
+ ### Django
407
+
408
+ Install `hyperclass[django]`, create the route application, and include it in a
409
+ normal Django URLconf:
410
+
411
+ ~~~python
412
+ from django.urls import include, path
413
+ from hyperclass.django import App
414
+
415
+
416
+ class guestbook(GuestbookRoutes, App):
417
+ pass
418
+
419
+
420
+ guestbook_app = guestbook(title="Guestbook", namespace="guestbook")
421
+
422
+ urlpatterns = [
423
+ path("guestbook/", include(guestbook_app.urls)),
424
+ ]
425
+ ~~~
426
+
427
+ Handlers receive native `HttpRequest` objects and may return native
428
+ `HttpResponse` objects. Routes sharing a path are dispatched by HTTP method,
429
+ and route references use Django `reverse()`, including the mount and namespace.
430
+ Full Hyperclass pages inherit an htmx `X-CSRFToken` header and request a Django
431
+ CSRF cookie, so unsafe htmx requests work with `CsrfViewMiddleware` enabled.
432
+
433
+ ### htmx 4
434
+
357
435
  `Page(...)` controls the document explicitly. Pages include a pinned htmx 4
358
436
  asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
359
437
  object-selected regions from one request.
360
438
 
439
+ When an htmx response introduces a component that was not present on the first
440
+ page, Hyperclass includes its CSS in a partial targeting the page's stable
441
+ `hyperclass-styles` stylesheet. The new fragment is styled immediately, without
442
+ a reload or a global CSS build.
443
+
361
444
  ## Try the examples
362
445
 
363
446
  Clone the repository and run the persistent SQLite bookmark inbox:
@@ -366,11 +449,13 @@ Clone the repository and run the persistent SQLite bookmark inbox:
366
449
  git clone https://github.com/grantjenks/python-hyperclass
367
450
  cd python-hyperclass
368
451
  python -m hyperclass examples.bookmarks:app
452
+ # Flask: flask --app examples.bookmarks_flask run
369
453
  ~~~
370
454
 
371
455
  The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
372
- Its implementation is Python plus SQLite, WSGI, generated CSS, and htmx. It is
373
- also a compact integration test for the framework's ideas.
456
+ Its route mixin and component tree are shared by Lite, Flask, and Django. The
457
+ same browser contract adds, toggles, edits, searches, and deletes a bookmark on
458
+ all three hosts.
374
459
 
375
460
  For the smallest example:
376
461
 
@@ -390,8 +475,27 @@ python -m hyperclass examples.counter:app
390
475
  client component lifecycle.
391
476
  - **Output should be boring.** Generated markup stays readable in View Source
392
477
  and DevTools.
393
- - **Small is a feature.** Prefer the standard library, WSGI, and a pinned htmx
394
- asset over a framework stack.
478
+ - **Choose your host.** Start with the standard library, or use Flask/Django
479
+ where their ecosystem and infrastructure are already the right answer.
480
+
481
+ ## Development
482
+
483
+ Run the Python test matrix locally with:
484
+
485
+ ~~~console
486
+ uvx nox -s tests
487
+ ~~~
488
+
489
+ The browser contract starts each of the Lite, Flask, and Django bookmark hosts
490
+ on an ephemeral port and exercises add, toggle, edit, search, and delete
491
+ through htmx in Chromium:
492
+
493
+ ~~~console
494
+ uvx nox -s browser
495
+ ~~~
496
+
497
+ Playwright is used only by that development session and is not a Hyperclass
498
+ runtime dependency.
395
499
 
396
500
  ## Status
397
501
 
@@ -7,10 +7,14 @@ Python class hierarchies.
7
7
 
8
8
  HTML elements are Python base classes. Python subclasses become CSS classes.
9
9
  Styles follow inheritance. Routes are methods. Decorated handlers are URLs.
10
- Interaction is ordinary HTTP over WSGI, with htmx 4 in the browser.
10
+ Interaction is ordinary HTTP, with htmx 4 in the browser. Run it with the tiny
11
+ built-in WSGI host, or put the same components and routes inside Flask or
12
+ Django.
11
13
 
12
14
  ~~~console
13
15
  pip install hyperclass
16
+ # or: pip install "hyperclass[flask]"
17
+ # or: pip install "hyperclass[django]"
14
18
  ~~~
15
19
 
16
20
  ## Sixty-second tour
@@ -53,7 +57,7 @@ class Guest:
53
57
  name: str
54
58
 
55
59
 
56
- class guestbook(App):
60
+ class GuestbookRoutes:
57
61
  @get("/")
58
62
  def index(self, request):
59
63
  return card(
@@ -72,6 +76,10 @@ class guestbook(App):
72
76
  return card(f"Hello, {form.name}!")
73
77
 
74
78
 
79
+ class guestbook(GuestbookRoutes, App):
80
+ pass
81
+
82
+
75
83
  app = guestbook(title="Guestbook")
76
84
  ~~~
77
85
 
@@ -84,6 +92,10 @@ python -m hyperclass myapp:app
84
92
  Then open <http://127.0.0.1:8000>. There is no JavaScript build, template
85
93
  language, ASGI dependency, or CSS file hidden elsewhere.
86
94
 
95
+ `App` is the zero-dependency host and remains the shortest way to start. The
96
+ HTML, CSS, htmx, selector, route, and form-binding APIs are shared by every
97
+ host.
98
+
87
99
  ## HTML classes are Python classes
88
100
 
89
101
  Every built-in element can be subclassed:
@@ -209,8 +221,9 @@ class primary_button(button):
209
221
  narrow = media(max_width=40 * rem, width="100%")
210
222
  ~~~
211
223
 
212
- Pages collect only the rules used by their element tree. Python inheritance and
213
- the CSS cascade cooperate instead of imitating one another.
224
+ Pages collect only the rules used by their element tree. Rules use the concrete
225
+ semantic class chain as their selector, so Python inheritance and the CSS
226
+ cascade cooperate even when new component styles arrive later.
214
227
 
215
228
  ## Classes, IDs, and names are selectors
216
229
 
@@ -266,7 +279,7 @@ class bookmarks(App):
266
279
  return bookmark_card(...)
267
280
  ~~~
268
281
 
269
- Decorated handlers retain their route metadata:
282
+ Decorated handlers retain their route metadata. Their URLs are lazy values:
270
283
 
271
284
  ~~~python
272
285
  bookmarks.toggle.url(bookmark_id=42)
@@ -281,6 +294,9 @@ hx.patch(
281
294
 
282
295
  Typed path parameters are converted before the handler runs. Query strings can
283
296
  be attached with `.url(query={...})` or the `query=` option on an htmx request.
297
+ The URL is resolved only while rendering, so a Flask mount prefix or Django URL
298
+ namespace is included automatically. Components never need to know where their
299
+ application was mounted.
284
300
 
285
301
  ## Forms bind to dataclasses
286
302
 
@@ -304,16 +320,20 @@ class bookmarks(App):
304
320
  Binding supports strings, integers, floats, booleans, optional values, and
305
321
  lists or tuples of those values. Dataclass defaults remain defaults. Invalid or
306
322
  missing required values produce a `400 Bad Request`; application validation can
307
- return a more specific `Response`.
323
+ return `(body, status)`.
324
+
325
+ The lightweight host also exposes `request.form` and `request.query`. Flask and
326
+ Django handlers receive their native request objects, so use `request.form` and
327
+ `request.args` in Flask or `request.POST` and `request.GET` in Django. Their
328
+ Werkzeug `MultiDict` and Django `QueryDict` values feed the same dataclass
329
+ binder without a request wrapper.
308
330
 
309
- The underlying values remain available as `request.form`, `request.query`,
310
- `.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
311
- Those accessors accept first-class `name.*` objects as well as strings.
331
+ ## Three hosts, one component model
312
332
 
313
- ## WSGI and htmx 4
333
+ ### Lightweight
314
334
 
315
- A Hyperclass application is a normal WSGI callable. Use the standard-library
316
- development server:
335
+ Top-level `App` is a deliberately small, dependency-free WSGI application. Use
336
+ the standard-library development server:
317
337
 
318
338
  ~~~console
319
339
  python -m hyperclass package.module:app
@@ -324,10 +344,67 @@ Production can use any WSGI server. Returning an element from an ordinary
324
344
  browser request wraps it in a complete page. Returning the same element to an
325
345
  htmx request sends only the fragment to swap.
326
346
 
347
+ The explicit import is `from hyperclass.lite import App`; top-level
348
+ `from hyperclass import App` is its convenient and backward-compatible alias.
349
+
350
+ ### Flask
351
+
352
+ Install `hyperclass[flask]`, then subclass the native Flask host:
353
+
354
+ ~~~python
355
+ from hyperclass.flask import App
356
+
357
+
358
+ class guestbook(GuestbookRoutes, App):
359
+ pass
360
+
361
+
362
+ app = guestbook(title="Guestbook")
363
+ ~~~
364
+
365
+ `hyperclass.flask.App` is a real `flask.Flask` subclass. Route handlers receive
366
+ Flask's request object, native Flask responses pass through unchanged, and
367
+ Flask extensions, middleware, test clients, and WSGI deployment continue to
368
+ work normally. Hyperclass route references use `url_for()` when rendered.
369
+
370
+ ### Django
371
+
372
+ Install `hyperclass[django]`, create the route application, and include it in a
373
+ normal Django URLconf:
374
+
375
+ ~~~python
376
+ from django.urls import include, path
377
+ from hyperclass.django import App
378
+
379
+
380
+ class guestbook(GuestbookRoutes, App):
381
+ pass
382
+
383
+
384
+ guestbook_app = guestbook(title="Guestbook", namespace="guestbook")
385
+
386
+ urlpatterns = [
387
+ path("guestbook/", include(guestbook_app.urls)),
388
+ ]
389
+ ~~~
390
+
391
+ Handlers receive native `HttpRequest` objects and may return native
392
+ `HttpResponse` objects. Routes sharing a path are dispatched by HTTP method,
393
+ and route references use Django `reverse()`, including the mount and namespace.
394
+ Full Hyperclass pages inherit an htmx `X-CSRFToken` header and request a Django
395
+ CSRF cookie, so unsafe htmx requests work with `CsrfViewMiddleware` enabled.
396
+
397
+ ### htmx 4
398
+
327
399
  `Page(...)` controls the document explicitly. Pages include a pinned htmx 4
328
400
  asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
329
401
  object-selected regions from one request.
330
402
 
403
+ When an htmx response introduces a component that was not present on the first
404
+ page, Hyperclass includes its CSS in a partial targeting the page's stable
405
+ `hyperclass-styles` stylesheet. The new fragment is styled immediately, without
406
+ a reload or a global CSS build.
407
+
331
408
  ## Try the examples
332
409
 
333
410
  Clone the repository and run the persistent SQLite bookmark inbox:
@@ -336,11 +413,13 @@ Clone the repository and run the persistent SQLite bookmark inbox:
336
413
  git clone https://github.com/grantjenks/python-hyperclass
337
414
  cd python-hyperclass
338
415
  python -m hyperclass examples.bookmarks:app
416
+ # Flask: flask --app examples.bookmarks_flask run
339
417
  ~~~
340
418
 
341
419
  The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
342
- Its implementation is Python plus SQLite, WSGI, generated CSS, and htmx. It is
343
- also a compact integration test for the framework's ideas.
420
+ Its route mixin and component tree are shared by Lite, Flask, and Django. The
421
+ same browser contract adds, toggles, edits, searches, and deletes a bookmark on
422
+ all three hosts.
344
423
 
345
424
  For the smallest example:
346
425
 
@@ -360,8 +439,27 @@ python -m hyperclass examples.counter:app
360
439
  client component lifecycle.
361
440
  - **Output should be boring.** Generated markup stays readable in View Source
362
441
  and DevTools.
363
- - **Small is a feature.** Prefer the standard library, WSGI, and a pinned htmx
364
- asset over a framework stack.
442
+ - **Choose your host.** Start with the standard library, or use Flask/Django
443
+ where their ecosystem and infrastructure are already the right answer.
444
+
445
+ ## Development
446
+
447
+ Run the Python test matrix locally with:
448
+
449
+ ~~~console
450
+ uvx nox -s tests
451
+ ~~~
452
+
453
+ The browser contract starts each of the Lite, Flask, and Django bookmark hosts
454
+ on an ephemeral port and exercises add, toggle, edit, search, and delete
455
+ through htmx in Chromium:
456
+
457
+ ~~~console
458
+ uvx nox -s browser
459
+ ~~~
460
+
461
+ Playwright is used only by that development session and is not a Hyperclass
462
+ runtime dependency.
365
463
 
366
464
  ## Status
367
465
 
@@ -46,13 +46,14 @@ from .htmx import (
46
46
  from .htmx import (
47
47
  delete as delete_swap,
48
48
  )
49
- from .wsgi import (
49
+ from .lite import (
50
50
  App,
51
51
  BoundEndpoint,
52
52
  Endpoint,
53
53
  Request,
54
54
  Response,
55
55
  Route,
56
+ RouteURL,
56
57
  Values,
57
58
  get,
58
59
  patch,
@@ -62,7 +63,7 @@ from .wsgi import (
62
63
  )
63
64
  from .wsgi import delete as delete_route
64
65
 
65
- __version__ = "0.0.4"
66
+ __version__ = "0.1.0"
66
67
 
67
68
  hidden = "hidden"
68
69
  submit = "submit"
@@ -84,6 +85,7 @@ __all__ = [
84
85
  "Request",
85
86
  "Response",
86
87
  "Route",
88
+ "RouteURL",
87
89
  "Style",
88
90
  "Target",
89
91
  "Unit",
@@ -0,0 +1,116 @@
1
+ """Bind form-like multidicts to typed dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Iterator, Mapping
7
+ from dataclasses import MISSING, fields, is_dataclass
8
+ from types import UnionType
9
+ from typing import Any, Protocol, Union, get_args, get_origin, get_type_hints
10
+
11
+
12
+ class MultiValues(Protocol):
13
+ def getlist(self, key: Any) -> list[Any]: ...
14
+
15
+
16
+ class Values(Mapping[str, str]):
17
+ def __init__(self, values: Mapping[str, list[str]] | None = None):
18
+ self._values = dict(values or {})
19
+
20
+ def __getitem__(self, key: Any) -> str:
21
+ return self._values[str(key)][-1]
22
+
23
+ def __iter__(self) -> Iterator[str]:
24
+ return iter(self._values)
25
+
26
+ def __len__(self) -> int:
27
+ return len(self._values)
28
+
29
+ def get(self, key: Any, default: Any = None) -> Any:
30
+ values = self._values.get(str(key))
31
+ return values[-1] if values else default
32
+
33
+ def getlist(self, key: Any) -> list[str]:
34
+ return list(self._values.get(str(key), ()))
35
+
36
+ def int(self, key: Any, default: int | None = None) -> int:
37
+ value = self.get(key)
38
+ if value is None:
39
+ if default is not None:
40
+ return default
41
+ raise ValueError(f"missing integer form value: {key}")
42
+ try:
43
+ return int(value)
44
+ except ValueError as error:
45
+ raise ValueError(
46
+ f"invalid integer form value for {key}: {value!r}"
47
+ ) from error
48
+
49
+ def bind(self, model: type[Any]) -> Any:
50
+ return bind(self, model)
51
+
52
+
53
+ def bind(values: MultiValues, model: type[Any]) -> Any:
54
+ """Build a dataclass from any Werkzeug/Django-style multidict."""
55
+
56
+ if not isinstance(model, type) or not is_dataclass(model):
57
+ raise TypeError("values can only bind to a dataclass type")
58
+ hints = get_type_hints(model)
59
+ arguments: dict[str, Any] = {}
60
+ for field in fields(model):
61
+ annotation = hints.get(field.name, field.type)
62
+ raw = [str(value) for value in values.getlist(field.name)]
63
+ if not raw:
64
+ if field.default is not MISSING or field.default_factory is not MISSING:
65
+ continue
66
+ if _optional(annotation):
67
+ arguments[field.name] = None
68
+ elif annotation is bool:
69
+ arguments[field.name] = False
70
+ else:
71
+ raise ValueError(f"missing form value: {field.name}")
72
+ continue
73
+ try:
74
+ arguments[field.name] = _convert_values(raw, annotation)
75
+ except (TypeError, ValueError) as error:
76
+ raise ValueError(
77
+ f"invalid form value for {field.name}: {raw[-1]!r}"
78
+ ) from error
79
+ return model(**arguments)
80
+
81
+
82
+ def _optional(annotation: Any) -> bool:
83
+ return get_origin(annotation) in (Union, UnionType) and type(None) in get_args(
84
+ annotation
85
+ )
86
+
87
+
88
+ def _convert_values(values: list[str], annotation: Any) -> Any:
89
+ origin = get_origin(annotation)
90
+ arguments = get_args(annotation)
91
+ if origin in (list, tuple):
92
+ item_type = arguments[0] if arguments else str
93
+ converted = [_convert_value(value, item_type) for value in values]
94
+ return converted if origin is list else tuple(converted)
95
+ if _optional(annotation):
96
+ item_type = next(value for value in arguments if value is not type(None))
97
+ return None if values[-1] == "" else _convert_value(values[-1], item_type)
98
+ return _convert_value(values[-1], annotation)
99
+
100
+
101
+ def _convert_value(value: str, annotation: Any) -> Any:
102
+ if annotation in (Any, inspect.Parameter.empty, str):
103
+ return value
104
+ if annotation is bool:
105
+ normalized = value.lower()
106
+ if normalized in {"1", "true", "yes", "on"}:
107
+ return True
108
+ if normalized in {"0", "false", "no", "off", ""}:
109
+ return False
110
+ raise ValueError(value)
111
+ if annotation in (int, float):
112
+ return annotation(value)
113
+ raise TypeError(f"unsupported form type: {annotation!r}")
114
+
115
+
116
+ __all__ = ["MultiValues", "Values", "bind"]