pywebview-htmx 0.2.0__tar.gz → 0.3.0__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 (20) hide show
  1. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/PKG-INFO +83 -3
  2. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/README.md +82 -2
  3. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/pyproject.toml +1 -1
  4. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/src/pywebview_htmx/__init__.py +2 -0
  5. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/src/pywebview_htmx/runtime.py +14 -1
  6. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/src/pywebview_htmx/static/runtime.js +70 -5
  7. pywebview_htmx-0.3.0/tests/test_js_contract_ergonomics.py +125 -0
  8. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/tests/test_js_contract_request_and_swap.py +18 -3
  9. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/tests/test_python_injection.py +7 -0
  10. pywebview_htmx-0.3.0/tests/test_python_params_attr.py +19 -0
  11. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/LICENSE +0 -0
  12. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/src/pywebview_htmx/static/themes/aurora.css +0 -0
  13. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/src/pywebview_htmx/static/themes/base.css +0 -0
  14. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
  15. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/src/pywebview_htmx/static/themes/paper.css +0 -0
  16. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/tests/conftest.py +0 -0
  17. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/tests/test_js_contract_config_and_binding.py +0 -0
  18. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/tests/test_python_create_window.py +0 -0
  19. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/tests/test_python_script_loading.py +0 -0
  20. {pywebview_htmx-0.2.0 → pywebview_htmx-0.3.0}/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.2.0
3
+ Version: 0.3.0
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
@@ -84,6 +84,34 @@ pdm install
84
84
  pdm add pywebview-htmx
85
85
  ```
86
86
 
87
+ ## Agent Skill
88
+
89
+ This repository includes an installable coding-agent skill at
90
+ `skills/use-pywebview-htmx/`.
91
+
92
+ The folder in this repository is the source for a skill that tools such as
93
+ [`npx skills`](https://skills.sh/) can discover and install from GitHub.
94
+
95
+ List the available skill from this repository:
96
+
97
+ ```bash
98
+ npx skills add btfranklin/pywebview-htmx --list --full-depth
99
+ ```
100
+
101
+ Install just this skill:
102
+
103
+ ```bash
104
+ npx skills add btfranklin/pywebview-htmx --skill use-pywebview-htmx
105
+ ```
106
+
107
+ Other skill installers can consume the same skill folder from this repository.
108
+ The skill includes:
109
+
110
+ - the core usage workflow
111
+ - a concise contract reference
112
+ - implementation patterns
113
+ - troubleshooting guidance
114
+
87
115
  ## Quick Start
88
116
 
89
117
  ```python
@@ -117,6 +145,18 @@ create_window("Quickstart", html, js_api=API())
117
145
  You do **not** manually include `runtime.js` when using `create_window()`;
118
146
  it is injected automatically.
119
147
 
148
+ ## Important Differences From HTMX
149
+
150
+ - There is no HTTP request layer. `pywebview-htmx` calls a Python method on
151
+ `window.pywebview.api` directly.
152
+ - Python handlers should return an HTML string fragment. Returning objects or
153
+ other data types is treated as an error.
154
+ - For ordinary buttons/links, params still come from `data-py-params`.
155
+ - For `<form py-trigger="submit">`, named fields are serialized automatically
156
+ and merged over `data-py-params`.
157
+ - Returned HTML is inserted directly into the DOM, so escape untrusted values
158
+ before returning markup.
159
+
120
160
  ## API Reference (Python)
121
161
 
122
162
  ### `create_window(title, html, js_api=None, theme="aurora", start=True, **kwargs)`
@@ -143,6 +183,18 @@ Returns the bundled JavaScript runtime as a string.
143
183
 
144
184
  Injects the runtime script tag into an HTML string (idempotent).
145
185
 
186
+ ### `encode_params_attr(params)`
187
+
188
+ Returns HTML-escaped JSON that is safe to place in a `data-py-params`
189
+ attribute.
190
+
191
+ ```python
192
+ from pywebview_htmx import encode_params_attr
193
+
194
+ payload = encode_params_attr({"user_id": 42, "mode": "full"})
195
+ button = f'<button data-py-params="{payload}">Load user</button>'
196
+ ```
197
+
146
198
  ### Theme helpers
147
199
 
148
200
  - `list_themes()` -> sorted list of available themes
@@ -183,6 +235,8 @@ JSON payload passed to Python method.
183
235
  Notes:
184
236
  - If missing, params default to `{}`.
185
237
  - Invalid JSON logs an error and falls back to `{}`.
238
+ - For submit-triggered forms, named form fields are serialized automatically and
239
+ merged over this payload.
186
240
 
187
241
  ### `py-target` (optional)
