pywebview-htmx 0.2.1__tar.gz → 0.3.1__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.2.1 → pywebview_htmx-0.3.1}/PKG-INFO +99 -10
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/README.md +97 -8
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/pyproject.toml +5 -5
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/__init__.py +2 -0
- pywebview_htmx-0.3.1/src/pywebview_htmx/py.typed +1 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/runtime.py +10 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/runtime.js +96 -12
- pywebview_htmx-0.3.1/tests/js_runtime_behavior_harness.mjs +369 -0
- pywebview_htmx-0.3.1/tests/test_js_contract_ergonomics.py +125 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_js_contract_request_and_swap.py +17 -4
- pywebview_htmx-0.3.1/tests/test_js_runtime_behavior.py +27 -0
- pywebview_htmx-0.3.1/tests/test_python_params_attr.py +19 -0
- pywebview_htmx-0.3.1/tests/test_repo_legibility.py +125 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/LICENSE +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/aurora.css +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/base.css +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/paper.css +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/conftest.py +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_js_contract_config_and_binding.py +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_python_create_window.py +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_python_injection.py +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_python_script_loading.py +0 -0
- {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_python_themes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pywebview-htmx
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -35,7 +35,7 @@ Classifier: Intended Audience :: Developers
|
|
|
35
35
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
36
36
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
37
37
|
Requires-Python: >=3.14
|
|
38
|
-
Requires-Dist: pywebview>=6.1
|
|
38
|
+
Requires-Dist: pywebview>=6.2.1
|
|
39
39
|
Description-Content-Type: text/markdown
|
|
40
40
|
|
|
41
41
|
# pywebview-htmx
|
|
@@ -84,6 +84,39 @@ pdm install
|
|
|
84
84
|
pdm add pywebview-htmx
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
## Maintainer Docs
|
|
88
|
+
|
|
89
|
+
For maintainer and agent-facing repository guidance, see
|
|
90
|
+
[`docs/index.md`](docs/index.md).
|
|
91
|
+
|
|
92
|
+
## Agent Skill
|
|
93
|
+
|
|
94
|
+
This repository includes an installable coding-agent skill at
|
|
95
|
+
`skills/use-pywebview-htmx/`.
|
|
96
|
+
|
|
97
|
+
The folder in this repository is the source for a skill that tools such as
|
|
98
|
+
[`npx skills`](https://skills.sh/) can discover and install from GitHub.
|
|
99
|
+
|
|
100
|
+
List the available skill from this repository:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npx skills add btfranklin/pywebview-htmx --list --full-depth
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Install just this skill:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npx skills add btfranklin/pywebview-htmx --skill use-pywebview-htmx
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Other skill installers can consume the same skill folder from this repository.
|
|
113
|
+
The skill includes:
|
|
114
|
+
|
|
115
|
+
- the core usage workflow
|
|
116
|
+
- a concise contract reference
|
|
117
|
+
- implementation patterns
|
|
118
|
+
- troubleshooting guidance
|
|
119
|
+
|
|
87
120
|
## Quick Start
|
|
88
121
|
|
|
89
122
|
```python
|
|
@@ -117,6 +150,18 @@ create_window("Quickstart", html, js_api=API())
|
|
|
117
150
|
You do **not** manually include `runtime.js` when using `create_window()`;
|
|
118
151
|
it is injected automatically.
|
|
119
152
|
|
|
153
|
+
## Important Differences From HTMX
|
|
154
|
+
|
|
155
|
+
- There is no HTTP request layer. `pywebview-htmx` calls a Python method on
|
|
156
|
+
`window.pywebview.api` directly.
|
|
157
|
+
- Python handlers should return an HTML string fragment. Returning objects or
|
|
158
|
+
other data types is treated as an error.
|
|
159
|
+
- For ordinary buttons/links, params still come from `data-py-params`.
|
|
160
|
+
- For `<form py-trigger="submit">`, named fields are serialized automatically
|
|
161
|
+
and merged over `data-py-params`.
|
|
162
|
+
- Returned HTML is inserted directly into the DOM, so escape untrusted values
|
|
163
|
+
before returning markup.
|
|
164
|
+
|
|
120
165
|
## API Reference (Python)
|
|
121
166
|
|
|
122
167
|
### `create_window(title, html, js_api=None, theme="aurora", start=True, **kwargs)`
|
|
@@ -143,6 +188,18 @@ Returns the bundled JavaScript runtime as a string.
|
|
|
143
188
|
|
|
144
189
|
Injects the runtime script tag into an HTML string (idempotent).
|
|
145
190
|
|
|
191
|
+
### `encode_params_attr(params)`
|
|
192
|
+
|
|
193
|
+
Returns HTML-escaped JSON that is safe to place in a `data-py-params`
|
|
194
|
+
attribute.
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from pywebview_htmx import encode_params_attr
|
|
198
|
+
|
|
199
|
+
payload = encode_params_attr({"user_id": 42, "mode": "full"})
|
|
200
|
+
button = f'<button data-py-params="{payload}">Load user</button>'
|
|
201
|
+
```
|
|
202
|
+
|
|
146
203
|
### Theme helpers
|
|
147
204
|
|
|
148
205
|
- `list_themes()` -> sorted list of available themes
|
|
@@ -183,6 +240,8 @@ JSON payload passed to Python method.
|
|
|
183
240
|
Notes:
|
|
184
241
|
- If missing, params default to `{}`.
|
|
185
242
|
- Invalid JSON logs an error and falls back to `{}`.
|
|
243
|
+
- For submit-triggered forms, named form fields are serialized automatically and
|
|
244
|
+
merged over this payload.
|
|
186
245
|
|
|
187
246
|
### `py-target` (optional)
|
|
188
247
|
|
|
@@ -201,7 +260,15 @@ Unknown value falls back to `innerHTML`.
|
|
|
201
260
|
### `py-wait` (optional)
|
|
202
261
|
|
|
203
262
|
CSS selector of element receiving `.py-waiting` while request is in flight.
|
|
204
|
-
If missing
|
|
263
|
+
If missing, empty, invalid, or unresolvable, the triggering element is used.
|
|
264
|
+
|
|
265
|
+
### `py-policy` (optional)
|
|
266
|
+
|
|
267
|
+
Per-element concurrency override. If omitted, the global
|
|
268
|
+
`window.pywebviewHtmx.config.requestPolicy` value is used.
|
|
269
|
+
|
|
270
|
+
- `latest-wins`
|
|
271
|
+
- `drop`
|
|
205
272
|
|
|
206
273
|
## Runtime Config (JavaScript)
|
|
207
274
|
|
|
@@ -240,17 +307,21 @@ document.body.addEventListener("py:error", (event) => {
|
|
|
240
307
|
|
|
241
308
|
## Concurrency Behavior
|
|
242
309
|
|
|
243
|
-
|
|
310
|
+
`pywebview-htmx` tracks request state by resolved target. Controls that share
|
|
311
|
+
the same `py-target` coordinate request ordering and `drop` behavior. Controls
|
|
312
|
+
without `py-target` use the triggering element as their request state scope.
|
|
313
|
+
Loading state is counted separately per resolved `py-wait` target so shared
|
|
314
|
+
spinners stay active until all in-flight requests finish.
|
|
244
315
|
|
|
245
316
|
### `latest-wins` (default)
|
|
246
317
|
|
|
247
|
-
Multiple rapid requests are allowed; stale responses
|
|
248
|
-
Only latest request updates the DOM.
|
|
318
|
+
Multiple rapid requests are allowed; stale responses in the same request state
|
|
319
|
+
scope are ignored. Only the latest request for that scope updates the DOM.
|
|
249
320
|
|
|
250
321
|
### `drop`
|
|
251
322
|
|
|
252
|
-
If a request is already in flight for
|
|
253
|
-
`py:ignored` is emitted.
|
|
323
|
+
If a request is already in flight for the same request state scope, new triggers
|
|
324
|
+
are ignored and `py:ignored` is emitted.
|
|
254
325
|
|
|
255
326
|
## Dynamic Content Re-processing
|
|
256
327
|
|
|
@@ -297,7 +368,8 @@ behavior, add your own `<style data-pywebview-theme="my-theme">...</style>` bloc
|
|
|
297
368
|
### Pattern 1: Form submit to Python
|
|
298
369
|
|
|
299
370
|
- Use `py-trigger="submit"` on `<form>`
|
|
300
|
-
-
|
|
371
|
+
- Give fields `name` attributes so they can be serialized
|
|
372
|
+
- Use `data-py-params` only for extra static values you want merged in
|
|
301
373
|
- `py-target` a result card/summary region
|
|
302
374
|
|
|
303
375
|
### Pattern 2: Append activity/log rows
|
|
@@ -308,7 +380,8 @@ behavior, add your own `<style data-pywebview-theme="my-theme">...</style>` bloc
|
|
|
308
380
|
### Pattern 3: Replace full component
|
|
309
381
|
|
|
310
382
|
- Use `py-swap="outerHTML"`
|
|
311
|
-
- Return full replacement markup with same outer id/selector
|
|
383
|
+
- Return full replacement markup with the same outer id/selector if future
|
|
384
|
+
interactions should keep targeting that component
|
|
312
385
|
|
|
313
386
|
### Pattern 4: Theme switch from Python
|
|
314
387
|
|
|
@@ -331,6 +404,7 @@ Check:
|
|
|
331
404
|
- method returns a string of HTML
|
|
332
405
|
- no JSON parse error in `data-py-params`
|
|
333
406
|
- `py-target` selector exists
|
|
407
|
+
- for forms, fields have `name` attributes
|
|
334
408
|
|
|
335
409
|
### "New buttons in swapped HTML do not work"
|
|
336
410
|
|
|
@@ -345,6 +419,21 @@ window.pywebviewHtmx.process(document.body);
|
|
|
345
419
|
|
|
346
420
|
Style `.py-waiting` globally and/or point `py-wait` at a dedicated element.
|
|
347
421
|
|
|
422
|
+
### "I got a Python API return type error"
|
|
423
|
+
|
|
424
|
+
`pywebview-htmx` expects handlers to return an HTML string fragment. Convert
|
|
425
|
+
structured data into markup before returning it.
|
|
426
|
+
|
|
427
|
+
## Common Mistakes
|
|
428
|
+
|
|
429
|
+
- Returning dicts/lists instead of HTML strings
|
|
430
|
+
- Forgetting to escape untrusted values before interpolating into markup
|
|
431
|
+
- Omitting `name` attributes on form fields and expecting them to serialize
|
|
432
|
+
- Using `py-swap="outerHTML"` but not preserving the target component's
|
|
433
|
+
selector/id
|
|
434
|
+
- Manually mutating the DOM outside of `pywebview-htmx` swaps without calling
|
|
435
|
+
`window.pywebviewHtmx.process(...)`
|
|
436
|
+
|
|
348
437
|
## Demo
|
|
349
438
|
|
|
350
439
|
Run the full feature showcase:
|
|
@@ -44,6 +44,39 @@ pdm install
|
|
|
44
44
|
pdm add pywebview-htmx
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
## Maintainer Docs
|
|
48
|
+
|
|
49
|
+
For maintainer and agent-facing repository guidance, see
|
|
50
|
+
[`docs/index.md`](docs/index.md).
|
|
51
|
+
|
|
52
|
+
## Agent Skill
|
|
53
|
+
|
|
54
|
+
This repository includes an installable coding-agent skill at
|
|
55
|
+
`skills/use-pywebview-htmx/`.
|
|
56
|
+
|
|
57
|
+
The folder in this repository is the source for a skill that tools such as
|
|
58
|
+
[`npx skills`](https://skills.sh/) can discover and install from GitHub.
|
|
59
|
+
|
|
60
|
+
List the available skill from this repository:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx skills add btfranklin/pywebview-htmx --list --full-depth
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Install just this skill:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx skills add btfranklin/pywebview-htmx --skill use-pywebview-htmx
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Other skill installers can consume the same skill folder from this repository.
|
|
73
|
+
The skill includes:
|
|
74
|
+
|
|
75
|
+
- the core usage workflow
|
|
76
|
+
- a concise contract reference
|
|
77
|
+
- implementation patterns
|
|
78
|
+
- troubleshooting guidance
|
|
79
|
+
|
|
47
80
|
## Quick Start
|
|
48
81
|
|
|
49
82
|
```python
|
|
@@ -77,6 +110,18 @@ create_window("Quickstart", html, js_api=API())
|
|
|
77
110
|
You do **not** manually include `runtime.js` when using `create_window()`;
|
|
78
111
|
it is injected automatically.
|
|
79
112
|
|
|
113
|
+
## Important Differences From HTMX
|
|
114
|
+
|
|
115
|
+
- There is no HTTP request layer. `pywebview-htmx` calls a Python method on
|
|
116
|
+
`window.pywebview.api` directly.
|
|
117
|
+
- Python handlers should return an HTML string fragment. Returning objects or
|
|
118
|
+
other data types is treated as an error.
|
|
119
|
+
- For ordinary buttons/links, params still come from `data-py-params`.
|
|
120
|
+
- For `<form py-trigger="submit">`, named fields are serialized automatically
|
|
121
|
+
and merged over `data-py-params`.
|
|
122
|
+
- Returned HTML is inserted directly into the DOM, so escape untrusted values
|
|
123
|
+
before returning markup.
|
|
124
|
+
|
|
80
125
|
## API Reference (Python)
|
|
81
126
|
|
|
82
127
|
### `create_window(title, html, js_api=None, theme="aurora", start=True, **kwargs)`
|
|
@@ -103,6 +148,18 @@ Returns the bundled JavaScript runtime as a string.
|
|
|
103
148
|
|
|
104
149
|
Injects the runtime script tag into an HTML string (idempotent).
|
|
105
150
|
|
|
151
|
+
### `encode_params_attr(params)`
|
|
152
|
+
|
|
153
|
+
Returns HTML-escaped JSON that is safe to place in a `data-py-params`
|
|
154
|
+
attribute.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from pywebview_htmx import encode_params_attr
|
|
158
|
+
|
|
159
|
+
payload = encode_params_attr({"user_id": 42, "mode": "full"})
|
|
160
|
+
button = f'<button data-py-params="{payload}">Load user</button>'
|
|
161
|
+
```
|
|
162
|
+
|
|
106
163
|
### Theme helpers
|
|
107
164
|
|
|
108
165
|
- `list_themes()` -> sorted list of available themes
|
|
@@ -143,6 +200,8 @@ JSON payload passed to Python method.
|
|
|
143
200
|
Notes:
|
|
144
201
|
- If missing, params default to `{}`.
|
|
145
202
|
- Invalid JSON logs an error and falls back to `{}`.
|
|
203
|
+
- For submit-triggered forms, named form fields are serialized automatically and
|
|
204
|
+
merged over this payload.
|
|
146
205
|
|
|
147
206
|
### `py-target` (optional)
|
|
148
207
|
|
|
@@ -161,7 +220,15 @@ Unknown value falls back to `innerHTML`.
|
|
|
161
220
|
### `py-wait` (optional)
|
|
162
221
|
|
|
163
222
|
CSS selector of element receiving `.py-waiting` while request is in flight.
|
|
164
|
-
If missing
|
|
223
|
+
If missing, empty, invalid, or unresolvable, the triggering element is used.
|
|
224
|
+
|
|
225
|
+
### `py-policy` (optional)
|
|
226
|
+
|
|
227
|
+
Per-element concurrency override. If omitted, the global
|
|
228
|
+
`window.pywebviewHtmx.config.requestPolicy` value is used.
|
|
229
|
+
|
|
230
|
+
- `latest-wins`
|
|
231
|
+
- `drop`
|
|
165
232
|
|
|
166
233
|
## Runtime Config (JavaScript)
|
|
167
234
|
|
|
@@ -200,17 +267,21 @@ document.body.addEventListener("py:error", (event) => {
|
|
|
200
267
|
|
|
201
268
|
## Concurrency Behavior
|
|
202
269
|
|
|
203
|
-
|
|
270
|
+
`pywebview-htmx` tracks request state by resolved target. Controls that share
|
|
271
|
+
the same `py-target` coordinate request ordering and `drop` behavior. Controls
|
|
272
|
+
without `py-target` use the triggering element as their request state scope.
|
|
273
|
+
Loading state is counted separately per resolved `py-wait` target so shared
|
|
274
|
+
spinners stay active until all in-flight requests finish.
|
|
204
275
|
|
|
205
276
|
### `latest-wins` (default)
|
|
206
277
|
|
|
207
|
-
Multiple rapid requests are allowed; stale responses
|
|
208
|
-
Only latest request updates the DOM.
|
|
278
|
+
Multiple rapid requests are allowed; stale responses in the same request state
|
|
279
|
+
scope are ignored. Only the latest request for that scope updates the DOM.
|
|
209
280
|
|
|
210
281
|
### `drop`
|
|
211
282
|
|
|
212
|
-
If a request is already in flight for
|
|
213
|
-
`py:ignored` is emitted.
|
|
283
|
+
If a request is already in flight for the same request state scope, new triggers
|
|
284
|
+
are ignored and `py:ignored` is emitted.
|
|
214
285
|
|
|
215
286
|
## Dynamic Content Re-processing
|
|
216
287
|
|
|
@@ -257,7 +328,8 @@ behavior, add your own `<style data-pywebview-theme="my-theme">...</style>` bloc
|
|
|
257
328
|
### Pattern 1: Form submit to Python
|
|
258
329
|
|
|
259
330
|
- Use `py-trigger="submit"` on `<form>`
|
|
260
|
-
-
|
|
331
|
+
- Give fields `name` attributes so they can be serialized
|
|
332
|
+
- Use `data-py-params` only for extra static values you want merged in
|
|
261
333
|
- `py-target` a result card/summary region
|
|
262
334
|
|
|
263
335
|
### Pattern 2: Append activity/log rows
|
|
@@ -268,7 +340,8 @@ behavior, add your own `<style data-pywebview-theme="my-theme">...</style>` bloc
|
|
|
268
340
|
### Pattern 3: Replace full component
|
|
269
341
|
|
|
270
342
|
- Use `py-swap="outerHTML"`
|
|
271
|
-
- Return full replacement markup with same outer id/selector
|
|
343
|
+
- Return full replacement markup with the same outer id/selector if future
|
|
344
|
+
interactions should keep targeting that component
|
|
272
345
|
|
|
273
346
|
### Pattern 4: Theme switch from Python
|
|
274
347
|
|
|
@@ -291,6 +364,7 @@ Check:
|
|
|
291
364
|
- method returns a string of HTML
|
|
292
365
|
- no JSON parse error in `data-py-params`
|
|
293
366
|
- `py-target` selector exists
|
|
367
|
+
- for forms, fields have `name` attributes
|
|
294
368
|
|
|
295
369
|
### "New buttons in swapped HTML do not work"
|
|
296
370
|
|
|
@@ -305,6 +379,21 @@ window.pywebviewHtmx.process(document.body);
|
|
|
305
379
|
|
|
306
380
|
Style `.py-waiting` globally and/or point `py-wait` at a dedicated element.
|
|
307
381
|
|
|
382
|
+
### "I got a Python API return type error"
|
|
383
|
+
|
|
384
|
+
`pywebview-htmx` expects handlers to return an HTML string fragment. Convert
|
|
385
|
+
structured data into markup before returning it.
|
|
386
|
+
|
|
387
|
+
## Common Mistakes
|
|
388
|
+
|
|
389
|
+
- Returning dicts/lists instead of HTML strings
|
|
390
|
+
- Forgetting to escape untrusted values before interpolating into markup
|
|
391
|
+
- Omitting `name` attributes on form fields and expecting them to serialize
|
|
392
|
+
- Using `py-swap="outerHTML"` but not preserving the target component's
|
|
393
|
+
selector/id
|
|
394
|
+
- Manually mutating the DOM outside of `pywebview-htmx` swaps without calling
|
|
395
|
+
`window.pywebviewHtmx.process(...)`
|
|
396
|
+
|
|
308
397
|
## Demo
|
|
309
398
|
|
|
310
399
|
Run the full feature showcase:
|
|
@@ -6,7 +6,7 @@ authors = [
|
|
|
6
6
|
{ name = "B.T. Franklin", email = "brandon.franklin@gmail.com" },
|
|
7
7
|
]
|
|
8
8
|
dependencies = [
|
|
9
|
-
"pywebview>=6.1",
|
|
9
|
+
"pywebview>=6.2.1",
|
|
10
10
|
]
|
|
11
11
|
requires-python = ">=3.14"
|
|
12
12
|
readme = "README.md"
|
|
@@ -21,14 +21,14 @@ classifiers = [
|
|
|
21
21
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
22
|
"Topic :: Internet :: WWW/HTTP",
|
|
23
23
|
]
|
|
24
|
-
version = "0.
|
|
24
|
+
version = "0.3.1"
|
|
25
25
|
|
|
26
26
|
[project.license]
|
|
27
27
|
file = "LICENSE"
|
|
28
28
|
|
|
29
29
|
[build-system]
|
|
30
30
|
requires = [
|
|
31
|
-
"pdm-backend",
|
|
31
|
+
"pdm-backend>=2.4.9",
|
|
32
32
|
]
|
|
33
33
|
build-backend = "pdm.backend"
|
|
34
34
|
|
|
@@ -65,6 +65,6 @@ select = [
|
|
|
65
65
|
|
|
66
66
|
[dependency-groups]
|
|
67
67
|
dev = [
|
|
68
|
-
"pytest>=9.0.
|
|
69
|
-
"ruff>=0.15.
|
|
68
|
+
"pytest>=9.0.3",
|
|
69
|
+
"ruff>=0.15.17",
|
|
70
70
|
]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from .runtime import (
|
|
2
2
|
DEFAULT_THEME,
|
|
3
3
|
create_window,
|
|
4
|
+
encode_params_attr,
|
|
4
5
|
get_runtime_script,
|
|
5
6
|
get_theme_css,
|
|
6
7
|
inject_runtime,
|
|
@@ -11,6 +12,7 @@ from .runtime import (
|
|
|
11
12
|
__all__ = [
|
|
12
13
|
"DEFAULT_THEME",
|
|
13
14
|
"create_window",
|
|
15
|
+
"encode_params_attr",
|
|
14
16
|
"get_runtime_script",
|
|
15
17
|
"get_theme_css",
|
|
16
18
|
"inject_runtime",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from importlib.resources import files
|
|
4
|
+
import json
|
|
4
5
|
import re
|
|
6
|
+
from html import escape as html_escape
|
|
5
7
|
from typing import Any
|
|
6
8
|
|
|
7
9
|
import webview
|
|
@@ -56,6 +58,14 @@ def get_runtime_script() -> str:
|
|
|
56
58
|
)
|
|
57
59
|
|
|
58
60
|
|
|
61
|
+
def encode_params_attr(params: Any) -> str:
|
|
62
|
+
"""Encode a payload for safe embedding in ``data-py-params``."""
|
|
63
|
+
return html_escape(
|
|
64
|
+
json.dumps(params, sort_keys=True),
|
|
65
|
+
quote=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
59
69
|
def inject_runtime(html: str) -> str:
|
|
60
70
|
"""Inject the runtime script into an HTML string before ``</body>`` when present."""
|
|
61
71
|
if _SCRIPT_TAG_RE.search(html):
|
|
@@ -8,10 +8,16 @@
|
|
|
8
8
|
requestPolicy: "latest-wins",
|
|
9
9
|
};
|
|
10
10
|
const requestState = new WeakMap();
|
|
11
|
+
const waitState = new WeakMap();
|
|
11
12
|
|
|
12
13
|
function $(selector, root = document) {
|
|
13
14
|
if (!selector) return null;
|
|
14
|
-
|
|
15
|
+
try {
|
|
16
|
+
return root.querySelector(selector);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.warn("PyWebview HTMX: invalid selector", selector, error);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
function triggerEvent(target, eventName, detail = {}) {
|
|
@@ -58,6 +64,47 @@
|
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
function mergeParamValue(target, key, value) {
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
|
69
|
+
const existing = target[key];
|
|
70
|
+
if (Array.isArray(existing)) {
|
|
71
|
+
existing.push(value);
|
|
72
|
+
} else {
|
|
73
|
+
target[key] = [existing, value];
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
target[key] = value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function serializeForm(form, event) {
|
|
81
|
+
const params = {};
|
|
82
|
+
const formData = new FormData(form);
|
|
83
|
+
|
|
84
|
+
for (const [key, value] of formData.entries()) {
|
|
85
|
+
if (typeof File !== "undefined" && value instanceof File) {
|
|
86
|
+
console.warn("PyWebview HTMX: file inputs are not supported in data-py-params", key);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
mergeParamValue(params, key, value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const submitter = event && "submitter" in event ? event.submitter : null;
|
|
93
|
+
if (submitter && submitter.name) {
|
|
94
|
+
mergeParamValue(params, submitter.name, submitter.value || "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return params;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildRequestParams(element, eventName, event) {
|
|
101
|
+
const params = parseParams(element);
|
|
102
|
+
if (element.tagName !== "FORM" || eventName !== "submit") {
|
|
103
|
+
return params;
|
|
104
|
+
}
|
|
105
|
+
return { ...params, ...serializeForm(element, event) };
|
|
106
|
+
}
|
|
107
|
+
|
|
61
108
|
function getWaitTarget(element) {
|
|
62
109
|
if (!element.hasAttribute("py-wait")) {
|
|
63
110
|
return element;
|
|
@@ -85,25 +132,60 @@
|
|
|
85
132
|
return await func(params);
|
|
86
133
|
}
|
|
87
134
|
|
|
88
|
-
function getRequestState(
|
|
89
|
-
let state = requestState.get(
|
|
135
|
+
function getRequestState(key) {
|
|
136
|
+
let state = requestState.get(key);
|
|
90
137
|
if (!state) {
|
|
91
138
|
state = { lastIssued: 0, inFlightCount: 0 };
|
|
92
|
-
requestState.set(
|
|
139
|
+
requestState.set(key, state);
|
|
93
140
|
}
|
|
94
141
|
return state;
|
|
95
142
|
}
|
|
96
143
|
|
|
144
|
+
function getRequestStateKey(element, targetSelector) {
|
|
145
|
+
if (!targetSelector) {
|
|
146
|
+
return element;
|
|
147
|
+
}
|
|
148
|
+
return $(targetSelector) || element;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function addWaiting(waitTarget) {
|
|
152
|
+
const count = (waitState.get(waitTarget) || 0) + 1;
|
|
153
|
+
waitState.set(waitTarget, count);
|
|
154
|
+
waitTarget.classList.add("py-waiting");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function removeWaiting(waitTarget) {
|
|
158
|
+
const count = Math.max(0, (waitState.get(waitTarget) || 0) - 1);
|
|
159
|
+
if (count === 0) {
|
|
160
|
+
waitState.delete(waitTarget);
|
|
161
|
+
waitTarget.classList.remove("py-waiting");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
waitState.set(waitTarget, count);
|
|
165
|
+
}
|
|
166
|
+
|
|
97
167
|
function shouldPreventDefault(eventName) {
|
|
98
168
|
return eventName === "submit";
|
|
99
169
|
}
|
|
100
170
|
|
|
171
|
+
function getRequestPolicy(element) {
|
|
172
|
+
const policy = element.getAttribute("py-policy") || config.requestPolicy;
|
|
173
|
+
if (policy === "drop" || policy === "latest-wins") {
|
|
174
|
+
return policy;
|
|
175
|
+
}
|
|
176
|
+
return config.requestPolicy;
|
|
177
|
+
}
|
|
178
|
+
|
|
101
179
|
function processPyWebviewHtmxNodes(root = document) {
|
|
102
180
|
if (!root || typeof root.querySelectorAll !== "function") {
|
|
103
181
|
return;
|
|
104
182
|
}
|
|
105
183
|
|
|
106
|
-
const elements =
|
|
184
|
+
const elements = [];
|
|
185
|
+
if (root.matches && root.matches("[py-call]")) {
|
|
186
|
+
elements.push(root);
|
|
187
|
+
}
|
|
188
|
+
elements.push(...root.querySelectorAll("[py-call]"));
|
|
107
189
|
|
|
108
190
|
elements.forEach((element) => {
|
|
109
191
|
if (element.getAttribute("data-pywebview-bound") === "true") {
|
|
@@ -127,9 +209,10 @@
|
|
|
127
209
|
event.preventDefault();
|
|
128
210
|
}
|
|
129
211
|
|
|
130
|
-
const params =
|
|
131
|
-
const state = getRequestState(element);
|
|
132
|
-
|
|
212
|
+
const params = buildRequestParams(element, eventName, event);
|
|
213
|
+
const state = getRequestState(getRequestStateKey(element, targetSelector));
|
|
214
|
+
const requestPolicy = getRequestPolicy(element);
|
|
215
|
+
if (requestPolicy === "drop" && state.inFlightCount > 0) {
|
|
133
216
|
triggerEvent(element, "py:ignored", { reason: "in-flight" });
|
|
134
217
|
return;
|
|
135
218
|
}
|
|
@@ -144,13 +227,16 @@
|
|
|
144
227
|
requestId,
|
|
145
228
|
});
|
|
146
229
|
|
|
147
|
-
waitTarget
|
|
230
|
+
addWaiting(waitTarget);
|
|
148
231
|
|
|
149
232
|
try {
|
|
150
233
|
const response = await invokePython(functionName, params);
|
|
151
234
|
if (requestId !== state.lastIssued) {
|
|
152
235
|
return;
|
|
153
236
|
}
|
|
237
|
+
if (typeof response !== "string") {
|
|
238
|
+
throw new Error("PyWebview HTMX: Python API methods must return an HTML string");
|
|
239
|
+
}
|
|
154
240
|
|
|
155
241
|
const target = targetSelector ? $(targetSelector) : element;
|
|
156
242
|
if (!target) {
|
|
@@ -182,9 +268,7 @@
|
|
|
182
268
|
triggerEvent(element, "py:error", { error, requestId });
|
|
183
269
|
} finally {
|
|
184
270
|
state.inFlightCount = Math.max(0, state.inFlightCount - 1);
|
|
185
|
-
|
|
186
|
-
waitTarget.classList.remove("py-waiting");
|
|
187
|
-
}
|
|
271
|
+
removeWaiting(waitTarget);
|
|
188
272
|
}
|
|
189
273
|
});
|
|
190
274
|
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import vm from "node:vm";
|
|
4
|
+
|
|
5
|
+
class DomEvent {
|
|
6
|
+
constructor(type, options = {}) {
|
|
7
|
+
this.type = type;
|
|
8
|
+
this.bubbles = Boolean(options.bubbles);
|
|
9
|
+
this.cancelable = Boolean(options.cancelable);
|
|
10
|
+
this.detail = options.detail || {};
|
|
11
|
+
this.defaultPrevented = false;
|
|
12
|
+
this.target = null;
|
|
13
|
+
this.currentTarget = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
preventDefault() {
|
|
17
|
+
if (this.cancelable) {
|
|
18
|
+
this.defaultPrevented = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class DomCustomEvent extends DomEvent {}
|
|
24
|
+
|
|
25
|
+
class ClassList {
|
|
26
|
+
constructor() {
|
|
27
|
+
this.tokens = new Set();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
add(...tokens) {
|
|
31
|
+
tokens.forEach((token) => this.tokens.add(token));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
remove(...tokens) {
|
|
35
|
+
tokens.forEach((token) => this.tokens.delete(token));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
contains(token) {
|
|
39
|
+
return this.tokens.has(token);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class Element {
|
|
44
|
+
constructor(tagName, ownerDocument) {
|
|
45
|
+
this.tagName = tagName.toUpperCase();
|
|
46
|
+
this.ownerDocument = ownerDocument;
|
|
47
|
+
this.parentElement = null;
|
|
48
|
+
this.children = [];
|
|
49
|
+
this.attributes = new Map();
|
|
50
|
+
this.classList = new ClassList();
|
|
51
|
+
this.listeners = new Map();
|
|
52
|
+
this._innerHTML = "";
|
|
53
|
+
this.id = "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
append(...children) {
|
|
57
|
+
children.forEach((child) => {
|
|
58
|
+
child.parentElement = this;
|
|
59
|
+
child.ownerDocument = this.ownerDocument;
|
|
60
|
+
this.children.push(child);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setAttribute(name, value) {
|
|
65
|
+
const stringValue = String(value);
|
|
66
|
+
this.attributes.set(name, stringValue);
|
|
67
|
+
if (name === "id") {
|
|
68
|
+
this.id = stringValue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getAttribute(name) {
|
|
73
|
+
return this.attributes.has(name) ? this.attributes.get(name) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
hasAttribute(name) {
|
|
77
|
+
return this.attributes.has(name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
matches(selector) {
|
|
81
|
+
return this.ownerDocument.matchesSelector(this, selector);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
querySelector(selector) {
|
|
85
|
+
return this.ownerDocument.querySelectorFrom(selector, this);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
querySelectorAll(selector) {
|
|
89
|
+
return this.ownerDocument.querySelectorAllFrom(selector, this);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
addEventListener(type, listener) {
|
|
93
|
+
const listeners = this.listeners.get(type) || [];
|
|
94
|
+
listeners.push(listener);
|
|
95
|
+
this.listeners.set(type, listeners);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dispatchEvent(event) {
|
|
99
|
+
if (!event.target) {
|
|
100
|
+
event.target = this;
|
|
101
|
+
}
|
|
102
|
+
event.currentTarget = this;
|
|
103
|
+
|
|
104
|
+
for (const listener of this.listeners.get(event.type) || []) {
|
|
105
|
+
listener.call(this, event);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (event.bubbles && this.parentElement) {
|
|
109
|
+
this.parentElement.dispatchEvent(event);
|
|
110
|
+
}
|
|
111
|
+
return !event.defaultPrevented;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
set innerHTML(value) {
|
|
115
|
+
this._innerHTML = String(value);
|
|
116
|
+
this.children = [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get innerHTML() {
|
|
120
|
+
return this._innerHTML;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
set outerHTML(value) {
|
|
124
|
+
if (!this.parentElement) {
|
|
125
|
+
this.innerHTML = value;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const replacement = this.ownerDocument.createElement("div");
|
|
130
|
+
replacement.innerHTML = value;
|
|
131
|
+
const index = this.parentElement.children.indexOf(this);
|
|
132
|
+
this.parentElement.children.splice(index, 1, replacement);
|
|
133
|
+
replacement.parentElement = this.parentElement;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
insertAdjacentHTML(position, value) {
|
|
137
|
+
assert.equal(position, "beforeend");
|
|
138
|
+
this._innerHTML += String(value);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
class Document {
|
|
143
|
+
constructor() {
|
|
144
|
+
this.listeners = new Map();
|
|
145
|
+
this.body = new Element("body", this);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
createElement(tagName) {
|
|
149
|
+
return new Element(tagName, this);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
addEventListener(type, listener) {
|
|
153
|
+
const listeners = this.listeners.get(type) || [];
|
|
154
|
+
listeners.push(listener);
|
|
155
|
+
this.listeners.set(type, listeners);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
querySelector(selector) {
|
|
159
|
+
return this.querySelectorFrom(selector, this.body);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
querySelectorAll(selector) {
|
|
163
|
+
return this.querySelectorAllFrom(selector, this.body);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
querySelectorFrom(selector, root) {
|
|
167
|
+
return this.querySelectorAllFrom(selector, root)[0] || null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
querySelectorAllFrom(selector, root) {
|
|
171
|
+
this.validateSelector(selector);
|
|
172
|
+
const matches = [];
|
|
173
|
+
|
|
174
|
+
const visit = (node) => {
|
|
175
|
+
for (const child of node.children) {
|
|
176
|
+
if (this.matchesSelector(child, selector)) {
|
|
177
|
+
matches.push(child);
|
|
178
|
+
}
|
|
179
|
+
visit(child);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
visit(root);
|
|
184
|
+
return matches;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
matchesSelector(element, selector) {
|
|
188
|
+
this.validateSelector(selector);
|
|
189
|
+
if (selector === "[py-call]") {
|
|
190
|
+
return element.hasAttribute("py-call");
|
|
191
|
+
}
|
|
192
|
+
if (selector.startsWith("#")) {
|
|
193
|
+
return element.id === selector.slice(1);
|
|
194
|
+
}
|
|
195
|
+
throw new SyntaxError(`Unsupported selector in test harness: ${selector}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
validateSelector(selector) {
|
|
199
|
+
if (!selector || selector === "[" || selector.includes("###")) {
|
|
200
|
+
throw new SyntaxError(`Invalid selector: ${selector}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function makeElement(document, tagName, attributes = {}) {
|
|
206
|
+
const element = document.createElement(tagName);
|
|
207
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
208
|
+
element.setAttribute(name, value);
|
|
209
|
+
}
|
|
210
|
+
return element;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createHarness(runtimeSource) {
|
|
214
|
+
const document = new Document();
|
|
215
|
+
const warnings = [];
|
|
216
|
+
const context = {
|
|
217
|
+
clearTimeout,
|
|
218
|
+
console: {
|
|
219
|
+
...console,
|
|
220
|
+
warn: (...args) => warnings.push(args.map(String).join(" ")),
|
|
221
|
+
},
|
|
222
|
+
CustomEvent: DomCustomEvent,
|
|
223
|
+
document,
|
|
224
|
+
Promise,
|
|
225
|
+
setTimeout,
|
|
226
|
+
};
|
|
227
|
+
context.window = context;
|
|
228
|
+
context.globalThis = context;
|
|
229
|
+
|
|
230
|
+
vm.createContext(context);
|
|
231
|
+
vm.runInContext(runtimeSource, context);
|
|
232
|
+
|
|
233
|
+
context.pywebviewHtmx.config.settleDelay = 0;
|
|
234
|
+
context.pywebviewHtmx.config.swapDelay = 0;
|
|
235
|
+
|
|
236
|
+
return { document, warnings, window: context };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function flushAsyncWork() {
|
|
240
|
+
await Promise.resolve();
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
242
|
+
await Promise.resolve();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function click(element) {
|
|
246
|
+
element.dispatchEvent(new DomEvent("click", { bubbles: true, cancelable: true }));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function testInvalidWaitSelectorFallsBackAndDoesNotAbort(runtimeSource) {
|
|
250
|
+
const { document, warnings, window } = createHarness(runtimeSource);
|
|
251
|
+
const result = makeElement(document, "div", { id: "result" });
|
|
252
|
+
const badWait = makeElement(document, "button", {
|
|
253
|
+
"py-call": "load",
|
|
254
|
+
"py-target": "#result",
|
|
255
|
+
"py-wait": "[",
|
|
256
|
+
});
|
|
257
|
+
const laterButton = makeElement(document, "button", { "py-call": "later" });
|
|
258
|
+
document.body.append(badWait, result, laterButton);
|
|
259
|
+
|
|
260
|
+
let resolveLoad;
|
|
261
|
+
window.pywebview = {
|
|
262
|
+
api: {
|
|
263
|
+
load: () => new Promise((resolve) => {
|
|
264
|
+
resolveLoad = resolve;
|
|
265
|
+
}),
|
|
266
|
+
later: () => "<p>later</p>",
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
window.pywebviewHtmx.process(document.body);
|
|
271
|
+
assert.equal(badWait.getAttribute("data-pywebview-bound"), "true");
|
|
272
|
+
assert.equal(laterButton.getAttribute("data-pywebview-bound"), "true");
|
|
273
|
+
assert.ok(warnings.some((warning) => warning.includes("invalid selector")));
|
|
274
|
+
|
|
275
|
+
click(badWait);
|
|
276
|
+
assert.equal(badWait.classList.contains("py-waiting"), true);
|
|
277
|
+
|
|
278
|
+
resolveLoad("<p>loaded</p>");
|
|
279
|
+
await flushAsyncWork();
|
|
280
|
+
|
|
281
|
+
assert.equal(result.innerHTML, "<p>loaded</p>");
|
|
282
|
+
assert.equal(badWait.classList.contains("py-waiting"), false);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function testLatestWinsIsSharedByTarget(runtimeSource) {
|
|
286
|
+
const { document, window } = createHarness(runtimeSource);
|
|
287
|
+
const result = makeElement(document, "div", { id: "race-result" });
|
|
288
|
+
const wait = makeElement(document, "span", { id: "race-status" });
|
|
289
|
+
const slow = makeElement(document, "button", {
|
|
290
|
+
"py-call": "long_task",
|
|
291
|
+
"py-target": "#race-result",
|
|
292
|
+
"py-wait": "#race-status",
|
|
293
|
+
});
|
|
294
|
+
const fast = makeElement(document, "button", {
|
|
295
|
+
"py-call": "long_task",
|
|
296
|
+
"py-target": "#race-result",
|
|
297
|
+
"py-wait": "#race-status",
|
|
298
|
+
});
|
|
299
|
+
document.body.append(slow, fast, wait, result);
|
|
300
|
+
|
|
301
|
+
const requests = [];
|
|
302
|
+
window.pywebview = {
|
|
303
|
+
api: {
|
|
304
|
+
long_task: () => new Promise((resolve) => {
|
|
305
|
+
requests.push(resolve);
|
|
306
|
+
}),
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
window.pywebviewHtmx.process(document.body);
|
|
311
|
+
click(slow);
|
|
312
|
+
click(fast);
|
|
313
|
+
|
|
314
|
+
assert.equal(requests.length, 2);
|
|
315
|
+
assert.equal(wait.classList.contains("py-waiting"), true);
|
|
316
|
+
|
|
317
|
+
requests[1]("<p>fast</p>");
|
|
318
|
+
await flushAsyncWork();
|
|
319
|
+
|
|
320
|
+
assert.equal(result.innerHTML, "<p>fast</p>");
|
|
321
|
+
assert.equal(wait.classList.contains("py-waiting"), true);
|
|
322
|
+
|
|
323
|
+
requests[0]("<p>slow</p>");
|
|
324
|
+
await flushAsyncWork();
|
|
325
|
+
|
|
326
|
+
assert.equal(result.innerHTML, "<p>fast</p>");
|
|
327
|
+
assert.equal(wait.classList.contains("py-waiting"), false);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function testDropPolicyIsSharedByTarget(runtimeSource) {
|
|
331
|
+
const { document, window } = createHarness(runtimeSource);
|
|
332
|
+
const result = makeElement(document, "div", { id: "drop-result" });
|
|
333
|
+
const first = makeElement(document, "button", {
|
|
334
|
+
"py-call": "long_task",
|
|
335
|
+
"py-policy": "drop",
|
|
336
|
+
"py-target": "#drop-result",
|
|
337
|
+
});
|
|
338
|
+
const second = makeElement(document, "button", {
|
|
339
|
+
"py-call": "long_task",
|
|
340
|
+
"py-policy": "drop",
|
|
341
|
+
"py-target": "#drop-result",
|
|
342
|
+
});
|
|
343
|
+
document.body.append(first, second, result);
|
|
344
|
+
|
|
345
|
+
const requests = [];
|
|
346
|
+
window.pywebview = {
|
|
347
|
+
api: {
|
|
348
|
+
long_task: () => new Promise((resolve) => {
|
|
349
|
+
requests.push(resolve);
|
|
350
|
+
}),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
window.pywebviewHtmx.process(document.body);
|
|
355
|
+
click(first);
|
|
356
|
+
click(second);
|
|
357
|
+
|
|
358
|
+
assert.equal(requests.length, 1);
|
|
359
|
+
requests[0]("<p>first</p>");
|
|
360
|
+
await flushAsyncWork();
|
|
361
|
+
assert.equal(result.innerHTML, "<p>first</p>");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const runtimePath = process.argv[2];
|
|
365
|
+
const runtimeSource = fs.readFileSync(runtimePath, "utf8");
|
|
366
|
+
|
|
367
|
+
await testInvalidWaitSelectorFallsBackAndDoesNotAbort(runtimeSource);
|
|
368
|
+
await testLatestWinsIsSharedByTarget(runtimeSource);
|
|
369
|
+
await testDropPolicyIsSharedByTarget(runtimeSource);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
TESTED_BEHAVIOR_IDS = set(range(101, 114))
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _assert_fragments_present(js_compact: str, fragments: tuple[str, ...]) -> None:
|
|
9
|
+
for fragment in fragments:
|
|
10
|
+
assert fragment in js_compact
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
JS_BEHAVIOR_FRAGMENTS: list[tuple[int, tuple[str, ...]]] = [
|
|
14
|
+
(
|
|
15
|
+
101,
|
|
16
|
+
(
|
|
17
|
+
"function mergeParamValue(target, key, value)",
|
|
18
|
+
"if (Object.prototype.hasOwnProperty.call(target, key))",
|
|
19
|
+
),
|
|
20
|
+
),
|
|
21
|
+
(
|
|
22
|
+
102,
|
|
23
|
+
(
|
|
24
|
+
"function serializeForm(form, event)",
|
|
25
|
+
"const formData = new FormData(form);",
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
(
|
|
29
|
+
103,
|
|
30
|
+
(
|
|
31
|
+
'const submitter = event && "submitter" in event ? event.submitter : null;',
|
|
32
|
+
"submitter.name",
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
(
|
|
36
|
+
104,
|
|
37
|
+
(
|
|
38
|
+
"value instanceof File",
|
|
39
|
+
(
|
|
40
|
+
'console.warn("PyWebview HTMX: file inputs are not supported '
|
|
41
|
+
'in data-py-params"'
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
105,
|
|
47
|
+
(
|
|
48
|
+
"function buildRequestParams(element, eventName, event)",
|
|
49
|
+
'if (element.tagName !== "FORM" || eventName !== "submit")',
|
|
50
|
+
"return { ...params, ...serializeForm(element, event) };",
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
106,
|
|
55
|
+
(
|
|
56
|
+
"const elements = [];",
|
|
57
|
+
'if (root.matches && root.matches("[py-call]"))',
|
|
58
|
+
"elements.push(root);",
|
|
59
|
+
'elements.push(...root.querySelectorAll("[py-call]"));',
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
(
|
|
63
|
+
107,
|
|
64
|
+
(
|
|
65
|
+
"function getRequestPolicy(element)",
|
|
66
|
+
'const policy = element.getAttribute("py-policy") || config.requestPolicy;',
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
(
|
|
70
|
+
108,
|
|
71
|
+
(
|
|
72
|
+
'policy === "drop" || policy === "latest-wins"',
|
|
73
|
+
"return config.requestPolicy;",
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
109,
|
|
78
|
+
(
|
|
79
|
+
"const requestPolicy = getRequestPolicy(element);",
|
|
80
|
+
'if (requestPolicy === "drop" && state.inFlightCount > 0)',
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
(
|
|
84
|
+
110,
|
|
85
|
+
(
|
|
86
|
+
"const params = buildRequestParams(element, eventName, event);",
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
111,
|
|
91
|
+
(
|
|
92
|
+
'if (typeof response !== "string") {',
|
|
93
|
+
(
|
|
94
|
+
'throw new Error("PyWebview HTMX: Python API methods must return '
|
|
95
|
+
'an HTML string")'
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
(
|
|
100
|
+
112,
|
|
101
|
+
(
|
|
102
|
+
'triggerEvent(element, "py:error", { error, requestId });',
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
(
|
|
106
|
+
113,
|
|
107
|
+
(
|
|
108
|
+
'element.getAttribute("py-policy")',
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
("behavior_id", "fragments"),
|
|
116
|
+
JS_BEHAVIOR_FRAGMENTS,
|
|
117
|
+
ids=[f"B{behavior_id}" for behavior_id, _ in JS_BEHAVIOR_FRAGMENTS],
|
|
118
|
+
)
|
|
119
|
+
def test_js_ergonomic_contract_fragments(
|
|
120
|
+
behavior_id: int,
|
|
121
|
+
fragments: tuple[str, ...],
|
|
122
|
+
js_compact: str,
|
|
123
|
+
) -> None:
|
|
124
|
+
_ = behavior_id
|
|
125
|
+
_assert_fragments_present(js_compact, fragments)
|
|
@@ -20,12 +20,25 @@ JS_BEHAVIOR_FRAGMENTS: list[tuple[int, tuple[str, ...]]] = [
|
|
|
20
20
|
"event.preventDefault();",
|
|
21
21
|
),
|
|
22
22
|
),
|
|
23
|
-
(
|
|
23
|
+
(
|
|
24
|
+
74,
|
|
25
|
+
(
|
|
26
|
+
"function getRequestState(key)",
|
|
27
|
+
"let state = requestState.get(key);",
|
|
28
|
+
"requestState.set(key, state);",
|
|
29
|
+
),
|
|
30
|
+
),
|
|
24
31
|
(75, ("lastIssued: 0", "state.lastIssued = requestId;")),
|
|
25
32
|
(76, ("inFlightCount: 0", "state.inFlightCount += 1;")),
|
|
26
|
-
(77, ('
|
|
33
|
+
(77, ('requestPolicy === "drop"', 'triggerEvent(element, "py:ignored"')),
|
|
27
34
|
(78, ('triggerEvent(element, "py:trigger"', "requestId,")),
|
|
28
|
-
(
|
|
35
|
+
(
|
|
36
|
+
79,
|
|
37
|
+
(
|
|
38
|
+
"function addWaiting(waitTarget)",
|
|
39
|
+
'waitTarget.classList.add("py-waiting")',
|
|
40
|
+
),
|
|
41
|
+
),
|
|
29
42
|
(80, ("if (requestId !== state.lastIssued) { return; }",)),
|
|
30
43
|
(81, ("const target = targetSelector ? $(targetSelector) : element;",)),
|
|
31
44
|
(82, (" : element;",)),
|
|
@@ -50,7 +63,7 @@ JS_BEHAVIOR_FRAGMENTS: list[tuple[int, tuple[str, ...]]] = [
|
|
|
50
63
|
(
|
|
51
64
|
94,
|
|
52
65
|
(
|
|
53
|
-
"
|
|
66
|
+
"function removeWaiting(waitTarget)",
|
|
54
67
|
'waitTarget.classList.remove("py-waiting")',
|
|
55
68
|
),
|
|
56
69
|
),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
HARNESS = REPO_ROOT / "tests/js_runtime_behavior_harness.mjs"
|
|
12
|
+
RUNTIME = REPO_ROOT / "src/pywebview_htmx/static/runtime.js"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_runtime_behaviors_execute_in_dom_harness() -> None:
|
|
16
|
+
node = shutil.which("node")
|
|
17
|
+
if node is None:
|
|
18
|
+
pytest.skip("Node.js is required for JavaScript runtime behavior tests")
|
|
19
|
+
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
[node, str(HARNESS), str(RUNTIME)],
|
|
22
|
+
capture_output=True,
|
|
23
|
+
check=False,
|
|
24
|
+
text=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pywebview_htmx import encode_params_attr
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_encode_params_attr_returns_json_string() -> None:
|
|
7
|
+
encoded = encode_params_attr({"count": 3, "enabled": True})
|
|
8
|
+
assert encoded == "{"count": 3, "enabled": true}"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_encode_params_attr_escapes_quotes_and_tags() -> None:
|
|
12
|
+
encoded = encode_params_attr({"message": '"hi" <b>unsafe</b>'})
|
|
13
|
+
assert "\\"hi\\"" in encoded
|
|
14
|
+
assert "<b>unsafe</b>" in encoded
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_encode_params_attr_is_exported_from_package() -> None:
|
|
18
|
+
encoded = encode_params_attr({"name": "pywebview-htmx"})
|
|
19
|
+
assert isinstance(encoded, str)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
8
|
+
REQUIRED_DOCS = [
|
|
9
|
+
"AGENTS.md",
|
|
10
|
+
"docs/index.md",
|
|
11
|
+
"docs/architecture.md",
|
|
12
|
+
"docs/development.md",
|
|
13
|
+
"docs/releasing.md",
|
|
14
|
+
]
|
|
15
|
+
AGENT_REQUIRED_ROUTES = [
|
|
16
|
+
"README.md",
|
|
17
|
+
"docs/index.md",
|
|
18
|
+
"src/pywebview_htmx/runtime.py",
|
|
19
|
+
"src/pywebview_htmx/static/runtime.js",
|
|
20
|
+
"app.py",
|
|
21
|
+
"pyproject.toml",
|
|
22
|
+
"docs/releasing.md",
|
|
23
|
+
"tests/",
|
|
24
|
+
"skills/use-pywebview-htmx/",
|
|
25
|
+
]
|
|
26
|
+
LOCAL_MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_repo_file(path: str) -> str:
|
|
30
|
+
return (REPO_ROOT / path).read_text(encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _iter_local_links(markdown_path: Path) -> list[str]:
|
|
34
|
+
links: list[str] = []
|
|
35
|
+
for raw_link in LOCAL_MARKDOWN_LINK_RE.findall(
|
|
36
|
+
markdown_path.read_text(encoding="utf-8"),
|
|
37
|
+
):
|
|
38
|
+
target = raw_link.split("#", 1)[0].strip()
|
|
39
|
+
if not target or re.match(r"^[a-z][a-z0-9+.-]*:", target):
|
|
40
|
+
continue
|
|
41
|
+
links.append(target)
|
|
42
|
+
return links
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_required_agent_legibility_docs_exist() -> None:
|
|
46
|
+
missing = [path for path in REQUIRED_DOCS if not (REPO_ROOT / path).exists()]
|
|
47
|
+
assert not missing, f"Missing agent-legibility docs: {missing}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_new_docs_have_resolvable_local_markdown_links() -> None:
|
|
51
|
+
checked_paths = [REPO_ROOT / path for path in REQUIRED_DOCS]
|
|
52
|
+
broken_links: list[str] = []
|
|
53
|
+
|
|
54
|
+
for markdown_path in checked_paths:
|
|
55
|
+
for target in _iter_local_links(markdown_path):
|
|
56
|
+
resolved = (markdown_path.parent / target).resolve()
|
|
57
|
+
link_description = f"{markdown_path.relative_to(REPO_ROOT)} -> {target}"
|
|
58
|
+
try:
|
|
59
|
+
resolved.relative_to(REPO_ROOT)
|
|
60
|
+
except ValueError:
|
|
61
|
+
broken_links.append(link_description)
|
|
62
|
+
continue
|
|
63
|
+
if not resolved.exists():
|
|
64
|
+
broken_links.append(link_description)
|
|
65
|
+
|
|
66
|
+
assert not broken_links, f"Broken local Markdown links: {broken_links}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_agents_md_routes_to_core_repo_surfaces() -> None:
|
|
70
|
+
agents_md = _read_repo_file("AGENTS.md")
|
|
71
|
+
missing_routes = [
|
|
72
|
+
route for route in AGENT_REQUIRED_ROUTES if route not in agents_md
|
|
73
|
+
]
|
|
74
|
+
assert not missing_routes, f"AGENTS.md is missing route(s): {missing_routes}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_development_guidance_captures_package_policy() -> None:
|
|
78
|
+
combined_guidance = "\n".join(
|
|
79
|
+
_read_repo_file(path) for path in ("AGENTS.md", "docs/development.md")
|
|
80
|
+
).lower()
|
|
81
|
+
|
|
82
|
+
required_fragments = [
|
|
83
|
+
"pdm install",
|
|
84
|
+
"pdm run lint",
|
|
85
|
+
"pdm run test",
|
|
86
|
+
"pdm run python app.py",
|
|
87
|
+
"do not use pip to add",
|
|
88
|
+
">=",
|
|
89
|
+
"latest version",
|
|
90
|
+
]
|
|
91
|
+
missing_fragments = [
|
|
92
|
+
fragment for fragment in required_fragments if fragment not in combined_guidance
|
|
93
|
+
]
|
|
94
|
+
assert not missing_fragments, (
|
|
95
|
+
"Development guidance is missing durable instruction(s): "
|
|
96
|
+
f"{missing_fragments}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_release_guidance_captures_tag_first_release_notes_flow() -> None:
|
|
101
|
+
release_guidance = _read_repo_file("docs/releasing.md")
|
|
102
|
+
|
|
103
|
+
required_fragments = [
|
|
104
|
+
"release-notes-scribe",
|
|
105
|
+
"Draft Release Notes",
|
|
106
|
+
"git tag",
|
|
107
|
+
"git push origin",
|
|
108
|
+
".github/workflows/draft-release-notes.yml",
|
|
109
|
+
".github/workflows/python-publish.yml",
|
|
110
|
+
"PyPI",
|
|
111
|
+
]
|
|
112
|
+
missing_fragments = [
|
|
113
|
+
fragment for fragment in required_fragments if fragment not in release_guidance
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
assert not missing_fragments, (
|
|
117
|
+
"Release guidance must preserve the tag-first release notes process. "
|
|
118
|
+
f"Missing: {missing_fragments}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_typed_classifier_has_pep_561_marker() -> None:
|
|
123
|
+
pyproject = _read_repo_file("pyproject.toml")
|
|
124
|
+
assert "Typing :: Typed" in pyproject
|
|
125
|
+
assert (REPO_ROOT / "src/pywebview_htmx/py.typed").is_file()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/cybermind.css
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|