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.
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/PKG-INFO +5 -1
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/README.md +4 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/pyproject.toml +1 -1
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/runtime.js +13 -4
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/conftest.py +0 -7
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_js_runtime.py +132 -0
- pywebview_htmx-0.4.1/tests/test_repo_legibility.py +125 -0
- pywebview_htmx-0.4.0/tests/test_repo_legibility.py +0 -125
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/LICENSE +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/__init__.py +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/py.typed +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/runtime.py +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/aurora.css +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/base.css +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/src/pywebview_htmx/static/themes/paper.css +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_python_create_window.py +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_python_injection.py +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_python_params_attr.py +0 -0
- {pywebview_htmx-0.4.0 → pywebview_htmx-0.4.1}/tests/test_python_script_loading.py +0 -0
- {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.
|
|
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
|
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pywebview_htmx-0.4.0 → pywebview_htmx-0.4.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
|