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.
Files changed (49) hide show
  1. {htag-2.0.7 → htag-2.0.9}/PKG-INFO +2 -1
  2. {htag-2.0.7 → htag-2.0.9}/README.md +1 -0
  3. {htag-2.0.7 → htag-2.0.9}/htag/core.py +24 -4
  4. {htag-2.0.7 → htag-2.0.9}/htag/runner.py +49 -0
  5. {htag-2.0.7 → htag-2.0.9}/htag.egg-info/PKG-INFO +2 -1
  6. {htag-2.0.7 → htag-2.0.9}/htag.egg-info/SOURCES.txt +2 -0
  7. {htag-2.0.7 → htag-2.0.9}/pyproject.toml +1 -1
  8. htag-2.0.9/tests/test_on_mount_yield.py +90 -0
  9. htag-2.0.9/tests/test_on_unmount_yield.py +84 -0
  10. {htag-2.0.7 → htag-2.0.9}/htag/__init__.py +0 -0
  11. {htag-2.0.7 → htag-2.0.9}/htag/cli.py +0 -0
  12. {htag-2.0.7 → htag-2.0.9}/htag/client_js.py +0 -0
  13. {htag-2.0.7 → htag-2.0.9}/htag/context.py +0 -0
  14. {htag-2.0.7 → htag-2.0.9}/htag/css.py +0 -0
  15. {htag-2.0.7 → htag-2.0.9}/htag/exceptions.py +0 -0
  16. {htag-2.0.7 → htag-2.0.9}/htag/logo.py +0 -0
  17. {htag-2.0.7 → htag-2.0.9}/htag/runners/__init__.py +0 -0
  18. {htag-2.0.7 → htag-2.0.9}/htag/runners/chromeapp.py +0 -0
  19. {htag-2.0.7 → htag-2.0.9}/htag/runners/pyscript.py +0 -0
  20. {htag-2.0.7 → htag-2.0.9}/htag/server.py +0 -0
  21. {htag-2.0.7 → htag-2.0.9}/htag/tag.py +0 -0
  22. {htag-2.0.7 → htag-2.0.9}/htag/utils.py +0 -0
  23. {htag-2.0.7 → htag-2.0.9}/htag/web.py +0 -0
  24. {htag-2.0.7 → htag-2.0.9}/htag.egg-info/dependency_links.txt +0 -0
  25. {htag-2.0.7 → htag-2.0.9}/htag.egg-info/entry_points.txt +0 -0
  26. {htag-2.0.7 → htag-2.0.9}/htag.egg-info/requires.txt +0 -0
  27. {htag-2.0.7 → htag-2.0.9}/htag.egg-info/top_level.txt +0 -0
  28. {htag-2.0.7 → htag-2.0.9}/setup.cfg +0 -0
  29. {htag-2.0.7 → htag-2.0.9}/tests/test_cmd.py +0 -0
  30. {htag-2.0.7 → htag-2.0.9}/tests/test_core.py +0 -0
  31. {htag-2.0.7 → htag-2.0.9}/tests/test_deprecation_warnings.py +0 -0
  32. {htag-2.0.7 → htag-2.0.9}/tests/test_fallback.py +0 -0
  33. {htag-2.0.7 → htag-2.0.9}/tests/test_interacting_robustness.py +0 -0
  34. {htag-2.0.7 → htag-2.0.9}/tests/test_lifecycle.py +0 -0
  35. {htag-2.0.7 → htag-2.0.9}/tests/test_memory.py +0 -0
  36. {htag-2.0.7 → htag-2.0.9}/tests/test_parano.py +0 -0
  37. {htag-2.0.7 → htag-2.0.9}/tests/test_reactivity_edge_cases.py +0 -0
  38. {htag-2.0.7 → htag-2.0.9}/tests/test_runner_pyscript.py +0 -0
  39. {htag-2.0.7 → htag-2.0.9}/tests/test_runners_reload.py +0 -0
  40. {htag-2.0.7 → htag-2.0.9}/tests/test_server.py +0 -0
  41. {htag-2.0.7 → htag-2.0.9}/tests/test_simple_events.py +0 -0
  42. {htag-2.0.7 → htag-2.0.9}/tests/test_state_advanced.py +0 -0
  43. {htag-2.0.7 → htag-2.0.9}/tests/test_state_features.py +0 -0
  44. {htag-2.0.7 → htag-2.0.9}/tests/test_state_proxy.py +0 -0
  45. {htag-2.0.7 → htag-2.0.9}/tests/test_state_reactivity.py +0 -0
  46. {htag-2.0.7 → htag-2.0.9}/tests/test_statics_deduplication.py +0 -0
  47. {htag-2.0.7 → htag-2.0.9}/tests/test_tag_names.py +0 -0
  48. {htag-2.0.7 → htag-2.0.9}/tests/test_web_sessions.py +0 -0
  49. {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.7
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
- else:
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
- self.on_mount()
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
- self.on_unmount()
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.7
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.7"
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