pyjs-examples 0.0.1__tar.gz → 0.0.2__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 (84) hide show
  1. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/PKG-INFO +5 -4
  2. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/README.md +3 -2
  3. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/calculator/README.md +4 -1
  4. pyjs_examples-0.0.2/calculator/__main__.py +15 -0
  5. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/calculator/calculator.py +122 -7
  6. pyjs_examples-0.0.2/calculator/tests/test_pwa.py +67 -0
  7. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/chatroom/chatroom.py +35 -32
  8. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/chatroom/tests/test_browser_chatroom.py +17 -13
  9. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/kanban/tests/test_browser_kanban.py +6 -1
  10. pyjs_examples-0.0.2/navigation/README.md +20 -0
  11. pyjs_examples-0.0.2/navigation/__init__.py +1 -0
  12. pyjs_examples-0.0.2/navigation/__main__.py +49 -0
  13. pyjs_examples-0.0.2/navigation/navigation.py +800 -0
  14. pyjs_examples-0.0.2/navigation/tests/test_browser_navigation.py +83 -0
  15. pyjs_examples-0.0.2/navigation/tests/test_navigation.py +59 -0
  16. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/pyjs_examples.egg-info/PKG-INFO +5 -4
  17. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/pyjs_examples.egg-info/SOURCES.txt +14 -0
  18. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/pyjs_examples.egg-info/entry_points.txt +1 -0
  19. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/pyjs_examples.egg-info/requires.txt +1 -1
  20. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/pyproject.toml +10 -2
  21. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/space_invaders/space_invaders.py +8 -5
  22. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/todomvc/tests/test_browser_todomvc.py +18 -11
  23. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/todomvc/todomvc.py +12 -10
  24. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/workspace/tests/test_browser_workspace.py +2 -1
  25. pyjs_examples-0.0.1/calculator/__main__.py +0 -9
  26. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/__init__.py +0 -0
  27. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/calculator/__init__.py +0 -0
  28. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/calculator/tests/test_browser_calculator.py +0 -0
  29. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/calculator/tests/test_calculator.py +0 -0
  30. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/catalog.py +0 -0
  31. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/chatroom/README.md +0 -0
  32. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/chatroom/__init__.py +0 -0
  33. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/chatroom/__main__.py +0 -0
  34. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/chatroom/chat_server.py +0 -0
  35. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/chatroom/tests/test_chat_server.py +0 -0
  36. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/chatroom/visual_test.py +0 -0
  37. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/counter.py +0 -0
  38. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/kanban/README.md +0 -0
  39. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/kanban/__init__.py +0 -0
  40. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/kanban/__main__.py +0 -0
  41. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/kanban/kanban.py +0 -0
  42. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/kanban/tests/renders/kanban-board.png +0 -0
  43. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/kanban/tests/test_kanban.py +0 -0
  44. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/kanban/visual_test.py +0 -0
  45. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/nativescript_counter/README.md +0 -0
  46. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/nativescript_counter/__init__.py +0 -0
  47. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/nativescript_counter/__main__.py +0 -0
  48. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/nativescript_counter/counter.py +0 -0
  49. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/nativescript_counter/tests/test_android_counter.py +0 -0
  50. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/nativescript_counter/tests/test_counter.py +0 -0
  51. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/pyjs_examples.egg-info/dependency_links.txt +0 -0
  52. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/pyjs_examples.egg-info/top_level.txt +0 -0
  53. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/__init__.py +0 -0
  54. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/state_counter/README.md +0 -0
  55. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/state_counter/__init__.py +0 -0
  56. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/state_counter/__main__.py +0 -0
  57. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/state_counter/state_counter.py +0 -0
  58. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/state_counter/tests/test_browser_state_counter.py +0 -0
  59. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/state_counter/tests/test_state_counter.py +0 -0
  60. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/ui_dialog/README.md +0 -0
  61. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/ui_dialog/__init__.py +0 -0
  62. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/ui_dialog/__main__.py +0 -0
  63. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/ui_dialog/tests/test_browser_ui_dialog.py +0 -0
  64. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/ui_dialog/tests/test_ui_dialog.py +0 -0
  65. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/samples/ui_dialog/ui_dialog.py +0 -0
  66. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/setup.cfg +0 -0
  67. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/space_invaders/README.md +0 -0
  68. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/space_invaders/__init__.py +0 -0
  69. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/space_invaders/__main__.py +0 -0
  70. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/space_invaders/tests/renders/action_scene.png +0 -0
  71. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/space_invaders/tests/renders/initial_wave.png +0 -0
  72. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/space_invaders/tests/test_browser_game.py +0 -0
  73. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/space_invaders/tests/test_game.py +0 -0
  74. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/todomvc/README.md +0 -0
  75. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/todomvc/__init__.py +0 -0
  76. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/todomvc/__main__.py +0 -0
  77. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/todomvc/base.css +0 -0
  78. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/todomvc/index.css +0 -0
  79. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/todomvc/tests/test_todomvc.py +0 -0
  80. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/workspace/README.md +0 -0
  81. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/workspace/__init__.py +0 -0
  82. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/workspace/__main__.py +0 -0
  83. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/workspace/tests/test_workspace.py +0 -0
  84. {pyjs_examples-0.0.1 → pyjs_examples-0.0.2}/workspace/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyjs-examples
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Official example applications for PyJS.
5
5
  Author-email: Lex Berezhny <lex@damoti.com>
