pywebview-htmx 0.4.1__tar.gz → 0.4.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 (22) hide show
  1. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/PKG-INFO +33 -1
  2. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/README.md +32 -0
  3. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/pyproject.toml +2 -1
  4. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/runtime.py +74 -30
  5. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/runtime.js +25 -1
  6. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/base.css +0 -1
  7. pywebview_htmx-0.4.2/tests/native_smoke.py +104 -0
  8. pywebview_htmx-0.4.2/tests/test_html_assets_browser.py +42 -0
  9. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_js_runtime.py +225 -0
  10. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_injection.py +42 -0
  11. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_params_attr.py +8 -0
  12. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_themes.py +41 -0
  13. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/LICENSE +0 -0
  14. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/__init__.py +0 -0
  15. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/py.typed +0 -0
  16. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/aurora.css +0 -0
  17. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
  18. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/paper.css +0 -0
  19. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/conftest.py +0 -0
  20. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_create_window.py +0 -0
  21. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_script_loading.py +0 -0
  22. {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_repo_legibility.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pywebview-htmx
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: HTMX-style declarative UI bindings for PyWebview apps
5
5
  Author-Email: "B.T. Franklin" <brandon.franklin@gmail.com>
6
6
  License: MIT License
@@ -195,6 +195,9 @@ Injects the runtime script tag into an HTML string (idempotent).
195
195
  Returns HTML-escaped JSON that is safe to place in a `data-py-params`
196
196
  attribute.
197
197
 
198
+ Non-finite numbers (`NaN` and positive or negative infinity) raise `ValueError`.
199
+ JSON does not support these values.
200
+
198
201
  ```python
199
202
  from pywebview_htmx import encode_params_attr
200
203
 
@@ -237,6 +240,9 @@ form submission, link navigation, and submit/reset controls associated with a
237
240
  form. Ordinary buttons and the native state changes of checkboxes, radio
238
241
  buttons, and labels are preserved.
239
242
 
243
+ A label that contains its control calls Python once per activation. Clicking
244
+ the label text still changes the checkbox or radio state.
245
+
240
246
  ### `data-py-params` (optional)
241
247
 
242
248
  JSON payload passed to Python method.
@@ -346,6 +352,9 @@ The `py-wait` selector is resolved for each accepted request. Loading state is
346
352
  counted separately per resolved wait element, so a shared spinner stays active
347
353
  until every request using that particular element finishes.
348
354
 
355
+ The bundled loading styles show `.py-waiting` without blocking pointer input.
356
+ The request policy controls whether another trigger is accepted.
357
+
349
358
  ### `latest-wins` (default)
350
359
 
351
360
  Multiple rapid requests are allowed; stale responses in the same request state
@@ -356,6 +365,29 @@ scope are ignored. Only the latest request for that scope updates the DOM.
356
365
  If a request is already in flight for the same request state scope, new triggers
357
366
  are ignored and `py:ignored` is emitted.
358
367
 
368
+ ### Python Handler Concurrency
369
+
370
+ `latest-wins` does not cancel Python work. Each accepted call still runs, even
371
+ when its returned HTML is later ignored. PyWebview runs exposed handlers in
372
+ separate threads. Protect shared mutable data with a lock or a database
373
+ transaction when needed.
374
+
375
+ `drop` applies to the same request scope. It does not prevent calls from other
376
+ scopes from changing the same Python data.
377
+
378
+ ### Bridge Readiness
379
+
380
+ PyWebview creates the bridge after the document loads. Wait for the
381
+ `pywebviewready` event before starting programmatic calls:
382
+
383
+ ```js
384
+ window.addEventListener("pywebviewready", () => {
385
+ document.querySelector("#load-button").click();
386
+ });
387
+ ```
388
+
389
+ A call made before the bridge is available emits `py:error`. It is not queued.
390
+
359
391
  ## Dynamic Content Re-processing
360
392
 
361
393
  After a swap, `pywebview-htmx` automatically scans swapped content for new `py-call`
@@ -155,6 +155,9 @@ Injects the runtime script tag into an HTML string (idempotent).
155
155
  Returns HTML-escaped JSON that is safe to place in a `data-py-params`
156
156
  attribute.
157
157
 
158
+ Non-finite numbers (`NaN` and positive or negative infinity) raise `ValueError`.
159
+ JSON does not support these values.
160
+
158
161
  ```python
159
162
  from pywebview_htmx import encode_params_attr
160
163
 
@@ -197,6 +200,9 @@ form submission, link navigation, and submit/reset controls associated with a
197
200
  form. Ordinary buttons and the native state changes of checkboxes, radio
198
201
  buttons, and labels are preserved.
199
202
 
203
+ A label that contains its control calls Python once per activation. Clicking
204
+ the label text still changes the checkbox or radio state.
205
+
200
206
  ### `data-py-params` (optional)
201
207
 
202
208
  JSON payload passed to Python method.
@@ -306,6 +312,9 @@ The `py-wait` selector is resolved for each accepted request. Loading state is
306
312
  counted separately per resolved wait element, so a shared spinner stays active
307
313
  until every request using that particular element finishes.
308
314
 
315
+ The bundled loading styles show `.py-waiting` without blocking pointer input.
316
+ The request policy controls whether another trigger is accepted.
317
+
309
318
  ### `latest-wins` (default)
310
319
 
311
320
  Multiple rapid requests are allowed; stale responses in the same request state
@@ -316,6 +325,29 @@ scope are ignored. Only the latest request for that scope updates the DOM.
316
325
  If a request is already in flight for the same request state scope, new triggers
317
326
  are ignored and `py:ignored` is emitted.
318
327
 
328
+ ### Python Handler Concurrency
329
+
330
+ `latest-wins` does not cancel Python work. Each accepted call still runs, even
331
+ when its returned HTML is later ignored. PyWebview runs exposed handlers in
332
+ separate threads. Protect shared mutable data with a lock or a database
333
+ transaction when needed.
334
+
335
+ `drop` applies to the same request scope. It does not prevent calls from other
336
+ scopes from changing the same Python data.
337
+
338
+ ### Bridge Readiness
339
+
340
+ PyWebview creates the bridge after the document loads. Wait for the
341
+ `pywebviewready` event before starting programmatic calls:
342
+
343
+ ```js
344
+ window.addEventListener("pywebviewready", () => {
345
+ document.querySelector("#load-button").click();
346
+ });
347
+ ```
348
+
349
+ A call made before the bridge is available emits `py:error`. It is not queued.
350
+
319
351
  ## Dynamic Content Re-processing
320
352
 
321
353
  After a swap, `pywebview-htmx` automatically scans swapped content for new `py-call`
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
22
  "Topic :: Internet :: WWW/HTTP",
23
23
  ]
