hyperclass 0.0.5__tar.gz → 0.1.1__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.5 → hyperclass-0.1.1}/PKG-INFO +114 -15
  2. {hyperclass-0.0.5 → hyperclass-0.1.1}/README.md +107 -14
  3. {hyperclass-0.0.5 → hyperclass-0.1.1}/hyperclass/__init__.py +4 -2
  4. hyperclass-0.1.1/hyperclass/binding.py +116 -0
  5. hyperclass-0.1.1/hyperclass/django.py +172 -0
  6. hyperclass-0.1.1/hyperclass/flask.py +119 -0
  7. {hyperclass-0.0.5 → hyperclass-0.1.1}/hyperclass/html.py +27 -10
  8. hyperclass-0.1.1/hyperclass/lite.py +229 -0
  9. hyperclass-0.1.1/hyperclass/rendering.py +60 -0
  10. hyperclass-0.1.1/hyperclass/routing.py +300 -0
  11. hyperclass-0.1.1/hyperclass/wsgi.py +37 -0
  12. {hyperclass-0.0.5 → hyperclass-0.1.1}/hyperclass.egg-info/PKG-INFO +114 -15
  13. {hyperclass-0.0.5 → hyperclass-0.1.1}/hyperclass.egg-info/SOURCES.txt +8 -0
  14. hyperclass-0.1.1/hyperclass.egg-info/requires.txt +12 -0
  15. {hyperclass-0.0.5 → hyperclass-0.1.1}/pyproject.toml +9 -1
  16. {hyperclass-0.0.5 → hyperclass-0.1.1}/tests/test_bookmarks.py +1 -0
  17. hyperclass-0.1.1/tests/test_django.py +97 -0
  18. hyperclass-0.1.1/tests/test_flask.py +85 -0
  19. hyperclass-0.1.1/tests/test_package.py +5 -0
  20. hyperclass-0.0.5/hyperclass/wsgi.py +0 -521
  21. hyperclass-0.0.5/hyperclass.egg-info/requires.txt +0 -4
  22. hyperclass-0.0.5/tests/test_package.py +0 -5
  23. {hyperclass-0.0.5 → hyperclass-0.1.1}/LICENSE +0 -0
  24. {hyperclass-0.0.5 → hyperclass-0.1.1}/hyperclass/__main__.py +0 -0
  25. {hyperclass-0.0.5 → hyperclass-0.1.1}/hyperclass/css.py +0 -0
  26. {hyperclass-0.0.5 → hyperclass-0.1.1}/hyperclass/htmx.py +0 -0
  27. {hyperclass-0.0.5 → hyperclass-0.1.1}/hyperclass.egg-info/dependency_links.txt +0 -0
  28. {hyperclass-0.0.5 → hyperclass-0.1.1}/hyperclass.egg-info/top_level.txt +0 -0
  29. {hyperclass-0.0.5 → hyperclass-0.1.1}/setup.cfg +0 -0
  30. {hyperclass-0.0.5 → hyperclass-0.1.1}/tests/test_cli.py +0 -0
  31. {hyperclass-0.0.5 → hyperclass-0.1.1}/tests/test_html.py +0 -0
  32. {hyperclass-0.0.5 → hyperclass-0.1.1}/tests/test_htmx.py +0 -0
  33. {hyperclass-0.0.5 → hyperclass-0.1.1}/tests/test_wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperclass
3
- Version: 0.0.5
3
+ Version: 0.1.1
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:
@@ -297,7 +315,7 @@ class bookmarks(App):
297
315
  return bookmark_card(...)
298
316
  ~~~
299
317
 
300
- Decorated handlers retain their route metadata:
318
+ Decorated handlers retain their route metadata. Their URLs are lazy values:
301
319
 
302
320
  ~~~python
303
321
  bookmarks.toggle.url(bookmark_id=42)