6
6
  License-Expression: BSD-3-Clause
@@ -18,7 +18,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.14
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: pyjs==0.0.1
21
+ Requires-Dist: pyjs==0.0.2
22
22
  Provides-Extra: test
23
23
  Requires-Dist: pytest>=9.0; extra == "test"
24
24
 
@@ -31,7 +31,7 @@ browse in the repository while installing as an ordinary top-level package.
31
31
  From the repository root, install the core and examples from a source checkout:
32
32
 
33
33
  ```bash
34
- python -m pip install -e . -e ./examples
34
+ uv sync
35
35
  ```
36
36
 
37
37
  An installed PyJS release provides the same package through the `examples` extra:
@@ -40,13 +40,14 @@ An installed PyJS release provides the same package through the `examples` extra
40
40
  python -m pip install "pyjs[examples]"
41
41
  ```
42
42
 
43
- Run any complete application as a module:
43
+ Run each complete application with the command shown:
44
44
 
45
45
  | Example | Command |
46
46
  | --- | --- |
47
47
  | [Calculator](calculator/) | `python -m pyjs_examples.calculator` |
48
48
  | [Chatroom](chatroom/) | `python -m pyjs_examples.chatroom` |
49
49
  | [Kanban board](kanban/) | `python -m pyjs_examples.kanban` |
50
+ | [Navigation workspace](navigation/) | `python -m pyjs_examples.navigation` |
50
51
  | [NativeScript Counter](nativescript_counter/) | `python -m pyjs_examples.nativescript_counter` |
51
52
  | [Space Invaders](space_invaders/) | `python -m pyjs_examples.space_invaders` |
52
53
  | [TodoMVC](todomvc/) | `python -m pyjs_examples.todomvc` |
@@ -7,7 +7,7 @@ browse in the repository while installing as an ordinary top-level package.
7
7
  From the repository root, install the core and examples from a source checkout:
8
8
 
9
9
  ```bash
10
- python -m pip install -e . -e ./examples
10
+ uv sync
11
11
  ```
12
12
 
13
13
  An installed PyJS release provides the same package through the `examples` extra:
@@ -16,13 +16,14 @@ An installed PyJS release provides the same package through the `examples` extra
16
16
  python -m pip install "pyjs[examples]"
17
17
  ```
18
18
 
19
- Run any complete application as a module:
19
+ Run each complete application with the command shown:
20
20
 
21
21
  | Example | Command |
22
22
  | --- | --- |
23
23
  | [Calculator](calculator/) | `python -m pyjs_examples.calculator` |
24
24
  | [Chatroom](chatroom/) | `python -m pyjs_examples.chatroom` |
