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.
- {hyperclass-0.0.4 → hyperclass-0.1.0}/PKG-INFO +121 -17
- {hyperclass-0.0.4 → hyperclass-0.1.0}/README.md +114 -16
- {hyperclass-0.0.4 → 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.4 → hyperclass-0.1.0}/hyperclass/html.py +54 -31
- 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.4 → hyperclass-0.1.0}/hyperclass.egg-info/PKG-INFO +121 -17
- {hyperclass-0.0.4 → 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.4 → hyperclass-0.1.0}/pyproject.toml +9 -1
- {hyperclass-0.0.4 → hyperclass-0.1.0}/tests/test_bookmarks.py +5 -0
- hyperclass-0.1.0/tests/test_django.py +97 -0
- hyperclass-0.1.0/tests/test_flask.py +70 -0
- {hyperclass-0.0.4 → hyperclass-0.1.0}/tests/test_html.py +12 -5
- hyperclass-0.1.0/tests/test_package.py +5 -0
- {hyperclass-0.0.4 → hyperclass-0.1.0}/tests/test_wsgi.py +20 -0
- hyperclass-0.0.4/hyperclass/wsgi.py +0 -503
- hyperclass-0.0.4/hyperclass.egg-info/requires.txt +0 -4
- hyperclass-0.0.4/tests/test_package.py +0 -5
- {hyperclass-0.0.4 → hyperclass-0.1.0}/LICENSE +0 -0
- {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass/__main__.py +0 -0
- {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass/css.py +0 -0
- {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass/htmx.py +0 -0
- {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass.egg-info/dependency_links.txt +0 -0
- {hyperclass-0.0.4 → hyperclass-0.1.0}/hyperclass.egg-info/top_level.txt +0 -0
- {hyperclass-0.0.4 → hyperclass-0.1.0}/setup.cfg +0 -0
- {hyperclass-0.0.4 → hyperclass-0.1.0}/tests/test_cli.py +0 -0
- {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
|
|
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:
|
|
@@ -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.
|
|
243
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
369
|
+
### Lightweight
|
|
344
370
|
|
|
345
|
-
|
|
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
|
|
373
|
-
|
|
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
|
-
- **
|
|
394
|
-
|
|
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
|
|
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:
|
|
@@ -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.
|
|
213
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
333
|
+
### Lightweight
|
|
314
334
|
|
|
315
|
-
|
|
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
|
|
343
|
-
|
|
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
|
-
- **
|
|
364
|
-
|
|
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 .
|
|
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"]
|