hyperclass 0.1.0__tar.gz → 0.2.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.1.0 → hyperclass-0.2.0}/PKG-INFO +72 -9
- {hyperclass-0.1.0 → hyperclass-0.2.0}/README.md +71 -8
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/__init__.py +6 -1
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/django.py +14 -2
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/flask.py +14 -1
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/html.py +34 -5
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/htmx.py +54 -3
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/lite.py +32 -5
- hyperclass-0.2.0/hyperclass/streaming.py +113 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/PKG-INFO +72 -9
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/SOURCES.txt +3 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/pyproject.toml +1 -1
- hyperclass-0.2.0/tests/test_chat.py +112 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_django.py +15 -2
- {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_flask.py +29 -1
- {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_html.py +8 -0
- hyperclass-0.2.0/tests/test_htmx.py +64 -0
- hyperclass-0.2.0/tests/test_package.py +5 -0
- hyperclass-0.2.0/tests/test_streaming.py +48 -0
- hyperclass-0.1.0/tests/test_htmx.py +0 -42
- hyperclass-0.1.0/tests/test_package.py +0 -5
- {hyperclass-0.1.0 → hyperclass-0.2.0}/LICENSE +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/__main__.py +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/binding.py +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/css.py +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/rendering.py +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/routing.py +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/wsgi.py +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/dependency_links.txt +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/requires.txt +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/top_level.txt +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/setup.cfg +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_bookmarks.py +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_cli.py +0 -0
- {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_wsgi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperclass
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Build interactive web applications as Python class hierarchies.
|
|
5
5
|
Author: Grant Jenks
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -279,11 +279,16 @@ span("3 unread", id=id.unread_count)
|
|
|
279
279
|
hx.get(count, target=id.unread_count)
|
|
280
280
|
|
|
281
281
|
assert id.unread_count is id.unread_count
|
|
282
|
+
assert id.message[42] is id.message[42]
|
|
282
283
|
~~~
|
|
283
284
|
|
|
284
285
|
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
285
286
|
selector, it renders as `#unread-count`.
|
|
286
287
|
|
|
288
|
+
IDs can carry application keys without returning to strings. `id.message[42]`
|
|
289
|
+
renders as `message-42` in an `id=` attribute and `#message-42` when used as a
|
|
290
|
+
selector. String keys translate underscores to dashes too.
|
|
291
|
+
|
|
287
292
|
Form names work the same way while preserving Python underscores:
|
|
288
293
|
|
|
289
294
|
~~~python
|
|
@@ -427,8 +432,9 @@ urlpatterns = [
|
|
|
427
432
|
Handlers receive native `HttpRequest` objects and may return native
|
|
428
433
|
`HttpResponse` objects. Routes sharing a path are dispatched by HTTP method,
|
|
429
434
|
and route references use Django `reverse()`, including the mount and namespace.
|
|
430
|
-
Full Hyperclass pages
|
|
431
|
-
CSRF cookie, so unsafe htmx
|
|
435
|
+
Full Hyperclass pages emit htmx 4's explicit `hx-headers:inherited` modifier
|
|
436
|
+
with an `X-CSRFToken` header and request a Django CSRF cookie, so unsafe htmx
|
|
437
|
+
requests work with `CsrfViewMiddleware` enabled.
|
|
432
438
|
|
|
433
439
|
### htmx 4
|
|
434
440
|
|
|
@@ -441,15 +447,71 @@ page, Hyperclass includes its CSS in a partial targeting the page's stable
|
|
|
441
447
|
`hyperclass-styles` stylesheet. The new fragment is styled immediately, without
|
|
442
448
|
a reload or a global CSS build.
|
|
443
449
|
|
|
450
|
+
htmx attributes are mappings and compose with `|`. Modifiers and events remain
|
|
451
|
+
Python expressions:
|
|
452
|
+
|
|
453
|
+
~~~python
|
|
454
|
+
attributes = (
|
|
455
|
+
hx.post(send, stream=True, target=id.stream_sink, swap="none")
|
|
456
|
+
| hx.on.before_request("this.reset()")
|
|
457
|
+
| hx.headers.inherited({"X-Workspace": "demo"})
|
|
458
|
+
)
|
|
459
|
+
~~~
|
|
460
|
+
|
|
461
|
+
`stream=True` enables htmx 4's SSE response handling and adds the pinned
|
|
462
|
+
`hx-sse` extension to that page. `hx.sse.connect(events)` is available for
|
|
463
|
+
persistent connections.
|
|
464
|
+
|
|
465
|
+
## Streaming responses
|
|
466
|
+
|
|
467
|
+
Any host can return a `stream(...)` of ordinary Hyperclass values. Each value
|
|
468
|
+
becomes one correctly framed, escaped server-sent event:
|
|
469
|
+
|
|
470
|
+
~~~python
|
|
471
|
+
from hyperclass import id, outer_morph, partial, stream
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@post("/messages/<int:message_id>/regenerate")
|
|
475
|
+
def regenerate(self, request, message_id):
|
|
476
|
+
def events():
|
|
477
|
+
answer = ""
|
|
478
|
+
for token in model(prompt):
|
|
479
|
+
answer += token
|
|
480
|
+
yield partial(
|
|
481
|
+
message_view(message_id, answer),
|
|
482
|
+
hx_target=id.message[message_id].selector,
|
|
483
|
+
hx_swap=outer_morph,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
return stream(events())
|
|
487
|
+
~~~
|
|
488
|
+
|
|
489
|
+
The response is streamed by Lite, Flask, or Django with the same handler.
|
|
490
|
+
`event(value, name=..., id=..., retry=...)` exposes named events and replay
|
|
491
|
+
fields when an application needs them. Component CSS discovered during a
|
|
492
|
+
stream is carried to the browser once, just like a normal htmx fragment.
|
|
493
|
+
|
|
444
494
|
## Try the examples
|
|
445
495
|
|
|
446
|
-
Clone the repository and run the persistent
|
|
496
|
+
Clone the repository and run the persistent streaming chat:
|
|
447
497
|
|
|
448
498
|
~~~console
|
|
449
499
|
git clone https://github.com/grantjenks/python-hyperclass
|
|
450
500
|
cd python-hyperclass
|
|
501
|
+
python -m hyperclass examples.chat:app
|
|
502
|
+
# Flask: flask --app examples.chat_flask run
|
|
503
|
+
~~~
|
|
504
|
+
|
|
505
|
+
The chat demonstrates an AI-style interface without an API key: persistent
|
|
506
|
+
conversations, responsive history, token streaming, stop, and regenerate. Pass
|
|
507
|
+
any `model(prompt) -> iterable[str]` to `create_app` or `ChatRoutes` to replace
|
|
508
|
+
the deterministic local demo model. Its components and routes are shared by
|
|
509
|
+
Lite, Flask, and Django.
|
|
510
|
+
|
|
511
|
+
The SQLite bookmark inbox remains a smaller CRUD example:
|
|
512
|
+
|
|
513
|
+
~~~console
|
|
451
514
|
python -m hyperclass examples.bookmarks:app
|
|
452
|
-
# Flask: flask --app examples.bookmarks_flask run
|
|
453
515
|
~~~
|
|
454
516
|
|
|
455
517
|
The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
|
|
@@ -486,9 +548,9 @@ Run the Python test matrix locally with:
|
|
|
486
548
|
uvx nox -s tests
|
|
487
549
|
~~~
|
|
488
550
|
|
|
489
|
-
The browser contract starts
|
|
490
|
-
on
|
|
491
|
-
through htmx in Chromium:
|
|
551
|
+
The browser contract starts the Lite, Flask, and Django bookmark and chat hosts
|
|
552
|
+
on ephemeral ports. It exercises CRUD plus streamed send, concurrent stop,
|
|
553
|
+
regenerate, and persistence through htmx in Chromium:
|
|
492
554
|
|
|
493
555
|
~~~console
|
|
494
556
|
uvx nox -s browser
|
|
@@ -500,6 +562,7 @@ runtime dependency.
|
|
|
500
562
|
## Status
|
|
501
563
|
|
|
502
564
|
Hyperclass is deliberately pre-alpha: useful enough to build small applications
|
|
503
|
-
and young enough for its API to change. Python 3.10 through 3.14
|
|
565
|
+
and young enough for its API to change. Python 3.10 through 3.14 and the
|
|
566
|
+
cross-host browser contract are tested on every change.
|
|
504
567
|
|
|
505
568
|
Apache-2.0 licensed.
|
|
@@ -243,11 +243,16 @@ span("3 unread", id=id.unread_count)
|
|
|
243
243
|
hx.get(count, target=id.unread_count)
|
|
244
244
|
|
|
245
245
|
assert id.unread_count is id.unread_count
|
|
246
|
+
assert id.message[42] is id.message[42]
|
|
246
247
|
~~~
|
|
247
248
|
|
|
248
249
|
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
249
250
|
selector, it renders as `#unread-count`.
|
|
250
251
|
|
|
252
|
+
IDs can carry application keys without returning to strings. `id.message[42]`
|
|
253
|
+
renders as `message-42` in an `id=` attribute and `#message-42` when used as a
|
|
254
|
+
selector. String keys translate underscores to dashes too.
|
|
255
|
+
|
|
251
256
|
Form names work the same way while preserving Python underscores:
|
|
252
257
|
|
|
253
258
|
~~~python
|
|
@@ -391,8 +396,9 @@ urlpatterns = [
|
|
|
391
396
|
Handlers receive native `HttpRequest` objects and may return native
|
|
392
397
|
`HttpResponse` objects. Routes sharing a path are dispatched by HTTP method,
|
|
393
398
|
and route references use Django `reverse()`, including the mount and namespace.
|
|
394
|
-
Full Hyperclass pages
|
|
395
|
-
CSRF cookie, so unsafe htmx
|
|
399
|
+
Full Hyperclass pages emit htmx 4's explicit `hx-headers:inherited` modifier
|
|
400
|
+
with an `X-CSRFToken` header and request a Django CSRF cookie, so unsafe htmx
|
|
401
|
+
requests work with `CsrfViewMiddleware` enabled.
|
|
396
402
|
|
|
397
403
|
### htmx 4
|
|
398
404
|
|
|
@@ -405,15 +411,71 @@ page, Hyperclass includes its CSS in a partial targeting the page's stable
|
|
|
405
411
|
`hyperclass-styles` stylesheet. The new fragment is styled immediately, without
|
|
406
412
|
a reload or a global CSS build.
|
|
407
413
|
|
|
414
|
+
htmx attributes are mappings and compose with `|`. Modifiers and events remain
|
|
415
|
+
Python expressions:
|
|
416
|
+
|
|
417
|
+
~~~python
|
|
418
|
+
attributes = (
|
|
419
|
+
hx.post(send, stream=True, target=id.stream_sink, swap="none")
|
|
420
|
+
| hx.on.before_request("this.reset()")
|
|
421
|
+
| hx.headers.inherited({"X-Workspace": "demo"})
|
|
422
|
+
)
|
|
423
|
+
~~~
|
|
424
|
+
|
|
425
|
+
`stream=True` enables htmx 4's SSE response handling and adds the pinned
|
|
426
|
+
`hx-sse` extension to that page. `hx.sse.connect(events)` is available for
|
|
427
|
+
persistent connections.
|
|
428
|
+
|
|
429
|
+
## Streaming responses
|
|
430
|
+
|
|
431
|
+
Any host can return a `stream(...)` of ordinary Hyperclass values. Each value
|
|
432
|
+
becomes one correctly framed, escaped server-sent event:
|
|
433
|
+
|
|
434
|
+
~~~python
|
|
435
|
+
from hyperclass import id, outer_morph, partial, stream
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@post("/messages/<int:message_id>/regenerate")
|
|
439
|
+
def regenerate(self, request, message_id):
|
|
440
|
+
def events():
|
|
441
|
+
answer = ""
|
|
442
|
+
for token in model(prompt):
|
|
443
|
+
answer += token
|
|
444
|
+
yield partial(
|
|
445
|
+
message_view(message_id, answer),
|
|
446
|
+
hx_target=id.message[message_id].selector,
|
|
447
|
+
hx_swap=outer_morph,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
return stream(events())
|
|
451
|
+
~~~
|
|
452
|
+
|
|
453
|
+
The response is streamed by Lite, Flask, or Django with the same handler.
|
|
454
|
+
`event(value, name=..., id=..., retry=...)` exposes named events and replay
|
|
455
|
+
fields when an application needs them. Component CSS discovered during a
|
|
456
|
+
stream is carried to the browser once, just like a normal htmx fragment.
|
|
457
|
+
|
|
408
458
|
## Try the examples
|
|
409
459
|
|
|
410
|
-
Clone the repository and run the persistent
|
|
460
|
+
Clone the repository and run the persistent streaming chat:
|
|
411
461
|
|
|
412
462
|
~~~console
|
|
413
463
|
git clone https://github.com/grantjenks/python-hyperclass
|
|
414
464
|
cd python-hyperclass
|
|
465
|
+
python -m hyperclass examples.chat:app
|
|
466
|
+
# Flask: flask --app examples.chat_flask run
|
|
467
|
+
~~~
|
|
468
|
+
|
|
469
|
+
The chat demonstrates an AI-style interface without an API key: persistent
|
|
470
|
+
conversations, responsive history, token streaming, stop, and regenerate. Pass
|
|
471
|
+
any `model(prompt) -> iterable[str]` to `create_app` or `ChatRoutes` to replace
|
|
472
|
+
the deterministic local demo model. Its components and routes are shared by
|
|
473
|
+
Lite, Flask, and Django.
|
|
474
|
+
|
|
475
|
+
The SQLite bookmark inbox remains a smaller CRUD example:
|
|
476
|
+
|
|
477
|
+
~~~console
|
|
415
478
|
python -m hyperclass examples.bookmarks:app
|
|
416
|
-
# Flask: flask --app examples.bookmarks_flask run
|
|
417
479
|
~~~
|
|
418
480
|
|
|
419
481
|
The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
|
|
@@ -450,9 +512,9 @@ Run the Python test matrix locally with:
|
|
|
450
512
|
uvx nox -s tests
|
|
451
513
|
~~~
|
|
452
514
|
|
|
453
|
-
The browser contract starts
|
|
454
|
-
on
|
|
455
|
-
through htmx in Chromium:
|
|
515
|
+
The browser contract starts the Lite, Flask, and Django bookmark and chat hosts
|
|
516
|
+
on ephemeral ports. It exercises CRUD plus streamed send, concurrent stop,
|
|
517
|
+
regenerate, and persistence through htmx in Chromium:
|
|
456
518
|
|
|
457
519
|
~~~console
|
|
458
520
|
uvx nox -s browser
|
|
@@ -464,6 +526,7 @@ runtime dependency.
|
|
|
464
526
|
## Status
|
|
465
527
|
|
|
466
528
|
Hyperclass is deliberately pre-alpha: useful enough to build small applications
|
|
467
|
-
and young enough for its API to change. Python 3.10 through 3.14
|
|
529
|
+
and young enough for its API to change. Python 3.10 through 3.14 and the
|
|
530
|
+
cross-host browser contract are tested on every change.
|
|
468
531
|
|
|
469
532
|
Apache-2.0 licensed.
|
|
@@ -62,8 +62,9 @@ from .lite import (
|
|
|
62
62
|
route,
|
|
63
63
|
)
|
|
64
64
|
from .wsgi import delete as delete_route
|
|
65
|
+
from .streaming import EventStream, SSEEvent, event, stream
|
|
65
66
|
|
|
66
|
-
__version__ = "0.
|
|
67
|
+
__version__ = "0.2.0"
|
|
67
68
|
|
|
68
69
|
hidden = "hidden"
|
|
69
70
|
submit = "submit"
|
|
@@ -79,6 +80,7 @@ __all__ = [
|
|
|
79
80
|
"BoundEndpoint",
|
|
80
81
|
"Color",
|
|
81
82
|
"Endpoint",
|
|
83
|
+
"EventStream",
|
|
82
84
|
"Htmx",
|
|
83
85
|
"Length",
|
|
84
86
|
"Media",
|
|
@@ -87,6 +89,7 @@ __all__ = [
|
|
|
87
89
|
"Route",
|
|
88
90
|
"RouteURL",
|
|
89
91
|
"Style",
|
|
92
|
+
"SSEEvent",
|
|
90
93
|
"Target",
|
|
91
94
|
"Unit",
|
|
92
95
|
"Values",
|
|
@@ -99,6 +102,7 @@ __all__ = [
|
|
|
99
102
|
"delete_swap",
|
|
100
103
|
"delete_route",
|
|
101
104
|
"em",
|
|
105
|
+
"event",
|
|
102
106
|
"find",
|
|
103
107
|
"flex",
|
|
104
108
|
"grid",
|
|
@@ -126,6 +130,7 @@ __all__ = [
|
|
|
126
130
|
"rem",
|
|
127
131
|
"route",
|
|
128
132
|
"submit",
|
|
133
|
+
"stream",
|
|
129
134
|
"vh",
|
|
130
135
|
"vw",
|
|
131
136
|
*_html.__all__,
|
|
@@ -8,7 +8,7 @@ from collections.abc import Callable
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
from urllib.parse import urlencode
|
|
10
10
|
|
|
11
|
-
from django.http import HttpRequest, HttpResponse, QueryDict
|
|
11
|
+
from django.http import HttpRequest, HttpResponse, QueryDict, StreamingHttpResponse
|
|
12
12
|
from django.middleware.csrf import get_token
|
|
13
13
|
from django.urls import URLPattern, reverse
|
|
14
14
|
from django.urls import path as django_path
|
|
@@ -17,6 +17,7 @@ from .html import Fragment, Page, element
|
|
|
17
17
|
from .lite import call_handler
|
|
18
18
|
from .rendering import render_result, unpack_result
|
|
19
19
|
from .routing import Endpoint, Handler, Route, RouteURL, class_endpoints, route
|
|
20
|
+
from .streaming import EventStream
|
|
20
21
|
|
|
21
22
|
_BARE_PARAMETER = re.compile(r"<([A-Za-z_]\w*)>")
|
|
22
23
|
|
|
@@ -136,6 +137,17 @@ class App:
|
|
|
136
137
|
return result
|
|
137
138
|
|
|
138
139
|
body, status, headers = unpack_result(result)
|
|
140
|
+
if isinstance(body, EventStream):
|
|
141
|
+
response = StreamingHttpResponse(
|
|
142
|
+
body.iter_bytes(title=self.title, url_resolver=self.resolve_url),
|
|
143
|
+
status=status,
|
|
144
|
+
content_type="text/event-stream; charset=utf-8",
|
|
145
|
+
)
|
|
146
|
+
response["Cache-Control"] = "no-cache"
|
|
147
|
+
response["X-Accel-Buffering"] = "no"
|
|
148
|
+
for name, value in headers:
|
|
149
|
+
response[name] = value
|
|
150
|
+
return response
|
|
139
151
|
if isinstance(body, (element, Fragment, Page)):
|
|
140
152
|
body = render_result(
|
|
141
153
|
body,
|
|
@@ -143,7 +155,7 @@ class App:
|
|
|
143
155
|
is_htmx=request.headers.get("HX-Request", "").lower() == "true",
|
|
144
156
|
url_resolver=self.resolve_url,
|
|
145
157
|
body_attributes={
|
|
146
|
-
"
|
|
158
|
+
"hx-headers:inherited": json.dumps(
|
|
147
159
|
{"X-CSRFToken": get_token(request)}, separators=(",", ":")
|
|
148
160
|
)
|
|
149
161
|
},
|
|
@@ -6,13 +6,14 @@ from collections.abc import Callable
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
from urllib.parse import urlencode
|
|
8
8
|
|
|
9
|
-
from flask import Flask, request, url_for
|
|
9
|
+
from flask import Flask, request, stream_with_context, url_for
|
|
10
10
|
from flask import Response as FlaskResponse
|
|
11
11
|
|
|
12
12
|
from .html import Fragment, Page, element
|
|
13
13
|
from .lite import call_handler
|
|
14
14
|
from .rendering import render_result, unpack_result
|
|
15
15
|
from .routing import Endpoint, Handler, Route, RouteURL, class_endpoints, route
|
|
16
|
+
from .streaming import EventStream
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class App(Flask):
|
|
@@ -106,6 +107,18 @@ class App(Flask):
|
|
|
106
107
|
return super().make_response(rv)
|
|
107
108
|
|
|
108
109
|
body, status, headers = unpack_result(rv)
|
|
110
|
+
if isinstance(body, EventStream):
|
|
111
|
+
response = FlaskResponse(
|
|
112
|
+
stream_with_context(
|
|
113
|
+
body.iter_text(title=self.title, url_resolver=self.resolve_url)
|
|
114
|
+
),
|
|
115
|
+
status=status,
|
|
116
|
+
headers=headers,
|
|
117
|
+
content_type="text/event-stream; charset=utf-8",
|
|
118
|
+
)
|
|
119
|
+
response.headers.setdefault("Cache-Control", "no-cache")
|
|
120
|
+
response.headers.setdefault("X-Accel-Buffering", "no")
|
|
121
|
+
return response
|
|
109
122
|
if isinstance(body, (element, Fragment, Page)):
|
|
110
123
|
body = render_result(
|
|
111
124
|
body,
|
|
@@ -13,6 +13,9 @@ HTMX_SRC = "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0"
|
|
|
13
13
|
HTMX_INTEGRITY = (
|
|
14
14
|
"sha384-BvJpBiO8Kh31EqtJe5DRIeWrHWnCGkwytKs9NKFi86Hhw96dEqdEMzZDeK9iEGTc"
|
|
15
15
|
)
|
|
16
|
+
HTMX_EXTENSIONS = {
|
|
17
|
+
"sse": "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-sse.min.js",
|
|
18
|
+
}
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
def class_name(value: type) -> str:
|
|
@@ -32,6 +35,11 @@ class Id:
|
|
|
32
35
|
def __str__(self) -> str:
|
|
33
36
|
return self.name
|
|
34
37
|
|
|
38
|
+
def __getitem__(self, value: Any) -> Id:
|
|
39
|
+
"""Return an interned child id, e.g. ``id.message[42]``."""
|
|
40
|
+
|
|
41
|
+
return id._intern(f"{self.name}-{str(value).replace('_', '-')}")
|
|
42
|
+
|
|
35
43
|
|
|
36
44
|
class IdNamespace:
|
|
37
45
|
def __init__(self) -> None:
|
|
@@ -40,12 +48,15 @@ class IdNamespace:
|
|
|
40
48
|
def __getattr__(self, name: str) -> Id:
|
|
41
49
|
if name.startswith("_"):
|
|
42
50
|
raise AttributeError(name)
|
|
51
|
+
return self._intern(name.replace("_", "-"))
|
|
52
|
+
|
|
53
|
+
def _intern(self, value: str) -> Id:
|
|
43
54
|
try:
|
|
44
|
-
return self._values[
|
|
55
|
+
return self._values[value]
|
|
45
56
|
except KeyError:
|
|
46
|
-
|
|
47
|
-
self._values[
|
|
48
|
-
return
|
|
57
|
+
identity = Id(value)
|
|
58
|
+
self._values[value] = identity
|
|
59
|
+
return identity
|
|
49
60
|
|
|
50
61
|
|
|
51
62
|
id = IdNamespace()
|
|
@@ -176,6 +187,8 @@ class RenderContext:
|
|
|
176
187
|
self.url_resolver = url_resolver
|
|
177
188
|
self.styled_elements: list[type] = []
|
|
178
189
|
self._seen_elements: set[type] = set()
|
|
190
|
+
self.extensions: list[str] = []
|
|
191
|
+
self._seen_extensions: set[str] = set()
|
|
179
192
|
|
|
180
193
|
def register(self, value: type) -> None:
|
|
181
194
|
if value in self._seen_elements:
|
|
@@ -189,6 +202,12 @@ class RenderContext:
|
|
|
189
202
|
self._seen_elements.add(value)
|
|
190
203
|
self.styled_elements.append(value)
|
|
191
204
|
|
|
205
|
+
def register_extensions(self, values: Iterable[str]) -> None:
|
|
206
|
+
for value in values:
|
|
207
|
+
if value not in self._seen_extensions:
|
|
208
|
+
self._seen_extensions.add(value)
|
|
209
|
+
self.extensions.append(value)
|
|
210
|
+
|
|
192
211
|
def stylesheet(self) -> str:
|
|
193
212
|
rules: list[str] = []
|
|
194
213
|
for element_type in self.styled_elements:
|
|
@@ -229,6 +248,10 @@ def _attribute_value(value: Any, context: RenderContext) -> str:
|
|
|
229
248
|
def _attribute(target: dict[str, Any], name: str, value: Any) -> None:
|
|
230
249
|
if name == "hx" and isinstance(value, Mapping):
|
|
231
250
|
target.update(value)
|
|
251
|
+
extensions = getattr(value, "extensions", ())
|
|
252
|
+
if extensions:
|
|
253
|
+
existing = target.setdefault("_hyperclass_extensions", set())
|
|
254
|
+
existing.update(extensions)
|
|
232
255
|
else:
|
|
233
256
|
target[_attribute_name(name)] = value
|
|
234
257
|
|
|
@@ -279,7 +302,7 @@ Element = element
|
|
|
279
302
|
TAG_NAMES = (
|
|
280
303
|
"html head body title meta link style script main header footer nav section "
|
|
281
304
|
"article "
|
|
282
|
-
"aside div span p a h1 h2 h3 h4 h5 h6 ul ol li dl dt dd form label input "
|
|
305
|
+
"aside div span p a h1 h2 h3 h4 h5 h6 ul ol li dl dt dd form fieldset legend label input "
|
|
283
306
|
"button output textarea select option table thead tbody tfoot tr th td figure "
|
|
284
307
|
"figcaption picture source img video audio canvas template details summary dialog "
|
|
285
308
|
"blockquote pre code strong em small br hr"
|
|
@@ -321,6 +344,7 @@ def _render_attributes(
|
|
|
321
344
|
) -> str:
|
|
322
345
|
attributes = _class_attributes(type(node))
|
|
323
346
|
attributes.update(getattr(node, "_attrs", {}))
|
|
347
|
+
context.register_extensions(attributes.pop("_hyperclass_extensions", ()))
|
|
324
348
|
generated = [class_name(value) for value in classes]
|
|
325
349
|
explicit = attributes.pop("class", attributes.pop("class-", None))
|
|
326
350
|
if explicit:
|
|
@@ -413,6 +437,10 @@ class Page:
|
|
|
413
437
|
f'<script src="{HTMX_SRC}" integrity="{HTMX_INTEGRITY}" '
|
|
414
438
|
'crossorigin="anonymous"></script>'
|
|
415
439
|
)
|
|
440
|
+
script_html += "".join(
|
|
441
|
+
f'<script src="{escape(HTMX_EXTENSIONS[value], quote=True)}"></script>'
|
|
442
|
+
for value in context.extensions
|
|
443
|
+
)
|
|
416
444
|
return (
|
|
417
445
|
"<!doctype html>"
|
|
418
446
|
f'<html lang="{escape(self.lang, quote=True)}"><head>'
|
|
@@ -437,6 +465,7 @@ __all__ = [
|
|
|
437
465
|
"Element",
|
|
438
466
|
"Fragment",
|
|
439
467
|
"HTMX_INTEGRITY",
|
|
468
|
+
"HTMX_EXTENSIONS",
|
|
440
469
|
"HTMX_SRC",
|
|
441
470
|
"Id",
|
|
442
471
|
"IdNamespace",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Iterator, Mapping
|
|
6
|
+
import json
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
from .html import selector
|
|
@@ -33,8 +34,9 @@ def previous(value: Any | None = None) -> Target:
|
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
class Attributes(Mapping[str, Any]):
|
|
36
|
-
def __init__(self, values: Mapping[str, Any]):
|
|
37
|
+
def __init__(self, values: Mapping[str, Any], *, extensions: tuple[str, ...] = ()):
|
|
37
38
|
self.values = dict(values)
|
|
39
|
+
self.extensions = extensions
|
|
38
40
|
|
|
39
41
|
def __getitem__(self, key: str) -> Any:
|
|
40
42
|
return self.values[key]
|
|
@@ -45,13 +47,55 @@ class Attributes(Mapping[str, Any]):
|
|
|
45
47
|
def __len__(self) -> int:
|
|
46
48
|
return len(self.values)
|
|
47
49
|
|
|
50
|
+
def __or__(self, other: Mapping[str, Any]) -> Attributes:
|
|
51
|
+
values = {**self.values, **dict(other)}
|
|
52
|
+
extensions = tuple(
|
|
53
|
+
dict.fromkeys((*self.extensions, *getattr(other, "extensions", ())))
|
|
54
|
+
)
|
|
55
|
+
return Attributes(values, extensions=extensions)
|
|
56
|
+
|
|
57
|
+
def __ror__(self, other: Mapping[str, Any]) -> Attributes:
|
|
58
|
+
return Attributes(other) | self
|
|
59
|
+
|
|
48
60
|
|
|
49
61
|
def _option_name(value: str) -> str:
|
|
50
62
|
return f"hx-{value.rstrip('_').replace('_', '-')}"
|
|
51
63
|
|
|
52
64
|
|
|
65
|
+
class HtmxAttribute:
|
|
66
|
+
def __init__(self, *parts: str):
|
|
67
|
+
self.parts = parts
|
|
68
|
+
|
|
69
|
+
def __getattr__(self, name: str) -> HtmxAttribute:
|
|
70
|
+
if name.startswith("_"):
|
|
71
|
+
raise AttributeError(name)
|
|
72
|
+
return HtmxAttribute(*self.parts, name)
|
|
73
|
+
|
|
74
|
+
def __call__(self, value: Any = True) -> Attributes:
|
|
75
|
+
root, *modifiers = self.parts
|
|
76
|
+
extension = ("sse",) if root == "sse" else ()
|
|
77
|
+
if root == "on":
|
|
78
|
+
suffix = ":".join(part.replace("_", ":") for part in modifiers)
|
|
79
|
+
attribute = f"hx-on::{suffix}"
|
|
80
|
+
else:
|
|
81
|
+
attribute = _option_name(root)
|
|
82
|
+
if modifiers:
|
|
83
|
+
attribute += ":" + ":".join(
|
|
84
|
+
part.rstrip("_").replace("_", "-") for part in modifiers
|
|
85
|
+
)
|
|
86
|
+
if isinstance(value, Mapping):
|
|
87
|
+
value = json.dumps(value, separators=(",", ":"))
|
|
88
|
+
return Attributes({attribute: value}, extensions=extension)
|
|
89
|
+
|
|
90
|
+
|
|
53
91
|
class Htmx:
|
|
92
|
+
def __getattr__(self, name: str) -> HtmxAttribute:
|
|
93
|
+
if name.startswith("_"):
|
|
94
|
+
raise AttributeError(name)
|
|
95
|
+
return HtmxAttribute(name)
|
|
96
|
+
|
|
54
97
|
def request(self, method: str, url: Any, **options: Any) -> Attributes:
|
|
98
|
+
stream = bool(options.pop("stream", False))
|
|
55
99
|
route_url = getattr(url, "url", None)
|
|
56
100
|
route_parameters = getattr(url, "parameters", None)
|
|
57
101
|
if callable(route_url) and callable(route_parameters):
|
|
@@ -66,10 +110,17 @@ class Htmx:
|
|
|
66
110
|
for name, value in options.items():
|
|
67
111
|
if value is None:
|
|
68
112
|
continue
|
|
69
|
-
if name in {
|
|
113
|
+
if name in {
|
|
114
|
+
"disable",
|
|
115
|
+
"include",
|
|
116
|
+
"target",
|
|
117
|
+
"select",
|
|
118
|
+
"select_oob",
|
|
119
|
+
"sync",
|
|
120
|
+
}:
|
|
70
121
|
value = selector(value) if not isinstance(value, Target) else value
|
|
71
122
|
values[_option_name(name)] = value
|
|
72
|
-
return Attributes(values)
|
|
123
|
+
return Attributes(values, extensions=("sse",) if stream else ())
|
|
73
124
|
|
|
74
125
|
def get(self, url: Any, **options: Any) -> Attributes:
|
|
75
126
|
return self.request("get", url, **options)
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import inspect
|
|
6
|
-
from collections.abc import Callable, Mapping
|
|
6
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
7
7
|
from dataclasses import dataclass, is_dataclass
|
|
8
8
|
from http import HTTPStatus
|
|
9
|
+
from socketserver import ThreadingMixIn
|
|
9
10
|
from typing import Any, get_type_hints
|
|
10
11
|
from urllib.parse import parse_qs
|
|
11
|
-
from wsgiref.simple_server import make_server
|
|
12
|
+
from wsgiref.simple_server import WSGIServer, make_server
|
|
12
13
|
|
|
13
14
|
from .binding import Values, bind
|
|
14
15
|
from .rendering import render_result, unpack_result
|
|
@@ -26,10 +27,17 @@ from .routing import (
|
|
|
26
27
|
put,
|
|
27
28
|
route,
|
|
28
29
|
)
|
|
30
|
+
from .streaming import EventStream
|
|
29
31
|
|
|
30
32
|
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
|
31
33
|
|
|
32
34
|
|
|
35
|
+
class ThreadingWSGIServer(ThreadingMixIn, WSGIServer):
|
|
36
|
+
"""The standard WSGI server with concurrent request handling."""
|
|
37
|
+
|
|
38
|
+
daemon_threads = True
|
|
39
|
+
|
|
40
|
+
|
|
33
41
|
class Request:
|
|
34
42
|
def __init__(self, environ: Mapping[str, Any]):
|
|
35
43
|
self.environ = environ
|
|
@@ -146,7 +154,7 @@ class App:
|
|
|
146
154
|
|
|
147
155
|
def __call__(
|
|
148
156
|
self, environ: Mapping[str, Any], start_response: StartResponse
|
|
149
|
-
) ->
|
|
157
|
+
) -> Iterable[bytes]:
|
|
150
158
|
request = Request(environ)
|
|
151
159
|
handler = self.routes.get((request.method, request.path))
|
|
152
160
|
parameters: dict[str, Any] = {}
|
|
@@ -179,7 +187,21 @@ class App:
|
|
|
179
187
|
|
|
180
188
|
def _respond(
|
|
181
189
|
self, start_response: StartResponse, response: Response, request: Request
|
|
182
|
-
) ->
|
|
190
|
+
) -> Iterable[bytes]:
|
|
191
|
+
if isinstance(response.body, EventStream):
|
|
192
|
+
headers = [
|
|
193
|
+
("Content-Type", "text/event-stream; charset=utf-8"),
|
|
194
|
+
("Cache-Control", "no-cache"),
|
|
195
|
+
("X-Accel-Buffering", "no"),
|
|
196
|
+
]
|
|
197
|
+
headers.extend(response.headers)
|
|
198
|
+
start_response(
|
|
199
|
+
f"{response.status} {HTTPStatus(response.status).phrase}", headers
|
|
200
|
+
)
|
|
201
|
+
return response.body.iter_bytes(
|
|
202
|
+
title=self.title,
|
|
203
|
+
url_resolver=self.resolve_url,
|
|
204
|
+
)
|
|
183
205
|
text = render_result(
|
|
184
206
|
response.body,
|
|
185
207
|
title=self.title,
|
|
@@ -205,7 +227,12 @@ class App:
|
|
|
205
227
|
)
|
|
206
228
|
|
|
207
229
|
def run(self, host: str = "127.0.0.1", port: int = 8000) -> None:
|
|
208
|
-
with make_server(
|
|
230
|
+
with make_server(
|
|
231
|
+
host,
|
|
232
|
+
port,
|
|
233
|
+
self,
|
|
234
|
+
server_class=ThreadingWSGIServer,
|
|
235
|
+
) as server:
|
|
209
236
|
print(f"Serving on http://{host}:{port}")
|
|
210
237
|
server.serve_forever()
|
|
211
238
|
|