25
25
  | [Kanban board](kanban/) | `python -m pyjs_examples.kanban` |
26
+ | [Navigation workspace](navigation/) | `python -m pyjs_examples.navigation` |
26
27
  | [NativeScript Counter](nativescript_counter/) | `python -m pyjs_examples.nativescript_counter` |
27
28
  | [Space Invaders](space_invaders/) | `python -m pyjs_examples.space_invaders` |
28
29
  | [TodoMVC](todomvc/) | `python -m pyjs_examples.todomvc` |
@@ -13,6 +13,7 @@ It demonstrates:
13
13
  - sign, percent, clear, and divide-by-zero behavior
14
14
  - asynchronous result persistence through an HTTP POST route
15
15
  - per-browser in-memory sessions that restore the last result after refresh
16
+ - a Python-authored service worker with cache-first offline application loading
16
17
  - browser tests that interact with the real DOM through button events
17
18
 
18
19
  The example intentionally stays focused on calculator behavior. Reusable widget
@@ -30,7 +31,9 @@ From the repository root:
30
31
  Open `http://127.0.0.1:8000/`. Completing a calculation with `=` saves its
31
32
  result to the server. Refreshing the page restores that result through the
32
33
  browser's session cookie. Sessions are intentionally in memory, so restarting
33
- the server clears them.
34
+ the server clears them. The app also registers `/calculator-serviceworker.js`.
35
+ After its first controlled reload, the calculator can load and calculate while
36
+ the browser is offline.
34
37
 
35
38
  ## Run the tests
36
39
 
@@ -0,0 +1,15 @@
1
+ import os
2
+
3
+ from pyjs.server import serve
4
+
5
+ from .calculator import render, save_result, service_worker_javascript
6
+
7
+ serve(
8
+ "pyjs_examples.calculator.calculator",
9
+ render=render,
10
+ routes={
11
+ ("POST", "/api/calculator/result"): save_result,
12
+ ("GET", "/calculator-serviceworker.js"): service_worker_javascript,
13
+ },
14
+ port=int(os.environ.get("PYJS_PORT", "8000")),
15
+ )
@@ -1,10 +1,94 @@
1
1
  from pyjs import effect, js, nojs, state
2
2
  from pyjs.web.domx import ProxyElement, button, ref, tag, tw
3
3
  from pyjs.web.events import Event
4
- from pyjs.web.network import fetch
4
+ from pyjs.web.network import Request, Response, fetch
5
+ from pyjs.web.serviceworker import (
6
+ ExtendableEvent,
7
+ FetchEvent,
8
+ caches,
9
+ navigator,
10
+ service_worker_scope,
11
+ )
5
12
  from pyjs.web.ui import Widget
6
13
 
7
14
 
