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.
Files changed (35) hide show
  1. {hyperclass-0.1.0 → hyperclass-0.2.0}/PKG-INFO +72 -9
  2. {hyperclass-0.1.0 → hyperclass-0.2.0}/README.md +71 -8
  3. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/__init__.py +6 -1
  4. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/django.py +14 -2
  5. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/flask.py +14 -1
  6. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/html.py +34 -5
  7. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/htmx.py +54 -3
  8. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/lite.py +32 -5
  9. hyperclass-0.2.0/hyperclass/streaming.py +113 -0
  10. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/PKG-INFO +72 -9
  11. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/SOURCES.txt +3 -0
  12. {hyperclass-0.1.0 → hyperclass-0.2.0}/pyproject.toml +1 -1
  13. hyperclass-0.2.0/tests/test_chat.py +112 -0
  14. {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_django.py +15 -2
  15. {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_flask.py +29 -1
  16. {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_html.py +8 -0
  17. hyperclass-0.2.0/tests/test_htmx.py +64 -0
  18. hyperclass-0.2.0/tests/test_package.py +5 -0
  19. hyperclass-0.2.0/tests/test_streaming.py +48 -0
  20. hyperclass-0.1.0/tests/test_htmx.py +0 -42
  21. hyperclass-0.1.0/tests/test_package.py +0 -5
  22. {hyperclass-0.1.0 → hyperclass-0.2.0}/LICENSE +0 -0
  23. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/__main__.py +0 -0
  24. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/binding.py +0 -0
  25. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/css.py +0 -0
  26. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/rendering.py +0 -0
  27. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/routing.py +0 -0
  28. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass/wsgi.py +0 -0
  29. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/dependency_links.txt +0 -0
  30. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/requires.txt +0 -0
  31. {hyperclass-0.1.0 → hyperclass-0.2.0}/hyperclass.egg-info/top_level.txt +0 -0
  32. {hyperclass-0.1.0 → hyperclass-0.2.0}/setup.cfg +0 -0
  33. {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_bookmarks.py +0 -0
  34. {hyperclass-0.1.0 → hyperclass-0.2.0}/tests/test_cli.py +0 -0
  35. {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.1.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 inherit an htmx `X-CSRFToken` header and request a Django
431
- CSRF cookie, so unsafe htmx requests work with `CsrfViewMiddleware` enabled.
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 SQLite bookmark inbox:
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 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:
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 are tested.
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 inherit an htmx `X-CSRFToken` header and request a Django
395
- CSRF cookie, so unsafe htmx requests work with `CsrfViewMiddleware` enabled.
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 SQLite bookmark inbox:
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 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:
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 are tested.
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.1.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
- "hx_headers": json.dumps(
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[name]
55
+ return self._values[value]
45
56
  except KeyError:
46
- value = Id(name.replace("_", "-"))
47
- self._values[name] = value
48
- return value
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 {"include", "target", "select", "select_oob", "sync"}:
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
- ) -> list[bytes]:
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
- ) -> list[bytes]:
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(host, port, self) as 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