188
242
 
@@ -203,6 +257,14 @@ Unknown value falls back to `innerHTML`.
203
257
  CSS selector of element receiving `.py-waiting` while request is in flight.
204
258
  If missing/empty/unresolvable, the triggering element is used.
205
259
 
260
+ ### `py-policy` (optional)
261
+
262
+ Per-element concurrency override. If omitted, the global
263
+ `window.pywebviewHtmx.config.requestPolicy` value is used.
264
+
265
+ - `latest-wins`
266
+ - `drop`
267
+
206
268
  ## Runtime Config (JavaScript)
207
269
 
208
270
  `window.pywebviewHtmx.config` includes:
@@ -297,7 +359,8 @@ behavior, add your own `<style data-pywebview-theme="my-theme">...</style>` bloc
297
359
  ### Pattern 1: Form submit to Python
298
360
 
299
361
  - Use `py-trigger="submit"` on `<form>`
300
- - Keep `data-py-params` in sync from form inputs (via JS)
362
+ - Give fields `name` attributes so they can be serialized
363
+ - Use `data-py-params` only for extra static values you want merged in
301
364
  - `py-target` a result card/summary region
302
365
 
303
366
  ### Pattern 2: Append activity/log rows
@@ -308,7 +371,8 @@ behavior, add your own `<style data-pywebview-theme="my-theme">...</style>` bloc
308
371
  ### Pattern 3: Replace full component
309
372
 
310
373
  - Use `py-swap="outerHTML"`
311
- - Return full replacement markup with same outer id/selector
374
+ - Return full replacement markup with the same outer id/selector if future
375
+ interactions should keep targeting that component
312
376
 
313
377
  ### Pattern 4: Theme switch from Python
314
378
 
@@ -331,6 +395,7 @@ Check:
331
395
  - method returns a string of HTML
332
396
  - no JSON parse error in `data-py-params`
333
397
  - `py-target` selector exists
398
+ - for forms, fields have `name` attributes
334
399
 
335
400
  ### "New buttons in swapped HTML do not work"
336
401
 
@@ -345,6 +410,21 @@ window.pywebviewHtmx.process(document.body);
345
410
 
346
411
  Style `.py-waiting` globally and/or point `py-wait` at a dedicated element.
347
412
 
413
+ ### "I got a Python API return type error"
414
+
415
+ `pywebview-htmx` expects handlers to return an HTML string fragment. Convert
416
+ structured data into markup before returning it.
417
+
418
+ ## Common Mistakes
419
+
420
+ - Returning dicts/lists instead of HTML strings
421
+ - Forgetting to escape untrusted values before interpolating into markup
422
+ - Omitting `name` attributes on form fields and expecting them to serialize
423
+ - Using `py-swap="outerHTML"` but not preserving the target component's
424
+ selector/id
425
+ - Manually mutating the DOM outside of `pywebview-htmx` swaps without calling
426
+ `window.pywebviewHtmx.process(...)`
427
+
348
428
  ## Demo
349
429
 
350
430
  Run the full feature showcase:
@@ -44,6 +44,34 @@ pdm install
44
44
  pdm add pywebview-htmx
45
45
  ```
46
46
 
47
+ ## Agent Skill
48
+
49
+ This repository includes an installable coding-agent skill at
50
+ `skills/use-pywebview-htmx/`.
51
+
52
+ The folder in this repository is the source for a skill that tools such as
53
+ [`npx skills`](https://skills.sh/) can discover and install from GitHub.
54
+
55
+ List the available skill from this repository:
56
+
57
+ ```bash
58
+ npx skills add btfranklin/pywebview-htmx --list --full-depth
59
+ ```
60
+
61
+ Install just this skill:
62
+
63
+ ```bash
64
+ npx skills add btfranklin/pywebview-htmx --skill use-pywebview-htmx
65
+ ```
66
+
67
+ Other skill installers can consume the same skill folder from this repository.
68
+ The skill includes:
69
+
70
+ - the core usage workflow
71
+ - a concise contract reference
72
+ - implementation patterns
73
+ - troubleshooting guidance
74
+
47
75
  ## Quick Start
48
76
 
49
77
  ```python
@@ -77,6 +105,18 @@ create_window("Quickstart", html, js_api=API())
77
105
  You do **not** manually include `runtime.js` when using `create_window()`;
78
106
  it is injected automatically.
79
107
 