15
+ @js
16
+ async def install_app_shell(cache_name: str, assets: list[str]):
17
+ cache = await caches.open(cache_name)
18
+ await cache.addAll(assets)
19
+ await service_worker_scope().skipWaiting()
20
+
21
+
22
+ @js
23
+ async def activate_app_shell():
24
+ await service_worker_scope().clients.claim()
25
+
26
+
27
+ @js
28
+ async def cache_first(request: Request, cache_name: str) -> Response:
29
+ cached = await caches.match(request)
30
+ if cached is not None:
31
+ return cached
32
+ response = await fetch(request.url)
33
+ cache = await caches.open(cache_name)
34
+ await cache.put(request, response.clone())
35
+ return response
36
+
37
+
38
+ @js
39
+ class InstallHandler:
40
+ def __init__(self, cache_name: str, assets: list[str]):
41
+ self.cache_name = cache_name
42
+ self.assets = assets
43
+
44
+ @js(include=True)
45
+ def handleEvent(self, event: ExtendableEvent):
46
+ event.waitUntil(install_app_shell(self.cache_name, self.assets))
47
+
48
+
49
+ @js
50
+ class ActivateHandler:
51
+ @js(include=True)
52
+ def handleEvent(self, event: ExtendableEvent):
53
+ event.waitUntil(activate_app_shell())
54
+
55
+
56
+ @js
57
+ class FetchHandler:
58
+ def __init__(self, cache_name: str):
59
+ self.cache_name = cache_name
60
+
61
+ @js(include=True)
62
+ def handleEvent(self, event: FetchEvent):
63
+ if event.request.method == "GET":
64
+ event.respondWith(cache_first(event.request, self.cache_name))
65
+
66
+
67
+ @js
68
+ def service_worker_main():
69
+ cache_name = "pyjs-calculator-v1"
70
+ scope = service_worker_scope()
71
+ scope.addEventListener("install", InstallHandler(cache_name, ["/", "/index.css"]))
72
+ scope.addEventListener("activate", ActivateHandler())
73
+ scope.addEventListener("fetch", FetchHandler(cache_name))
74
+
75
+
76
+ @nojs
77
+ def service_worker_javascript(request):
78
+ from pyjs.server import ServerResponse
79
+ from pyjs.transpiler import bundle
80
+
81
+ source, _ = bundle(service_worker_main, include_main=True, include_css=False)
82
+ return ServerResponse(
83
+ source,
84
+ content_type="application/javascript",
85
+ headers={
86
+ "Cache-Control": "no-cache",
87
+ "Service-Worker-Allowed": "/",
88
+ },
89
+ )
90
+
91
+
8
92
  @js
9
93
  class CalculatorKeyListener:
10
94
  def __init__(self, app: CalculatorApp, kind: str, value: str):
@@ -78,8 +162,14 @@ class CalculatorApp(Widget):
78
162
  replace_display = state(True)
79
163
  error = state(False)
80
164
  save_url = state("")
81
-
82
- def __init__(self, initial_value: str = "0", save_url: str = ""):
165
+ service_worker_url = state("")
166
+
167
+ def __init__(
168
+ self,
169
+ initial_value: str = "0",
170
+ save_url: str = "",
171
+ service_worker_url: str = "",
172
+ ):
83
173
  super().__init__()
84
174
  self.display = ref(
85
175
  tag(
@@ -98,6 +188,7 @@ class CalculatorApp(Widget):
98
188
  )
99
189
  self.display_value = initial_value
100
190
  self.save_url = save_url
191
+ self.service_worker_url = service_worker_url
101
192
 
102
193
  self.clear_button = ref(CalculatorKey("AC", "clear", "action", False))
103
194
  self.clear_button.bind(self, "action", "clear")
@@ -180,6 +271,12 @@ class CalculatorApp(Widget):
180
271
  card,
181
272
  )
182
273
 
274
+ async def initialize(self):
275
+ if self.service_worker_url == "":
276
+ return
277
+ registration = await navigator.serviceWorker.register(self.service_worker_url)
278
+ self.set_data("service-worker", registration.scope)
279
+
183
280
  @effect(display_value)
184
281
  def render_display(self):
185
282
  self.display.textContent = self.display_value
@@ -294,13 +391,25 @@ class CalculatorApp(Widget):
294
391
  self.percent()
295
392
 
296
393
 
297
- def main(initial_value: str = "0", save_url: str = ""):
298
- return CalculatorApp(initial_value=initial_value, save_url=save_url)
394
+ def main(
395
+ initial_value: str = "0",
396
+ save_url: str = "",
397
+ service_worker_url: str = "",
398
+ ):
399
+ return CalculatorApp(
400
+ initial_value=initial_value,
401
+ save_url=save_url,
402
+ service_worker_url=service_worker_url,
403
+ )
299
404
 
300
405
 
301
406
  @nojs
302
407
  def render(request):
303
- return main(request.session.get("last_result", "0"), "/api/calculator/result")
408
+ return main(
409
+ request.session.get("last_result", "0"),
410
+ "/api/calculator/result",
411
+ "/calculator-serviceworker.js",
412
+ )
304
413
 
305
414
 
306
415
  @nojs
@@ -316,10 +425,16 @@ def save_result(request):
316
425
 
317
426
 
318
427
  if __name__ == "__main__":
