pyweber 1.6.0.dev0__tar.gz → 1.6.0.dev3__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 (123) hide show
  1. {pyweber-1.6.0.dev0/pyweber.egg-info → pyweber-1.6.0.dev3}/PKG-INFO +12 -3
  2. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/README.md +10 -2
  3. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyproject.toml +3 -1
  4. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/__init__.py +18 -1
  5. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/connection/session.py +14 -3
  6. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/connection/websocket.py +240 -122
  7. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/core/element.py +66 -8
  8. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/core/events.py +70 -7
  9. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/core/template.py +77 -9
  10. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/core/window.py +5 -2
  11. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/db/cli.py +21 -3
  12. pyweber-1.6.0.dev3/pyweber/models/dom_merge.py +92 -0
  13. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/element.py +11 -8
  14. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/handoff.py +9 -2
  15. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/ws_message.py +28 -4
  16. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/pyweber/pyweber.py +17 -3
  17. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/services/template_service.py +33 -1
  18. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/config.toml +3 -0
  19. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/js.js +78 -4
  20. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/security.py +78 -0
  21. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3/pyweber.egg-info}/PKG-INFO +12 -3
  22. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber.egg-info/SOURCES.txt +1 -0
  23. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/LICENSE +0 -0
  24. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/MANIFEST.in +0 -0
  25. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/NOTICE +0 -0
  26. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/admin/index.html +0 -0
  27. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/admin/src/script.js +0 -0
  28. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/admin/src/style.css +0 -0
  29. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/auth/__init__.py +0 -0
  30. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/auth/decorators.py +0 -0
  31. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/auth/passwords.py +0 -0
  32. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/auth/rbac.py +0 -0
  33. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/auth/session.py +0 -0
  34. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/cli/__init__.py +0 -0
  35. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/cli/commands.py +0 -0
  36. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/components/__init__.py +0 -0
  37. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/components/form.py +0 -0
  38. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/components/general.py +0 -0
  39. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/components/input.py +0 -0
  40. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/config/__init__.py +0 -0
  41. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/config/config.py +0 -0
  42. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/connection/__init__.py +0 -0
  43. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/connection/http.py +0 -0
  44. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/connection/reload.py +0 -0
  45. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/connection/selector.py +0 -0
  46. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/core/__init__.py +0 -0
  47. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/core/html_parser.py +0 -0
  48. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/db/__init__.py +0 -0
  49. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/db/config.py +0 -0
  50. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/db/engine.py +0 -0
  51. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/db/model.py +0 -0
  52. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/db/query.py +0 -0
  53. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/db/session.py +0 -0
  54. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/__init__.py +0 -0
  55. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/context.py +0 -0
  56. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/cookies.py +0 -0
  57. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/create_app.py +0 -0
  58. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/error_pages.py +0 -0
  59. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/field.py +0 -0
  60. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/field_storage.py +0 -0
  61. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/file.py +0 -0
  62. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/file_stream.py +0 -0
  63. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/headers.py +0 -0
  64. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/middleware.py +0 -0
  65. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/openapi.py +0 -0
  66. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/rate_limit.py +0 -0
  67. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/request.py +0 -0
  68. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/response.py +0 -0
  69. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/routes.py +0 -0
  70. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/run.py +0 -0
  71. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/security.py +0 -0
  72. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/stream_stats.py +0 -0
  73. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/strem_stats.py +0 -0
  74. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/task_manager.py +0 -0
  75. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/models/template_diff.py +0 -0
  76. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/pyweber/__init__.py +0 -0
  77. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/services/__init__.py +0 -0
  78. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/services/openapi_setup.py +0 -0
  79. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/services/response_pipeline.py +0 -0
  80. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/services/static_files.py +0 -0
  81. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/session_store/__init__.py +0 -0
  82. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/.gitignore +0 -0
  83. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/css.css +0 -0
  84. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/docs.html +0 -0
  85. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/favicon/android-chrome-192x192.png +0 -0
  86. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/favicon/android-chrome-512x512.png +0 -0
  87. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/favicon/apple-touch-icon.png +0 -0
  88. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/favicon/favicon-16x16.png +0 -0
  89. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/favicon/favicon-32x32.png +0 -0
  90. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/favicon/favicon.ico +0 -0
  91. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/favicon/pyweber.png +0 -0
  92. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/favicon/site.webmanifest +0 -0
  93. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/handlers.js +0 -0
  94. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/html.html +0 -0
  95. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/html401.html +0 -0
  96. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/html404.html +0 -0
  97. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/html500.html +0 -0
  98. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/loading.html +0 -0
  99. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/main.py +0 -0
  100. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/pyweber.css +0 -0
  101. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/static/update.py +0 -0
  102. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/testing/__init__.py +0 -0
  103. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/testing/client.py +0 -0
  104. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/__init__.py +0 -0
  105. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/async_utils.py +0 -0
  106. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/deprecation.py +0 -0
  107. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/exceptions.py +0 -0
  108. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/icons.py +0 -0
  109. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/loads.py +0 -0
  110. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/mime.py +0 -0
  111. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/types.py +0 -0
  112. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber/utils/utils.py +0 -0
  113. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber.egg-info/dependency_links.txt +0 -0
  114. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber.egg-info/entry_points.txt +0 -0
  115. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber.egg-info/requires.txt +0 -0
  116. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/pyweber.egg-info/top_level.txt +0 -0
  117. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/setup.cfg +0 -0
  118. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/tests/test_config.py +0 -0
  119. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/tests/test_context.py +0 -0
  120. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/tests/test_cookies.py +0 -0
  121. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/tests/test_public_api.py +0 -0
  122. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/tests/test_response.py +0 -0
  123. {pyweber-1.6.0.dev0 → pyweber-1.6.0.dev3}/tests/test_template_diff.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyweber
3
- Version: 1.6.0.dev0
3
+ Version: 1.6.0.dev3
4
4
  Summary: A lightweight Python framework for building and managing web applications.
5
5
  Author-email: DevPythonMZ <pypi.dev@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -10,6 +10,7 @@ Project-URL: Tracker, https://github.com/pyweber/pyweber/issues
10
10
  Project-URL: PyPI, https://pypi.org/project/pyweber/
11
11
  Project-URL: YouTube, https://youtube.com/@devpythonMZ
12
12
  Project-URL: Examples, https://github.com/pyweber-examples
13
+ Project-URL: Security, https://pyweber.dev/guides/supported-versions/
13
14
  Requires-Python: >=3.10
14
15
  Description-Content-Type: text/markdown
15
16
  License-File: LICENSE
@@ -55,17 +56,25 @@ Pyweber is a lightweight Python web framework designed to create dynamic, reacti
55
56
  - **Intuitive DOM Manipulation**: Query and modify elements with familiar selectors
56
57
  - **Event-Driven Programming**: Handle user interactions with Python event handlers
57
58
  - **WebSocket Integration**: Real-time communication between client and server
58
- - **Template Handoff (1.3.0)**: WebSocket sessions reuse the HTTP-rendered template — handlers are not executed twice on connect
59
+ - **Template Handoff (1.3.0+)**: WebSocket sessions reuse the HTTP-rendered template — handlers are not executed twice on connect
60
+ - **Identity-preserving sync (1.6.0+)**: `self` / subclass element refs stay valid after WS connect
61
+ - **Optional ORM & Redis sessions (1.6.0+)**: `pyweber[db]`, `pyweber[redis]`
62
+ - **Auth & RBAC (1.5+)**: signed login cookie, roles/permissions
59
63
  - **Minimal Configuration**: Get started quickly with sensible defaults
60
64
 
61
65
  ## Quick Start
62
66
 
63
67
  ### Installation
64
68
 
69
+ !!! danger "Avoid ≤ 1.3.1"
70
+ Older lines are **not recommended** for production. Use a current release:
71
+
65
72
  ```bash
66
- pip install pyweber
73
+ pip install -U 'pyweber>=1.5'
67
74
  ```
68
75
 
