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.
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/PKG-INFO +37 -1
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/README.md +36 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/pyproject.toml +2 -1
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/runtime.py +74 -30
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/runtime.js +38 -5
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/base.css +0 -1
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/conftest.py +0 -7
- pywebview_htmx-0.4.2/tests/native_smoke.py +104 -0
- pywebview_htmx-0.4.2/tests/test_html_assets_browser.py +42 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_js_runtime.py +357 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_python_injection.py +42 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_python_params_attr.py +8 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_python_themes.py +41 -0
- pywebview_htmx-0.4.2/tests/test_repo_legibility.py +125 -0
- pywebview_htmx-0.4.0/tests/test_repo_legibility.py +0 -125
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/LICENSE +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/__init__.py +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/py.typed +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/aurora.css +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/paper.css +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.2}/tests/test_python_create_window.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
if
|
|
93
|
-
current_theme =
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|