428
+ import os
429
+
319
430
  from pyjs.server import serve
320
431
 
321
432
  serve(
322
433
  "calculator",
323
434
  render=render,
324
- routes={("POST", "/api/calculator/result"): save_result},
435
+ routes={
436
+ ("POST", "/api/calculator/result"): save_result,
437
+ ("GET", "/calculator-serviceworker.js"): service_worker_javascript,
438
+ },
439
+ port=int(os.environ.get("PYJS_PORT", "8000")),
325
440
  )
@@ -0,0 +1,67 @@
1
+ import asyncio
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from pyjs.tooling import stop_process
9
+ from pyjs.tooling.browser import Browser, free_port, wait_for_http
10
+
11
+ pytestmark = pytest.mark.pyjs_python
12
+
13
+
14
+ def test_calculator_registers_python_service_worker_and_runs_offline():
15
+ async def run():
16
+ port = free_port()
17
+ url = f"http://127.0.0.1:{port}/"
18
+ environment = os.environ.copy()
19
+ environment["PYJS_PORT"] = str(port)
20
+ environment["PYTHONUNBUFFERED"] = "1"
21
+ application = Path(__file__).parents[1] / "calculator.py"
22
+ server = subprocess.Popen(
23
+ [sys.executable, str(application)],
24
+ env=environment,
25
+ stdout=subprocess.PIPE,
26
+ stderr=subprocess.STDOUT,
27
+ text=True,
28
+ )
29
+ try:
30
+ try:
31
+ await wait_for_http(url, timeout=120)
32
+ except TimeoutError as error:
33
+ stop_process(server)
34
+ output = server.stdout.read() if server.stdout is not None else ""
35
+ raise TimeoutError(f"{error}\nServer output:\n{output}") from error
36
+ async with Browser() as browser:
37
+ page = await browser.new_page(url)
38
+ await page.wait_for_selector("calculator-app")
39
+ await page.wait_for(
40
+ "navigator.serviceWorker.controller !== null",
41
+ "the calculator service worker to control the page",
42
+ )
43
+
44
+ scope = await page.attribute("calculator-app", "data-service-worker")
45
+ assert scope == url
46
+
47
+ await page.navigate(url)
48
+ await page.wait_for_selector('[data-testid="calculator-display"]')
49
+ cached_urls = await page.evaluate(
50
+ "(async () => {"
51
+ " const cache = await caches.open('pyjs-calculator-v1');"
52
+ " return (await cache.keys()).map((request) => request.url);"
53
+ "})()"
54
+ )
55
+ assert url in cached_urls
56
+ assert any(cached_url.endswith(".js") for cached_url in cached_urls)
57
+
58
+ await page.set_offline()
59
+ await page.navigate(url)
60
+ await page.wait_for_selector('[data-testid="calculator-display"]')
61
+ await page.click('[data-key="7"]')
62
+ assert await page.text('[data-testid="calculator-display"]') == "7"
63
+ assert page.exceptions == []
64
+ finally:
65
+ stop_process(server)
66
+
67
+ asyncio.run(run())
@@ -1,31 +1,29 @@
1
+ from typing import TypedDict
2
+
1
3
  from pyjs import effect, js, nojs, state
4
+ from pyjs.javascript import JSON
2
5
  from pyjs.web.dom import location, sessionStorage
3
6
  from pyjs.web.domx import button, form, input, ref, tag, tw
4
7
  from pyjs.web.events import Event, MessageEvent
5
- from pyjs.web.javascript import JSON
6
8
  from pyjs.web.network import WebSocket
7
9
  from pyjs.web.ui import Widget
8
10
 
9
11
 
10
- @js
11
- class ChatMessage:
12
- def __init__(self):
13
- self.id = 0
14
- self.room = ""
15
- self.sender = ""
16
- self.text = ""
17
- self.time = ""
12
+ class ChatMessage(TypedDict):
13
+ id: int
14
+ room: str
15
+ sender: str
16
+ text: str
17
+ time: str
18
18
 
19
19
 
