pywebview-htmx 0.3.1__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 (25) hide show
  1. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/PKG-INFO +37 -11
  2. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/README.md +36 -10
  3. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/pyproject.toml +22 -19
  4. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/runtime.js +42 -18
  5. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/themes/base.css +44 -102
  6. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/themes/cybermind.css +4 -13
  7. pywebview_htmx-0.4.0/tests/test_js_runtime.py +642 -0
  8. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/tests/test_repo_legibility.py +1 -1
  9. pywebview_htmx-0.3.1/tests/js_runtime_behavior_harness.mjs +0 -369
  10. pywebview_htmx-0.3.1/tests/test_js_contract_config_and_binding.py +0 -76
  11. pywebview_htmx-0.3.1/tests/test_js_contract_ergonomics.py +0 -125
  12. pywebview_htmx-0.3.1/tests/test_js_contract_request_and_swap.py +0 -96
  13. pywebview_htmx-0.3.1/tests/test_js_runtime_behavior.py +0 -27
  14. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/LICENSE +0 -0
  15. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/src/pywebview_htmx/__init__.py +0 -0
  16. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/src/pywebview_htmx/py.typed +0 -0
  17. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/src/pywebview_htmx/runtime.py +0 -0
  18. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/themes/aurora.css +0 -0
  19. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/src/pywebview_htmx/static/themes/paper.css +0 -0
  20. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/tests/conftest.py +0 -0
  21. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/tests/test_python_create_window.py +0 -0
  22. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/tests/test_python_injection.py +0 -0
  23. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/tests/test_python_params_attr.py +0 -0
  24. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.0}/tests/test_python_script_loading.py +0 -0
  25. {pywebview_htmx-0.3.1 → 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.1
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
@@ -120,12 +120,14 @@ The skill includes:
120
120
  ## Quick Start
121
121
 
122
122
  ```python
123
+ from html import escape
124
+
123
125
  from pywebview_htmx import create_window
124
126
 
125
127
 
126
128
  class API:
127
129
  def greeting(self, params: dict) -> str:
128
- name = params.get("name", "world")
130
+ name = escape(str(params.get("name", "world")))
129
131
  return f"<p>Hello, {name}!</p>"
130
132
 
131
133
 
@@ -229,6 +231,12 @@ DOM event name to bind. Default is `click`.
229
231
  <div py-call="show_tip" py-trigger="mouseenter">...</div>
230
232
  ```
231
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
+
232
240
  ### `data-py-params` (optional)
233
241
 
234
242
  JSON payload passed to Python method.
@@ -261,6 +269,8 @@ Unknown value falls back to `innerHTML`.
261
269
 
262
270
  CSS selector of element receiving `.py-waiting` while request is in flight.
263
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.
264
274
 
265
275
  ### `py-policy` (optional)
266
276
 
@@ -279,6 +289,11 @@ Per-element concurrency override. If omitted, the global
279
289
  - `settleDelay` (ms, default: `20`)
280
290
  - `requestPolicy` (`"latest-wins"` default, or `"drop"`)
281
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
+
282
297
  Example:
283
298
 
284
299
  ```js
@@ -297,21 +312,35 @@ window.pywebviewHtmx.config.settleDelay = 50;
297
312
  - `py:ignored` (when `requestPolicy="drop"` and request is in flight)
298
313
  - `py:error`
299
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
+
300
322
  Example:
301
323
 
302
324
  ```js
303
325
  document.body.addEventListener("py:error", (event) => {
304
- 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
+ });
305
330
  });
