pywebview-htmx 0.3.0__tar.gz → 0.4.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 (23) hide show
  1. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/PKG-INFO +49 -14
  2. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/README.md +47 -12
  3. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/pyproject.toml +24 -21
  4. pywebview_htmx-0.4.0/src/pywebview_htmx/py.typed +1 -0
  5. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/runtime.js +70 -19
  6. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/themes/base.css +44 -102
  7. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/themes/cybermind.css +4 -13
  8. pywebview_htmx-0.4.0/tests/test_js_runtime.py +642 -0
  9. pywebview_htmx-0.4.0/tests/test_repo_legibility.py +125 -0
  10. pywebview_htmx-0.3.0/tests/test_js_contract_config_and_binding.py +0 -76
  11. pywebview_htmx-0.3.0/tests/test_js_contract_ergonomics.py +0 -125
  12. pywebview_htmx-0.3.0/tests/test_js_contract_request_and_swap.py +0 -83
  13. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/LICENSE +0 -0
  14. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/src/pywebview_htmx/__init__.py +0 -0
  15. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/src/pywebview_htmx/runtime.py +0 -0
  16. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/themes/aurora.css +0 -0
  17. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/themes/paper.css +0 -0
  18. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/tests/conftest.py +0 -0
  19. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/tests/test_python_create_window.py +0 -0
  20. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/tests/test_python_injection.py +0 -0
  21. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/tests/test_python_params_attr.py +0 -0
  22. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.0}/tests/test_python_script_loading.py +0 -0
  23. {pywebview_htmx-0.3.0 → pywebview_htmx-0.4.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.3.0
3
+ Version: 0.4.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
@@ -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
@@ -115,12 +120,14 @@ The skill includes:
115
120
  ## Quick Start
116
121
 
117
122
  ```python
123
+ from html import escape
124
+
118
125
  from pywebview_htmx import create_window
119
126
 
120
127
 
121
128
  class API:
122
129
  def greeting(self, params: dict) -> str:
123
- name = params.get("name", "world")
130
+ name = escape(str(params.get("name", "world")))
124
131
  return f"<p>Hello, {name}!</p>"
125
132
 
126
133
 
@@ -224,6 +231,12 @@ DOM event name to bind. Default is `click`.
224
231
  <div py-call="show_tip" py-trigger="mouseenter">...</div>
225
232
  ```
226
233
 
234
+ When the runtime handles a native action that would leave or destructively
235
+ change the current document, it prevents that default action. This includes
236
+ form submission, link navigation, and submit/reset controls associated with a
237
+ form. Ordinary buttons and the native state changes of checkboxes, radio
238
+ buttons, and labels are preserved.
239
+
227
240
  ### `data-py-params` (optional)
228
241
 
229
242
  JSON payload passed to Python method.
@@ -255,7 +268,9 @@ Unknown value falls back to `innerHTML`.
255
268
  ### `py-wait` (optional)
256
269
 
257
270
  CSS selector of element receiving `.py-waiting` while request is in flight.
258
- If missing/empty/unresolvable, the triggering element is used.
271
+ If missing, empty, invalid, or unresolvable, the triggering element is used.
272
+ The selector is resolved when each accepted request starts, so replacement
273
+ markup can provide the wait target for later requests.
259
274
 
260
275
  ### `py-policy` (optional)
261
276
 
@@ -274,6 +289,11 @@ Per-element concurrency override. If omitted, the global
274
289
  - `settleDelay` (ms, default: `20`)
275
290
  - `requestPolicy` (`"latest-wins"` default, or `"drop"`)
276
291
 
292
+ Runtime defaults are read for each accepted request. An element's explicit
293
+ `py-swap` continues to override `defaultSwapStyle`; otherwise, changing
294
+ `defaultSwapStyle` affects its next request even when the element is already
295
+ bound.
296
+
277
297
  Example:
278
298
 
279
299
  ```js
@@ -292,27 +312,45 @@ window.pywebviewHtmx.config.settleDelay = 50;
292
312
  - `py:ignored` (when `requestPolicy="drop"` and request is in flight)
293
313
  - `py:error`
294
314
 
315
+ These lifecycle events are observational and non-cancelable. Calling
316
+ `preventDefault()` on them does not change request or swap behavior. Every
317
+ `py:error` event includes `event.detail.stale`, a boolean indicating whether a
318
+ newer request had already been issued in the same request scope when the error
319
+ was emitted. Errors from stale requests remain observable even though stale
320
+ successful responses do not update the DOM.
321
+
295
322
  Example:
296
323
 
297
324
  ```js
298
325
  document.body.addEventListener("py:error", (event) => {
299
- console.error("pywebview-htmx error", event.detail.error);
326
+ console.error("pywebview-htmx error", {
327
+ error: event.detail.error,
328
+ stale: event.detail.stale,
329
+ });
300
330
  });
301
331
  ```
302
332
 
303
333
  ## Concurrency Behavior
304
334
 
305
- Per trigger element, `pywebview-htmx` tracks request state.
335
+ For a nonempty `py-target`, `pywebview-htmx` scopes request state by its
336
+ normalized selector string. Controls with the same selector coordinate request
337
+ ordering and `drop` behavior even when a swap replaces the matching DOM node.
338
+ Selector state is discarded after its in-flight requests finish. Controls
339
+ without `py-target` use the triggering element as their request state scope.
340
+
341
+ The `py-wait` selector is resolved for each accepted request. Loading state is
342
+ counted separately per resolved wait element, so a shared spinner stays active
343
+ until every request using that particular element finishes.
306
344
 
307
345
  ### `latest-wins` (default)
308
346
 
309
- Multiple rapid requests are allowed; stale responses are ignored.
310
- Only latest request updates the DOM.
347
+ Multiple rapid requests are allowed; stale responses in the same request state
348
+ scope are ignored. Only the latest request for that scope updates the DOM.
311
349
 
312
350
  ### `drop`
313
351
 
314
- If a request is already in flight for that element, new triggers are ignored and
315
- `py:ignored` is emitted.
352
+ If a request is already in flight for the same request state scope, new triggers
353
+ are ignored and `py:ignored` is emitted.
316
354
 
317
355
  ## Dynamic Content Re-processing
318
356
 
@@ -337,10 +375,7 @@ Set `theme=None` to disable automatic theme injection.
337
375
 
338
376
  ### Built-in Component Classes
339
377
 
340
- The bundled CSS supports both `pyh-*` classes and compatibility aliases used by
341
- the demo (`.hero`, `.demo-card`, `.btn`, etc.).
342
-
343
- Recommended canonical classes:
378
+ The bundled CSS exposes canonical `pyh-*` component classes:
344
379
 
345
380
  - Layout: `.pyh-shell`, `.pyh-hero`, `.pyh-grid`, `.pyh-card`
346
381
  - Controls: `.pyh-btn`, `.pyh-btn-primary`, `.pyh-btn-secondary`, `.pyh-btn-ghost`, `.pyh-btn-danger`
@@ -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
@@ -75,12 +80,14 @@ The skill includes:
75
80
  ## Quick Start
76
81
 
77
82
  ```python
83
+ from html import escape
84
+
78
85
  from pywebview_htmx import create_window
79
86
 
80
87
 
81
88
  class API:
82
89
  def greeting(self, params: dict) -> str:
83
- name = params.get("name", "world")
90
+ name = escape(str(params.get("name", "world")))
84
91
  return f"<p>Hello, {name}!</p>"
85
92
 
86
93
 
@@ -184,6 +191,12 @@ DOM event name to bind. Default is `click`.
184
191
  <div py-call="show_tip" py-trigger="mouseenter">...</div>
185
192
  ```
186
193
 
194
+ When the runtime handles a native action that would leave or destructively
195
+ change the current document, it prevents that default action. This includes
196
+ form submission, link navigation, and submit/reset controls associated with a
197
+ form. Ordinary buttons and the native state changes of checkboxes, radio
198
+ buttons, and labels are preserved.
199
+
187
200
  ### `data-py-params` (optional)
188
201
 
189
202
  JSON payload passed to Python method.
@@ -215,7 +228,9 @@ Unknown value falls back to `innerHTML`.
215
228
  ### `py-wait` (optional)
216
229
 
217
230
  CSS selector of element receiving `.py-waiting` while request is in flight.
218
- If missing/empty/unresolvable, the triggering element is used.
231
+ If missing, empty, invalid, or unresolvable, the triggering element is used.
232
+ The selector is resolved when each accepted request starts, so replacement
233
+ markup can provide the wait target for later requests.
219
234
 
220
235
  ### `py-policy` (optional)
221
236
 
@@ -234,6 +249,11 @@ Per-element concurrency override. If omitted, the global
234
249
  - `settleDelay` (ms, default: `20`)
235
250
  - `requestPolicy` (`"latest-wins"` default, or `"drop"`)
236
251
 
252
+ Runtime defaults are read for each accepted request. An element's explicit
253
+ `py-swap` continues to override `defaultSwapStyle`; otherwise, changing
254
+ `defaultSwapStyle` affects its next request even when the element is already
255
+ bound.
256
+
237
257
  Example:
238
258
 
239
259
  ```js
@@ -252,27 +272,45 @@ window.pywebviewHtmx.config.settleDelay = 50;
252
272
  - `py:ignored` (when `requestPolicy="drop"` and request is in flight)
253
273
  - `py:error`
254
274
 
275
+ These lifecycle events are observational and non-cancelable. Calling
276
+ `preventDefault()` on them does not change request or swap behavior. Every
277
+ `py:error` event includes `event.detail.stale`, a boolean indicating whether a
278
+ newer request had already been issued in the same request scope when the error
279
+ was emitted. Errors from stale requests remain observable even though stale
280
+ successful responses do not update the DOM.
281
+
255
282
  Example:
256
283
 
257
284
  ```js
258
285
  document.body.addEventListener("py:error", (event) => {
259
- console.error("pywebview-htmx error", event.detail.error);
286
+ console.error("pywebview-htmx error", {
287
+ error: event.detail.error,
288
+ stale: event.detail.stale,
289
+ });
260
290
  });
261
291
  ```
262
292
 
263
293
  ## Concurrency Behavior
264
294
 
265
- Per trigger element, `pywebview-htmx` tracks request state.
295
+ For a nonempty `py-target`, `pywebview-htmx` scopes request state by its
296
+ normalized selector string. Controls with the same selector coordinate request
297
+ ordering and `drop` behavior even when a swap replaces the matching DOM node.
298
+ Selector state is discarded after its in-flight requests finish. Controls
299
+ without `py-target` use the triggering element as their request state scope.
300
+
301
+ The `py-wait` selector is resolved for each accepted request. Loading state is
302
+ counted separately per resolved wait element, so a shared spinner stays active
303
+ until every request using that particular element finishes.
266
304
 
267
305
  ### `latest-wins` (default)
268
306
 
269
- Multiple rapid requests are allowed; stale responses are ignored.
270
- Only latest request updates the DOM.
307
+ Multiple rapid requests are allowed; stale responses in the same request state
308
+ scope are ignored. Only the latest request for that scope updates the DOM.
271
309
 
272
310
  ### `drop`
273
311
 
274
- If a request is already in flight for that element, new triggers are ignored and
275
- `py:ignored` is emitted.
312
+ If a request is already in flight for the same request state scope, new triggers
313
+ are ignored and `py:ignored` is emitted.
276
314
 
277
315
  ## Dynamic Content Re-processing
278
316
 
@@ -297,10 +335,7 @@ Set `theme=None` to disable automatic theme injection.
297
335
 
298
336
  ### Built-in Component Classes
299
337
 
300
- The bundled CSS supports both `pyh-*` classes and compatibility aliases used by
301
- the demo (`.hero`, `.demo-card`, `.btn`, etc.).
302
-
303
- Recommended canonical classes:
338
+ The bundled CSS exposes canonical `pyh-*` component classes:
304
339
 
305
340
  - Layout: `.pyh-shell`, `.pyh-hero`, `.pyh-grid`, `.pyh-card`
306
341
  - Controls: `.pyh-btn`, `.pyh-btn-primary`, `.pyh-btn-secondary`, `.pyh-btn-ghost`, `.pyh-btn-danger`
@@ -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,33 +21,25 @@ classifiers = [
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
22
  "Topic :: Internet :: WWW/HTTP",
23
23
  ]
24
- version = "0.3.0"
24
+ version = "0.4.0"
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
 
35
- [tool.pdm]
36
- distribution = true
37
-
38
- [tool.pdm.version]
39
- source = "scm"
40
-
41
- [tool.pdm.build]
42
- package-dir = "src"
43
- includes = [
44
- "src/pywebview_htmx",
35
+ [dependency-groups]
36
+ dev = [
37
+ "pytest>=9.1.1",
38
+ "ruff>=0.15.19",
39
+ "playwright>=1.61.0",
40
+ "pytest-playwright>=0.8.0",
45
41
  ]
46
42
 
47
- [tool.pdm.scripts]
48
- test = "pytest"
49
- lint = "ruff check ."
50
-
51
43
  [tool.pytest.ini_options]
52
44
  testpaths = [
53
45
  "tests",
@@ -63,8 +55,19 @@ select = [
63
55
  "W",
64
56
  ]
65
57
 
66
- [dependency-groups]
67
- dev = [
68
- "pytest>=9.0.2",
69
- "ruff>=0.15.4",
58
+ [tool.pdm]
59
+ distribution = true
60
+
61
+ [tool.pdm.version]
62
+ source = "scm"
63
+
64
+ [tool.pdm.build]
65
+ package-dir = "src"
66
+ includes = [
67
+ "src/pywebview_htmx",
70
68
  ]
69
+
70
+ [tool.pdm.scripts]
71
+ test = "pytest"
72
+ lint = "ruff check ."
73
+ install-browser = "playwright install chromium"
@@ -7,17 +7,24 @@
7
7
  settleDelay: 20,
8
8
  requestPolicy: "latest-wins",
9
9
  };
10
- const requestState = new WeakMap();
10
+ const selfRequestState = new WeakMap();
11
+ const targetedRequestState = new Map();
12
+ const waitState = new WeakMap();
11
13
 
12
14
  function $(selector, root = document) {
13
15
  if (!selector) return null;
14
- return root.querySelector(selector);
16
+ try {
17
+ return root.querySelector(selector);
18
+ } catch (error) {
19
+ console.warn("PyWebview HTMX: invalid selector", selector, error);
20
+ return null;
21
+ }
15
22
  }
16
23
 
17
24
  function triggerEvent(target, eventName, detail = {}) {
18
25
  const event = new CustomEvent(eventName, {
19
26
  bubbles: true,
20
- cancelable: true,
27
+ cancelable: false,
21
28
  detail,
22
29
  });
23
30
  target.dispatchEvent(event);
@@ -126,17 +133,57 @@
126
133
  return await func(params);
127
134
  }
128
135
 
129
- function getRequestState(element) {
130
- let state = requestState.get(element);
136
+ function getRequestScope(element, targetSelector) {
137
+ const key = targetSelector || element;
138
+ const store = targetSelector ? targetedRequestState : selfRequestState;
139
+ let state = store.get(key);
131
140
  if (!state) {
132
141
  state = { lastIssued: 0, inFlightCount: 0 };
133
- requestState.set(element, state);
142
+ store.set(key, state);
143
+ }
144
+ return { key, state, store };
145
+ }
146
+
147
+ function releaseRequestScope(scope) {
148
+ if (scope.state.inFlightCount === 0 && scope.store.get(scope.key) === scope.state) {
149
+ scope.store.delete(scope.key);
134
150
  }
135
- return state;
136
151
  }
137
152
 
138
- function shouldPreventDefault(eventName) {
139
- return eventName === "submit";
153
+ function addWaiting(waitTarget) {
154
+ const count = (waitState.get(waitTarget) || 0) + 1;
155
+ waitState.set(waitTarget, count);
156
+ waitTarget.classList.add("py-waiting");
157
+ }
158
+
159
+ function removeWaiting(waitTarget) {
160
+ const count = Math.max(0, (waitState.get(waitTarget) || 0) - 1);
161
+ if (count === 0) {
162
+ waitState.delete(waitTarget);
163
+ waitTarget.classList.remove("py-waiting");
164
+ return;
165
+ }
166
+ waitState.set(waitTarget, count);
167
+ }
168
+
169
+ function shouldPreventDefault(element, eventName) {
170
+ if (eventName === "submit") {
171
+ return true;
172
+ }
173
+ if (eventName !== "click") {
174
+ return false;
175
+ }
176
+
177
+ if ((element.tagName === "A" || element.tagName === "AREA") && element.hasAttribute("href")) {
178
+ return true;
179
+ }
180
+ if (element.tagName === "BUTTON" && element.form) {
181
+ return element.type !== "button";
182
+ }
183
+ if (element.tagName === "INPUT" && element.form) {
184
+ return ["image", "reset", "submit"].includes(element.type);
185
+ }
186
+ return false;
140
187
  }
141
188
 
142
189
  function getRequestPolicy(element) {
@@ -166,9 +213,7 @@
166
213
 
167
214
  const functionName = element.getAttribute("py-call");
168
215
  const eventName = element.getAttribute("py-trigger") || "click";
169
- const targetSelector = element.getAttribute("py-target");
170
- const swapStyle = element.getAttribute("py-swap") || config.defaultSwapStyle;
171
- const waitTarget = getWaitTarget(element);
216
+ const targetSelector = (element.getAttribute("py-target") || "").trim() || null;
172
217
 
173
218
  if (!functionName) {
174
219
  console.warn("PyWebview HTMX: py-call is empty", element);
@@ -176,12 +221,13 @@
176
221
  }
177
222
 
178
223
  element.addEventListener(eventName, async (event) => {
179
- if (shouldPreventDefault(eventName) && event.cancelable) {
224
+ if (shouldPreventDefault(element, eventName) && event.cancelable) {
180
225
  event.preventDefault();
181
226
  }
182
227
 
183
228
  const params = buildRequestParams(element, eventName, event);
184
- const state = getRequestState(element);
229
+ const scope = getRequestScope(element, targetSelector);
230
+ const state = scope.state;
185
231
  const requestPolicy = getRequestPolicy(element);
186
232
  if (requestPolicy === "drop" && state.inFlightCount > 0) {
187
233
  triggerEvent(element, "py:ignored", { reason: "in-flight" });
@@ -198,7 +244,9 @@
198
244
  requestId,
199
245
  });
200
246
 
201
- waitTarget.classList.add("py-waiting");
247
+ const swapStyle = element.getAttribute("py-swap") || config.defaultSwapStyle;
248
+ const waitTarget = getWaitTarget(element);
249
+ addWaiting(waitTarget);
202
250
 
203
251
  try {
204
252
  const response = await invokePython(functionName, params);
@@ -236,12 +284,15 @@
236
284
  }
237
285
  } catch (error) {
238
286
  console.error("PyWebview HTMX: error calling Python function", error);
239
- triggerEvent(element, "py:error", { error, requestId });
287
+ triggerEvent(element, "py:error", {
288
+ error,
289
+ requestId,
290
+ stale: requestId !== state.lastIssued,
291
+ });
240
292
  } finally {
241
293
  state.inFlightCount = Math.max(0, state.inFlightCount - 1);
242
- if (state.inFlightCount === 0) {
243
- waitTarget.classList.remove("py-waiting");
244
- }
294
+ releaseRequestScope(scope);
295
+ removeWaiting(waitTarget);
245
296
  }
246
297
  });
247
298
  });