20
- @js
21
- class ChatSnapshot:
22
- def __init__(self):
23
- self.type = ""
24
- self.room = ""
25
- self.rooms: list[str] = []
26
- self.messages: list[ChatMessage] = []
27
- self.users: list[str] = []
28
- self.message = ""
20
+ class ChatSnapshot(TypedDict):
21
+ type: str
22
+ room: str
23
+ rooms: list[str]
24
+ messages: list[ChatMessage]
25
+ users: list[str]
26
+ message: str
29
27
 
30
28
 
31
29
  @js
@@ -441,10 +439,7 @@ class ChatRoomApp(Widget):
441
439
  def initialize(self):
442
440
  self.socket: WebSocket | None = None
443
441
  name = sessionStorage.getItem("pyjs-chatroom-display-name")
444
- if name is None:
445
- self.show_sign_in()
446
- return
447
- if name == "":
442
+ if name is None or name == "":
448
443
  self.show_sign_in()
449
444
  return
450
445
  self.name = name
@@ -535,14 +530,14 @@ class ChatRoomApp(Widget):
535
530
  socket.send(JSON.stringify(message))
536
531
 
537
532
  def receive(self, message: ChatSnapshot):
538
- if message.type == "error":
539
- self.show_error(message.message)
533
+ if message["type"] == "error":
534
+ self.show_error(message["message"])
540
535
  return
541
536
  self.hide_error()
542
- self.room = message.room
543
- self.render_rooms(message.rooms)
544
- self.render_messages(message.messages)
545
- self.render_members(message.users)
537
+ self.room = message["room"]
538
+ self.render_rooms(message["rooms"])
539
+ self.render_messages(message["messages"])
540
+ self.render_members(message["users"])
546
541
 
547
542
  def render_rooms(self, rooms: list[str]):
548
543
  self.room_list.replaceChildren()
@@ -604,7 +599,7 @@ class ChatRoomApp(Widget):
604
599
  tag(
605
600
  "article",
606
601
  tw("group flex gap-3 rounded-lg px-2 py-2 hover:bg-slate-50"),
607
- {"data-message-id": str(message.id)},
602
+ {"data-message-id": str(message["id"])},
608
603
  tag(
609
604
  "div",
610
605
  tw(
@@ -619,8 +614,16 @@ class ChatRoomApp(Widget):
619
614
  tag(
620
615
  "div",
621
616
  tw("flex items-baseline gap-2"),
622
- tag("strong", tw("text-sm font-bold text-slate-900"), message.sender),
623
- tag("time", tw("text-[0.7rem] text-slate-400"), message.time),
617
+ tag(
618
+ "strong",
619
+ tw("text-sm font-bold text-slate-900"),
620
+ message["sender"],
621
+ ),
622
+ tag(
623
+ "time",
624
+ tw("text-[0.7rem] text-slate-400"),
625
+ message["time"],
626
+ ),
624
627
  ),
625
628
  tag(
626
629
  "p",
@@ -628,7 +631,7 @@ class ChatRoomApp(Widget):
628
631
  "mt-0.5 whitespace-pre-wrap break-words text-sm leading-6 "
629
632
  "text-slate-700"
630
633
  ),
631
- message.text,
634
+ message["text"],
632
635
  ),
633
636
  ),
634
637
  )
@@ -1,7 +1,8 @@
1
1
  import pytest
2
2
  from pyjs import js
3
- from pyjs.testing.web import mount, require_element
3
+ from pyjs.testing.web import mount
4
4
  from pyjs.web.dom import sessionStorage
5
+ from pyjs.web.domx import require_element
5
6
  from pyjs.web.events import Event
6
7
  from pyjs_examples.chatroom.chatroom import ChatMessage, ChatRoomApp, ChatSnapshot
7
8
 
@@ -36,18 +37,21 @@ def test_chatroom_prompts_for_a_per_tab_display_name():
36
37
  def test_chatroom_renders_rooms_messages_and_presence_from_a_snapshot():
37
38
  sessionStorage.setItem("pyjs-chatroom-display-name", "Alice")