108
+ ## Important Differences From HTMX
109
+
110
+ - There is no HTTP request layer. `pywebview-htmx` calls a Python method on
111
+ `window.pywebview.api` directly.
112
+ - Python handlers should return an HTML string fragment. Returning objects or
113
+ other data types is treated as an error.
114
+ - For ordinary buttons/links, params still come from `data-py-params`.
115
+ - For `<form py-trigger="submit">`, named fields are serialized automatically
116
+ and merged over `data-py-params`.
117
+ - Returned HTML is inserted directly into the DOM, so escape untrusted values
118
+ before returning markup.
119
+
80
120
  ## API Reference (Python)
81
121
 
82
122
  ### `create_window(title, html, js_api=None, theme="aurora", start=True, **kwargs)`
@@ -103,6 +143,18 @@ Returns the bundled JavaScript runtime as a string.
103
143
 
104
144
  Injects the runtime script tag into an HTML string (idempotent).
105
145
 
146
+ ### `encode_params_attr(params)`
147
+
148
+ Returns HTML-escaped JSON that is safe to place in a `data-py-params`
149
+ attribute.
150
+
151
+ ```python
152
+ from pywebview_htmx import encode_params_attr
153
+
154
+ payload = encode_params_attr({"user_id": 42, "mode": "full"})
155
+ button = f'<button data-py-params="{payload}">Load user</button>'
156
+ ```
157
+
106
158
  ### Theme helpers
107
159
 
108
160
  - `list_themes()` -> sorted list of available themes
@@ -143,6 +195,8 @@ JSON payload passed to Python method.
143
195
  Notes:
144
196
  - If missing, params default to `{}`.
145
197
  - Invalid JSON logs an error and falls back to `{}`.
198
+ - For submit-triggered forms, named form fields are serialized automatically and
199
+ merged over this payload.
146
200
 
147
201
  ### `py-target` (optional)
148
202
 
@@ -163,6 +217,14 @@ Unknown value falls back to `innerHTML`.
163
217
  CSS selector of element receiving `.py-waiting` while request is in flight.
164
218
  If missing/empty/unresolvable, the triggering element is used.
165
219
 
220
+ ### `py-policy` (optional)
221
+
222
+ Per-element concurrency override. If omitted, the global
223
+ `window.pywebviewHtmx.config.requestPolicy` value is used.
224
+
225
+ - `latest-wins`
226
+ - `drop`
227
+
166
228
  ## Runtime Config (JavaScript)
167
229
 
168
230
  `window.pywebviewHtmx.config` includes:
@@ -257,7 +319,8 @@ behavior, add your own `<style data-pywebview-theme="my-theme">...</style>` bloc
257
319
  ### Pattern 1: Form submit to Python
258
320
 
259
321
  - Use `py-trigger="submit"` on `<form>`
260
- - Keep `data-py-params` in sync from form inputs (via JS)
322
+ - Give fields `name` attributes so they can be serialized
323
+ - Use `data-py-params` only for extra static values you want merged in
261
324
  - `py-target` a result card/summary region
262
325
 
263
326
  ### Pattern 2: Append activity/log rows
@@ -268,7 +331,8 @@ behavior, add your own `<style data-pywebview-theme="my-theme">...</style>` bloc
268
331
  ### Pattern 3: Replace full component
269
332
 
270
333
  - Use `py-swap="outerHTML"`
271
- - Return full replacement markup with same outer id/selector
334
+ - Return full replacement markup with the same outer id/selector if future
335
+ interactions should keep targeting that component
272
336
 
273
337
  ### Pattern 4: Theme switch from Python
274
338
 
@@ -291,6 +355,7 @@ Check:
291
355
  - method returns a string of HTML
292
356
  - no JSON parse error in `data-py-params`
293
357
  - `py-target` selector exists
358
+ - for forms, fields have `name` attributes
294
359
 
295
360
  ### "New buttons in swapped HTML do not work"
296
361
 
@@ -305,6 +370,21 @@ window.pywebviewHtmx.process(document.body);
305
370
 
306
371
  Style `.py-waiting` globally and/or point `py-wait` at a dedicated element.
307
372
 
373
+ ### "I got a Python API return type error"
374
+
375
+ `pywebview-htmx` expects handlers to return an HTML string fragment. Convert
376
+ structured data into markup before returning it.
377
+
378
+ ## Common Mistakes
379
+
380
+ - Returning dicts/lists instead of HTML strings
381
+ - Forgetting to escape untrusted values before interpolating into markup
382
+ - Omitting `name` attributes on form fields and expecting them to serialize
383
+ - Using `py-swap="outerHTML"` but not preserving the target component's
384
+ selector/id
385
+ - Manually mutating the DOM outside of `pywebview-htmx` swaps without calling
386
+ `window.pywebviewHtmx.process(...)`
387
+
308
388
  ## Demo
309
389
 
