pywebview-htmx 0.3.0__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.3.0 → pywebview_htmx-0.3.1}/PKG-INFO +17 -8
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/README.md +15 -6
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/pyproject.toml +5 -5
- pywebview_htmx-0.3.1/src/pywebview_htmx/py.typed +1 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/runtime.js +36 -9
- pywebview_htmx-0.3.1/tests/js_runtime_behavior_harness.mjs +369 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_js_contract_request_and_swap.py +16 -3
- pywebview_htmx-0.3.1/tests/test_js_runtime_behavior.py +27 -0
- pywebview_htmx-0.3.1/tests/test_repo_legibility.py +125 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/LICENSE +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/__init__.py +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/runtime.py +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/aurora.css +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/base.css +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/src/pywebview_htmx/static/themes/paper.css +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/conftest.py +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_js_contract_config_and_binding.py +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_js_contract_ergonomics.py +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_python_create_window.py +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_python_injection.py +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_python_params_attr.py +0 -0
- {pywebview_htmx-0.3.0 → pywebview_htmx-0.3.1}/tests/test_python_script_loading.py +0 -0
- {pywebview_htmx-0.3.0 → 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.
|
|
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,11 @@ 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
|
+
|
|
87
92
|
## Agent Skill
|
|
88
93
|
|
|
89
94
|
This repository includes an installable coding-agent skill at
|
|
@@ -255,7 +260,7 @@ Unknown value falls back to `innerHTML`.
|
|
|
255
260
|
### `py-wait` (optional)
|
|
256
261
|
|
|
257
262
|
CSS selector of element receiving `.py-waiting` while request is in flight.
|
|
258
|
-
If missing
|
|
263
|
+
If missing, empty, invalid, or unresolvable, the triggering element is used.
|
|
259
264
|
|
|
260
265
|
### `py-policy` (optional)
|
|
261
266
|
|
|
@@ -302,17 +307,21 @@ document.body.addEventListener("py:error", (event) => {
|
|
|
302
307
|
|
|
303
308
|
## Concurrency Behavior
|
|
304
309
|
|
|
305
|
-
|
|
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.
|
|
306
315
|
|
|
307
316
|
### `latest-wins` (default)
|
|
308
317
|
|
|
309
|
-
Multiple rapid requests are allowed; stale responses
|
|
310
|
-
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.
|
|
311
320
|
|
|
312
321
|
### `drop`
|
|
313
322
|
|
|
314
|
-
If a request is already in flight for
|
|
315
|
-
`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.
|
|
316
325
|
|
|
317
326
|
## Dynamic Content Re-processing
|
|
318
327
|
|
|
@@ -44,6 +44,11 @@ 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
|
+
|
|
47
52
|
## Agent Skill
|
|
48
53
|
|
|
49
54
|
This repository includes an installable coding-agent skill at
|
|
@@ -215,7 +220,7 @@ Unknown value falls back to `innerHTML`.
|
|
|
215
220
|
### `py-wait` (optional)
|
|
216
221
|
|
|
217
222
|
CSS selector of element receiving `.py-waiting` while request is in flight.
|
|
218
|
-
If missing
|
|
223
|
+
If missing, empty, invalid, or unresolvable, the triggering element is used.
|
|
219
224
|
|
|
220
225
|
### `py-policy` (optional)
|
|
221
226
|
|
|
@@ -262,17 +267,21 @@ document.body.addEventListener("py:error", (event) => {
|
|
|
262
267
|
|
|
263
268
|
## Concurrency Behavior
|
|
264
269
|
|
|
265
|
-
|
|
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.
|
|
266
275
|
|
|
267
276
|
### `latest-wins` (default)
|
|
268
277
|
|
|
269
|
-
Multiple rapid requests are allowed; stale responses
|
|
270
|
-
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.
|
|
271
280
|
|
|
272
281
|
### `drop`
|
|
273
282
|
|
|
274
|
-
If a request is already in flight for
|
|
275
|
-
`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.
|
|
276
285
|
|
|
277
286
|
## Dynamic Content Re-processing
|
|
278
287
|
|
|
@@ -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.3.
|
|
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
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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 = {}) {
|
|
@@ -126,15 +132,38 @@
|
|
|
126
132
|
return await func(params);
|
|
127
133
|
}
|
|
128
134
|
|
|
129
|
-
function getRequestState(
|
|
130
|
-
let state = requestState.get(
|
|
135
|
+
function getRequestState(key) {
|
|
136
|
+
let state = requestState.get(key);
|
|
131
137
|
if (!state) {
|
|
132
138
|
state = { lastIssued: 0, inFlightCount: 0 };
|
|
133
|
-
requestState.set(
|
|
139
|
+
requestState.set(key, state);
|
|
134
140
|
}
|
|
135
141
|
return state;
|
|
136
142
|
}
|
|
137
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
|
+
|
|
138
167
|
function shouldPreventDefault(eventName) {
|
|
139
168
|
return eventName === "submit";
|
|
140
169
|
}
|
|
@@ -181,7 +210,7 @@
|
|
|
181
210
|
}
|
|
182
211
|
|
|
183
212
|
const params = buildRequestParams(element, eventName, event);
|
|
184
|
-
const state = getRequestState(element);
|
|
213
|
+
const state = getRequestState(getRequestStateKey(element, targetSelector));
|
|
185
214
|
const requestPolicy = getRequestPolicy(element);
|
|
186
215
|
if (requestPolicy === "drop" && state.inFlightCount > 0) {
|
|
187
216
|
triggerEvent(element, "py:ignored", { reason: "in-flight" });
|
|
@@ -198,7 +227,7 @@
|
|
|
198
227
|
requestId,
|
|
199
228
|
});
|
|
200
229
|
|
|
201
|
-
waitTarget
|
|
230
|
+
addWaiting(waitTarget);
|
|
202
231
|
|
|
203
232
|
try {
|
|
204
233
|
const response = await invokePython(functionName, params);
|
|
@@ -239,9 +268,7 @@
|
|
|
239
268
|
triggerEvent(element, "py:error", { error, requestId });
|
|
240
269
|
} finally {
|
|
241
270
|
state.inFlightCount = Math.max(0, state.inFlightCount - 1);
|
|
242
|
-
|
|
243
|
-
waitTarget.classList.remove("py-waiting");
|
|
244
|
-
}
|
|
271
|
+
removeWaiting(waitTarget);
|
|
245
272
|
}
|
|
246
273
|
});
|
|
247
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);
|
|
@@ -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
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,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
|
|
File without changes
|
|
File without changes
|
{pywebview_htmx-0.3.0 → 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
|
|
File without changes
|
|
File without changes
|