hyperclass 0.0.5__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.
- {hyperclass-0.0.5 → hyperclass-0.1.0}/PKG-INFO +113 -15
- {hyperclass-0.0.5 → hyperclass-0.1.0}/README.md +106 -14
- {hyperclass-0.0.5 → hyperclass-0.1.0}/hyperclass/__init__.py +4 -2
- hyperclass-0.1.0/hyperclass/binding.py +116 -0
- hyperclass-0.1.0/hyperclass/django.py +172 -0
- hyperclass-0.1.0/hyperclass/flask.py +119 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/hyperclass/html.py +27 -10
- hyperclass-0.1.0/hyperclass/lite.py +229 -0
- hyperclass-0.1.0/hyperclass/rendering.py +60 -0
- hyperclass-0.1.0/hyperclass/routing.py +300 -0
- hyperclass-0.1.0/hyperclass/wsgi.py +37 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/hyperclass.egg-info/PKG-INFO +113 -15
- {hyperclass-0.0.5 → hyperclass-0.1.0}/hyperclass.egg-info/SOURCES.txt +8 -0
- hyperclass-0.1.0/hyperclass.egg-info/requires.txt +12 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/pyproject.toml +9 -1
- {hyperclass-0.0.5 → hyperclass-0.1.0}/tests/test_bookmarks.py +1 -0
- hyperclass-0.1.0/tests/test_django.py +97 -0
- hyperclass-0.1.0/tests/test_flask.py +70 -0
- hyperclass-0.1.0/tests/test_package.py +5 -0
- hyperclass-0.0.5/hyperclass/wsgi.py +0 -521
- hyperclass-0.0.5/hyperclass.egg-info/requires.txt +0 -4
- hyperclass-0.0.5/tests/test_package.py +0 -5
- {hyperclass-0.0.5 → hyperclass-0.1.0}/LICENSE +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/hyperclass/__main__.py +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/hyperclass/css.py +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/hyperclass/htmx.py +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/hyperclass.egg-info/dependency_links.txt +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/hyperclass.egg-info/top_level.txt +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/setup.cfg +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/tests/test_cli.py +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/tests/test_html.py +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/tests/test_htmx.py +0 -0
- {hyperclass-0.0.5 → hyperclass-0.1.0}/tests/test_wsgi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperclass
|
|
3
|
-
Version: 0.0
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
369
|
+
### Lightweight
|
|
345
370
|
|
|
346
|
-
|
|
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,58 @@ 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 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
|
+
|
|
358
435
|
`Page(...)` controls the document explicitly. Pages include a pinned htmx 4
|
|
359
436
|
asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
|
|
360
437
|
object-selected regions from one request.
|
|
@@ -372,11 +449,13 @@ Clone the repository and run the persistent SQLite bookmark inbox:
|
|
|
372
449
|
git clone https://github.com/grantjenks/python-hyperclass
|
|
373
450
|
cd python-hyperclass
|
|
374
451
|
python -m hyperclass examples.bookmarks:app
|
|
452
|
+
# Flask: flask --app examples.bookmarks_flask run
|
|
375
453
|
~~~
|
|
376
454
|
|
|
377
455
|
The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
|
|
378
|
-
Its
|
|
379
|
-
|
|
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.
|
|
380
459
|
|
|
381
460
|
For the smallest example:
|
|
382
461
|
|
|
@@ -396,8 +475,27 @@ python -m hyperclass examples.counter:app
|
|
|
396
475
|
client component lifecycle.
|
|
397
476
|
- **Output should be boring.** Generated markup stays readable in View Source
|
|
398
477
|
and DevTools.
|
|
399
|
-
- **
|
|
400
|
-
|
|
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.
|
|
401
499
|
|
|
402
500
|
## Status
|
|
403
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
333
|
+
### Lightweight
|
|
315
334
|
|
|
316
|
-
|
|
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,58 @@ 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 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
|
+
|
|
328
399
|
`Page(...)` controls the document explicitly. Pages include a pinned htmx 4
|
|
329
400
|
asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
|
|
330
401
|
object-selected regions from one request.
|
|
@@ -342,11 +413,13 @@ Clone the repository and run the persistent SQLite bookmark inbox:
|
|
|
342
413
|
git clone https://github.com/grantjenks/python-hyperclass
|
|
343
414
|
cd python-hyperclass
|
|
344
415
|
python -m hyperclass examples.bookmarks:app
|
|
416
|
+
# Flask: flask --app examples.bookmarks_flask run
|
|
345
417
|
~~~
|
|
346
418
|
|
|
347
419
|
The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
|
|
348
|
-
Its
|
|
349
|
-
|
|
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.
|
|
350
423
|
|
|
351
424
|
For the smallest example:
|
|
352
425
|
|
|
@@ -366,8 +439,27 @@ python -m hyperclass examples.counter:app
|
|
|
366
439
|
client component lifecycle.
|
|
367
440
|
- **Output should be boring.** Generated markup stays readable in View Source
|
|
368
441
|
and DevTools.
|
|
369
|
-
- **
|
|
370
|
-
|
|
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.
|
|
371
463
|
|
|
372
464
|
## Status
|
|
373
465
|
|
|
@@ -46,13 +46,14 @@ from .htmx import (
|
|
|
46
46
|
from .htmx import (
|
|
47
47
|
delete as delete_swap,
|
|
48
48
|
)
|
|
49
|
-
from .
|
|
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
|
|
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"]
|