306
331
  ```
307
332
 
308
333
  ## Concurrency Behavior
309
334
 
310
- `pywebview-htmx` tracks request state by resolved target. Controls that share
311
- the same `py-target` coordinate request ordering and `drop` behavior. Controls
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
312
339
  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.
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.
315
344
 
316
345
  ### `latest-wins` (default)
317
346
 
@@ -346,10 +375,7 @@ Set `theme=None` to disable automatic theme injection.
346
375
 
347
376
  ### Built-in Component Classes
348
377
 
349
- The bundled CSS supports both `pyh-*` classes and compatibility aliases used by
350
- the demo (`.hero`, `.demo-card`, `.btn`, etc.).
351
-
352
- Recommended canonical classes:
378
+ The bundled CSS exposes canonical `pyh-*` component classes:
353
379
 
354
380
  - Layout: `.pyh-shell`, `.pyh-hero`, `.pyh-grid`, `.pyh-card`
355
381
  - Controls: `.pyh-btn`, `.pyh-btn-primary`, `.pyh-btn-secondary`, `.pyh-btn-ghost`, `.pyh-btn-danger`
@@ -80,12 +80,14 @@ The skill includes:
80
80
  ## Quick Start
81
81
 
82
82
  ```python
83
+ from html import escape
84
+
83
85
  from pywebview_htmx import create_window
84
86
 
85
87
 
86
88
  class API:
87
89
  def greeting(self, params: dict) -> str:
88
- name = params.get("name", "world")
90
+ name = escape(str(params.get("name", "world")))
89
91
  return f"<p>Hello, {name}!</p>"
90
92
 
91
93
 
@@ -189,6 +191,12 @@ DOM event name to bind. Default is `click`.
189
191
  <div py-call="show_tip" py-trigger="mouseenter">...</div>
190
192
  ```
191
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
+
192
200
  ### `data-py-params` (optional)
193
201
 
194
202
  JSON payload passed to Python method.
@@ -221,6 +229,8 @@ Unknown value falls back to `innerHTML`.
221
229
 
222
230
  CSS selector of element receiving `.py-waiting` while request is in flight.
223
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.
224
234
 
225
235
  ### `py-policy` (optional)
226
236
 
@@ -239,6 +249,11 @@ Per-element concurrency override. If omitted, the global
239
249
  - `settleDelay` (ms, default: `20`)
240
250
  - `requestPolicy` (`"latest-wins"` default, or `"drop"`)
241
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
+
242
257
  Example:
243
258
 
244
259
  ```js
@@ -257,21 +272,35 @@ window.pywebviewHtmx.config.settleDelay = 50;
257
272
  - `py:ignored` (when `requestPolicy="drop"` and request is in flight)
258
273
  - `py:error`
259
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
+
260
282
  Example:
261
283
 
262
284
  ```js
263
285
  document.body.addEventListener("py:error", (event) => {
264
- 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
+ });
265
290
  });
266
291
  ```
267
292
 
268
293
  ## Concurrency Behavior
269
294
 
270
- `pywebview-htmx` tracks request state by resolved target. Controls that share
271
- the same `py-target` coordinate request ordering and `drop` behavior. Controls
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
272
299
  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.
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.
275
304
 
276
305
  ### `latest-wins` (default)
277
306
 
@@ -306,10 +335,7 @@ Set `theme=None` to disable automatic theme injection.
306
335
 
307
336
  ### Built-in Component Classes
308
337
 
309
- The bundled CSS supports both `pyh-*` classes and compatibility aliases used by
310
- the demo (`.hero`, `.demo-card`, `.btn`, etc.).
311
-
312
- Recommended canonical classes:
338
+ The bundled CSS exposes canonical `pyh-*` component classes:
313
339
 
314
340
  - Layout: `.pyh-shell`, `.pyh-hero`, `.pyh-grid`, `.pyh-card`
315
341
  - Controls: `.pyh-btn`, `.pyh-btn-primary`, `.pyh-btn-secondary`, `.pyh-btn-ghost`, `.pyh-btn-danger`
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
22
  "Topic :: Internet :: WWW/HTTP",
23
23
  ]
24
- version = "0.3.1"
24
+ version = "0.4.0"
25
25
 
26
26
  [project.license]
27
27
  file = "LICENSE"
