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.
Files changed (35) hide show
  1. {hyperclass-0.1.1 → hyperclass-0.2.0}/PKG-INFO +69 -7
  2. {hyperclass-0.1.1 → hyperclass-0.2.0}/README.md +68 -6
  3. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/__init__.py +6 -1
  4. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/django.py +13 -1
  5. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/flask.py +14 -1
  6. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/html.py +34 -5
  7. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/htmx.py +54 -3
  8. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/lite.py +32 -5
  9. hyperclass-0.2.0/hyperclass/streaming.py +113 -0
  10. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/PKG-INFO +69 -7
  11. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/SOURCES.txt +3 -0
  12. {hyperclass-0.1.1 → hyperclass-0.2.0}/pyproject.toml +1 -1
  13. hyperclass-0.2.0/tests/test_chat.py +112 -0
  14. {hyperclass-0.1.1 → hyperclass-0.2.0}/tests/test_django.py +14 -1
  15. {hyperclass-0.1.1 → hyperclass-0.2.0}/tests/test_flask.py +14 -1
  16. {hyperclass-0.1.1 → 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.1/tests/test_htmx.py +0 -42
  21. hyperclass-0.1.1/tests/test_package.py +0 -5
  22. {hyperclass-0.1.1 → hyperclass-0.2.0}/LICENSE +0 -0
  23. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/__main__.py +0 -0
  24. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/binding.py +0 -0
  25. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/css.py +0 -0
  26. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/rendering.py +0 -0
  27. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/routing.py +0 -0
  28. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass/wsgi.py +0 -0
  29. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/dependency_links.txt +0 -0
  30. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/requires.txt +0 -0
  31. {hyperclass-0.1.1 → hyperclass-0.2.0}/hyperclass.egg-info/top_level.txt +0 -0
  32. {hyperclass-0.1.1 → hyperclass-0.2.0}/setup.cfg +0 -0
  33. {hyperclass-0.1.1 → hyperclass-0.2.0}/tests/test_bookmarks.py +0 -0
  34. {hyperclass-0.1.1 → hyperclass-0.2.0}/tests/test_cli.py +0 -0
  35. {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.1.1
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 SQLite bookmark inbox:
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 each of the Lite, Flask, and Django bookmark hosts
491
- on an ephemeral port and exercises add, toggle, edit, search, and delete
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 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.
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 SQLite bookmark inbox:
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 each of the Lite, Flask, and Django bookmark hosts
455
- on an ephemeral port and exercises add, toggle, edit, search, and delete
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 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.
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.1.1"
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[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
 
@@ -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.1.1
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 SQLite bookmark inbox:
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 each of the Lite, Flask, and Django bookmark hosts
491
- on an ephemeral port and exercises add, toggle, edit, search, and delete
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 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.
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperclass"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Build interactive web applications as Python class hierarchies."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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 &quot;missing&quot;." 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=&quot;query&quot;]" 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="{&quot;X-CSRFToken&quot;:&quot;token&quot;}"' 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,5 @@
1
+ import hyperclass
2
+
3
+
4
+ def test_version():
5
+ assert hyperclass.__version__ == "0.2.0"
@@ -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: &lt;thinking&gt;\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=&quot;query&quot;]" hx-target="#results" '
41
- 'hx-swap="outerMorph"></div>'
42
- )
@@ -1,5 +0,0 @@
1
- import hyperclass
2
-
3
-
4
- def test_version():
5
- assert hyperclass.__version__ == "0.1.1"
File without changes
File without changes
File without changes
File without changes