38
39
  app = mounted_chatroom()
39
- message = ChatMessage()
40
- message.id = 1
41
- message.room = "design"
42
- message.sender = "Bob"
43
- message.text = "The new navigation is ready."
44
- message.time = "09:42"
45
- snapshot = ChatSnapshot()
46
- snapshot.type = "snapshot"
47
- snapshot.room = "design"
48
- snapshot.rooms = ["general", "design"]
49
- snapshot.messages = [message]
50
- snapshot.users = ["Alice", "Bob"]
40
+ message = ChatMessage(
41
+ id=1,
42
+ room="design",
43
+ sender="Bob",
44
+ text="The new navigation is ready.",
45
+ time="09:42",
46
+ )
47
+ snapshot = ChatSnapshot(
48
+ type="snapshot",
49
+ room="design",
50
+ rooms=["general", "design"],
51
+ messages=[message],
52
+ users=["Alice", "Bob"],
53
+ message="",
54
+ )
51
55
 
52
56
  app.receive(snapshot)
53
57
 
@@ -147,4 +147,9 @@ def test_kanban_visual_showcase():
147
147
 
148
148
  assert card_order(board.in_progress) == "drag-drop,empty-states,onboarding-audit"
149
149
  mount(board.columns)
150
- assert_render(board.columns, "kanban-board", pixel_threshold=10)
150
+ assert_render(
151
+ board.columns,
152
+ "kanban-board",
153
+ pixel_threshold=10,
154
+ max_different_pixel_ratio=0.06,
155
+ )
@@ -0,0 +1,20 @@
1
+ # Navigation Workspace Example
2
+
3
+ This example demonstrates server-rendered pages inside a persistent application layout. Normal
4
+ requests return complete HTML documents. Navigation initiated inside the application requests only
5
+ the selected page, updates the `PageOutlet`, and preserves the sidebar and header without requiring
6
+ a client-side routing table.
7
+
8
+ Run it from the repository root:
9
+
10
+ ```bash
11
+ .venv/bin/python -m pyjs_examples.navigation
12
+ ```
13
+
14
+ Open `http://127.0.0.1:8000/`, then move between the eight pages using the sidebar.
15
+
16
+ Run its server and browser coverage with:
17
+
18
+ ```bash
19
+ .venv/bin/python -m pytest -q examples/navigation/tests
20
+ ```
@@ -0,0 +1 @@
1
+ """Persistent layout and partial page navigation example."""
@@ -0,0 +1,49 @@
1
+ import os
2
+
3
+ from pyjs.server import serve
4
+ from pyjs_examples.navigation.navigation import (
5
+ analytics_page,
6
+ analytics_view,
7
+ calendar_page,
8
+ calendar_view,
9
+ layout,
10
+ messages_page,
11
+ messages_view,
12
+ overview_page,
13
+ overview_view,
14
+ projects_page,
15
+ projects_view,
16
+ reports_page,
17
+ reports_view,
18
+ settings_page,
19
+ settings_view,
20
+ team_page,
21
+ team_view,
22
+ )
23
+
24
+ serve(
25
+ "pyjs_examples.navigation.navigation",
26
+ routes={
27
+ ("GET", "/"): overview_page,
28
+ ("GET", "/analytics"): analytics_page,
29
+ ("GET", "/calendar"): calendar_page,
30
+ ("GET", "/messages"): messages_page,
31
+ ("GET", "/projects"): projects_page,
32
+ ("GET", "/reports"): reports_page,
33
+ ("GET", "/settings"): settings_page,
34
+ ("GET", "/team"): team_page,
35
+ },
36
+ layout=layout,
37
+ layout_id="atlas",
38
+ page_entry_points=(
39
+ overview_view,
40
+ analytics_view,
41
+ calendar_view,
42
+ messages_view,
43
+ projects_view,
44
+ reports_view,
45
+ settings_view,
46
+ team_view,
47
+ ),
48
+ port=int(os.environ.get("PYJS_PORT", "8000")),
49
+ )