hyperclass 0.1.1__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.1 → hyperclass-0.2.0}/PKG-INFO +69 -7
- {hyperclass-0.1.1 → hyperclass-0.2.0}/README.md +68 -6
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/__init__.py +6 -1
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/django.py +13 -1
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/flask.py +14 -1
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/html.py +34 -5
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/htmx.py +54 -3
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/lite.py +32 -5
- hyperclass-0.2.0/hyperclass/streaming.py +113 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/PKG-INFO +69 -7
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/SOURCES.txt +3 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/pyproject.toml +1 -1
- hyperclass-0.2.0/tests/test_chat.py +112 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/tests/test_django.py +14 -1
- {hyperclass-0.1.1 → hyperclass-0.2.0}/tests/test_flask.py +14 -1
- {hyperclass-0.1.1 → 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.1/tests/test_htmx.py +0 -42
- hyperclass-0.1.1/tests/test_package.py +0 -5
- {hyperclass-0.1.1 → hyperclass-0.2.0}/LICENSE +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/__main__.py +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/binding.py +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/css.py +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/rendering.py +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/routing.py +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/wsgi.py +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/dependency_links.txt +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/requires.txt +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/top_level.txt +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/setup.cfg +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/tests/test_bookmarks.py +0 -0
- {hyperclass-0.1.1 → hyperclass-0.2.0}/tests/test_cli.py +0 -0
- {hyperclass-0.1.1 → 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
|
|
@@ -442,15 +447,71 @@ page, Hyperclass includes its CSS in a partial targeting the page's stable
|
|
|
442
447
|
`hyperclass-styles` stylesheet. The new fragment is styled immediately, without
|
|
443
448
|
a reload or a global CSS build.
|
|
444
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
|
+
|
|
445
494
|
## Try the examples
|
|
446
495
|
|
|
447
|
-
Clone the repository and run the persistent
|
|
496
|
+
Clone the repository and run the persistent streaming chat:
|
|
448
497
|
|
|
449
498
|
~~~console
|
|
450
499
|
git clone https://github.com/grantjenks/python-hyperclass
|
|
451
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
|
|
452
514
|
python -m hyperclass examples.bookmarks:app
|
|
453
|
-
# Flask: flask --app examples.bookmarks_flask run
|
|
454
515
|
~~~
|
|
455
516
|
|
|
456
517
|
The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
|
|
@@ -487,9 +548,9 @@ Run the Python test matrix locally with:
|
|
|
487
548
|
uvx nox -s tests
|
|
488
549
|
~~~
|
|
489
550
|
|
|
490
|
-
The browser contract starts
|
|
491
|
-
on
|
|
492
|
-
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:
|
|
493
554
|
|
|
494
555
|
~~~console
|
|
495
556
|
uvx nox -s browser
|
|
@@ -501,6 +562,7 @@ runtime dependency.
|
|
|
501
562
|
## Status
|
|
502
563
|
|
|
503
564
|
Hyperclass is deliberately pre-alpha: useful enough to build small applications
|
|
504
|
-
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.
|
|
505
567
|
|
|
506
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
|
|
@@ -406,15 +411,71 @@ page, Hyperclass includes its CSS in a partial targeting the page's stable
|
|
|
406
411
|
`hyperclass-styles` stylesheet. The new fragment is styled immediately, without
|
|
407
412
|
a reload or a global CSS build.
|
|
408
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
|
+
|
|
409
458
|
## Try the examples
|
|
410
459
|
|
|
411
|
-
Clone the repository and run the persistent
|
|
460
|
+
Clone the repository and run the persistent streaming chat:
|
|
412
461
|
|
|
413
462
|
~~~console
|
|
414
463
|
git clone https://github.com/grantjenks/python-hyperclass
|
|
415
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
|
|
416
478
|
python -m hyperclass examples.bookmarks:app
|
|
417
|
-
# Flask: flask --app examples.bookmarks_flask run
|
|
418
479
|
~~~
|
|
419
480
|
|
|
420
481
|
The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
|
|
@@ -451,9 +512,9 @@ Run the Python test matrix locally with:
|
|
|
451
512
|
uvx nox -s tests
|
|
452
513
|
~~~
|
|
453
514
|
|
|
454
|
-
The browser contract starts
|
|
455
|
-
on
|
|
456
|
-
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:
|
|
457
518
|
|
|
458
519
|
~~~console
|
|
459
520
|
uvx nox -s browser
|
|
@@ -465,6 +526,7 @@ runtime dependency.
|
|
|
465
526
|
## Status
|
|
466
527
|
|
|
467
528
|
Hyperclass is deliberately pre-alpha: useful enough to build small applications
|
|
468
|
-
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.
|
|
469
531
|
|
|
470
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,
|
|
@@ -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
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Host-neutral server-sent event responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .html import (
|
|
10
|
+
Fragment,
|
|
11
|
+
Markup,
|
|
12
|
+
Page,
|
|
13
|
+
RenderContext,
|
|
14
|
+
element,
|
|
15
|
+
id as html_id,
|
|
16
|
+
markup,
|
|
17
|
+
partial,
|
|
18
|
+
render,
|
|
19
|
+
)
|
|
20
|
+
from .rendering import render_result
|
|
21
|
+
from .routing import URLResolver
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class SSEEvent:
|
|
26
|
+
"""One server-sent event containing a renderable Hyperclass value."""
|
|
27
|
+
|
|
28
|
+
content: Any
|
|
29
|
+
name: str | None = None
|
|
30
|
+
id: str | None = None
|
|
31
|
+
retry: int | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def event(
|
|
35
|
+
content: Any,
|
|
36
|
+
*,
|
|
37
|
+
name: str | None = None,
|
|
38
|
+
id: str | None = None,
|
|
39
|
+
retry: int | None = None,
|
|
40
|
+
) -> SSEEvent:
|
|
41
|
+
return SSEEvent(content, name=name, id=id, retry=retry)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EventStream:
|
|
45
|
+
"""An iterable response rendered as ``text/event-stream``."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, events: Iterable[Any]):
|
|
48
|
+
self.events = events
|
|
49
|
+
|
|
50
|
+
def iter_text(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
title: str = "Hyperclass",
|
|
54
|
+
url_resolver: URLResolver | None = None,
|
|
55
|
+
) -> Iterator[str]:
|
|
56
|
+
context = RenderContext(url_resolver=url_resolver)
|
|
57
|
+
emitted_styles = ""
|
|
58
|
+
for value in self.events:
|
|
59
|
+
current = value if isinstance(value, SSEEvent) else SSEEvent(value)
|
|
60
|
+
content = current.content
|
|
61
|
+
if isinstance(content, Page):
|
|
62
|
+
text = render_result(
|
|
63
|
+
content,
|
|
64
|
+
title=title,
|
|
65
|
+
is_htmx=True,
|
|
66
|
+
url_resolver=url_resolver,
|
|
67
|
+
)
|
|
68
|
+
elif isinstance(content, (element, Fragment, Markup)):
|
|
69
|
+
text = render(content, context=context)
|
|
70
|
+
stylesheet = context.stylesheet()
|
|
71
|
+
if stylesheet.startswith(emitted_styles):
|
|
72
|
+
new_styles = stylesheet[len(emitted_styles) :]
|
|
73
|
+
else:
|
|
74
|
+
new_styles = stylesheet
|
|
75
|
+
emitted_styles = stylesheet
|
|
76
|
+
if new_styles:
|
|
77
|
+
text += render(
|
|
78
|
+
partial(
|
|
79
|
+
markup(new_styles),
|
|
80
|
+
id=html_id.hyperclass_styles,
|
|
81
|
+
hx_swap="append",
|
|
82
|
+
),
|
|
83
|
+
context=context,
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
text = render(content)
|
|
87
|
+
fields: list[str] = []
|
|
88
|
+
if current.name is not None:
|
|
89
|
+
fields.append(f"event: {current.name}")
|
|
90
|
+
if current.id is not None:
|
|
91
|
+
fields.append(f"id: {current.id}")
|
|
92
|
+
if current.retry is not None:
|
|
93
|
+
fields.append(f"retry: {current.retry}")
|
|
94
|
+
fields.extend(f"data: {line}" for line in text.splitlines() or [""])
|
|
95
|
+
yield "\n".join(fields) + "\n\n"
|
|
96
|
+
|
|
97
|
+
def iter_bytes(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
title: str = "Hyperclass",
|
|
101
|
+
url_resolver: URLResolver | None = None,
|
|
102
|
+
) -> Iterator[bytes]:
|
|
103
|
+
for value in self.iter_text(title=title, url_resolver=url_resolver):
|
|
104
|
+
yield value.encode("utf-8")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def stream(events: Iterable[Any]) -> EventStream:
|
|
108
|
+
"""Create a streaming response from renderable values or ``event`` objects."""
|
|
109
|
+
|
|
110
|
+
return EventStream(events)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = ["EventStream", "SSEEvent", "event", "stream"]
|
|
@@ -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
|
|
@@ -442,15 +447,71 @@ page, Hyperclass includes its CSS in a partial targeting the page's stable
|
|
|
442
447
|
`hyperclass-styles` stylesheet. The new fragment is styled immediately, without
|
|
443
448
|
a reload or a global CSS build.
|
|
444
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
|
+
|
|
445
494
|
## Try the examples
|
|
446
495
|
|
|
447
|
-
Clone the repository and run the persistent
|
|
496
|
+
Clone the repository and run the persistent streaming chat:
|
|
448
497
|
|
|
449
498
|
~~~console
|
|
450
499
|
git clone https://github.com/grantjenks/python-hyperclass
|
|
451
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
|
|
452
514
|
python -m hyperclass examples.bookmarks:app
|
|
453
|
-
# Flask: flask --app examples.bookmarks_flask run
|
|
454
515
|
~~~
|
|
455
516
|
|
|
456
517
|
The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
|
|
@@ -487,9 +548,9 @@ Run the Python test matrix locally with:
|
|
|
487
548
|
uvx nox -s tests
|
|
488
549
|
~~~
|
|
489
550
|
|
|
490
|
-
The browser contract starts
|
|
491
|
-
on
|
|
492
|
-
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:
|
|
493
554
|
|
|
494
555
|
~~~console
|
|
495
556
|
uvx nox -s browser
|
|
@@ -501,6 +562,7 @@ runtime dependency.
|
|
|
501
562
|
## Status
|
|
502
563
|
|
|
503
564
|
Hyperclass is deliberately pre-alpha: useful enough to build small applications
|
|
504
|
-
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.
|
|
505
567
|
|
|
506
568
|
Apache-2.0 licensed.
|
|
@@ -12,6 +12,7 @@ hyperclass/htmx.py
|
|
|
12
12
|
hyperclass/lite.py
|
|
13
13
|
hyperclass/rendering.py
|
|
14
14
|
hyperclass/routing.py
|
|
15
|
+
hyperclass/streaming.py
|
|
15
16
|
hyperclass/wsgi.py
|
|
16
17
|
hyperclass.egg-info/PKG-INFO
|
|
17
18
|
hyperclass.egg-info/SOURCES.txt
|
|
@@ -19,10 +20,12 @@ hyperclass.egg-info/dependency_links.txt
|
|
|
19
20
|
hyperclass.egg-info/requires.txt
|
|
20
21
|
hyperclass.egg-info/top_level.txt
|
|
21
22
|
tests/test_bookmarks.py
|
|
23
|
+
tests/test_chat.py
|
|
22
24
|
tests/test_cli.py
|
|
23
25
|
tests/test_django.py
|
|
24
26
|
tests/test_flask.py
|
|
25
27
|
tests/test_html.py
|
|
26
28
|
tests/test_htmx.py
|
|
27
29
|
tests/test_package.py
|
|
30
|
+
tests/test_streaming.py
|
|
28
31
|
tests/test_wsgi.py
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
from urllib.parse import urlencode
|
|
3
|
+
from wsgiref.util import setup_testing_defaults
|
|
4
|
+
|
|
5
|
+
from examples.chat import create_app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def request(app, method="GET", path="/", data=None, htmx=False):
|
|
9
|
+
environ = {}
|
|
10
|
+
setup_testing_defaults(environ)
|
|
11
|
+
body = urlencode(data or {}).encode()
|
|
12
|
+
environ.update(
|
|
13
|
+
REQUEST_METHOD=method,
|
|
14
|
+
PATH_INFO=path,
|
|
15
|
+
CONTENT_TYPE="application/x-www-form-urlencoded",
|
|
16
|
+
CONTENT_LENGTH=str(len(body)),
|
|
17
|
+
**{"wsgi.input": BytesIO(body)},
|
|
18
|
+
)
|
|
19
|
+
if htmx:
|
|
20
|
+
environ["HTTP_HX_REQUEST"] = "true"
|
|
21
|
+
captured = {}
|
|
22
|
+
|
|
23
|
+
def start_response(status, headers):
|
|
24
|
+
captured["status"] = status
|
|
25
|
+
captured["headers"] = dict(headers)
|
|
26
|
+
|
|
27
|
+
payload = b"".join(app(environ, start_response)).decode()
|
|
28
|
+
return captured, payload
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_chat_page_uses_indexed_ids_routes_and_sse_extension(tmp_path):
|
|
32
|
+
app = create_app(tmp_path / "chat.db", model=lambda prompt: ["Hello"], token_delay=0)
|
|
33
|
+
captured, payload = request(app)
|
|
34
|
+
conversation = app.store.latest_or_create()
|
|
35
|
+
|
|
36
|
+
assert captured["status"] == "200 OK"
|
|
37
|
+
assert "dist/ext/hx-sse.min.js" in payload
|
|
38
|
+
assert f'hx-post="/chats/{conversation.id}/messages"' in payload
|
|
39
|
+
assert 'hx-disable="find .composer-fields"' in payload
|
|
40
|
+
assert 'hx-on::before:request="this.reset()"' in payload
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_chat_stream_persists_and_targets_each_message(tmp_path):
|
|
44
|
+
app = create_app(
|
|
45
|
+
tmp_path / "chat.db",
|
|
46
|
+
model=lambda prompt: ["Hello ", prompt],
|
|
47
|
+
token_delay=0,
|
|
48
|
+
)
|
|
49
|
+
conversation = app.store.latest_or_create()
|
|
50
|
+
captured, payload = request(
|
|
51
|
+
app,
|
|
52
|
+
"POST",
|
|
53
|
+
f"/chats/{conversation.id}/messages",
|
|
54
|
+
{"message": "Python"},
|
|
55
|
+
htmx=True,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert captured["headers"]["Content-Type"] == "text/event-stream; charset=utf-8"
|
|
59
|
+
assert f'hx-target="#messages" hx-swap="append"' in payload
|
|
60
|
+
messages = app.store.messages(conversation.id)
|
|
61
|
+
assert [message.role for message in messages] == ["user", "assistant"]
|
|
62
|
+
assistant = messages[-1]
|
|
63
|
+
assert assistant.content == "Hello Python"
|
|
64
|
+
assert assistant.status == "complete"
|
|
65
|
+
assert f'id="message-{assistant.id}"' in payload
|
|
66
|
+
assert f'hx-target="#message-{assistant.id}" hx-swap="outerMorph"' in payload
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_chat_accepts_an_injected_model_and_regenerates(tmp_path):
|
|
70
|
+
prompts = []
|
|
71
|
+
|
|
72
|
+
def model(prompt):
|
|
73
|
+
prompts.append(prompt)
|
|
74
|
+
yield "Replied"
|
|
75
|
+
|
|
76
|
+
app = create_app(tmp_path / "chat.db", model=model, token_delay=0)
|
|
77
|
+
conversation = app.store.latest_or_create()
|
|
78
|
+
request(
|
|
79
|
+
app,
|
|
80
|
+
"POST",
|
|
81
|
+
f"/chats/{conversation.id}/messages",
|
|
82
|
+
{"message": "Again"},
|
|
83
|
+
htmx=True,
|
|
84
|
+
)
|
|
85
|
+
assistant = app.store.messages(conversation.id)[-1]
|
|
86
|
+
captured, payload = request(
|
|
87
|
+
app,
|
|
88
|
+
"POST",
|
|
89
|
+
f"/messages/{assistant.id}/regenerate",
|
|
90
|
+
htmx=True,
|
|
91
|
+
)
|
|
92
|
+
assert captured["status"] == "200 OK"
|
|
93
|
+
assert "Replied" in payload
|
|
94
|
+
assert prompts == ["Again", "Again"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_superseded_generator_cannot_write_into_a_regeneration(tmp_path):
|
|
98
|
+
app = create_app(tmp_path / "chat.db", token_delay=0)
|
|
99
|
+
conversation = app.store.latest_or_create()
|
|
100
|
+
assistant = app.store.add_message(
|
|
101
|
+
conversation.id, "assistant", "old", "streaming", generation=1
|
|
102
|
+
)
|
|
103
|
+
app.store.stop(assistant.id)
|
|
104
|
+
restarted = app.store.restart(assistant.id)
|
|
105
|
+
|
|
106
|
+
assert restarted.generation == 2
|
|
107
|
+
assert app.store.update_generation(
|
|
108
|
+
assistant.id, generation=1, content="stale write", status="streaming"
|
|
109
|
+
) is None
|
|
110
|
+
current = app.store.message(assistant.id)
|
|
111
|
+
assert current.content == ""
|
|
112
|
+
assert current.status == "streaming"
|
|
@@ -21,7 +21,7 @@ from django.http import HttpRequest, HttpResponse
|
|
|
21
21
|
from django.test import Client
|
|
22
22
|
from django.urls import clear_url_caches, include, path
|
|
23
23
|
|
|
24
|
-
from hyperclass import div, form, get, hx, post
|
|
24
|
+
from hyperclass import div, form, get, hx, post, stream
|
|
25
25
|
from hyperclass.django import App
|
|
26
26
|
|
|
27
27
|
|
|
@@ -95,3 +95,16 @@ def test_django_dispatches_methods_on_one_url_pattern():
|
|
|
95
95
|
client = mounted(DjangoRoutes(namespace="methods"))
|
|
96
96
|
response = client.get("/mounted/items/3")
|
|
97
97
|
assert response.status_code == 405
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_django_streams_server_sent_events():
|
|
101
|
+
class Events(App):
|
|
102
|
+
@get("/events")
|
|
103
|
+
def events(self, request):
|
|
104
|
+
return stream([div("one"), div("two")])
|
|
105
|
+
|
|
106
|
+
response = mounted(Events(namespace="events")).get("/mounted/events")
|
|
107
|
+
assert response["Content-Type"] == "text/event-stream; charset=utf-8"
|
|
108
|
+
assert b"".join(response.streaming_content) == (
|
|
109
|
+
b"data: <div>one</div>\n\ndata: <div>two</div>\n\n"
|
|
110
|
+
)
|
|
@@ -2,7 +2,7 @@ from dataclasses import dataclass
|
|
|
2
2
|
|
|
3
3
|
from flask import Request, Response
|
|
4
4
|
|
|
5
|
-
from hyperclass import div, form, get, hx, post, render
|
|
5
|
+
from hyperclass import div, form, get, hx, post, render, stream
|
|
6
6
|
from hyperclass.flask import App
|
|
7
7
|
from examples.bookmarks import BookmarkRoutes
|
|
8
8
|
|
|
@@ -83,3 +83,16 @@ def test_flask_bookmark_routes_read_native_query_keys(tmp_path):
|
|
|
83
83
|
)
|
|
84
84
|
assert response.status_code == 200
|
|
85
85
|
assert "No bookmarks matching "missing"." in response.text
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_flask_streams_server_sent_events():
|
|
89
|
+
app = App()
|
|
90
|
+
|
|
91
|
+
@app.get("/events")
|
|
92
|
+
def events(request):
|
|
93
|
+
return stream([div("one"), div("two")])
|
|
94
|
+
|
|
95
|
+
response = app.test_client().get("/events")
|
|
96
|
+
assert response.content_type == "text/event-stream; charset=utf-8"
|
|
97
|
+
assert response.text.startswith("data: <div>one</div>\n\n")
|
|
98
|
+
assert response.text.endswith("data: <div>two</div>\n\n")
|
|
@@ -101,6 +101,14 @@ def test_ids_are_lazy_interned_python_references():
|
|
|
101
101
|
)
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
def test_ids_can_be_parameterized_with_python_values():
|
|
105
|
+
assert id.message[42] is id.message[42]
|
|
106
|
+
assert id.message["draft_reply"] is id.message["draft_reply"]
|
|
107
|
+
assert str(id.message[42]) == "message-42"
|
|
108
|
+
assert id.message[42].selector == "#message-42"
|
|
109
|
+
assert str(id.message["draft_reply"]) == "message-draft-reply"
|
|
110
|
+
|
|
111
|
+
|
|
104
112
|
def test_page_collects_state_and_media_styles_from_classes():
|
|
105
113
|
class interactive_card(card):
|
|
106
114
|
hover = css(background="lavender")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from hyperclass import Page, closest, div, find, hx, id, name, outer_morph, render
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class counter(div):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_htmx_request_attributes_accept_class_selectors():
|
|
9
|
+
value = div(
|
|
10
|
+
"Update",
|
|
11
|
+
hx=hx.post(
|
|
12
|
+
"/counter",
|
|
13
|
+
target=closest(counter),
|
|
14
|
+
swap=outer_morph,
|
|
15
|
+
),
|
|
16
|
+
)
|
|
17
|
+
assert render(value) == (
|
|
18
|
+
'<div hx-post="/counter" hx-target="closest .counter" '
|
|
19
|
+
'hx-swap="outerMorph">Update</div>'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_plain_class_target_becomes_a_selector():
|
|
24
|
+
assert render(div(hx=hx.get("/counter", target=counter))) == (
|
|
25
|
+
'<div hx-get="/counter" hx-target=".counter"></div>'
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_class_attributes_can_declare_htmx_and_name_selectors():
|
|
30
|
+
class live_search(div):
|
|
31
|
+
hx = hx.get(
|
|
32
|
+
"/search",
|
|
33
|
+
include=name.query,
|
|
34
|
+
target=id.results,
|
|
35
|
+
swap=outer_morph,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assert render(live_search()) == (
|
|
39
|
+
'<div class="live-search" hx-get="/search" '
|
|
40
|
+
'hx-include="[name="query"]" hx-target="#results" '
|
|
41
|
+
'hx-swap="outerMorph"></div>'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_htmx_attributes_compose_and_support_modifiers():
|
|
46
|
+
attributes = (
|
|
47
|
+
hx.post("/messages", target=id.messages, stream=True, disable=find("fieldset"))
|
|
48
|
+
| hx.on.before_request("this.reset()")
|
|
49
|
+
| hx.headers.inherited({"X-CSRFToken": "token"})
|
|
50
|
+
)
|
|
51
|
+
document = Page(div(hx=attributes)).render()
|
|
52
|
+
assert 'hx-post="/messages"' in document
|
|
53
|
+
assert 'hx-target="#messages"' in document
|
|
54
|
+
assert 'hx-disable="find fieldset"' in document
|
|
55
|
+
assert 'hx-on::before:request="this.reset()"' in document
|
|
56
|
+
assert 'hx-headers:inherited="{"X-CSRFToken":"token"}"' in document
|
|
57
|
+
assert "dist/ext/hx-sse.min.js" in document
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_htmx_sse_connect_loads_extension_without_a_fake_html_attribute():
|
|
61
|
+
document = Page(div(hx=hx.sse.connect("/events"))).render()
|
|
62
|
+
assert 'hx-sse:connect="/events"' in document
|
|
63
|
+
assert "dist/ext/hx-sse.min.js" in document
|
|
64
|
+
assert "_hyperclass" not in document
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
|
|
3
|
+
from hyperclass import App, div, event, id, partial, post, stream
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_event_stream_escapes_text_and_renders_hyperclass_partials():
|
|
7
|
+
response = stream(
|
|
8
|
+
[
|
|
9
|
+
"<thinking>",
|
|
10
|
+
event(partial(div("Hello", id=id.message[7]), id=id.message[7]), name="done"),
|
|
11
|
+
]
|
|
12
|
+
)
|
|
13
|
+
assert list(response.iter_text()) == [
|
|
14
|
+
"data: <thinking>\n\n",
|
|
15
|
+
"event: done\ndata: <hx-partial id=\"message-7\"><div id=\"message-7\">Hello</div></hx-partial>\n\n",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_lite_serves_streaming_responses_without_content_length():
|
|
20
|
+
class StreamingApp(App):
|
|
21
|
+
@post("/reply")
|
|
22
|
+
def reply(self, request):
|
|
23
|
+
return stream([div("one"), event(div("two"), name="done")])
|
|
24
|
+
|
|
25
|
+
statuses = []
|
|
26
|
+
headers = []
|
|
27
|
+
|
|
28
|
+
def start_response(status, values):
|
|
29
|
+
statuses.append(status)
|
|
30
|
+
headers.extend(values)
|
|
31
|
+
|
|
32
|
+
environ = {
|
|
33
|
+
"REQUEST_METHOD": "POST",
|
|
34
|
+
"PATH_INFO": "/reply",
|
|
35
|
+
"QUERY_STRING": "",
|
|
36
|
+
"CONTENT_TYPE": "application/x-www-form-urlencoded",
|
|
37
|
+
"CONTENT_LENGTH": "0",
|
|
38
|
+
"wsgi.input": BytesIO(),
|
|
39
|
+
"HTTP_HX_REQUEST": "true",
|
|
40
|
+
}
|
|
41
|
+
chunks = list(StreamingApp()(environ, start_response))
|
|
42
|
+
assert statuses == ["200 OK"]
|
|
43
|
+
assert ("Content-Type", "text/event-stream; charset=utf-8") in headers
|
|
44
|
+
assert not any(name == "Content-Length" for name, _ in headers)
|
|
45
|
+
assert chunks == [
|
|
46
|
+
b"data: <div>one</div>\n\n",
|
|
47
|
+
b"event: done\ndata: <div>two</div>\n\n",
|
|
48
|
+
]
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
from hyperclass import closest, div, hx, id, name, outer_morph, render
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class counter(div):
|
|
5
|
-
pass
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_htmx_request_attributes_accept_class_selectors():
|
|
9
|
-
value = div(
|
|
10
|
-
"Update",
|
|
11
|
-
hx=hx.post(
|
|
12
|
-
"/counter",
|
|
13
|
-
target=closest(counter),
|
|
14
|
-
swap=outer_morph,
|
|
15
|
-
),
|
|
16
|
-
)
|
|
17
|
-
assert render(value) == (
|
|
18
|
-
'<div hx-post="/counter" hx-target="closest .counter" '
|
|
19
|
-
'hx-swap="outerMorph">Update</div>'
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_plain_class_target_becomes_a_selector():
|
|
24
|
-
assert render(div(hx=hx.get("/counter", target=counter))) == (
|
|
25
|
-
'<div hx-get="/counter" hx-target=".counter"></div>'
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_class_attributes_can_declare_htmx_and_name_selectors():
|
|
30
|
-
class live_search(div):
|
|
31
|
-
hx = hx.get(
|
|
32
|
-
"/search",
|
|
33
|
-
include=name.query,
|
|
34
|
-
target=id.results,
|
|
35
|
-
swap=outer_morph,
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
assert render(live_search()) == (
|
|
39
|
-
'<div class="live-search" hx-get="/search" '
|
|
40
|
-
'hx-include="[name="query"]" hx-target="#results" '
|
|
41
|
-
'hx-swap="outerMorph"></div>'
|
|
42
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|