@@ -312,6 +330,9 @@ hx.patch(
312
330
 
313
331
  Typed path parameters are converted before the handler runs. Query strings can
314
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.
315
336
 
316
337
  ## Forms bind to dataclasses
317
338
 
@@ -335,16 +356,20 @@ class bookmarks(App):
335
356
  Binding supports strings, integers, floats, booleans, optional values, and
336
357
  lists or tuples of those values. Dataclass defaults remain defaults. Invalid or
337
358
  missing required values produce a `400 Bad Request`; application validation can
338
- 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.
339
366
 
340
- The underlying values remain available as `request.form`, `request.query`,
341
- `.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
342
- Those accessors accept first-class `name.*` objects as well as strings.
367
+ ## Three hosts, one component model
343
368
 
344
- ## WSGI and htmx 4
369
+ ### Lightweight
345
370
 
346
- A Hyperclass application is a normal WSGI callable. Use the standard-library
347
- development server:
371
+ Top-level `App` is a deliberately small, dependency-free WSGI application. Use
372
+ the standard-library development server:
348
373
 
349
374
  ~~~console
350
375
  python -m hyperclass package.module:app
@@ -355,6 +380,59 @@ Production can use any WSGI server. Returning an element from an ordinary
355
380
  browser request wraps it in a complete page. Returning the same element to an
356
381
  htmx request sends only the fragment to swap.
357
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 emit htmx 4's explicit `hx-headers:inherited` modifier
431
+ with an `X-CSRFToken` header and request a Django CSRF cookie, so unsafe htmx
432
+ requests work with `CsrfViewMiddleware` enabled.
433
+
434
+ ### htmx 4
435
+
358
436
  `Page(...)` controls the document explicitly. Pages include a pinned htmx 4
359
437
  asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
360
438
  object-selected regions from one request.
@@ -372,11 +450,13 @@ Clone the repository and run the persistent SQLite bookmark inbox:
372
450
  git clone https://github.com/grantjenks/python-hyperclass
373
451
  cd python-hyperclass
374
452
  python -m hyperclass examples.bookmarks:app
453
+ # Flask: flask --app examples.bookmarks_flask run
375
454
  ~~~
376
455
 
377
456
  The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
378
- Its implementation is Python plus SQLite, WSGI, generated CSS, and htmx. It is
379
- also a compact integration test for the framework's ideas.
457
+ Its route mixin and component tree are shared by Lite, Flask, and Django. The
458
+ same browser contract adds, toggles, edits, searches, and deletes a bookmark on
459
+ all three hosts.
380
460
 
381
461
  For the smallest example:
382
462
 
@@ -396,8 +476,27 @@ python -m hyperclass examples.counter:app
396
476
  client component lifecycle.
397
477
  - **Output should be boring.** Generated markup stays readable in View Source
398
478
  and DevTools.
399
- - **Small is a feature.** Prefer the standard library, WSGI, and a pinned htmx
400
- asset over a framework stack.
479
+ - **Choose your host.** Start with the standard library, or use Flask/Django
480
+ where their ecosystem and infrastructure are already the right answer.
481
+
482
+ ## Development
483
+
484
+ Run the Python test matrix locally with:
485
+
486
+ ~~~console
487
+ uvx nox -s tests
488
+ ~~~
489
+
490
+ The browser contract starts each of the Lite, Flask, and Django bookmark hosts
491
+ on an ephemeral port and exercises add, toggle, edit, search, and delete
492
+ through htmx in Chromium:
493
+
494
+ ~~~console
495
+ uvx nox -s browser
496
+ ~~~
497
+
498
+ Playwright is used only by that development session and is not a Hyperclass
499
+ runtime dependency.
401
500
 
402
501
  ## Status
403
502
 
@@ -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:
@@ -267,7 +279,7 @@ class bookmarks(App):
267
279
  return bookmark_card(...)
268
280
  ~~~
269
281
 
270
- Decorated handlers retain their route metadata:
282
+ Decorated handlers retain their route metadata. Their URLs are lazy values:
271
283
 
272
284
  ~~~python
273
285
  bookmarks.toggle.url(bookmark_id=42)
@@ -282,6 +294,9 @@ hx.patch(
282
294
 
283
295
  Typed path parameters are converted before the handler runs. Query strings can
284
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.
285
300
 
286
301
  ## Forms bind to dataclasses
287
302
 
@@ -305,16 +320,20 @@ class bookmarks(App):
305
320
  Binding supports strings, integers, floats, booleans, optional values, and
306
321
  lists or tuples of those values. Dataclass defaults remain defaults. Invalid or
307
322
  missing required values produce a `400 Bad Request`; application validation can
308
- 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.
309
330
 
310
- The underlying values remain available as `request.form`, `request.query`,
311
- `.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
312
- Those accessors accept first-class `name.*` objects as well as strings.
331
+ ## Three hosts, one component model
313
332
 
314
- ## WSGI and htmx 4
333
+ ### Lightweight
315
334
 
316
- A Hyperclass application is a normal WSGI callable. Use the standard-library
317
- development server:
335
+ Top-level `App` is a deliberately small, dependency-free WSGI application. Use
336
+ the standard-library development server:
318
337
 
319
338
  ~~~console
320
339
  python -m hyperclass package.module:app
@@ -325,6 +344,59 @@ Production can use any WSGI server. Returning an element from an ordinary
325
344
  browser request wraps it in a complete page. Returning the same element to an
326
345
  htmx request sends only the fragment to swap.
327
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 emit htmx 4's explicit `hx-headers:inherited` modifier
395
+ with an `X-CSRFToken` header and request a Django CSRF cookie, so unsafe htmx
396
+ requests work with `CsrfViewMiddleware` enabled.
397
+
398
+ ### htmx 4
399
+
328
400
  `Page(...)` controls the document explicitly. Pages include a pinned htmx 4
329
401
  asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
330
402
  object-selected regions from one request.
@@ -342,11 +414,13 @@ Clone the repository and run the persistent SQLite bookmark inbox:
342
414
  git clone https://github.com/grantjenks/python-hyperclass
343
415
  cd python-hyperclass
344
416
  python -m hyperclass examples.bookmarks:app
417
+ # Flask: flask --app examples.bookmarks_flask run
345
418
  ~~~
346
419
 
347
420
  The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
348
- Its implementation is Python plus SQLite, WSGI, generated CSS, and htmx. It is
349
- also a compact integration test for the framework's ideas.
421
+ Its route mixin and component tree are shared by Lite, Flask, and Django. The
422
+ same browser contract adds, toggles, edits, searches, and deletes a bookmark on
423
+ all three hosts.
350
424
 
351
425
  For the smallest example:
352
426
 
@@ -366,8 +440,27 @@ python -m hyperclass examples.counter:app
366
440
  client component lifecycle.
367
441
  - **Output should be boring.** Generated markup stays readable in View Source
368
442
  and DevTools.
369
- - **Small is a feature.** Prefer the standard library, WSGI, and a pinned htmx
370
- asset over a framework stack.
443
+ - **Choose your host.** Start with the standard library, or use Flask/Django
444
+ where their ecosystem and infrastructure are already the right answer.
445
+
446
+ ## Development
447
+
448
+ Run the Python test matrix locally with:
449
+
450
+ ~~~console
451
+ uvx nox -s tests
452
+ ~~~
453
+
454
+ The browser contract starts each of the Lite, Flask, and Django bookmark hosts
455
+ on an ephemeral port and exercises add, toggle, edit, search, and delete
456
+ through htmx in Chromium:
457
+
458
+ ~~~console
459
+ uvx nox -s browser
460
+ ~~~
461
+
462
+ Playwright is used only by that development session and is not a Hyperclass
463
+ runtime dependency.
371
464
 
372
465
  ## Status
373
466
 
@@ -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.5"
66
+ __version__ = "0.1.1"
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"]