310
390
  Run the full feature showcase:
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
22
  "Topic :: Internet :: WWW/HTTP",
23
23
  ]
24
- version = "0.2.0"
24
+ version = "0.3.0"
25
25
 
26
26
  [project.license]
27
27
  file = "LICENSE"
@@ -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",
@@ -1,12 +1,17 @@
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
8
10
 
9
- _SCRIPT_TAG_RE = re.compile(r"<script[^>]*\bdata-pywebview-htmx\b", re.IGNORECASE)
11
+ _SCRIPT_TAG_RE = re.compile(
12
+ r"<script[^>]*\bdata-pywebview-htmx\s*=\s*(?:[\"']true[\"']|true\b)",
13
+ re.IGNORECASE,
14
+ )
10
15
  _THEME_TAG_RE = re.compile(
11
16
  r"<style[^>]*\bdata-pywebview-theme\s*=\s*[\"'](?P<name>[^\"']+)[\"'][^>]*>.*?</style>",
12
17
  re.IGNORECASE | re.DOTALL,
@@ -53,6 +58,14 @@ def get_runtime_script() -> str:
53
58
  )
54
59
 
55
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
+
56
69
  def inject_runtime(html: str) -> str:
57
70
  """Inject the runtime script into an HTML string before ``</body>`` when present."""
58
71
  if _SCRIPT_TAG_RE.search(html):
@@ -58,6 +58,47 @@
58
58
  }
59
59
  }
60
60
 
