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.
Files changed (24) hide show
  1. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/PKG-INFO +99 -10
  2. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/README.md +97 -8
  3. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/pyproject.toml +5 -5
  4. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/__init__.py +2 -0
  5. pywebview_htmx-0.3.1/src/pywebview_htmx/py.typed +1 -0
  6. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/runtime.py +10 -0
  7. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/runtime.js +96 -12
  8. pywebview_htmx-0.3.1/tests/js_runtime_behavior_harness.mjs +369 -0
  9. pywebview_htmx-0.3.1/tests/test_js_contract_ergonomics.py +125 -0
  10. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_js_contract_request_and_swap.py +17 -4
  11. pywebview_htmx-0.3.1/tests/test_js_runtime_behavior.py +27 -0
  12. pywebview_htmx-0.3.1/tests/test_python_params_attr.py +19 -0
  13. pywebview_htmx-0.3.1/tests/test_repo_legibility.py +125 -0
  14. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/LICENSE +0 -0
  15. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/aurora.css +0 -0
  16. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/base.css +0 -0
  17. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
  18. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/paper.css +0 -0
  19. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/conftest.py +0 -0
  20. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_js_contract_config_and_binding.py +0 -0
  21. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_python_create_window.py +0 -0
  22. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_python_injection.py +0 -0
  23. {pywebview_htmx-0.2.1 → pywebview_htmx-0.3.1}/tests/test_python_script_loading.py +0 -0
  24. {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.2.1
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/empty/unresolvable, the triggering element is used.
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
- Per trigger element, `pywebview-htmx` tracks request state.
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 are ignored.
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 that element, new triggers are ignored and
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
- - Keep `data-py-params` in sync from form inputs (via JS)
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/empty/unresolvable, the triggering element is used.
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
- Per trigger element, `pywebview-htmx` tracks request state.
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 are ignored.
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 that element, new triggers are ignored and
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
- - Keep `data-py-params` in sync from form inputs (via JS)
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.2.1"
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.2",
69
- "ruff>=0.15.4",
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",
@@ -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
- return root.querySelector(selector);
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(element) {
89
- let state = requestState.get(element);
135
+ function getRequestState(key) {
136
+ let state = requestState.get(key);
90
137
  if (!state) {
91
138
  state = { lastIssued: 0, inFlightCount: 0 };
92
- requestState.set(element, state);
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 = root.querySelectorAll("[py-call]");
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 = parseParams(element);
131
- const state = getRequestState(element);
132
- if (config.requestPolicy === "drop" && state.inFlightCount > 0) {
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.classList.add("py-waiting");
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
- if (state.inFlightCount === 0) {
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
- (74, ("let state = requestState.get(element);", "if (!state) {")),
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, ('config.requestPolicy === "drop"', 'triggerEvent(element, "py:ignored"')),
33
+ (77, ('requestPolicy === "drop"', 'triggerEvent(element, "py:ignored"')),
27
34
  (78, ('triggerEvent(element, "py:trigger"', "requestId,")),
28
- (79, ('waitTarget.classList.add("py-waiting")',)),
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
- "if (state.inFlightCount === 0)",
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 == "{&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)
@@ -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