pywebview-htmx 0.4.0__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 (23) hide show
  1. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/PKG-INFO +37 -1
  2. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/README.md +36 -0
  3. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/pyproject.toml +2 -1
  4. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/runtime.py +74 -30
  5. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/runtime.js +38 -5
  6. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/base.css +0 -1
  7. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/conftest.py +0 -7
  8. pywebview_htmx-0.4.2/tests/native_smoke.py +104 -0
  9. pywebview_htmx-0.4.2/tests/test_html_assets_browser.py +42 -0
  10. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_js_runtime.py +357 -0
  11. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_python_injection.py +42 -0
  12. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_python_params_attr.py +8 -0
  13. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_python_themes.py +41 -0
  14. pywebview_htmx-0.4.2/tests/test_repo_legibility.py +125 -0
  15. pywebview_htmx-0.4.0/tests/test_repo_legibility.py +0 -125
  16. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/LICENSE +0 -0
  17. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/__init__.py +0 -0
  18. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/py.typed +0 -0
  19. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/aurora.css +0 -0
  20. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
  21. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/paper.css +0 -0
  22. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_python_create_window.py +0 -0
  23. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_python_script_loading.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pywebview-htmx
3
- Version: 0.4.0
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.
@@ -319,6 +325,10 @@ newer request had already been issued in the same request scope when the error
319
325
  was emitted. Errors from stale requests remain observable even though stale
320
326
  successful responses do not update the DOM.
321
327
 
328
+ `event.detail.element` identifies the original control. If that control has
329
+ been removed, the runtime dispatches the error on `document.body` so application
330
+ listeners can still receive it.
331
+
322
332
  Example:
323
333
 
324
334
  ```js
@@ -342,6 +352,9 @@ The `py-wait` selector is resolved for each accepted request. Loading state is
342
352
  counted separately per resolved wait element, so a shared spinner stays active
343
353
  until every request using that particular element finishes.
344
354
 
355
+ The bundled loading styles show `.py-waiting` without blocking pointer input.
356
+ The request policy controls whether another trigger is accepted.
357
+
345
358
  ### `latest-wins` (default)
346
359
 
347
360
  Multiple rapid requests are allowed; stale responses in the same request state
@@ -352,6 +365,29 @@ scope are ignored. Only the latest request for that scope updates the DOM.
352
365
  If a request is already in flight for the same request state scope, new triggers
353
366
  are ignored and `py:ignored` is emitted.
354
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
+
355
391
  ## Dynamic Content Re-processing
356
392
 
357
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.
@@ -279,6 +285,10 @@ newer request had already been issued in the same request scope when the error
279
285
  was emitted. Errors from stale requests remain observable even though stale
280
286
  successful responses do not update the DOM.
281
287
 
288
+ `event.detail.element` identifies the original control. If that control has
289
+ been removed, the runtime dispatches the error on `document.body` so application
290
+ listeners can still receive it.
291
+
282
292
  Example:
283
293
 
284
294
  ```js
@@ -302,6 +312,9 @@ The `py-wait` selector is resolved for each accepted request. Loading state is
302
312
  counted separately per resolved wait element, so a shared spinner stays active
303
313
  until every request using that particular element finishes.
304
314
 
315
+ The bundled loading styles show `.py-waiting` without blocking pointer input.
316
+ The request policy controls whether another trigger is accepted.
317
+
305
318
  ### `latest-wins` (default)
306
319
 
307
320
  Multiple rapid requests are allowed; stale responses in the same request state
@@ -312,6 +325,29 @@ scope are ignored. Only the latest request for that scope updates the DOM.
312
325
  If a request is already in flight for the same request state scope, new triggers
313
326
  are ignored and `py:ignored` is emitted.
314
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
+
315
351
  ## Dynamic Content Re-processing
316
352
 
317
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.0"
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(
@@ -10,6 +10,8 @@
10
10
  const selfRequestState = new WeakMap();
11
11
  const targetedRequestState = new Map();
12
12
  const waitState = new WeakMap();
13
+ const boundElements = new WeakSet();
14
+ const pendingLabelControlClicks = new WeakSet();
13
15
 
14
16
  function $(selector, root = document) {
15
17
  if (!selector) return null;
@@ -79,7 +81,7 @@
79
81
  }
80
82
 
81
83
  function serializeForm(form, event) {
82
- const params = {};
84
+ const params = Object.create(null);
83
85
  const formData = new FormData(form);
84
86
 
85
87
  for (const [key, value] of formData.entries()) {
@@ -206,21 +208,44 @@
206
208
  elements.push(...root.querySelectorAll("[py-call]"));
207
209
 
208
210
  elements.forEach((element) => {
209
- if (element.getAttribute("data-pywebview-bound") === "true") {
211
+ if (boundElements.has(element)) {
210
212
  return;
211
213
  }
212
- element.setAttribute("data-pywebview-bound", "true");
213
214
 
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);
220
224
  return;
221
225
  }
226
+ boundElements.add(element);
222
227
 
223
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
+
224
249
  if (shouldPreventDefault(element, eventName) && event.cancelable) {
225
250
  event.preventDefault();
226
251
  }
@@ -257,7 +282,7 @@
257
282
  throw new Error("PyWebview HTMX: Python API methods must return an HTML string");
258
283
  }
259
284
 
260
- const target = targetSelector ? $(targetSelector) : element;
285
+ let target = targetSelector ? $(targetSelector) : element;
261
286
  if (!target) {
262
287
  console.warn("PyWebview HTMX: target element not found", targetSelector);
263
288
  return;
@@ -271,6 +296,12 @@
271
296
  return;
272
297
  }
273
298
 
299
+ target = targetSelector ? $(targetSelector) : element;
300
+ if (!target || !target.isConnected) {
301
+ console.warn("PyWebview HTMX: target element not found", targetSelector);
302
+ return;
303
+ }
304
+
274
305
  const processRoot = pySwap(target, response, swapStyle);
275
306
  const afterSwapTarget =
276
307
  swapStyle === "outerHTML"
@@ -284,7 +315,9 @@
284
315
  }
285
316
  } catch (error) {
286
317
  console.error("PyWebview HTMX: error calling Python function", error);
287
- triggerEvent(element, "py:error", {
318
+ const errorTarget = element.isConnected ? element : document.body || document;
319
+ triggerEvent(errorTarget, "py:error", {
320
+ element,
288
321
  error,
289
322
  requestId,
290
323
  stale: requestId !== state.lastIssued,
@@ -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 {
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
4
-
5
3
  import pytest
6
4
 
7
5
  from pywebview_htmx import get_runtime_script
@@ -10,8 +8,3 @@ from pywebview_htmx import get_runtime_script
10
8
  @pytest.fixture(scope="session")
11
9
  def js_source() -> str:
12
10
  return get_runtime_script()
13
-
14
-
15
- @pytest.fixture(scope="session")
16
- def js_compact(js_source: str) -> str:
17
- return re.sub(r"\s+", " ", js_source).strip()
@@ -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