htag 2.0.7__tar.gz → 2.0.9__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.
- {htag-2.0.7 → htag-2.0.9}/PKG-INFO +2 -1
- {htag-2.0.7 → htag-2.0.9}/README.md +1 -0
- {htag-2.0.7 → htag-2.0.9}/htag/core.py +24 -4
- {htag-2.0.7 → htag-2.0.9}/htag/runner.py +49 -0
- {htag-2.0.7 → htag-2.0.9}/htag.egg-info/PKG-INFO +2 -1
- {htag-2.0.7 → htag-2.0.9}/htag.egg-info/SOURCES.txt +2 -0
- {htag-2.0.7 → htag-2.0.9}/pyproject.toml +1 -1
- htag-2.0.9/tests/test_on_mount_yield.py +90 -0
- htag-2.0.9/tests/test_on_unmount_yield.py +84 -0
- {htag-2.0.7 → htag-2.0.9}/htag/__init__.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/cli.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/client_js.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/context.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/css.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/exceptions.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/logo.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/runners/__init__.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/runners/chromeapp.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/runners/pyscript.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/server.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/tag.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/utils.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag/web.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag.egg-info/dependency_links.txt +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag.egg-info/entry_points.txt +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag.egg-info/requires.txt +0 -0
- {htag-2.0.7 → htag-2.0.9}/htag.egg-info/top_level.txt +0 -0
- {htag-2.0.7 → htag-2.0.9}/setup.cfg +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_cmd.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_core.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_deprecation_warnings.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_fallback.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_interacting_robustness.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_lifecycle.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_memory.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_parano.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_reactivity_edge_cases.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_runner_pyscript.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_runners_reload.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_server.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_simple_events.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_state_advanced.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_state_features.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_state_proxy.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_state_reactivity.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_statics_deduplication.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_tag_names.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_web_sessions.py +0 -0
- {htag-2.0.7 → htag-2.0.9}/tests/test_webapp_run.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htag
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.9
|
|
4
4
|
Summary: Python3 GUI toolkit for building 'beautiful' applications for mobile, web, and desktop from a single codebase
|
|
5
5
|
Author: manatlan
|
|
6
6
|
License: MIT
|
|
@@ -101,6 +101,7 @@ htag is a Python library for building web applications using HTML, CSS, and Java
|
|
|
101
101
|
* **HTTP Fallback (SSE + POST)**: If WebSockets are blocked (e.g. strict proxies) or fail to connect, the client seamlessly falls back to HTTP POST for events and Server-Sent Events (SSE) for receiving UI updates.
|
|
102
102
|
* **Production Debug Mode**: Easily disable error reporting in the client by setting `debug=False` on the runner (e.g. `WebApp(App, debug=False).app`), preventing internal stacktraces from leaking to users.
|
|
103
103
|
* **Parano Mode (Payload Obfuscation)**: By initializing `WebApp(App, parano=True)`, all data exchanged between the frontend and backend is automatically obfuscated using a dynamic XOR cipher and Base64 wrapping, making network traffic unreadable to MITM proxies.
|
|
104
|
+
* **Progressive UI in Lifecycle Hooks**: Both `on_mount()` and `on_unmount()` fully support yielding intermediate UI states natively. `htag` intelligently queues `on_mount` generators until the client establishes a connection, and gracefully processes `on_unmount` broadcasts.
|
|
104
105
|
* **`.root`, `.parent`, and `.childs` properties**: Every `GTag` exposes its position in the component tree. `.root` references the main `Tag` instance, `.parent` references the direct parent component, and `.childs` is a list of its children. This allows components to easily navigate the DOM tree and trigger app-level actions.
|
|
105
106
|
* **Declarative UI with Context Managers (`with`)**: You can now build component trees visually using `with` blocks (e.g., `with Tag.div(): Tag.h1("Hello")`), removing the need for `self <= ...` boilerplate.
|
|
106
107
|
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you must now use `self["class"] = "foo"` for dynamic property management after instantiation. This is the only way to handle all attributes, including those with dashes (e.g., `self["data-id"] = 123`).
|
|
@@ -80,6 +80,7 @@ htag is a Python library for building web applications using HTML, CSS, and Java
|
|
|
80
80
|
* **HTTP Fallback (SSE + POST)**: If WebSockets are blocked (e.g. strict proxies) or fail to connect, the client seamlessly falls back to HTTP POST for events and Server-Sent Events (SSE) for receiving UI updates.
|
|
81
81
|
* **Production Debug Mode**: Easily disable error reporting in the client by setting `debug=False` on the runner (e.g. `WebApp(App, debug=False).app`), preventing internal stacktraces from leaking to users.
|
|
82
82
|
* **Parano Mode (Payload Obfuscation)**: By initializing `WebApp(App, parano=True)`, all data exchanged between the frontend and backend is automatically obfuscated using a dynamic XOR cipher and Base64 wrapping, making network traffic unreadable to MITM proxies.
|
|
83
|
+
* **Progressive UI in Lifecycle Hooks**: Both `on_mount()` and `on_unmount()` fully support yielding intermediate UI states natively. `htag` intelligently queues `on_mount` generators until the client establishes a connection, and gracefully processes `on_unmount` broadcasts.
|
|
83
84
|
* **`.root`, `.parent`, and `.childs` properties**: Every `GTag` exposes its position in the component tree. `.root` references the main `Tag` instance, `.parent` references the direct parent component, and `.childs` is a list of its children. This allows components to easily navigate the DOM tree and trigger app-level actions.
|
|
84
85
|
* **Declarative UI with Context Managers (`with`)**: You can now build component trees visually using `with` blocks (e.g., `with Tag.div(): Tag.h1("Hello")`), removing the need for `self <= ...` boilerplate.
|
|
85
86
|
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you must now use `self["class"] = "foo"` for dynamic property management after instantiation. This is the only way to handle all attributes, including those with dashes (e.g., `self["data-id"] = 123`).
|
|
@@ -392,8 +392,8 @@ class GTag: # aka "Generic Tag"
|
|
|
392
392
|
# Always append htag's internal unique ID as 'data-htag-id' or just 'id' if not already present
|
|
393
393
|
if "id" not in self.__attrs:
|
|
394
394
|
attrs += f' id="{self.id}"'
|
|
395
|
-
|
|
396
|
-
# If user provided a custom ID, we still need htag id for event mapping
|
|
395
|
+
elif str(self.__attrs["id"]) != self.id:
|
|
396
|
+
# If user provided a custom ID different from htag internal ID, we still need htag id for event mapping
|
|
397
397
|
attrs += f' data-htag-id="{self.id}"'
|
|
398
398
|
return attrs
|
|
399
399
|
|
|
@@ -440,6 +440,8 @@ class GTag: # aka "Generic Tag"
|
|
|
440
440
|
|
|
441
441
|
if getattr(self, "tag", None) in ("style", "script"):
|
|
442
442
|
self.id = ""
|
|
443
|
+
elif "_id" in kwargs:
|
|
444
|
+
self.id = str(kwargs["_id"])
|
|
443
445
|
else:
|
|
444
446
|
self.id = f"{self.tag}-{id(self)}"
|
|
445
447
|
logger.debug("Created Tag: %s (id: %s)", self.tag, self.id)
|
|
@@ -503,7 +505,16 @@ class GTag: # aka "Generic Tag"
|
|
|
503
505
|
pass
|
|
504
506
|
|
|
505
507
|
def _trigger_mount(self) -> None:
|
|
506
|
-
|
|
508
|
+
import inspect
|
|
509
|
+
res = self.on_mount()
|
|
510
|
+
if inspect.isgenerator(res) or inspect.isasyncgen(res):
|
|
511
|
+
root = self.root
|
|
512
|
+
if root is not None and type(root).__name__ != "GTag":
|
|
513
|
+
# Only root representing the application
|
|
514
|
+
if not hasattr(root, "_pending_lifecycle_generators"):
|
|
515
|
+
root._pending_lifecycle_generators = []
|
|
516
|
+
root._pending_lifecycle_generators.append((self, res))
|
|
517
|
+
|
|
507
518
|
for child in self.childs:
|
|
508
519
|
if isinstance(child, GTag):
|
|
509
520
|
child._trigger_mount()
|
|
@@ -512,7 +523,16 @@ class GTag: # aka "Generic Tag"
|
|
|
512
523
|
t._trigger_mount()
|
|
513
524
|
|
|
514
525
|
def _trigger_unmount(self) -> None:
|
|
515
|
-
|
|
526
|
+
import inspect
|
|
527
|
+
res = self.on_unmount()
|
|
528
|
+
if inspect.isgenerator(res) or inspect.isasyncgen(res):
|
|
529
|
+
root = self.root
|
|
530
|
+
if root is not None and type(root).__name__ != "GTag":
|
|
531
|
+
# Only root representing the application
|
|
532
|
+
if not hasattr(root, "_pending_lifecycle_generators"):
|
|
533
|
+
root._pending_lifecycle_generators = []
|
|
534
|
+
root._pending_lifecycle_generators.append((self, res))
|
|
535
|
+
|
|
516
536
|
for child in self.childs:
|
|
517
537
|
if isinstance(child, GTag):
|
|
518
538
|
child._trigger_unmount()
|
|
@@ -182,7 +182,9 @@ class AppRunner(BaseApp):
|
|
|
182
182
|
<!DOCTYPE html>
|
|
183
183
|
<html>
|
|
184
184
|
<head>
|
|
185
|
+
<meta charset="utf-8">
|
|
185
186
|
<title>{self.__class__.__name__}</title>
|
|
187
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
186
188
|
<link rel="icon" href="/logo.png">
|
|
187
189
|
<script>{CLIENT_JS}</script>
|
|
188
190
|
<script>
|
|
@@ -217,6 +219,7 @@ class AppRunner(BaseApp):
|
|
|
217
219
|
payload_str = self._build_initial_payload()
|
|
218
220
|
# EventSource requires 'data: {payload}\n\n'
|
|
219
221
|
yield f"data: {payload_str}\n\n"
|
|
222
|
+
self._flush_pending_lifecycle_generators(None)
|
|
220
223
|
except Exception as e:
|
|
221
224
|
logger.error("Failed to send initial SSE state: %s", e)
|
|
222
225
|
|
|
@@ -246,6 +249,7 @@ class AppRunner(BaseApp):
|
|
|
246
249
|
payload_str = self._build_initial_payload()
|
|
247
250
|
await websocket.send_text(payload_str)
|
|
248
251
|
logger.debug("Sent initial state to client")
|
|
252
|
+
self._flush_pending_lifecycle_generators(websocket)
|
|
249
253
|
except Exception as e:
|
|
250
254
|
logger.error("Failed to send initial state: %s", e)
|
|
251
255
|
|
|
@@ -464,6 +468,51 @@ class AppRunner(BaseApp):
|
|
|
464
468
|
if callback_id:
|
|
465
469
|
await self.broadcast_updates(result=None, callback_id=callback_id, ws=ws)
|
|
466
470
|
|
|
471
|
+
self._flush_pending_lifecycle_generators(ws)
|
|
472
|
+
|
|
473
|
+
def _flush_pending_lifecycle_generators(self, ws: WebSocket | None = None) -> None:
|
|
474
|
+
if hasattr(self, "_pending_lifecycle_generators") and self._pending_lifecycle_generators:
|
|
475
|
+
while self._pending_lifecycle_generators:
|
|
476
|
+
tag, gen = self._pending_lifecycle_generators.pop(0)
|
|
477
|
+
asyncio.create_task(self._consume_generator(gen, ws))
|
|
478
|
+
|
|
479
|
+
async def _consume_generator(self, gen: Any, ws: WebSocket | None = None) -> None:
|
|
480
|
+
try:
|
|
481
|
+
if inspect.isasyncgen(gen):
|
|
482
|
+
async for _ in gen:
|
|
483
|
+
await self.broadcast_updates(ws=ws)
|
|
484
|
+
elif inspect.isgenerator(gen):
|
|
485
|
+
try:
|
|
486
|
+
while True:
|
|
487
|
+
next(gen)
|
|
488
|
+
await self.broadcast_updates(ws=ws)
|
|
489
|
+
except StopIteration:
|
|
490
|
+
pass
|
|
491
|
+
except Exception as e:
|
|
492
|
+
error_trace: str = traceback.format_exc()
|
|
493
|
+
error_msg: str = f"Error in on_mount generator: {str(e)}\n{error_trace}"
|
|
494
|
+
print(error_msg)
|
|
495
|
+
logger.error(error_msg)
|
|
496
|
+
err_payload: str = _obf_dumps(
|
|
497
|
+
{
|
|
498
|
+
"action": "error",
|
|
499
|
+
"traceback": error_trace if self.debug else "Internal Server Error",
|
|
500
|
+
"callback_id": None,
|
|
501
|
+
"result": None,
|
|
502
|
+
},
|
|
503
|
+
getattr(self, "parano_key", None),
|
|
504
|
+
)
|
|
505
|
+
for queue in self.sse_queues:
|
|
506
|
+
queue.put_nowait(err_payload)
|
|
507
|
+
dead_ws = []
|
|
508
|
+
for client in list(self.websockets):
|
|
509
|
+
try:
|
|
510
|
+
await client.send_text(err_payload)
|
|
511
|
+
except Exception:
|
|
512
|
+
dead_ws.append(client)
|
|
513
|
+
for client in dead_ws:
|
|
514
|
+
self.websockets.discard(client)
|
|
515
|
+
|
|
467
516
|
async def broadcast_updates(
|
|
468
517
|
self, result: Any = None, callback_id: str | None = None, ws: WebSocket | None = None
|
|
469
518
|
) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htag
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.9
|
|
4
4
|
Summary: Python3 GUI toolkit for building 'beautiful' applications for mobile, web, and desktop from a single codebase
|
|
5
5
|
Author: manatlan
|
|
6
6
|
License: MIT
|
|
@@ -101,6 +101,7 @@ htag is a Python library for building web applications using HTML, CSS, and Java
|
|
|
101
101
|
* **HTTP Fallback (SSE + POST)**: If WebSockets are blocked (e.g. strict proxies) or fail to connect, the client seamlessly falls back to HTTP POST for events and Server-Sent Events (SSE) for receiving UI updates.
|
|
102
102
|
* **Production Debug Mode**: Easily disable error reporting in the client by setting `debug=False` on the runner (e.g. `WebApp(App, debug=False).app`), preventing internal stacktraces from leaking to users.
|
|
103
103
|
* **Parano Mode (Payload Obfuscation)**: By initializing `WebApp(App, parano=True)`, all data exchanged between the frontend and backend is automatically obfuscated using a dynamic XOR cipher and Base64 wrapping, making network traffic unreadable to MITM proxies.
|
|
104
|
+
* **Progressive UI in Lifecycle Hooks**: Both `on_mount()` and `on_unmount()` fully support yielding intermediate UI states natively. `htag` intelligently queues `on_mount` generators until the client establishes a connection, and gracefully processes `on_unmount` broadcasts.
|
|
104
105
|
* **`.root`, `.parent`, and `.childs` properties**: Every `GTag` exposes its position in the component tree. `.root` references the main `Tag` instance, `.parent` references the direct parent component, and `.childs` is a list of its children. This allows components to easily navigate the DOM tree and trigger app-level actions.
|
|
105
106
|
* **Declarative UI with Context Managers (`with`)**: You can now build component trees visually using `with` blocks (e.g., `with Tag.div(): Tag.h1("Hello")`), removing the need for `self <= ...` boilerplate.
|
|
106
107
|
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you must now use `self["class"] = "foo"` for dynamic property management after instantiation. This is the only way to handle all attributes, including those with dashes (e.g., `self["data-id"] = 123`).
|
|
@@ -29,6 +29,8 @@ tests/test_fallback.py
|
|
|
29
29
|
tests/test_interacting_robustness.py
|
|
30
30
|
tests/test_lifecycle.py
|
|
31
31
|
tests/test_memory.py
|
|
32
|
+
tests/test_on_mount_yield.py
|
|
33
|
+
tests/test_on_unmount_yield.py
|
|
32
34
|
tests/test_parano.py
|
|
33
35
|
tests/test_reactivity_edge_cases.py
|
|
34
36
|
tests/test_runner_pyscript.py
|
|
@@ -9,7 +9,7 @@ include-package-data = true
|
|
|
9
9
|
|
|
10
10
|
[project]
|
|
11
11
|
name = "htag"
|
|
12
|
-
version = "2.0.
|
|
12
|
+
version = "2.0.9"
|
|
13
13
|
description = "Python3 GUI toolkit for building 'beautiful' applications for mobile, web, and desktop from a single codebase"
|
|
14
14
|
readme = "README.md"
|
|
15
15
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import AsyncGenerator, Iterator
|
|
4
|
+
|
|
5
|
+
from starlette.testclient import TestClient
|
|
6
|
+
from starlette.websockets import WebSocketDisconnect
|
|
7
|
+
from htag import App, Tag
|
|
8
|
+
from htag.web import WebApp
|
|
9
|
+
|
|
10
|
+
class AppWithYields(App):
|
|
11
|
+
def init(self):
|
|
12
|
+
self += Tag.div("init", _id="status")
|
|
13
|
+
|
|
14
|
+
def on_mount(self) -> Iterator[str]:
|
|
15
|
+
self.clear()
|
|
16
|
+
self += Tag.div("loading...", _id="status")
|
|
17
|
+
yield "update1"
|
|
18
|
+
self.clear()
|
|
19
|
+
self += Tag.div("done", _id="status")
|
|
20
|
+
yield "update2"
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_on_mount_yields_websocket():
|
|
24
|
+
web = WebApp(AppWithYields)
|
|
25
|
+
|
|
26
|
+
with TestClient(web.app) as client:
|
|
27
|
+
# 1. Initial page load (no websocket yet, on_mount generator should be queued)
|
|
28
|
+
response = client.get("/")
|
|
29
|
+
assert response.status_code == 200
|
|
30
|
+
assert "status" in response.text
|
|
31
|
+
assert "init" in response.text # initial state before yields are consumed
|
|
32
|
+
|
|
33
|
+
# 2. Connect websocket
|
|
34
|
+
with client.websocket_connect("/ws") as websocket:
|
|
35
|
+
# First message received is the initial broadcast
|
|
36
|
+
data = websocket.receive_text()
|
|
37
|
+
assert "update" in data or "init" in data
|
|
38
|
+
|
|
39
|
+
# The pending generators are consumed, wait for the broadcast updates
|
|
40
|
+
# update1 (loading...)
|
|
41
|
+
data = websocket.receive_text()
|
|
42
|
+
assert "loading..." in data
|
|
43
|
+
|
|
44
|
+
# update2 (done)
|
|
45
|
+
data = websocket.receive_text()
|
|
46
|
+
assert "done" in data
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_on_mount_yields_sse():
|
|
50
|
+
web = WebApp(AppWithYields)
|
|
51
|
+
|
|
52
|
+
with TestClient(web.app) as client:
|
|
53
|
+
# 1. Initial page load
|
|
54
|
+
response = client.get("/")
|
|
55
|
+
assert response.status_code == 200
|
|
56
|
+
sid = response.cookies.get("htag_sid")
|
|
57
|
+
|
|
58
|
+
# 2. Extract app instance
|
|
59
|
+
instance = web.instances[sid]
|
|
60
|
+
|
|
61
|
+
# 3. Simulate _handle_sse
|
|
62
|
+
from starlette.requests import Request
|
|
63
|
+
|
|
64
|
+
class MockRequest:
|
|
65
|
+
cookies = {"htag_sid": sid}
|
|
66
|
+
async def is_disconnected(self):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
req = MockRequest()
|
|
70
|
+
|
|
71
|
+
gen = instance._handle_sse(req)
|
|
72
|
+
|
|
73
|
+
# Pull the first 'initial' payload + triggers background flush
|
|
74
|
+
initial_chunk = await gen.__anext__()
|
|
75
|
+
assert "data:" in initial_chunk
|
|
76
|
+
assert "loading..." not in initial_chunk # Pending update not yet given
|
|
77
|
+
|
|
78
|
+
# To let the created task flush the items to the queue, yield control
|
|
79
|
+
await asyncio.sleep(0.01)
|
|
80
|
+
|
|
81
|
+
# Pull first queued yield
|
|
82
|
+
q1 = await gen.__anext__()
|
|
83
|
+
assert "loading..." in q1
|
|
84
|
+
|
|
85
|
+
# Pull second queued yield
|
|
86
|
+
q2 = await gen.__anext__()
|
|
87
|
+
assert "done" in q2
|
|
88
|
+
|
|
89
|
+
# Cleanup tasks
|
|
90
|
+
instance.sse_queues.clear()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from typing import Iterator
|
|
3
|
+
from starlette.testclient import TestClient
|
|
4
|
+
from htag import App, Tag
|
|
5
|
+
from htag.web import WebApp
|
|
6
|
+
|
|
7
|
+
class UnmountingComponent(Tag.div):
|
|
8
|
+
def on_mount(self):
|
|
9
|
+
self.app_root = self.root
|
|
10
|
+
|
|
11
|
+
def on_unmount(self) -> Iterator[str]:
|
|
12
|
+
# Generators evaluate lazily, so we use the stored app_root
|
|
13
|
+
self.app_root.unmounted_triggered += 1
|
|
14
|
+
self.app_root.status.clear(str(self.app_root.unmounted_triggered))
|
|
15
|
+
yield "update1"
|
|
16
|
+
self.app_root.unmounted_triggered += 1
|
|
17
|
+
self.app_root.status.clear(str(self.app_root.unmounted_triggered))
|
|
18
|
+
yield "update2"
|
|
19
|
+
|
|
20
|
+
class AppWithUnmount(App):
|
|
21
|
+
def init(self):
|
|
22
|
+
self.unmounted_triggered = 0
|
|
23
|
+
self.comp = UnmountingComponent("I will go away")
|
|
24
|
+
self += self.comp
|
|
25
|
+
|
|
26
|
+
self.btn = Tag.button("Remove It", _onclick=self.do_remove, _id="btn")
|
|
27
|
+
self += self.btn
|
|
28
|
+
|
|
29
|
+
# UI element to track state
|
|
30
|
+
self.status = Tag.div(self.unmounted_triggered, _id="status")
|
|
31
|
+
self += self.status
|
|
32
|
+
|
|
33
|
+
def do_remove(self, o):
|
|
34
|
+
self.comp.remove()
|
|
35
|
+
|
|
36
|
+
def render_initial(self):
|
|
37
|
+
# ensure status is up-to-date
|
|
38
|
+
self.status.clear(str(self.unmounted_triggered))
|
|
39
|
+
return super().render_initial()
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_on_unmount_yields_websocket():
|
|
43
|
+
web = WebApp(AppWithUnmount)
|
|
44
|
+
|
|
45
|
+
with TestClient(web.app) as client:
|
|
46
|
+
# 1. Initial page load
|
|
47
|
+
response = client.get("/")
|
|
48
|
+
assert response.status_code == 200
|
|
49
|
+
|
|
50
|
+
# 2. Connect websocket
|
|
51
|
+
with client.websocket_connect("/ws") as websocket:
|
|
52
|
+
# First message received is the initial broadcast
|
|
53
|
+
data = websocket.receive_text()
|
|
54
|
+
assert "I will go away" in data
|
|
55
|
+
|
|
56
|
+
# 3. Simulate clicking the "Remove It" button
|
|
57
|
+
# We construct a fake message mimicking the browser sending the event
|
|
58
|
+
msg = {
|
|
59
|
+
"id": "btn",
|
|
60
|
+
"event": "click",
|
|
61
|
+
"data": {"callback_id": "cb1"}
|
|
62
|
+
}
|
|
63
|
+
# We have to obfuscate if parano_key is used, but by default it isn't
|
|
64
|
+
websocket.send_json(msg)
|
|
65
|
+
|
|
66
|
+
# The click event will call do_remove()
|
|
67
|
+
# Which calls self.comp.remove() -> _trigger_unmount()
|
|
68
|
+
# The unmount yields twice, and we expect 3 payloads total or 2
|
|
69
|
+
# because the event handler completes, so the runner will send the first broadcast
|
|
70
|
+
# THEN it flushes pending generators
|
|
71
|
+
|
|
72
|
+
# payload 1: Component removed (HTML delta), callback_id='cb1'
|
|
73
|
+
data = websocket.receive_text()
|
|
74
|
+
assert "cb1" in data # event resolution
|
|
75
|
+
|
|
76
|
+
# payload 2: First yield in on_unmount
|
|
77
|
+
data = websocket.receive_text()
|
|
78
|
+
assert "update" in data # action: update
|
|
79
|
+
assert "1" in data # from the counter 1
|
|
80
|
+
|
|
81
|
+
# payload 3: Second yield in on_unmount
|
|
82
|
+
data = websocket.receive_text()
|
|
83
|
+
assert "update" in data
|
|
84
|
+
assert "2" in data # from counter 2
|
|
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
|
|
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
|
|
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
|