pywebview-htmx 0.3.1__tar.gz → 0.4.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 (26) hide show
  1. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/PKG-INFO +41 -11
  2. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/README.md +40 -10
  3. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/pyproject.toml +22 -19
  4. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/runtime.js +54 -21
  5. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/base.css +44 -102
  6. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/cybermind.css +4 -13
  7. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/tests/conftest.py +0 -7
  8. pywebview_htmx-0.4.1/tests/test_js_runtime.py +774 -0
  9. pywebview_htmx-0.4.1/tests/test_repo_legibility.py +125 -0
  10. pywebview_htmx-0.3.1/tests/js_runtime_behavior_harness.mjs +0 -369
  11. pywebview_htmx-0.3.1/tests/test_js_contract_config_and_binding.py +0 -76
  12. pywebview_htmx-0.3.1/tests/test_js_contract_ergonomics.py +0 -125
  13. pywebview_htmx-0.3.1/tests/test_js_contract_request_and_swap.py +0 -96
  14. pywebview_htmx-0.3.1/tests/test_js_runtime_behavior.py +0 -27
  15. pywebview_htmx-0.3.1/tests/test_repo_legibility.py +0 -125
  16. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/LICENSE +0 -0
  17. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/src/pywebview_htmx/__init__.py +0 -0
  18. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/src/pywebview_htmx/py.typed +0 -0
  19. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/src/pywebview_htmx/runtime.py +0 -0
  20. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/aurora.css +0 -0
  21. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/paper.css +0 -0
  22. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/tests/test_python_create_window.py +0 -0
  23. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/tests/test_python_injection.py +0 -0
  24. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/tests/test_python_params_attr.py +0 -0
  25. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.1}/tests/test_python_script_loading.py +0 -0
  26. {pywebview_htmx-0.3.1 → pywebview_htmx-0.4.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.1
3
+ Version: 0.4.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
@@ -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,39 @@ 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
+
322
+ `event.detail.element` identifies the original control. If that control has
323
+ been removed, the runtime dispatches the error on `document.body` so application
324
+ listeners can still receive it.
325
+
300
326
  Example:
301
327
 
302
328
  ```js
303
329
  document.body.addEventListener("py:error", (event) => {
304
- console.error("pywebview-htmx error", event.detail.error);
330
+ console.error("pywebview-htmx error", {
331
+ error: event.detail.error,
332
+ stale: event.detail.stale,
333
+ });
305
334
  });
306
335
  ```
307
336
 
308
337
  ## Concurrency Behavior
309
338
 
310
- `pywebview-htmx` tracks request state by resolved target. Controls that share
311
- the same `py-target` coordinate request ordering and `drop` behavior. Controls
339
+ For a nonempty `py-target`, `pywebview-htmx` scopes request state by its
340
+ normalized selector string. Controls with the same selector coordinate request
341
+ ordering and `drop` behavior even when a swap replaces the matching DOM node.
342
+ Selector state is discarded after its in-flight requests finish. Controls
312
343
  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.
344
+
345
+ The `py-wait` selector is resolved for each accepted request. Loading state is
346
+ counted separately per resolved wait element, so a shared spinner stays active
347
+ until every request using that particular element finishes.
315
348
 
316
349
  ### `latest-wins` (default)
317
350
 
@@ -346,10 +379,7 @@ Set `theme=None` to disable automatic theme injection.
346
379
 
347
380
  ### Built-in Component Classes
348
381
 
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:
382
+ The bundled CSS exposes canonical `pyh-*` component classes:
353
383
 
354
384
  - Layout: `.pyh-shell`, `.pyh-hero`, `.pyh-grid`, `.pyh-card`
355
385
  - 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,39 @@ 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
+
282
+ `event.detail.element` identifies the original control. If that control has
283
+ been removed, the runtime dispatches the error on `document.body` so application
284
+ listeners can still receive it.
285
+
260
286
  Example:
261
287
 
262
288
  ```js
263
289
  document.body.addEventListener("py:error", (event) => {
264
- console.error("pywebview-htmx error", event.detail.error);
290
+ console.error("pywebview-htmx error", {
291
+ error: event.detail.error,
292
+ stale: event.detail.stale,
293
+ });
265
294
  });
266
295
  ```
267
296
 
268
297
  ## Concurrency Behavior
269
298
 
270
- `pywebview-htmx` tracks request state by resolved target. Controls that share
271
- the same `py-target` coordinate request ordering and `drop` behavior. Controls
299
+ For a nonempty `py-target`, `pywebview-htmx` scopes request state by its
300
+ normalized selector string. Controls with the same selector coordinate request
301
+ ordering and `drop` behavior even when a swap replaces the matching DOM node.
302
+ Selector state is discarded after its in-flight requests finish. Controls
272
303
  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.
304
+
305
+ The `py-wait` selector is resolved for each accepted request. Loading state is
306
+ counted separately per resolved wait element, so a shared spinner stays active
307
+ until every request using that particular element finishes.
275
308
 
276
309
  ### `latest-wins` (default)
277
310
 
@@ -306,10 +339,7 @@ Set `theme=None` to disable automatic theme injection.
306
339
 
307
340
  ### Built-in Component Classes
308
341
 
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:
342
+ The bundled CSS exposes canonical `pyh-*` component classes:
313
343
 
314
344
  - Layout: `.pyh-shell`, `.pyh-hero`, `.pyh-grid`, `.pyh-card`
315
345
  - 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.1"
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,8 +7,10 @@
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();
13
+ const boundElements = new WeakSet();
12
14
 
13
15
  function $(selector, root = document) {
14
16
  if (!selector) return null;
@@ -23,7 +25,7 @@
23
25
  function triggerEvent(target, eventName, detail = {}) {
24
26
  const event = new CustomEvent(eventName, {
25
27
  bubbles: true,
26
- cancelable: true,
28
+ cancelable: false,
27
29
  detail,
28
30
  });
29
31
  target.dispatchEvent(event);
@@ -132,20 +134,21 @@
132
134
  return await func(params);
133
135
  }
134
136
 
135
- function getRequestState(key) {
136
- let state = requestState.get(key);
137
+ function getRequestScope(element, targetSelector) {
138
+ const key = targetSelector || element;
139
+ const store = targetSelector ? targetedRequestState : selfRequestState;
140
+ let state = store.get(key);
137
141
  if (!state) {
138
142
  state = { lastIssued: 0, inFlightCount: 0 };
139
- requestState.set(key, state);
143
+ store.set(key, state);
140
144
  }
141
- return state;
145
+ return { key, state, store };
142
146
  }
143
147
 
144
- function getRequestStateKey(element, targetSelector) {
145
- if (!targetSelector) {
146
- return element;
148
+ function releaseRequestScope(scope) {
149
+ if (scope.state.inFlightCount === 0 && scope.store.get(scope.key) === scope.state) {
150
+ scope.store.delete(scope.key);
147
151
  }
148
- return $(targetSelector) || element;
149
152
  }
150
153
 
151
154
  function addWaiting(waitTarget) {
@@ -164,8 +167,24 @@
164
167
  waitState.set(waitTarget, count);
165
168
  }
166
169
 
167
- function shouldPreventDefault(eventName) {
168
- return eventName === "submit";
170
+ function shouldPreventDefault(element, eventName) {
171
+ if (eventName === "submit") {
172
+ return true;
173
+ }
174
+ if (eventName !== "click") {
175
+ return false;
176
+ }
177
+
178
+ if ((element.tagName === "A" || element.tagName === "AREA") && element.hasAttribute("href")) {
179
+ return true;
180
+ }
181
+ if (element.tagName === "BUTTON" && element.form) {
182
+ return element.type !== "button";
183
+ }
184
+ if (element.tagName === "INPUT" && element.form) {
185
+ return ["image", "reset", "submit"].includes(element.type);
186
+ }
187
+ return false;
169
188
  }
170
189
 
171
190
  function getRequestPolicy(element) {
@@ -188,29 +207,28 @@
188
207
  elements.push(...root.querySelectorAll("[py-call]"));
189
208
 
190
209
  elements.forEach((element) => {
191
- if (element.getAttribute("data-pywebview-bound") === "true") {
210
+ if (boundElements.has(element)) {
192
211
  return;
193
212
  }
194
- element.setAttribute("data-pywebview-bound", "true");
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);
204
220
  return;
205
221
  }
222
+ boundElements.add(element);
206
223
 
207
224
  element.addEventListener(eventName, async (event) => {
208
- if (shouldPreventDefault(eventName) && event.cancelable) {
225
+ if (shouldPreventDefault(element, eventName) && event.cancelable) {
209
226
  event.preventDefault();
210
227
  }
211
228
 
212
229
  const params = buildRequestParams(element, eventName, event);
213
- const state = getRequestState(getRequestStateKey(element, targetSelector));
230
+ const scope = getRequestScope(element, targetSelector);
231
+ const state = scope.state;
214
232
  const requestPolicy = getRequestPolicy(element);
215
233
  if (requestPolicy === "drop" && state.inFlightCount > 0) {
216
234
  triggerEvent(element, "py:ignored", { reason: "in-flight" });
@@ -227,6 +245,8 @@
227
245
  requestId,
228
246
  });
229
247
 
248
+ const swapStyle = element.getAttribute("py-swap") || config.defaultSwapStyle;
249
+ const waitTarget = getWaitTarget(element);
230
250
  addWaiting(waitTarget);
231
251
 
232
252
  try {
@@ -238,7 +258,7 @@
238
258
  throw new Error("PyWebview HTMX: Python API methods must return an HTML string");
239
259
  }
240
260
 
241
- const target = targetSelector ? $(targetSelector) : element;
261
+ let target = targetSelector ? $(targetSelector) : element;
242
262
  if (!target) {
243
263
  console.warn("PyWebview HTMX: target element not found", targetSelector);
244
264
  return;
@@ -252,6 +272,12 @@
252
272
  return;
253
273
  }
254
274
 
275
+ target = targetSelector ? $(targetSelector) : element;
276
+ if (!target || !target.isConnected) {
277
+ console.warn("PyWebview HTMX: target element not found", targetSelector);
278
+ return;
279
+ }
280
+
255
281
  const processRoot = pySwap(target, response, swapStyle);
256
282
  const afterSwapTarget =
257
283
  swapStyle === "outerHTML"
@@ -265,9 +291,16 @@
265
291
  }
266
292
  } catch (error) {
267
293
  console.error("PyWebview HTMX: error calling Python function", error);
268
- triggerEvent(element, "py:error", { error, requestId });
294
+ const errorTarget = element.isConnected ? element : document.body || document;
295
+ triggerEvent(errorTarget, "py:error", {
296
+ element,
297
+ error,
298
+ requestId,
299
+ stale: requestId !== state.lastIssued,
300
+ });
269
301
  } finally {
270
302
  state.inFlightCount = Math.max(0, state.inFlightCount - 1);
303
+ releaseRequestScope(scope);
271
304
  removeWaiting(waitTarget);
272
305
  }
273
306
  });