24
- version = "0.4.1"
24
+ version = "0.4.2"
25
25
 
26
26
  [project.license]
27
27
  file = "LICENSE"
@@ -69,5 +69,6 @@ includes = [
69
69
 
70
70
  [tool.pdm.scripts]
71
71
  test = "pytest"
72
+ test-native = "python tests/native_smoke.py"
72
73
  lint = "ruff check ."
73
74
  install-browser = "playwright install chromium"
@@ -2,24 +2,77 @@ from __future__ import annotations
2
2
 
3
3
  from importlib.resources import files
4
4
  import json
5
- import re
6
5
  from html import escape as html_escape
6
+ from html.parser import HTMLParser
7
7
  from typing import Any
8
8
 
9
9
  import webview
10
10
 
11
- _SCRIPT_TAG_RE = re.compile(
12
- r"<script[^>]*\bdata-pywebview-htmx\s*=\s*(?:[\"']true[\"']|true\b)",
13
- re.IGNORECASE,
14
- )
15
- _THEME_TAG_RE = re.compile(
16
- r"<style[^>]*\bdata-pywebview-theme\s*=\s*[\"'](?P<name>[^\"']+)[\"'][^>]*>.*?</style>",
17
- re.IGNORECASE | re.DOTALL,
18
- )
19
11
  _THEME_BASE_NAME = "base"
20
12
  DEFAULT_THEME = "aurora"
21
13
 
22
14
 
15
+ class _AssetTags(HTMLParser):
16
+ """Find active asset tags and closing tags in the original HTML."""
17
+
18
+ CDATA_CONTENT_ELEMENTS = (
19
+ *HTMLParser.CDATA_CONTENT_ELEMENTS, "title", "textarea", "noscript",
20
+ )
21
+
22
+ def __init__(self, html: str) -> None:
23
+ super().__init__(convert_charrefs=False)
24
+ self._html = html
25
+ self._line_starts = [0] + [
26
+ index + 1 for index, char in enumerate(html) if char == "\n"
27
+ ]
28
+ self._template_depth = 0
29
+ self._theme_start: tuple[int, str] | None = None
30
+ self.runtime_present = False
31
+ self.theme: tuple[int, int, str] | None = None
32
+ self.closing_tags: dict[str, int] = {}
33
+ self.feed(html)
34
+ self.close()
35
+
36
+ def _offset(self) -> int:
37
+ line, column = self.getpos()
38
+ return self._line_starts[line - 1] + column
39
+
40
+ def handle_starttag(
41
+ self, tag: str, attrs: list[tuple[str, str | None]],
42
+ ) -> None:
43
+ if tag == "template":
44
+ self._template_depth += 1
45
+ if self._template_depth:
46
+ return
47
+
48
+ attributes: dict[str, str | None] = {}
49
+ for name, value in attrs:
50
+ attributes.setdefault(name, value)
51
+ marker = attributes.get("data-pywebview-htmx")
52
+ if tag == "script" and marker is not None and marker.lower() == "true":
53
+ self.runtime_present = True
54
+ if tag == "style" and self.theme is None:
55
+ theme = attributes.get("data-pywebview-theme")
56
+ if theme is not None:
57
+ self._theme_start = (self._offset(), theme)
58
+
59
+ def handle_endtag(self, tag: str) -> None:
60
+ if tag == "template" and self._template_depth:
61
+ self._template_depth -= 1
62
+ return
63
+ if self._template_depth:
64
+ return
65
+
66
+ offset = self._offset()
67
+ if tag in {"head", "body"}:
68
+ self.closing_tags[tag] = offset
69
+ if tag == "style" and self._theme_start is not None:
70
+ start, theme = self._theme_start
71
+ end = self._html.index(">", offset) + 1
72
+ self.theme = (start, end, theme)
73
+ self._theme_start = None
74
+
75
+
23
76
  def list_themes() -> list[str]:
24
77
  """Return all bundled selectable theme names."""
25
78
  themes_dir = files("pywebview_htmx").joinpath("static/themes")
@@ -61,24 +114,21 @@ def get_runtime_script() -> str:
61
114
  def encode_params_attr(params: Any) -> str:
62
115
  """Encode a payload for safe embedding in ``data-py-params``."""
63
116
  return html_escape(
64
- json.dumps(params, sort_keys=True),
117
+ json.dumps(params, sort_keys=True, allow_nan=False),
65
118
  quote=True,
66
119
  )
67
120
 
68
121
 
69
122
  def inject_runtime(html: str) -> str:
70
123
  """Inject the runtime script into an HTML string before ``</body>`` when present."""
71
- if _SCRIPT_TAG_RE.search(html):
124
+ tags = _AssetTags(html)
125
+ if tags.runtime_present:
72
126
  return html
73
127
 
74
128
  script_content = get_runtime_script()
75
129
  injection = f'<script data-pywebview-htmx="true">{script_content}</script>'
76
130
 
77
- lower_html = html.lower()
78
- body_close_idx = lower_html.rfind("</body>")
79
- if body_close_idx == -1:
80
- return f"{html}{injection}"
81
-
131
+ body_close_idx = tags.closing_tags.get("body", len(html))
82
132
  return f"{html[:body_close_idx]}{injection}{html[body_close_idx:]}"
83
133
 
84
134
 
@@ -88,24 +138,18 @@ def inject_theme(html: str, theme: str = DEFAULT_THEME) -> str:
88
138
  css = get_theme_css(normalized_theme)
89
139
  injection = f'<style data-pywebview-theme="{normalized_theme}">{css}</style>'
90
140
 
91
- match = _THEME_TAG_RE.search(html)
92
- if match:
93
- current_theme = match.group("name").strip().lower()
141
+ tags = _AssetTags(html)
142
+ if tags.theme is not None:
143
+ start, end, current_theme = tags.theme
144
+ current_theme = current_theme.strip().lower()
94
145
  if current_theme == normalized_theme:
95
146
  return html
96
- start, end = match.span()
97
147
  return f"{html[:start]}{injection}{html[end:]}"
98
148
 
99
- lower_html = html.lower()
100
- head_close_idx = lower_html.rfind("</head>")
101
- if head_close_idx != -1:
102
- return f"{html[:head_close_idx]}{injection}{html[head_close_idx:]}"
103
-
104
- body_close_idx = lower_html.rfind("</body>")
105
- if body_close_idx != -1:
106
- return f"{html[:body_close_idx]}{injection}{html[body_close_idx:]}"
107
-
108
- return f"{injection}{html}"
149
+ insertion_idx = tags.closing_tags.get(
150
+ "head", tags.closing_tags.get("body", 0),
151
+ )
152
+ return f"{html[:insertion_idx]}{injection}{html[insertion_idx:]}"
109
153
 
110
154
 
111
155
  def create_window(
@@ -11,6 +11,7 @@
11
11
  const targetedRequestState = new Map();
12
12
  const waitState = new WeakMap();
13
13
  const boundElements = new WeakSet();
14
+ const pendingLabelControlClicks = new WeakSet();
14
15
 
15
16
  function $(selector, root = document) {
16
17
  if (!selector) return null;
@@ -80,7 +81,7 @@
80
81
  }
81
82
 
82
83
  function serializeForm(form, event) {
83
- const params = {};
84
+ const params = Object.create(null);
84
85
  const formData = new FormData(form);
85
86
 
86
87
  for (const [key, value] of formData.entries()) {
@@ -214,6 +215,9 @@
214
215
  const functionName = element.getAttribute("py-call");
215
216
  const eventName = element.getAttribute("py-trigger") || "click";
216
217
  const targetSelector = (element.getAttribute("py-target") || "").trim() || null;
218
+ const labelControl = element.tagName === "LABEL" && eventName === "click"
219
+ ? element.control
220
+ : null;
217
221
 
218
222
  if (!functionName) {
219
223
  console.warn("PyWebview HTMX: py-call is empty", element);
@@ -222,6 +226,26 @@
222
226
  boundElements.add(element);
223
227
 
224
228
  element.addEventListener(eventName, async (event) => {
229
+ if (eventName === "click" && pendingLabelControlClicks.has(element)) {
230
+ pendingLabelControlClicks.delete(element);
231
+ return;
232
+ }
233
+ if (labelControl && event.target === labelControl) {
234
+ if (pendingLabelControlClicks.has(labelControl)) {
235
+ pendingLabelControlClicks.delete(labelControl);
236
+ return;
237
+ }
238
+ if (
239
+ labelControl.hasAttribute("py-call") &&
240
+ (labelControl.getAttribute("py-trigger") || "click") === "click"
241
+ ) {
242
+ return;
243
+ }
244
+ } else if (labelControl && event.target !== labelControl) {
245
+ pendingLabelControlClicks.add(labelControl);
246
+ setTimeout(() => pendingLabelControlClicks.delete(labelControl), 0);
247
+ }
248
+
225
249
  if (shouldPreventDefault(element, eventName) && event.cancelable) {
226
250
  event.preventDefault();
227
251
  }
@@ -258,7 +258,6 @@ body {
258
258
 
259
259
  .py-waiting {
260
260
  opacity: 0.88;
261
- pointer-events: none;
262
261
  }
263
262
 
264
263
  .pyh-event-log {
@@ -0,0 +1,104 @@
1
+ """Check a real Python bridge in the local desktop renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html import escape
6
+ from threading import Event
7
+ from time import monotonic, sleep
8
+
9
+ import webview
10
+
11
+ from pywebview_htmx import create_window, encode_params_attr
12
+
13
+
14
+ class API:
15
+ def __init__(self) -> None:
16
+ self._done = Event()
17
+ self._params: dict[str, object] | None = None
18
+ self._result: dict[str, object] | None = None
19
+
20
+ def greeting(self, params: dict[str, object]) -> str:
21
+ self._params = params
22
+ return f'<p>Hello, {escape(str(params["name"]))}!</p>'
23
+
24
+ def fail(self, params: dict[str, object]) -> str:
25
+ raise ValueError("Expected native smoke test failure")
26
+
27
+ def report(self, result: dict[str, object]) -> str:
28
+ self._result = result
29
+ self._done.set()
30
+ return ""
31
+
32
+
33
+ def main() -> None:
34
+ name = 'İstanbul <bridge> "test"'
35
+ params = encode_params_attr({"name": name})
36
+ api = API()
37
+ html = f"""<!doctype html>
38
+ <html><head><title>İstanbul</title></head><body>
39
+ <button id="greeting" py-call="greeting" py-target="#result"
40
+ data-py-params="{params}">İstanbul</button>
41
+ <button id="failure" py-call="fail" py-target="#error-result">Fail</button>
42
+ <div id="result"></div><div id="error-result"></div>
43
+ <script>
44
+ document.addEventListener('DOMContentLoaded', () => {{
45
+ document.body.addEventListener('py:afterSwap', event => {{
46
+ if (event.target.id === 'result') {{
47
+ document.querySelector('#failure').click();
48
+ }}
49
+ }});
50
+ document.body.addEventListener('py:error', event => {{
51
+ pywebview.api.report({{
52
+ text: document.querySelector('#result').textContent,
53
+ error: event.detail.error.message,
54
+ name: event.detail.error.name,
55
+ element: event.detail.element.id,
56
+ stale: event.detail.stale
57
+ }}).then(() => {{ window.nativeReportDone = true; }});
58
+ }});
59
+ }});
60
+ </script>
61
+ </body></html>"""
62
+ window = create_window(
63
+ "pywebview-htmx native smoke test", html, js_api=api,
64
+ start=False, hidden=True, focus=False,
65
+ )
66
+ failures: list[Exception] = []
67
+ verified = Event()
68
+
69
+ def check() -> None:
70
+ try:
71
+ if not window.events.loaded.wait(15):
72
+ raise TimeoutError("The native window did not load")
73
+ window.evaluate_js("document.querySelector('#greeting').click()")
74
+ if not api._done.wait(15):
75
+ raise TimeoutError("The native bridge did not finish")
76
+ deadline = monotonic() + 15
77
+ while not window.evaluate_js("window.nativeReportDone === true"):
78
+ if monotonic() >= deadline:
79
+ raise TimeoutError("The native report did not finish")
80
+ sleep(0.01)
81
+ assert api._params == {"name": name}, api._params
82
+ assert api._result == {
83
+ "text": f"Hello, {name}!",
84
+ "error": "Expected native smoke test failure",
85
+ "name": "ValueError",
86
+ "element": "failure",
87
+ "stale": False,
88
+ }, api._result
89
+ verified.set()
90
+ except Exception as error:
91
+ failures.append(error)
92
+ finally:
93
+ window.destroy()
94
+
95
+ webview.start(check)
96
+ if failures:
97
+ raise failures[0]
98
+ if not verified.is_set():
99
+ raise RuntimeError("The native smoke test did not complete")
100
+ print(f"Native smoke test passed ({webview.renderer})")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from playwright.sync_api import Page
5
+
6
+ from pywebview_htmx import inject_runtime, inject_theme
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ "inactive_markup",
11
+ [
12
+ '<!-- <script data-pywebview-htmx="true"></script> -->',
13
+ '<template><script data-pywebview-htmx="true"></script></template>',
14
+ '<textarea><script data-pywebview-htmx="true"></script></textarea>',
15
+ '<!-- <style data-pywebview-theme="paper">example</style> -->',
16
+ '<template><style data-pywebview-theme="paper">example</style></template>',
17
+ ],
18
+ )
19
+ def test_injected_assets_work_with_unicode_and_inactive_markup(
20
+ page: Page, inactive_markup: str,
21
+ ) -> None:
22
+ html = (
23
+ "<html>\n<head><title>İstanbul</title></head>\n<body>"
24
+ f"{inactive_markup}"
25
+ '<button id="load" py-call="load" py-target="#result">İstanbul</button>'
26
+ '<div id="result"></div></body></html><!-- </body> </head> -->'
27
+ )
28
+ page_errors: list[str] = []
29
+ page.on("pageerror", lambda error: page_errors.append(str(error)))
30
+ page.set_content(inject_runtime(inject_theme(html)))
31
+ page.evaluate(
32
+ """() => {
33
+ window.pywebview = {api: {load: () => '<p>İstanbul loaded</p>'}};
34
+ }"""
35
+ )
36
+
37
+ page.locator("#load").click()
38
+ page.locator("#result").get_by_text("İstanbul loaded").wait_for()
39
+ assert page.title() == "İstanbul"
40
+ assert page.locator('body > script[data-pywebview-htmx="true"]').count() == 1
41
+ assert page.locator('head > style[data-pywebview-theme="aurora"]').count() == 1
42
+ assert not page_errors
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from dataclasses import dataclass, field
4
5
 
5
6
  import pytest
6
7
  from playwright.sync_api import Page
7
8
 
9
+ from pywebview_htmx import get_theme_css
10
+
8
11
 
9
12
  @dataclass
10
13
  class RuntimeBrowser:
@@ -359,6 +362,128 @@ def test_native_destructive_defaults_are_prevented_selectively(
359
362
  assert page.evaluate("window.calls") == 7
360
363
 
361
364
 
365
+ @pytest.mark.parametrize("control_type", ["checkbox", "radio"])
366
+ def test_label_text_click_does_not_repeat_for_forwarded_control_click(
367
+ runtime_browser: RuntimeBrowser,
368
+ js_source: str,
369
+ control_type: str,
370
+ ) -> None:
371
+ other_radio = (
372
+ '<input id="other" type="radio" name="choice">'
373
+ if control_type == "radio"
374
+ else ""
375
+ )
376
+ runtime_browser.load(
377
+ f'<label id="label" py-call="act" py-target="#result">'
378
+ f'<input id="check" type="{control_type}" name="choice">'
379
+ '<span id="label-text">Toggle</span></label>'
380
+ f'{other_radio}'
381
+ '<div id="result"></div>',
382
+ js_source,
383
+ )
384
+ page = runtime_browser.page
385
+ page.evaluate(
386
+ """
387
+ () => {
388
+ window.calls = 0;
389
+ window.pywebview = {api: {act: () => { calls += 1; return ''; }}};
390
+ }
391
+ """
392
+ )
393
+ runtime_browser.process()
394
+
395
+ page.locator("#label-text").click()
396
+ assert page.locator("#check").is_checked()
397
+ assert page.evaluate("window.calls") == 1
398
+
399
+ page.locator("#check").click()
400
+ assert page.locator("#check").is_checked() is (control_type == "radio")
401
+ assert page.evaluate("window.calls") == 2
402
+
403
+ if control_type == "radio":
404
+ page.locator("#other").click()
405
+ assert not page.locator("#check").is_checked()
406
+
407
+ page.locator("#check").focus()
408
+ page.keyboard.press("Space")
409
+ assert page.locator("#check").is_checked()
410
+ assert page.evaluate("window.calls") == 3
411
+
412
+
413
+ def test_external_label_and_control_clear_forwarding_marker_in_same_task(
414
+ runtime_browser: RuntimeBrowser,
415
+ js_source: str,
416
+ ) -> None:
417
+ runtime_browser.load(
418
+ '<label id="label" for="check" py-call="labelAction" '
419
+ 'py-target="#result">Toggle</label>'
420
+ '<input id="check" type="checkbox" py-call="controlAction" '
421
+ 'py-target="#result"><div id="result"></div>',
422
+ js_source,
423
+ )
424
+ page = runtime_browser.page
425
+ page.evaluate(
426
+ """
427
+ () => {
428
+ window.calls = [];
429
+ window.pywebview = {api: {
430
+ labelAction: () => { calls.push('label'); return ''; },
431
+ controlAction: () => { calls.push('control'); return ''; },
432
+ }};
433
+ }
434
+ """
435
+ )
436
+ runtime_browser.process()
437
+
438
+ page.evaluate(
439
+ """() => {
440
+ document.querySelector('#label').click();
441
+ document.querySelector('#check').click();
442
+ }"""
443
+ )
444
+ assert not page.locator("#check").is_checked()
445
+ assert page.evaluate("window.calls") == ["label", "control"]
446
+
447
+
448
+ def test_nested_checkbox_py_call_runs_once_per_activation(
449
+ runtime_browser: RuntimeBrowser,
450
+ js_source: str,
451
+ ) -> None:
452
+ runtime_browser.load(
453
+ '<label id="label" py-call="labelAction" py-target="#result">'
454
+ '<input id="check" type="checkbox" py-call="checkboxAction" '
455
+ 'py-target="#result"><span id="label-text">Toggle</span>'
456
+ '</label><div id="result"></div>',
457
+ js_source,
458
+ )
459
+ page = runtime_browser.page
460
+ page.evaluate(
461
+ """
462
+ () => {
463
+ window.calls = [];
464
+ window.pywebview = {api: {
465
+ labelAction: () => { calls.push('label'); return ''; },
466
+ checkboxAction: () => { calls.push('checkbox'); return ''; },
467
+ }};
468
+ }
469
+ """
470
+ )
471
+ runtime_browser.process()
472
+
473
+ page.locator("#label-text").click()
474
+ assert page.locator("#check").is_checked()
475
+ assert page.evaluate("window.calls") == ["label"]
476
+
477
+ page.locator("#check").click()
478
+ assert not page.locator("#check").is_checked()
479
+ assert page.evaluate("window.calls") == ["label", "checkbox"]
480
+
481
+ page.locator("#check").focus()
482
+ page.keyboard.press("Space")
483
+ assert page.locator("#check").is_checked()
484
+ assert page.evaluate("window.calls") == ["label", "checkbox", "checkbox"]
485
+
486
+
362
487
  def test_form_serialization_repeats_submitter_and_skips_files(
363
488
  runtime_browser: RuntimeBrowser,
364
489
  js_source: str,
@@ -400,6 +525,106 @@ def test_form_serialization_repeats_submitter_and_skips_files(
400
525
  }
401
526
 
402
527
 
528
+ def test_form_serialization_preserves_reserved_object_property_names(
529
+ runtime_browser: RuntimeBrowser,
530
+ js_source: str,
531
+ ) -> None:
532
+ runtime_browser.load(
533
+ """
534
+ <form id="form" py-call="save" py-trigger="submit"
535
+ data-py-params='{"fixed":"static","static":"yes"}'>
536
+ <input name="__proto__" value="one">
537
+ <input name="__proto__" value="two">
538
+ <input name="constructor" value="ctor">
539
+ <input name="toString" value="string">
540
+ <input name="fixed" value="form">
541
+ <button id="save">Save</button>
542
+ </form>
543
+ """,
544
+ js_source,
545
+ )
546
+ page = runtime_browser.page
547
+ page.evaluate(
548
+ """
549
+ () => {
550
+ window.receivedParams = null;
551
+ window.pywebview = {api: {save: params => {
552
+ window.receivedParams = params;
553
+ return '';
554
+ }}};
555
+ }
556
+ """
557
+ )
558
+ runtime_browser.process()
559
+ page.locator("#save").click()
560
+ page.wait_for_function("window.receivedParams !== null")
561
+
562
+ assert json.loads(page.evaluate("JSON.stringify(window.receivedParams)")) == {
563
+ "__proto__": ["one", "two"],
564
+ "constructor": "ctor",
565
+ "toString": "string",
566
+ "fixed": "form",
567
+ "static": "yes",
568
+ }
569
+
570
+
571
+ @pytest.mark.parametrize(
572
+ ("policy", "expected_calls", "expected_ignored"),
573
+ [("latest-wins", 2, 0), ("drop", 1, 1)],
574
+ )
575
+ def test_waiting_theme_keeps_controls_clickable_for_request_policy(
576
+ runtime_browser: RuntimeBrowser,
577
+ js_source: str,
578
+ policy: str,
579
+ expected_calls: int,
580
+ expected_ignored: int,
581
+ ) -> None:
582
+ runtime_browser.load(
583
+ f'<button id="load" py-call="load" py-policy="{policy}" '
584
+ 'py-target="#result">Load</button><div id="result"></div>',
585
+ js_source,
586
+ )
587
+ page = runtime_browser.page
588
+ page.add_style_tag(content=get_theme_css())
589
+ install_pending_bridge(page)
590
+ page.evaluate(
591
+ """
592
+ () => {
593
+ window.ignoredCalls = 0;
594
+ document.addEventListener('py:ignored', () => ignoredCalls++);
595
+ }
596
+ """
597
+ )
598
+ runtime_browser.process()
599
+
600
+ page.locator("#load").click()
601
+ assert page.locator("#load").evaluate(
602
+ "node => node.classList.contains('py-waiting')"
603
+ )
604
+ assert page.locator("#load").evaluate(
605
+ "node => getComputedStyle(node).pointerEvents"
606
+ ) == "auto"
607
+
608
+ page.locator("#load").click()
609
+ assert page.evaluate("window.bridgeCalls.length") == expected_calls
610
+ assert page.evaluate("window.ignoredCalls") == expected_ignored
611
+
612
+ if policy == "latest-wins":
613
+ resolve_request(page, 1, "newest")
614
+ page.wait_for_function(
615
+ "document.querySelector('#result').textContent === 'newest'"
616
+ )
617
+ resolve_request(page, 0, "stale")
618
+ else:
619
+ resolve_request(page, 0, "accepted")
620
+ page.wait_for_function(
621
+ "!document.querySelector('#load').classList.contains('py-waiting')"
622
+ )
623
+ assert page.locator("#result").inner_text() == (
624
+ "newest" if policy == "latest-wins" else "accepted"
625
+ )
626
+
627
+
403
628
  @pytest.mark.parametrize("swap_style", ["innerHTML", "outerHTML", "append"])
404
629
  def test_real_swaps_parse_html_and_bind_inserted_controls(
405
630
  runtime_browser: RuntimeBrowser,
@@ -128,3 +128,45 @@ def test_injection_preserves_unrelated_script_tags() -> None:
128
128
  result = inject_runtime(html)
129
129
  assert "<script>var x = 1;</script>" in result
130
130
  assert result.count("<script>var x = 1;</script>") == 1
131
+
132
+
133
+ @pytest.mark.parametrize(
134
+ "inactive_markup",
135
+ [
136
+ '<!-- <script data-pywebview-htmx="true"></script> -->',
137
+ '<template><script data-pywebview-htmx="true"></script></template>',
138
+ '<textarea><script data-pywebview-htmx="true"></script></textarea>',
139
+ '<script>const example = \'<script data-pywebview-htmx="true">\';</script>',
140
+ '<script x-data-pywebview-htmx="true"></script>',
141
+ ],
142
+ )
143
+ def test_injection_ignores_inactive_or_unrelated_markers(
144
+ inactive_markup: str,
145
+ ) -> None:
146
+ html = f"<html><body>{inactive_markup}</body></html>"
147
+ result = inject_runtime(html)
148
+ injection = f"{SCRIPT_MARKER}{get_runtime_script()}</script>"
149
+ assert result == html.replace("</body>", f"{injection}</body>")
150
+ assert inject_runtime(result) == result
151
+
152
+
153
+ def test_injection_preserves_unicode_and_ignores_comment_closing_tag() -> None:
154
+ html = (
155
+ "<html>\n<head><title>İstanbul</title></head>\n"
156
+ "<body><p>İstanbul</p></body></html><!-- </body> -->"
157
+ )
158
+ result = inject_runtime(html)
159
+ prefix, suffix = html.split("</body>", 1)
160
+ injection = f"{SCRIPT_MARKER}{get_runtime_script()}</script>"
161
+ assert result == f"{prefix}{injection}</body>{suffix}"
162
+
163
+
164
+ def test_injection_ignores_closing_body_text_in_attributes_and_raw_text() -> None:
165
+ html = (
166
+ '<html><body><div title="</body>">İ</div>'
167
+ '<textarea></body></textarea><template></body></template>'
168
+ '</body></html>'
169
+ )
170
+ result = inject_runtime(html)
171
+ injection = f"{SCRIPT_MARKER}{get_runtime_script()}</script>"
172
+ assert result.endswith(f"{injection}</body></html>")
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import pytest
4
+
3
5
  from pywebview_htmx import encode_params_attr
4
6
 
5
7
 
@@ -17,3 +19,9 @@ def test_encode_params_attr_escapes_quotes_and_tags() -> None:
17
19
  def test_encode_params_attr_is_exported_from_package() -> None:
18
20
  encoded = encode_params_attr({"name": "pywebview-htmx"})
19
21
  assert isinstance(encoded, str)
22
+
23
+
24
+ @pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")])
25
+ def test_encode_params_attr_rejects_non_finite_numbers(value: float) -> None:
26
+ with pytest.raises(ValueError, match="Out of range float values"):
27
+ encode_params_attr({"reading": value})
@@ -112,3 +112,44 @@ def test_create_window_theme_none_skips_theme_injection(
112
112
 
113
113
  assert "data-pywebview-theme" not in captured["kwargs"]["html"]
114
114
  assert 'data-pywebview-htmx="true"' in captured["kwargs"]["html"]
115
+
116
+
117
+ @pytest.mark.parametrize(
118
+ "inactive_markup",
119
+ [
120
+ '<!-- <style data-pywebview-theme="paper">example</style> -->',
121
+ '<template><style data-pywebview-theme="paper">example</style></template>',
122
+ '<textarea><style data-pywebview-theme="paper">example</style></textarea>',
123
+ '<style x-data-pywebview-theme="paper">body { color: red; }</style>',
124
+ ],
125
+ )
126
+ def test_theme_ignores_inactive_or_unrelated_markers(inactive_markup: str) -> None:
127
+ html = f"<html><head>{inactive_markup}</head><body>İstanbul</body></html>"
128
+ result = inject_theme(html, "aurora")
129
+ css = get_theme_css("aurora")
130
+ injection = f'<style data-pywebview-theme="aurora">{css}</style>'
131
+ assert result == html.replace("</head>", f"{injection}</head>")
132
+ assert inject_theme(result, "paper").count(inactive_markup) == 1
133
+
134
+
135
+ def test_theme_preserves_unicode_and_ignores_comment_closing_tag() -> None:
136
+ html = (
137
+ "<html>\n<head><title>İİstanbul</title></head>\n"
138
+ "<body>İstanbul</body></html><!-- </head> -->"
139
+ )
140
+ css = get_theme_css("aurora")
141
+ injection = f'<style data-pywebview-theme="aurora">{css}</style>'
142
+ assert inject_theme(html) == html.replace("</head>", f"{injection}</head>", 1)
143
+
144
+
145
+ def test_theme_replacement_preserves_surrounding_unicode_and_comments() -> None:
146
+ html = (
147
+ "<html>\n<head><title>İstanbul</title>"
148
+ '<style DATA-PYWEBVIEW-THEME=paper>body { color: red; }</STYLE >'
149
+ "</head><body>İstanbul</body></html><!-- </head> -->"
150
+ )
151
+ result = inject_theme(html, "aurora")
152
+ old_style = '<style DATA-PYWEBVIEW-THEME=paper>body { color: red; }</STYLE >'
153
+ css = get_theme_css("aurora")
154
+ new_style = f'<style data-pywebview-theme="aurora">{css}</style>'
155
+ assert result == html.replace(old_style, new_style)
File without changes