@@ -32,22 +32,14 @@ requires = [
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.3",
69
- "ruff>=0.15.17",
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,7 +7,8 @@
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();
11
12
  const waitState = new WeakMap();
12
13
 
13
14
  function $(selector, root = document) {
@@ -23,7 +24,7 @@
23
24
  function triggerEvent(target, eventName, detail = {}) {
24
25
  const event = new CustomEvent(eventName, {
25
26
  bubbles: true,
26
- cancelable: true,
27
+ cancelable: false,
27
28
  detail,
28
29
  });
29
30
  target.dispatchEvent(event);
@@ -132,20 +133,21 @@
132
133
  return await func(params);
133
134
  }
134
135
 
135
- function getRequestState(key) {
136
- let state = requestState.get(key);
136
+ function getRequestScope(element, targetSelector) {
137
+ const key = targetSelector || element;
138
+ const store = targetSelector ? targetedRequestState : selfRequestState;
139
+ let state = store.get(key);
137
140
  if (!state) {
138
141
  state = { lastIssued: 0, inFlightCount: 0 };
139
- requestState.set(key, state);
142
+ store.set(key, state);
140
143
  }
141
- return state;
144
+ return { key, state, store };
142
145
  }
143
146
 
144
- function getRequestStateKey(element, targetSelector) {
145
- if (!targetSelector) {
146
- return element;
147
+ function releaseRequestScope(scope) {
148
+ if (scope.state.inFlightCount === 0 && scope.store.get(scope.key) === scope.state) {
149
+ scope.store.delete(scope.key);
147
150
  }
148
- return $(targetSelector) || element;
149
151
  }
150
152
 
151
153
  function addWaiting(waitTarget) {
@@ -164,8 +166,24 @@
164
166
  waitState.set(waitTarget, count);
165
167
  }
166
168
 
167
- function shouldPreventDefault(eventName) {
168
- return eventName === "submit";
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;
169
187
  }
170
188
 
171
189
  function getRequestPolicy(element) {
@@ -195,9 +213,7 @@
195
213
 
196
214
  const functionName = element.getAttribute("py-call");
197
215
  const eventName = element.getAttribute("py-trigger") || "click";
198
- const targetSelector = element.getAttribute("py-target");
199
- const swapStyle = element.getAttribute("py-swap") || config.defaultSwapStyle;
200
- const waitTarget = getWaitTarget(element);
216
+ const targetSelector = (element.getAttribute("py-target") || "").trim() || null;
201
217
 
202
218
  if (!functionName) {
203
219
  console.warn("PyWebview HTMX: py-call is empty", element);
@@ -205,12 +221,13 @@
205
221
  }
206
222
 
207
223
  element.addEventListener(eventName, async (event) => {
208
- if (shouldPreventDefault(eventName) && event.cancelable) {
224
+ if (shouldPreventDefault(element, eventName) && event.cancelable) {
209
225
  event.preventDefault();
210
226
  }
211
227
 
212
228
  const params = buildRequestParams(element, eventName, event);
213
- const state = getRequestState(getRequestStateKey(element, targetSelector));
229
+ const scope = getRequestScope(element, targetSelector);
230
+ const state = scope.state;
214
231
  const requestPolicy = getRequestPolicy(element);
215
232
  if (requestPolicy === "drop" && state.inFlightCount > 0) {
216
233
  triggerEvent(element, "py:ignored", { reason: "in-flight" });
@@ -227,6 +244,8 @@
227
244
  requestId,
228
245
  });
229
246
 
247
+ const swapStyle = element.getAttribute("py-swap") || config.defaultSwapStyle;
248
+ const waitTarget = getWaitTarget(element);
230
249
  addWaiting(waitTarget);
231
250
 
232
251
  try {
@@ -265,9 +284,14 @@
265
284
  }
266
285
  } catch (error) {
267
286
  console.error("PyWebview HTMX: error calling Python function", error);
268
- triggerEvent(element, "py:error", { error, requestId });
287
+ triggerEvent(element, "py:error", {
288
+ error,
289
+ requestId,
290
+ stale: requestId !== state.lastIssued,
291
+ });
269
292
  } finally {
270
293
  state.inFlightCount = Math.max(0, state.inFlightCount - 1);
294
+ releaseRequestScope(scope);
271
295
  removeWaiting(waitTarget);
272
296
  }
273
297
  });