pywebview-htmx 0.4.0__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 (21) hide show
  1. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/PKG-INFO +5 -1
  2. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/README.md +4 -0
  3. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/pyproject.toml +1 -1
  4. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/runtime.js +13 -4
  5. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/conftest.py +0 -7
  6. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_js_runtime.py +132 -0
  7. pywebview_htmx-0.4.1/tests/test_repo_legibility.py +125 -0
  8. pywebview_htmx-0.4.0/tests/test_repo_legibility.py +0 -125
  9. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/LICENSE +0 -0
  10. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/__init__.py +0 -0
  11. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/py.typed +0 -0
  12. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/runtime.py +0 -0
  13. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/aurora.css +0 -0
  14. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/base.css +0 -0
  15. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
  16. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/paper.css +0 -0
  17. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_python_create_window.py +0 -0
  18. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_python_injection.py +0 -0
  19. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_python_params_attr.py +0 -0
  20. {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_python_script_loading.py +0 -0
  21. {pywebview_htmx-0.4.0 → 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.4.0
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
@@ -319,6 +319,10 @@ newer request had already been issued in the same request scope when the error
319
319
  was emitted. Errors from stale requests remain observable even though stale
320
320
  successful responses do not update the DOM.
321
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
+
322
326
  Example:
323
327
 
324
328
  ```js
@@ -279,6 +279,10 @@ newer request had already been issued in the same request scope when the error
279
279
  was emitted. Errors from stale requests remain observable even though stale
280
280
  successful responses do not update the DOM.
281
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
+
282
286
  Example:
283
287
 
284
288
  ```js
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
22
  "Topic :: Internet :: WWW/HTTP",
23
23
  ]
24
- version = "0.4.0"
24
+ version = "0.4.1"
25
25
 
26
26
  [project.license]
27
27
  file = "LICENSE"
@@ -10,6 +10,7 @@
10
10
  const selfRequestState = new WeakMap();
11
11
  const targetedRequestState = new Map();
12
12
  const waitState = new WeakMap();
13
+ const boundElements = new WeakSet();
13
14
 
14
15
  function $(selector, root = document) {
15
16
  if (!selector) return null;
@@ -206,10 +207,9 @@
206
207
  elements.push(...root.querySelectorAll("[py-call]"));
207
208
 
208
209
  elements.forEach((element) => {
209
- if (element.getAttribute("data-pywebview-bound") === "true") {
210
+ if (boundElements.has(element)) {
210
211
  return;
211
212
  }
212
- element.setAttribute("data-pywebview-bound", "true");
213
213
 
214
214
  const functionName = element.getAttribute("py-call");
215
215
  const eventName = element.getAttribute("py-trigger") || "click";
@@ -219,6 +219,7 @@
219
219
  console.warn("PyWebview HTMX: py-call is empty", element);
220
220
  return;
221
221
  }
222
+ boundElements.add(element);
222
223
 
223
224
  element.addEventListener(eventName, async (event) => {
224
225
  if (shouldPreventDefault(element, eventName) && event.cancelable) {
@@ -257,7 +258,7 @@
257
258
  throw new Error("PyWebview HTMX: Python API methods must return an HTML string");
258
259
  }
259
260
 
260
- const target = targetSelector ? $(targetSelector) : element;
261
+ let target = targetSelector ? $(targetSelector) : element;
261
262
  if (!target) {
262
263
  console.warn("PyWebview HTMX: target element not found", targetSelector);
263
264
  return;
@@ -271,6 +272,12 @@
271
272
  return;
272
273
  }
273
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
+
274
281
  const processRoot = pySwap(target, response, swapStyle);
275
282
  const afterSwapTarget =
276
283
  swapStyle === "outerHTML"
@@ -284,7 +291,9 @@
284
291
  }
285
292
  } catch (error) {
286
293
  console.error("PyWebview HTMX: error calling Python function", error);
287
- triggerEvent(element, "py:error", {
294
+ const errorTarget = element.isConnected ? element : document.body || document;
295
+ triggerEvent(errorTarget, "py:error", {
296
+ element,
288
297
  error,
289
298
  requestId,
290
299
  stale: requestId !== state.lastIssued,
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
4
-
5
3
  import pytest
6
4
 
7
5
  from pywebview_htmx import get_runtime_script
@@ -10,8 +8,3 @@ from pywebview_htmx import get_runtime_script
10
8
  @pytest.fixture(scope="session")
11
9
  def js_source() -> str:
12
10
  return get_runtime_script()
13
-
14
-
15
- @pytest.fixture(scope="session")
16
- def js_compact(js_source: str) -> str:
17
- return re.sub(r"\s+", " ", js_source).strip()
@@ -640,3 +640,135 @@ def test_invalid_params_and_selectors_degrade_safely(
640
640
  assert not page.locator("#invalid-wait").evaluate(
641
641
  "node => node.classList.contains('py-waiting')"
642
642
  )
643
+
644
+
645
+ @pytest.mark.parametrize("remove_target", [False, True], ids=["replace", "remove"])
646
+ def test_target_is_resolved_again_after_swap_delay(
647
+ runtime_browser: RuntimeBrowser, js_source: str, remove_target: bool,
648
+ ) -> None:
649
+ runtime_browser.load(
650
+ '<button id="load" py-call="load" py-target="#result">Load</button>'
651
+ '<div id="result">original</div>',
652
+ js_source,
653
+ )
654
+ page = runtime_browser.page
655
+ install_pending_bridge(page)
656
+ runtime_browser.process()
657
+ page.evaluate(
658
+ """() => {
659
+ const originalTimeout = window.setTimeout;
660
+ window.setTimeout = (callback, ms, ...args) => {
661
+ if (ms === 1234) {
662
+ window.finishSwapDelay = callback;
663
+ return 0;
664
+ }
665
+ return originalTimeout(callback, ms, ...args);
666
+ };
667
+ pywebviewHtmx.config.swapDelay = 1234;
668
+ window.oldTarget = document.querySelector('#result');
669
+ window.afterSwaps = 0;
670
+ document.body.addEventListener('py:afterSwap', () => afterSwaps++);
671
+ }"""
672
+ )
673
+ page.locator("#load").click()
674
+ resolve_request(page, 0, "response")
675
+ page.wait_for_function("typeof window.finishSwapDelay === 'function'")
676
+ page.evaluate(
677
+ """remove => {
678
+ if (remove) oldTarget.remove();
679
+ else oldTarget.outerHTML = '<div id="result">replacement</div>';
680
+ finishSwapDelay();
681
+ }""",
682
+ remove_target,
683
+ )
684
+ page.wait_for_function(
685
+ "!document.querySelector('#load').classList.contains('py-waiting')"
686
+ )
687
+ assert page.evaluate("oldTarget.textContent") == "original"
688
+ assert page.evaluate("afterSwaps") == (0 if remove_target else 1)
689
+ if not remove_target:
690
+ assert page.locator("#result").inner_text() == "response"
691
+
692
+
693
+ def test_detached_control_error_reaches_body_listener(
694
+ runtime_browser: RuntimeBrowser, js_source: str,
695
+ ) -> None:
696
+ runtime_browser.load(
697
+ '<section id="host"><button id="load" py-call="load" '
698
+ 'py-target="#host" py-swap="outerHTML">Load</button></section>',
699
+ js_source,
700
+ )
701
+ page = runtime_browser.page
702
+ install_pending_bridge(page)
703
+ runtime_browser.process()
704
+ page.evaluate(
705
+ """() => {
706
+ window.originalControl = document.querySelector('#load');
707
+ window.errors = [];
708
+ document.body.addEventListener('py:error', event => errors.push({
709
+ stale: event.detail.stale,
710
+ original: event.detail.element === originalControl,
711
+ message: event.detail.error.message,
712
+ }));
713
+ }"""
714
+ )
715
+ page.locator("#load").click()
716
+ page.locator("#load").click()
717
+ resolve_request(page, 1, '<section id="host">replaced</section>')
718
+ page.locator("#host").get_by_text("replaced").wait_for()
719
+ runtime_browser.expect_console_error("older failure")
720
+ reject_request(page, 0, "older failure")
721
+ page.wait_for_function("errors.length === 1")
722
+ assert page.evaluate("errors") == [
723
+ {"stale": True, "original": True, "message": "older failure"}
724
+ ]
725
+ assert page.evaluate("originalControl.classList.contains('py-waiting')") is False
726
+
727
+
728
+ def test_cloned_controls_bind_once_when_processed_repeatedly(
729
+ runtime_browser: RuntimeBrowser, js_source: str,
730
+ ) -> None:
731
+ runtime_browser.load(
732
+ '<button id="load" py-call="load" py-target="#result">Load</button>'
733
+ '<div id="result"></div>',
734
+ js_source,
735
+ )
736
+ page = runtime_browser.page
737
+ install_pending_bridge(page)
738
+ runtime_browser.process()
739
+ page.evaluate(
740
+ """() => {
741
+ const clone = document.querySelector('#load').cloneNode(true);
742
+ clone.id = 'clone';
743
+ document.body.append(clone);
744
+ pywebviewHtmx.process(clone);
745
+ pywebviewHtmx.process(document.body);
746
+ pywebviewHtmx.process(document.body);
747
+ }"""
748
+ )
749
+ page.locator("#clone").click()
750
+ assert page.evaluate("bridgeCalls.length") == 1
751
+ resolve_request(page, 0, "cloned")
752
+ page.locator("#result").get_by_text("cloned").wait_for()
753
+ page.locator("#load").click()
754
+ assert page.evaluate("bridgeCalls.length") == 2
755
+ resolve_request(page, 1, "original")
756
+ page.locator("#result").get_by_text("original").wait_for()
757
+
758
+
759
+ def test_injected_runtime_binds_controls_on_document_load(
760
+ runtime_browser: RuntimeBrowser,
761
+ ) -> None:
762
+ from pywebview_htmx import inject_runtime
763
+
764
+ page = runtime_browser.page
765
+ page.set_content(inject_runtime(
766
+ '<!doctype html><html><body>'
767
+ '<button id="load" py-call="load" py-target="#result">Load</button>'
768
+ '<div id="result"></div></body></html>'
769
+ ))
770
+ install_pending_bridge(page)
771
+ page.locator("#load").click()
772
+ assert page.evaluate("bridgeCalls") == [{}]
773
+ resolve_request(page, 0, "ready")
774
+ page.locator("#result").get_by_text("ready").wait_for()
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import subprocess
5
+ import tomllib
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[1]
11
+ REQUIRED_DOCS = [
12
+ "AGENTS.md",
13
+ "docs/index.md",
14
+ "docs/architecture.md",
15
+ "docs/development.md",
16
+ "docs/releasing.md",
17
+ ]
18
+ AGENT_REQUIRED_LINK_TARGETS = {
19
+ "README.md",
20
+ "docs/index.md",
21
+ "src/pywebview_htmx/runtime.py",
22
+ "src/pywebview_htmx/static/runtime.js",
23
+ "app.py",
24
+ "pyproject.toml",
25
+ "docs/releasing.md",
26
+ "tests",
27
+ "skills/use-pywebview-htmx",
28
+ }
29
+ REQUIRED_WHEEL_FILES = {
30
+ "pywebview_htmx/__init__.py",
31
+ "pywebview_htmx/py.typed",
32
+ "pywebview_htmx/runtime.py",
33
+ "pywebview_htmx/static/runtime.js",
34
+ "pywebview_htmx/static/themes/aurora.css",
35
+ "pywebview_htmx/static/themes/base.css",
36
+ "pywebview_htmx/static/themes/cybermind.css",
37
+ "pywebview_htmx/static/themes/paper.css",
38
+ }
39
+ LOCAL_MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
40
+
41
+
42
+ def _iter_local_links(markdown_path: Path) -> list[str]:
43
+ links: list[str] = []
44
+ for raw_link in LOCAL_MARKDOWN_LINK_RE.findall(
45
+ markdown_path.read_text(encoding="utf-8"),
46
+ ):
47
+ target = raw_link.split("#", 1)[0].strip()
48
+ if not target or re.match(r"^[a-z][a-z0-9+.-]*:", target):
49
+ continue
50
+ links.append(target)
51
+ return links
52
+
53
+
54
+ def _repo_relative_link_targets(markdown_path: Path) -> set[str]:
55
+ targets: set[str] = set()
56
+ for target in _iter_local_links(markdown_path):
57
+ resolved = (markdown_path.parent / target).resolve()
58
+ try:
59
+ relative_target = resolved.relative_to(REPO_ROOT)
60
+ except ValueError:
61
+ continue
62
+ targets.add(relative_target.as_posix())
63
+ return targets
64
+
65
+
66
+ def _load_pyproject() -> dict:
67
+ with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file:
68
+ return tomllib.load(pyproject_file)
69
+
70
+
71
+ def test_required_agent_legibility_docs_exist() -> None:
72
+ missing = [path for path in REQUIRED_DOCS if not (REPO_ROOT / path).exists()]
73
+ assert not missing, f"Missing agent-legibility docs: {missing}"
74
+
75
+
76
+ def test_required_docs_have_resolvable_local_markdown_links() -> None:
77
+ checked_paths = [REPO_ROOT / path for path in REQUIRED_DOCS]
78
+ broken_links: list[str] = []
79
+
80
+ for markdown_path in checked_paths:
81
+ for target in _iter_local_links(markdown_path):
82
+ resolved = (markdown_path.parent / target).resolve()
83
+ link_description = f"{markdown_path.relative_to(REPO_ROOT)} -> {target}"
84
+ try:
85
+ resolved.relative_to(REPO_ROOT)
86
+ except ValueError:
87
+ broken_links.append(link_description)
88
+ continue
89
+ if not resolved.exists():
90
+ broken_links.append(link_description)
91
+
92
+ assert not broken_links, f"Broken local Markdown links: {broken_links}"
93
+
94
+
95
+ def test_agents_md_links_to_core_repo_surfaces() -> None:
96
+ link_targets = _repo_relative_link_targets(REPO_ROOT / "AGENTS.md")
97
+ missing_targets = sorted(AGENT_REQUIRED_LINK_TARGETS - link_targets)
98
+ assert not missing_targets, (
99
+ f"AGENTS.md is missing link target(s): {missing_targets}"
100
+ )
101
+
102
+
103
+ def test_parsed_project_metadata_declares_pep_561_typing() -> None:
104
+ project_metadata = _load_pyproject()["project"]
105
+ assert project_metadata["name"] == "pywebview-htmx"
106
+ assert "Typing :: Typed" in project_metadata["classifiers"]
107
+ assert (REPO_ROOT / "src/pywebview_htmx/py.typed").is_file()
108
+
109
+
110
+ def test_wheel_contains_runtime_assets_and_typing_marker(tmp_path: Path) -> None:
111
+ subprocess.run(
112
+ ["pdm", "build", "--dest", str(tmp_path)],
113
+ cwd=REPO_ROOT,
114
+ check=True,
115
+ capture_output=True,
116
+ text=True,
117
+ )
118
+ wheels = list(tmp_path.glob("*.whl"))
119
+ assert len(wheels) == 1, f"Expected one wheel, found: {wheels}"
120
+
121
+ with zipfile.ZipFile(wheels[0]) as wheel:
122
+ wheel_files = set(wheel.namelist())
123
+
124
+ missing_files = sorted(REQUIRED_WHEEL_FILES - wheel_files)
125
+ assert not missing_files, f"Wheel is missing package file(s): {missing_files}"
@@ -1,125 +0,0 @@
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/create-draft-release.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