76
+ See [Supported versions](https://pyweber.dev/guides/supported-versions/) (docs) for yank / security notes. `pyproject.toml` cannot warn when someone pins an old wheel — that requires [PyPI yanking](https://docs.pypi.org/project-management/yanking/).
77
+
69
78
  ### Create a Project
70
79
 
71
80
  ```bash
@@ -14,17 +14,25 @@ Pyweber is a lightweight Python web framework designed to create dynamic, reacti
14
14
  - **Intuitive DOM Manipulation**: Query and modify elements with familiar selectors
15
15
  - **Event-Driven Programming**: Handle user interactions with Python event handlers
16
16
  - **WebSocket Integration**: Real-time communication between client and server
17
- - **Template Handoff (1.3.0)**: WebSocket sessions reuse the HTTP-rendered template — handlers are not executed twice on connect
17
+ - **Template Handoff (1.3.0+)**: WebSocket sessions reuse the HTTP-rendered template — handlers are not executed twice on connect
18
+ - **Identity-preserving sync (1.6.0+)**: `self` / subclass element refs stay valid after WS connect
19
+ - **Optional ORM & Redis sessions (1.6.0+)**: `pyweber[db]`, `pyweber[redis]`
20
+ - **Auth & RBAC (1.5+)**: signed login cookie, roles/permissions
18
21
  - **Minimal Configuration**: Get started quickly with sensible defaults
19
22
 
20
23
  ## Quick Start
21
24
 
22
25
  ### Installation
23
26
 
27
+ !!! danger "Avoid ≤ 1.3.1"
28
+ Older lines are **not recommended** for production. Use a current release:
29
+
24
30
  ```bash
25
- pip install pyweber
31
+ pip install -U 'pyweber>=1.5'
26
32
  ```
27
33
 
34
+ See [Supported versions](https://pyweber.dev/guides/supported-versions/) (docs) for yank / security notes. `pyproject.toml` cannot warn when someone pins an old wheel — that requires [PyPI yanking](https://docs.pypi.org/project-management/yanking/).
35
+
28
36
  ### Create a Project
29
37
 
30
38
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyweber"
7
- version = "1.6.0.dev0"
7
+ version = "1.6.0.dev3"
8
8
  description = "A lightweight Python framework for building and managing web applications."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "DevPythonMZ", email = "pypi.dev@gmail.com" }]
@@ -18,6 +18,8 @@ Tracker = "https://github.com/pyweber/pyweber/issues"
18
18
  PyPI = "https://pypi.org/project/pyweber/"
19
19
  YouTube = "https://youtube.com/@devpythonMZ"
20
20
  Examples = "https://github.com/pyweber-examples"
21
+ # Supported versions / security policy (≤1.3.1 not recommended; yank on PyPI — not via this file)
22
+ Security = "https://pyweber.dev/guides/supported-versions/"
21
23
 
22
24
  [project.scripts]
23
25
  pyweber = "pyweber.cli.commands:app"
@@ -105,7 +105,6 @@ from .utils.types import (
105
105
  EventType,
106
106
  HTMLTag,
107
107
  HTTPStatusCode,
108
- JWTAlgorithms,
109
108
  NonSelfClosingHTMLTags,
110
109
  WebSocketStatusCode,
111
110
  WindowEventType,
@@ -305,3 +304,21 @@ __all__ = [
305
304
  'Label',
306
305
  'TextArea',
307
306
  ]
307
+
308
+
309
+ def __getattr__(name: str):
310
+ if name == 'JWTAlgorithms':
311
+ from pyweber.utils.deprecation import warn_deprecated
312
+ from pyweber.utils.types import JWTAlgorithms as _JWTAlgorithms
313
+
314
+ warn_deprecated(
315
+ 'pyweber.JWTAlgorithms',
316
+ alternative=(
317
+ 'pyjwt (or similar) for encode/decode/verify; '
318
+ 'pyweber.auth for session login. '
319
+ 'JWTAlgorithms remains an algorithm-name enum only until 2.0'
320
+ ),
321
+ removal='2.0',
322
+ )
323
+ return _JWTAlgorithms
324
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
@@ -21,7 +21,12 @@ class Session:
21
21
  self.session_id = session_id
22
22
  self.create_at = time()
23
23
  self.current_route = current_route
24
- self.old_template = template
24
+ # Must be a distinct snapshot for TemplateDiff; aliasing template makes
25
+ # every e.update() produce an empty diff (mutations visible on both sides).
26
+ try:
27
+ self.old_template = template.clone()
28
+ except Exception:
29
+ self.old_template = None
25
30
 
26
31
 
27
32
  class SessionManager:
@@ -57,8 +62,14 @@ class SessionManager:
57
62
  self._schedule_persist(session)
58
63
 
59
64
  def remove_session(self, session_id: str):
60
- if session_id in self.sessions:
61
- del self.sessions[session_id]
65
+ session = self.sessions.pop(session_id, None)
66
+ if session is not None:
67
+ try:
68
+ from pyweber.core.events import cleanup_template_events
69
+ cleanup_template_events(getattr(session, 'template', None))
70
+ cleanup_template_events(getattr(session, 'old_template', None))
71
+ except Exception as exc:
72
+ logger.debug('EventBook cleanup skipped: %s', exc)
62
73
  self._schedule_delete(session_id)
63
74
 
64
75
  def get_session(self, session_id: str):
@@ -20,7 +20,7 @@ from pyweber.utils.security import (
20
20
  from pyweber.connection.session import sessions, Session
21
21
  from pyweber.models.template_diff import TemplateDiff
22
22
  from pyweber.models.task_manager import TaskManager
23
- from pyweber.core.events import EventConstrutor, EventBook
23
+ from pyweber.core.events import EventConstrutor
24
24
  from pyweber.models.context import set_current_window, reset_current_window
25
25
 
26
26
  if TYPE_CHECKING:
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
29
29
  from pyweber.core.window import Window
30
30
 
31
31
  def need_message_keys():
32
+ """Keys the browser always includes in ``getEventData`` payloads."""
32
33
  return [
33
34
  'type',
34
35
  'event_ref',
@@ -46,11 +47,38 @@ def need_message_keys():
46
47
  'handoffToken',
47
48
  ]
48
49
 
50
+
51
+ def _ws_message_is_actionable(message: dict) -> bool:
52
+ """True when the payload can create a session or drive an event/update.
53
+
54
+ The old gate rejected *any* message without ``template`` when the session
55
+ was unknown — that dropped clicks, window responses, and handshakes that
56
+ omit outerHTML (``includeTemplate: false``).
57
+ """
58
+ if message.get('template'):
59
+ return True
60
+ if message.get('handoffToken'):
61
+ return True
62
+ if message.get('type') and message.get('event_ref'):
63
+ return True
64
+ if message.get('window_response'):
65
+ return True
66
+ if message.get('file_content'):
67
+ return True
68
+ session_id = message.get('sessionId')
69
+ return bool(session_id and session_id in sessions.all_sessions)
70
+
49
71
  def event_is_running(message: wsMessage, task_manager: TaskManager) -> bool:
50
72
  """Check if event is running"""
51
73
  if message.session_id in task_manager.active_handlers_async:
52
74
  template = sessions.get_session(session_id=message.session_id).template
53
- event_id = template.getElement(by='uuid', value=message.target_uuid).events.__dict__.get(f'on{message.type}')
75
+ element = template.getElement(by='uuid', value=message.target_uuid)
76
+ handler = element.events.__dict__.get(f'on{message.type}') if element else None
77
+
78
+ if not callable(handler):
79
+ return False
80
+
81
+ event_id = f'event_{id(handler)}'
54
82
 
55
83
  if event_id in task_manager.active_handlers_async[message.session_id]:
56
84
  return True
@@ -150,6 +178,7 @@ class WebsocketServer:
150
178
  current_opcode = None
151
179
  is_coro = inspect.iscoroutinefunction(message_handler)
152
180
  timeout = 60 * 60
181
+ consumer_task: asyncio.Task | None = None
153
182
 
154
183
  try:
155
184
  while True:
@@ -174,14 +203,19 @@ class WebsocketServer:
174
203
  else self.__all_message
175
204
  )
176
205
  self.__messages.append(decoded)
177
-
178
- if is_coro:
179
- asyncio.create_task(message_handler(self))
180
- else:
181
- message_handler(self)
182
-
183
206
  self.__all_message = b''
184
207
 
208
+ # One long-lived consumer (async for). Spawning a
209
+ # task per frame made sync ``for`` exit after the
210
+ # first drain and raced multiple consumers.
211
+ if consumer_task is None or consumer_task.done():
212
+ if is_coro:
213
+ consumer_task = asyncio.create_task(
214
+ message_handler(self)
215
+ )
216
+ else:
217
+ message_handler(self)
218
+
185
219
  elif current_opcode == 8:
186
220
  break
187
221
 
@@ -209,6 +243,12 @@ class WebsocketServer:
209
243
  raise e
210
244
 
211
245
  finally:
246
+ if consumer_task is not None and not consumer_task.done():
247
+ consumer_task.cancel()
248
+ try:
249
+ await consumer_task
250
+ except (asyncio.CancelledError, Exception):
251
+ pass
212
252
  await self.close()
213
253
  PrintLine(text=f'Connection {self.id} closed.')
214
254
 
@@ -290,12 +330,13 @@ class BaseWebsockets:
290
330
  self.__window_response = value
291
331
 
292
332
  def event_handler(self, message: wsMessage):
333
+ session = sessions.get_session(session_id=message.session_id)
293
334
  return EventConstrutor(
294
335
  target_id=message.target_uuid,
295
336
  current_target_id=message.current_target_uuid,
296
337
  app=self.app,
297
338
  ws=self,
298
- session=sessions.get_session(session_id=message.session_id),
339
+ session=session,
299
340
  route=message.route,
300
341
  event_data=message.event_data,
301
342
  event_type=message.type
@@ -304,14 +345,28 @@ class BaseWebsockets:
304
345
  async def message_handler(self, message: wsMessage):
305
346
  token = set_current_window(message.window)
306
347
  try:
348
+ if sessions.get_session(session_id=message.session_id) is None:
349
+ return
350
+
307
351
  event_handler = self.event_handler(message)
308
352
 
309
353
  if message.event_ref == 'document':
310
354
  if event_handler.current_target:
311
- event_id = event_handler.current_target.events.__dict__.get(f'on{message.type}')
312
- if event_id and event_id in EventBook:
313
- handler: Callable[..., Any] = EventBook.get(event_id).get('event')
314
-
355
+ raw = event_handler.current_target.events.__dict__.get(f'on{message.type}')
356
+ handler: Callable[..., Any] | None = None
357
+ event_id: str | None = None
358
+
359
+ if callable(raw):
360
+ handler = raw
361
+ event_id = f'event_{id(handler)}'
362
+ elif isinstance(raw, str):
363
+ from pyweber.core.events import EventBook
364
+ entry = EventBook.get(raw)
365
+ if entry and callable(entry.get('event')):
366
+ handler = entry['event']
367
+ event_id = raw
368
+
369
+ if handler and event_id:
315
370
  if inspect.iscoroutinefunction(handler):
316
371
  if event_id not in self.task_manager.active_handlers_async.get(message.session_id, {}):
317
372
  await self.task_manager.create_task_async(
@@ -355,10 +410,7 @@ class BaseWebsockets:
355
410
  session = sessions.get_session(session_id=session_id)
356
411
  current_template: 'Template' = data.get('template', None)
357
412
 
358
- if current_template:
359
- updated_template = current_template.clone()
360
- # session.template = updated_template
361
-
413
+ if current_template and session is not None:
362
414
  data['template'] = await self.get_template_diff(
363
415
  session=session
364
416
  )
@@ -499,13 +551,27 @@ class BaseWebsockets:
499
551
 
500
552
  diff = TemplateDiff()
501
553
 
554
+ if session.old_template is None:
555
+ try:
556
+ session.old_template = session.template.clone()
557
+ except Exception:
558
+ # No baseline → empty diff rather than crashing the WS send path
559
+ return {}
560
+
502
561
  for tag in ['head', 'body']:
562
+ old_el = session.old_template.querySelector(tag)
563
+ new_el = session.template.querySelector(tag)
564
+ if old_el is None or new_el is None:
565
+ continue
503
566
  diff.track_differences(
504
- new_element=session.template.querySelector(tag),
505
- old_element=session.old_template.querySelector(tag)
567
+ new_element=new_el,
568
+ old_element=old_el,
506
569
  )
507
570
 
508
- session.old_template = session.template.clone()
571
+ try:
572
+ session.old_template = session.template.clone()
573
+ except Exception as exc:
574
+ PrintLine(text=f'template clone after diff failed: {exc}', level='ERROR')
509
575
 
510
576
  return diff.differences
511
577
 
@@ -518,18 +584,33 @@ class WebsocketManager(BaseWebsockets):
518
584
  self.ws_connections[connection.id] = connection
519
585
 
520
586
  def remove_connection(self, id: str):
521
- if id in self.ws_connections:
522
- self.ws_connections[id].close()
523
- del self.ws_connections[id]
587
+ conn = self.ws_connections.pop(id, None)
588
+ if conn is None:
589
+ return
590
+ try:
591
+ loop = asyncio.get_running_loop()
592
+ loop.create_task(conn.close())
593
+ except RuntimeError:
594
+ try:
595
+ conn.client.close()
596
+ except Exception:
597
+ pass
524
598
 
525
599
  def remove_all(self):
526
- for id, conn in self.ws_connections.items():
527
- conn.close()
528
- del self.ws_connections[id]
600
+ ids = list(self.ws_connections.keys())
601
+ for conn_id in ids:
602
+ self.remove_connection(conn_id)
529
603
 
530
604
  def send_all(self, message: bytes):
531
- for _, conn in self.ws_connections.items():
532
- conn.send(message=message)
605
+ for conn in list(self.ws_connections.values()):
606
+ try:
607
+ loop = asyncio.get_running_loop()
608
+ loop.create_task(conn.send(message=message))
609
+ except RuntimeError:
610
+ try:
611
+ asyncio.run(conn.send(message=message))
612
+ except Exception:
613
+ pass
533
614
 
534
615
  def get_connection(self, id: str):
535
616
  return self.ws_connections.get(id, None)
@@ -549,12 +630,109 @@ class WebsocketManager(BaseWebsockets):
549
630
  # Ignore client-supplied session_id when cookie is missing/invalid
550
631
  return generate_session_id()
551
632
 
633
+ def bind_session_id(
634
+ self,
635
+ *,
636
+ client_session_id: str | None,
637
+ connection_id: str | None,
638
+ cookies: dict[str, str] | None,
639
+ ) -> tuple[str, bool]:
640
+ """Resolve the session for this WS connection.
641
+
642
+ Prefer an id already bound on the socket (``connection_id``). That stops
643
+ the handshake race where a second frame still has ``sessionId: null`` and
644
+ would otherwise mint a new session + ``clone_template`` (new UUIDs → no
645
+ event handlers match the DOM).
646
+
647
+ Returns ``(session_id, is_new)``.
648
+ """
649
+ if connection_id and connection_id in sessions.all_sessions:
650
+ return connection_id, False
651
+
652
+ if client_session_id and client_session_id in sessions.all_sessions:
653
+ return client_session_id, False
654
+
655
+ resolved = self.get_session_id(
656
+ session_id=client_session_id,
657
+ cookies=cookies or {},
658
+ )
659
+ if resolved in sessions.all_sessions:
660
+ return resolved, False
661
+
662
+ # Keep a stable id for this socket even before add_session runs
663
+ if connection_id:
664
+ return connection_id, True
665
+
666
+ return resolved, True
667
+
668
+ async def _ensure_session_and_template(
669
+ self,
670
+ message: wsMessage,
671
+ *,
672
+ connection_id: str | None,
673
+ cookies: dict[str, str] | None,
674
+ send_target,
675
+ ) -> tuple[str, 'Template', bool]:
676
+ """Bind session id *before* template sync, then create/reuse session."""
677
+ session_id, is_new = self.bind_session_id(
678
+ client_session_id=message.session_id,
679
+ connection_id=connection_id,
680
+ cookies=cookies,
681
+ )
682
+ message.session_id = session_id
683
+
684
+ sync_template = await self.get_sync_template(message=message)
685
+
686
+ if is_new:
687
+ self.add_session(
688
+ session_id=session_id,
689
+ template=sync_template,
690
+ window=message.window,
691
+ route=message.route,
692
+ )
693
+ self.ws_connections[session_id] = send_target
694
+ await self.send_message(
695
+ data={
696
+ 'setSessionId': session_id,
697
+ 'windowEvents': message.window.get_all_event_ids,
698
+ },
699
+ session_id=session_id,
700
+ )
701
+ else:
702
+ self.ws_connections[session_id] = send_target
703
+ if message.get_value(key='template'):
704
+ self.update_session(
705
+ session_id=session_id,
706
+ template=sync_template,
707
+ window=message.window,
708
+ route=message.route,
709
+ )
710
+ session = sessions.get_session(session_id=session_id)
711
+ if session:
712
+ try:
713
+ session.old_template = sync_template.clone()
714
+ except Exception as exc:
715
+ PrintLine(
716
+ text=f'template clone for diff failed: {exc}',
717
+ level='ERROR',
718
+ )
719
+
720
+ return session_id, sync_template, is_new
721
+
552
722
  async def get_sync_template(self, message: wsMessage):
553
723
  assert isinstance(message, wsMessage)
554
724
  sync_template = await message.template
555
725
  session = sessions.get_session(session_id=message.session_id)
556
726
  if session:
557
- session.old_template = sync_template.clone()
727
+ try:
728
+ session.old_template = sync_template.clone()
729
+ except Exception as exc:
730
+ # Never drop the WS event path because old_template clone failed
731
+ # (historically Form/Input attrs setters broke Element.clone).
732
+ PrintLine(text=f'template clone for diff failed: {exc}', level='ERROR')
733
+ # Do not alias template (that yields empty diffs after mutations).
734
+ if session.old_template is sync_template:
735
+ session.old_template = None
558
736
  return sync_template
559
737
 
560
738
  def add_session(self, session_id: str, template: 'Template', window: 'Window', route: str):
@@ -578,7 +756,14 @@ class WebsocketManager(BaseWebsockets):
578
756
  sessions.remove_session(session_id=session_id)
579
757
 
580
758
  if session_id in self.ws_connections:
581
- del self.ws_connections[session_id]
759
+ conn = self.ws_connections.pop(session_id)
760
+ try:
761
+ await conn.close()
762
+ except Exception:
763
+ try:
764
+ conn.client.close()
765
+ except Exception:
766
+ pass
582
767
 
583
768
  try:
584
769
  await self.task_manager.cancel_session_handlers(session_id=session_id)
@@ -596,19 +781,19 @@ class WebsocketManager(BaseWebsockets):
596
781
  if not isinstance(message, dict):
597
782
  return {}
598
783
 
599
- if not all(key in need_message_keys() for key in message):
784
+ # Require the JS contract keys (values may be null). Extra keys are
785
+ # ignored — do not invert this into an allowlist over message.keys()
786
+ # (that rejects nothing useful and confuses debugging).
787
+ required = need_message_keys()
788
+ if not all(key in message for key in required):
600
789
  return {}
601
790
 
602
- session_id = message.get('sessionId')
603
- if (
604
- not message.get('template')
605
- and message.get('window_data')
606
- and (not session_id or session_id not in sessions.all_sessions)
607
- ):
791
+ if not _ws_message_is_actionable(message):
608
792
  return {}
609
793
 
610
- route = message.get('route', '')
794
+ route = message.get('route', '') or ''
611
795
  route = route[:-1] if route.endswith('/') and len(route) > 1 else route
796
+ message['route'] = route
612
797
  if route not in self.app.list_routes:
613
798
  return {}
614
799
 
@@ -618,7 +803,7 @@ class WebsocketManager(BaseWebsockets):
618
803
  return {}
619
804
 
620
805
  async def ws_handler_wsgi(self, ws_server: WebsocketServer):
621
- for message in ws_server:
806
+ async for message in ws_server:
622
807
  raw_message = self.process_ws_message_handler(message=message)
623
808
 
624
809
  if not raw_message:
@@ -632,54 +817,19 @@ class WebsocketManager(BaseWebsockets):
632
817
  if message.window_response:
633
818
  await self.set_window_response(message.window_response, message.session_id)
634
819
 
635
- sync_template = await self.get_sync_template(message=message)
636
-
637
- is_new_session = (
638
- not message.session_id
639
- or any(
640
- session_id not in sessions.all_sessions
641
- for session_id in [message.session_id, ws_server.id]
642
- )
820
+ session_id, sync_template, _is_new = await self._ensure_session_and_template(
821
+ message,
822
+ connection_id=ws_server.id,
823
+ cookies=getattr(ws_server, 'cookies', {}) or {},
824
+ send_target=ws_server,
643
825
  )
644
-
645
- if is_new_session:
646
- ws_server.id = self.get_session_id(
647
- session_id=message.session_id,
648
- cookies=getattr(ws_server, 'cookies', {}) or {},
649
- )
650
- self.ws_connections[ws_server.id] = ws_server
651
-
652
- self.add_session(
653
- session_id=ws_server.id,
654
- template=sync_template,
655
- window=message.window,
656
- route=message.route
657
- )
658
-
659
- await self.send_message(
660
- data={
661
- 'setSessionId': ws_server.id,
662
- 'windowEvents': message.window.get_all_event_ids
663
- },
664
- session_id=ws_server.id
665
- )
666
-
667
- elif message.get_value(key='template'):
668
- self.update_session(
669
- session_id=ws_server.id,
670
- template=sync_template,
671
- window=message.window,
672
- route=message.route
673
- )
674
- session = sessions.get_session(session_id=ws_server.id)
675
- if session:
676
- session.old_template = sync_template.clone()
826
+ ws_server.id = session_id
677
827
 
678
828
  if message.type and message.event_ref and not event_is_running(
679
829
  message=message, task_manager=self.task_manager
680
830
  ):
681
831
  self.update_session(
682
- session_id=ws_server.id,
832
+ session_id=session_id,
683
833
  template=sync_template,
684
834
  window=message.window,
685
835
  route=message.route
@@ -711,55 +861,23 @@ class WebsocketManager(BaseWebsockets):
711
861
  if message.file_content:
712
862
  await self.set_file_content(message.file_content, message.session_id)
713
863
 
714
- sync_template = await self.get_sync_template(message=message)
715
-
716
- is_new_session = (
717
- not message.session_id
718
- or message.session_id not in sessions.all_sessions
864
+ session_id, sync_template, _is_new = await self._ensure_session_and_template(
865
+ message,
866
+ connection_id=ws_connection,
867
+ cookies=handshake_cookies,
868
+ send_target=send,
719
869
  )
870
+ ws_connection = session_id
720
871
 
721
- if is_new_session:
722
- ws_connection = self.get_session_id(
723
- session_id=message.session_id,
724
- cookies=handshake_cookies,
725
- )
726
-
727
- self.add_session(
728
- session_id=ws_connection,
729
- template=sync_template,
730
- window=message.window,
731
- route=message.route
732
- )
733
-
734
- self.ws_connections[ws_connection] = send
735
-
736
- await self.send_message(
737
- data={
738
- 'setSessionId': ws_connection,
739
- 'windowEvents': message.window.get_all_event_ids,
740
- },
741
- session_id=ws_connection
742
- )
743
-
744
- elif message.get_value(key='template'):
745
- self.update_session(
746
- session_id=ws_connection,
747
- template=sync_template,
748
- window=message.window,
749
- route=message.route
750
- )
751
- session = sessions.get_session(session_id=ws_connection)
752
- if session:
753
- session.old_template = sync_template.clone()
754
-
755
- if message.type and message.event_ref and not event_is_running(message=message, task_manager=self.task_manager):
872
+ if message.type and message.event_ref and not event_is_running(
873
+ message=message, task_manager=self.task_manager
874
+ ):
756
875
  self.update_session(
757
876
  session_id=ws_connection,
758
877
  template=sync_template,
759
878
  window=message.window,
760
879
  route=message.route
761
880
  )
762
-
763
881
  await self.message_handler(message=message)
764
882
  else:
765
883
  break