61
+ function mergeParamValue(target, key, value) {
62
+ if (Object.prototype.hasOwnProperty.call(target, key)) {
63
+ const existing = target[key];
64
+ if (Array.isArray(existing)) {
65
+ existing.push(value);
66
+ } else {
67
+ target[key] = [existing, value];
68
+ }
69
+ return;
70
+ }
71
+ target[key] = value;
72
+ }
73
+
74
+ function serializeForm(form, event) {
75
+ const params = {};
76
+ const formData = new FormData(form);
77
+
78
+ for (const [key, value] of formData.entries()) {
79
+ if (typeof File !== "undefined" && value instanceof File) {
80
+ console.warn("PyWebview HTMX: file inputs are not supported in data-py-params", key);
81
+ continue;
82
+ }
83
+ mergeParamValue(params, key, value);
84
+ }
85
+
86
+ const submitter = event && "submitter" in event ? event.submitter : null;
87
+ if (submitter && submitter.name) {
88
+ mergeParamValue(params, submitter.name, submitter.value || "");
89
+ }
90
+
91
+ return params;
92
+ }
93
+
94
+ function buildRequestParams(element, eventName, event) {
95
+ const params = parseParams(element);
96
+ if (element.tagName !== "FORM" || eventName !== "submit") {
97
+ return params;
98
+ }
99
+ return { ...params, ...serializeForm(element, event) };
100
+ }
101
+
61
102
  function getWaitTarget(element) {
62
103
  if (!element.hasAttribute("py-wait")) {
63
104
  return element;
@@ -94,12 +135,28 @@
94
135
  return state;
95
136
  }
96
137
 
138
+ function shouldPreventDefault(eventName) {
139
+ return eventName === "submit";
140
+ }
141
+
142
+ function getRequestPolicy(element) {
143
+ const policy = element.getAttribute("py-policy") || config.requestPolicy;
144
+ if (policy === "drop" || policy === "latest-wins") {
145
+ return policy;
146
+ }
147
+ return config.requestPolicy;
148
+ }
149
+
97
150
  function processPyWebviewHtmxNodes(root = document) {
98
151
  if (!root || typeof root.querySelectorAll !== "function") {
99
152
  return;
100
153
  }
101
154
 
102
- const elements = root.querySelectorAll("[py-call]");
155
+ const elements = [];
156
+ if (root.matches && root.matches("[py-call]")) {
157
+ elements.push(root);
158
+ }
159
+ elements.push(...root.querySelectorAll("[py-call]"));
103
160
 
104
161
  elements.forEach((element) => {
105
162
  if (element.getAttribute("data-pywebview-bound") === "true") {
@@ -119,13 +176,14 @@
119
176
  }
120
177
 
121
178
  element.addEventListener(eventName, async (event) => {
122
- if (event.cancelable) {
179
+ if (shouldPreventDefault(eventName) && event.cancelable) {
123
180
  event.preventDefault();
124
181
  }
125
182
 
126
- const params = parseParams(element);
183
+ const params = buildRequestParams(element, eventName, event);
127
184
  const state = getRequestState(element);
128
- if (config.requestPolicy === "drop" && state.inFlightCount > 0) {
185
+ const requestPolicy = getRequestPolicy(element);
186
+ if (requestPolicy === "drop" && state.inFlightCount > 0) {
129
187
  triggerEvent(element, "py:ignored", { reason: "in-flight" });
130
188
  return;
131
189
  }
@@ -147,6 +205,9 @@
147
205
  if (requestId !== state.lastIssued) {
148
206
  return;
149
207
  }
208
+ if (typeof response !== "string") {
209
+ throw new Error("PyWebview HTMX: Python API methods must return an HTML string");
210
+ }
150
211
 
151
212
  const target = targetSelector ? $(targetSelector) : element;
152
213
  if (!target) {
@@ -163,7 +224,11 @@
163
224
  }
164
225
 
165
226
  const processRoot = pySwap(target, response, swapStyle);
166
- triggerEvent(target, "py:afterSwap", { response, requestId });
227
+ const afterSwapTarget =
228
+ swapStyle === "outerHTML"
229
+ ? (targetSelector ? $(targetSelector) || processRoot : processRoot)
230
+ : target;
231
+ triggerEvent(afterSwapTarget, "py:afterSwap", { response, requestId });
167
232
  processPyWebviewHtmxNodes(processRoot);
168
233
 
169
234
  if (config.settleDelay > 0) {
@@ -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)
@@ -11,11 +11,19 @@ def _assert_fragments_present(js_compact: str, fragments: tuple[str, ...]) -> No
11
11
 
12
12
 
13
13
  JS_BEHAVIOR_FRAGMENTS: list[tuple[int, tuple[str, ...]]] = [
14
- (73, ("if (event.cancelable)", "event.preventDefault();")),
14
+ (
15
+ 73,
16
+ (
17
+ 'function shouldPreventDefault(eventName)',
18
+ 'return eventName === "submit";',
19
+ "if (shouldPreventDefault(eventName) && event.cancelable)",
20
+ "event.preventDefault();",
21
+ ),
22
+ ),
15
23
  (74, ("let state = requestState.get(element);", "if (!state) {")),
16
24
  (75, ("lastIssued: 0", "state.lastIssued = requestId;")),
17
25
  (76, ("inFlightCount: 0", "state.inFlightCount += 1;")),
18
- (77, ('config.requestPolicy === "drop"', 'triggerEvent(element, "py:ignored"')),
26
+ (77, ('requestPolicy === "drop"', 'triggerEvent(element, "py:ignored"')),
19
27
  (78, ('triggerEvent(element, "py:trigger"', "requestId,")),
20
28
  (79, ('waitTarget.classList.add("py-waiting")',)),
21
29
  (80, ("if (requestId !== state.lastIssued) { return; }",)),
@@ -27,7 +35,14 @@ JS_BEHAVIOR_FRAGMENTS: list[tuple[int, tuple[str, ...]]] = [
27
35
  (86, ("if (requestId !== state.lastIssued) { return; }",)),
28
36
  (87, ("const processRoot = pySwap(target, response, swapStyle);",)),
29
37
  (88, ("processPyWebviewHtmxNodes(processRoot);",)),
30
- (89, ('triggerEvent(target, "py:afterSwap"',)),
38
+ (
39
+ 89,
40
+ (
41
+ "const afterSwapTarget =",
42
+ 'swapStyle === "outerHTML"',
43
+ 'triggerEvent(afterSwapTarget, "py:afterSwap"',
44
+ ),
45
+ ),
31
46
  (90, ("if (config.settleDelay > 0)", "await delay(config.settleDelay);")),
32
47
  (91, ('triggerEvent(element, "py:error"', "requestId")),
33
48
  (92, ("state.inFlightCount = Math.max(0, state.inFlightCount - 1);",)),
@@ -90,6 +90,13 @@ def test_non_script_data_marker_does_not_block_injection() -> None:
90
90
  assert result.count(SCRIPT_MARKER) == 1
91
91
 
92
92
 
93
+ def test_script_with_false_marker_does_not_block_injection() -> None:
94
+ html = '<html><body><script data-pywebview-htmx="false"></script></body></html>'
95
+ result = inject_runtime(html)
96
+ assert result != html
97
+ assert result.count(SCRIPT_MARKER) == 1
98
+
99
+
93
100
  def test_empty_html_input_gets_injected_script() -> None:
94
101
  result = inject_runtime("")
95
102
  assert MARKER in result
@@ -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 == "{&quot;count&quot;: 3, &quot;enabled&quot;: 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 "\\&quot;hi\\&quot;" in encoded
14
+ assert "&lt;b&gt;unsafe&lt;/b&gt;" 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)
File without changes