From d220cb1769894ed14d0719b73abd62b6b649c4f6 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:50:20 -0500 Subject: [PATCH] Merge remote-tracking branch 'origin/main' into fix/unittest-gallery-dependencies --- lib/examples/apollo/apollo.py | 2 +- requirements.txt | 2 +- scripts/gallery_generator.py | 40 ++- scripts/refresh-requirements.py | 6 + tests/test_gallery_screenshots.py | 24 +- tests/test_peterhinch_page.py | 396 +++++++++++++++--------------- 6 files changed, 263 insertions(+), 207 deletions(-) diff --git a/lib/examples/apollo/apollo.py b/lib/examples/apollo/apollo.py index b4291181..4621b9f0 100644 --- a/lib/examples/apollo/apollo.py +++ b/lib/examples/apollo/apollo.py @@ -1,4 +1,4 @@ -# deps: palettes +# deps: palettes, pygraphics # gallery: binaries """ apollo.py — Apollo Guidance Computer DSKY emulator. diff --git a/requirements.txt b/requirements.txt index 514c6dec..5e0575d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,9 +2,9 @@ --extra-index-url https://pypi.org/simple/ pydevices -pydevices-audioeffects pydevices-audioif pydevices-audioinstruments +pydevices-audioeffects pydevices-desktop pydevices-lvgl pydevices-palettes diff --git a/scripts/gallery_generator.py b/scripts/gallery_generator.py index 9b3a0612..1650e484 100755 --- a/scripts/gallery_generator.py +++ b/scripts/gallery_generator.py @@ -47,6 +47,7 @@ from __future__ import annotations import argparse +import ast import json from pathlib import Path import re @@ -104,6 +105,9 @@ HEADER_SCAN_LINES = 10 GALLERY_VALUES = frozenset({"featured", "skip", "binaries", "nochrome", "newwindow"}) +INSTALLABLE_DEPS = frozenset( + {"palettes", "pygraphics", "pdwidgets", "audioinstruments", "audioeffects"} +) LOCAL_IMPORT_RE = re.compile( r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))", @@ -429,6 +433,38 @@ def discover() -> list[Example]: return found +def imported_top_level_modules(ex: Example) -> set[str]: + """Return imports from every Python file shipped for an example.""" + imported: set[str] = set() + for rel in ex.pyscript_files: + path = EXAMPLES_DIR / rel + tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name.split(".", 1)[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".", 1)[0]) + return imported + + +def validate_example_deps(examples: list[Example]) -> None: + """Reject missing installable deps and warn about unused declarations.""" + errors: list[str] = [] + for ex in examples: + imported = imported_top_level_modules(ex) + missing = sorted((imported & INSTALLABLE_DEPS) - set(ex.deps)) + if missing: + errors.append(f"{ex.source_rel}: missing # deps: {', '.join(missing)}") + unused = sorted(set(ex.deps) - imported) + if unused: + print( + f"warning: {ex.source_rel}: declared # deps not imported: {', '.join(unused)}", + file=sys.stderr, + ) + if errors: + raise SystemExit("gallery dependency errors:\n " + "\n ".join(errors)) + + # Package deps that are also top-level PyDevices org repos get that repo's # real ecosystem tier (see .site/pyscript/site-chrome.js ECOSYSTEM_DATA). # appdev and multimer ship inside pydevices' own lib/, so they take its tier. @@ -742,8 +778,10 @@ def main(argv: list[str] | None = None) -> int: print(f"copied {n} gallery example file(s) to {args.copy_examples}") # featured, then new-window, then A-Z + discovered = discover() + validate_example_deps(discovered) examples = sorted( - discover(), + discovered, key=lambda e: (0 if e.featured else 1 if e.new_window else 2, e.title.lower()), ) stale: list[str] = [] diff --git a/scripts/refresh-requirements.py b/scripts/refresh-requirements.py index 890c86ed..8908dd72 100755 --- a/scripts/refresh-requirements.py +++ b/scripts/refresh-requirements.py @@ -29,6 +29,12 @@ PACKAGE_ORDER = ( "pydevices", "pydevices-audioif", + # audioinstruments and audioeffects publish from PyDevices/audiocomponents + # and sit on audioif; drum_machine and piano need them installed. Added + # here so refresh-requirements stops trying to drop them (they reached + # requirements.txt by hand in 0880cc63 and the check has been red since). + "pydevices-audioinstruments", + "pydevices-audioeffects", "pydevices-desktop", "pydevices-lvgl", "pydevices-palettes", diff --git a/tests/test_gallery_screenshots.py b/tests/test_gallery_screenshots.py index 32896a01..c554f5b4 100644 --- a/tests/test_gallery_screenshots.py +++ b/tests/test_gallery_screenshots.py @@ -17,11 +17,33 @@ class TestGalleryScreenshots(unittest.TestCase): - def test_direct_stage_excludes_generated_gui_cache(self): + def test_direct_stage_excludes_generated_gui_cache_and_validates_dependencies(self): utilities = gallery.tracked_utility_files() self.assertIn("utils/tft_config.py", utilities) self.assertFalse(any(path.startswith("utils/gui/") for path in utilities)) + with tempfile.TemporaryDirectory() as tmp, mock.patch.object( + gallery, "EXAMPLES_DIR", Path(tmp) + ): + package = Path(tmp) / "demo" + package.mkdir() + (package / "demo.py").write_text("import palettes\nfrom pygraphics import Area\n") + (package / "helper.py").write_text("from pdwidgets.button import Button\n") + example = gallery.Example("demo", "lib/examples/demo/demo.py", "manifest") + example.pyscript_files = ["demo/demo.py", "demo/helper.py"] + example.deps = ["palettes", "pygraphics", "pdwidgets", "audioif"] + + with mock.patch("sys.stderr") as stderr: + gallery.validate_example_deps([example]) + warning = "".join(call.args[0] for call in stderr.write.call_args_list) + self.assertIn("declared # deps not imported: audioif", warning) + + example.deps.remove("pdwidgets") + with mock.patch("sys.stderr"), self.assertRaisesRegex( + SystemExit, r"missing # deps: pdwidgets" + ): + gallery.validate_example_deps([example]) + def test_card_uses_existing_thumbnail(self): example = gallery.Example("demo", "demo.py", "module") with tempfile.TemporaryDirectory() as tmp, mock.patch.object( diff --git a/tests/test_peterhinch_page.py b/tests/test_peterhinch_page.py index 959d27a4..2ab75785 100644 --- a/tests/test_peterhinch_page.py +++ b/tests/test_peterhinch_page.py @@ -10,6 +10,7 @@ import json from pathlib import Path import re +import unittest ROOT = Path(__file__).resolve().parents[1] GALLERY = ROOT / ".site" / "gallery" @@ -31,208 +32,197 @@ def _excluded(gui): return set(re.findall(r'^\s+"([^"]+)",', match.group("body"), flags=re.MULTILINE)) -def test_page_declares_its_gui_specific_plan_before_the_host_loads(): - """The host reads ``?manifests=…&command=…`` from the address bar, but this - page's address bar carries the demo browser's own state (``?touch&demo=…``), - so it hands the host a GUI-specific plan directly — before the host loads.""" - source = _source() - interpreter = source.index('') - assert interpreter < manifest_map < plan < command < loader - assert "manifests: manifests[gui]" in source - # The retired PyScript plumbing must not come back with it. - assert "interpreter.type = 'mpy'" not in source - assert "dataset.configs" not in source - assert ".toml" not in source - assert "pyscript-config.js" not in source - assert "pyodide" not in source.lower() - - -def test_page_loads_the_shared_gallery_chrome_and_styles(): - """The COI shim the PyScript page carried is obsolete here: the direct host - runs MicroPython on the page's own main thread and never needs - SharedArrayBuffer. What survives is that the page wears the same chrome and - stylesheets as its sibling runtime page rather than forking them.""" - source = _source() - sibling = (GALLERY / "micropython.html").read_text(encoding="utf-8") - for link in ( - '', - '', - '', - ): - assert link in source - assert link in sibling - assert "mini-coi" not in source - assert "mini-coi" not in sibling - assert '' in source - assert '' in source - assert 'id="pydevices-site-header"' in source - assert 'id="pydevices-site-footer"' in source - - -def test_bare_url_defaults_to_touch_gui(): - source = _source() - assert "Bare URL (or only ?demo=…): same as ?touch" in source - assert "gui = 'touch';" in source - assert "selectors.length === 0 && unknownBare.length === 0" in source - - -def test_gui_files_come_from_per_gui_mip_manifests(): - """Each GUI's files come from its own manifest, sourced straight from - peterhinch's upstream repository — and nothing shared rides along in it. - ``pydevices-desktop`` (board_config, displaydev) is the host's job.""" - source = _source() - packages = { - "nano": "micropython-nano-gui", - "micro": "micropython-micro-gui", - "touch": "micropython-touch", - } - for gui, package in packages.items(): - assert f"{gui}: '{package}'" in source - manifest = json.loads((ROOT / "packages" / f"{package}.json").read_text()) - destinations = {destination for destination, _ in manifest["urls"]} - assert destinations - assert not any(name.endswith("board_config.py") for name in destinations) - assert all(name.startswith("gui/") for name in destinations) - assert any(name.startswith("gui/demos/") for name in destinations) - for destination, origin in manifest["urls"]: - assert origin == f"github:peterhinch/{package}/{destination}" - - -def test_gallery_pages_share_one_direct_wasm_host(): - """The modular ``.toml`` config chain retired with PyScript. The gallery's - loader pages now compose by sharing one host module instead.""" - for filename in ("micropython.html", "mp.html", "peterhinch.html"): - source = (GALLERY / filename).read_text(encoding="utf-8") - assert '' in source +class TestPeterHinchPage(unittest.TestCase): + def test_page_declares_its_gui_specific_plan_before_the_host_loads(self): + """The host reads ``?manifests=…&command=…`` from the address bar, but this + page's address bar carries the demo browser's own state (``?touch&demo=…``), + so it hands the host a GUI-specific plan directly — before the host loads.""" + source = _source() + interpreter = source.index('') + assert interpreter < manifest_map < plan < command < loader + assert "manifests: manifests[gui]" in source + # The retired PyScript plumbing must not come back with it. + assert "interpreter.type = 'mpy'" not in source + assert "dataset.configs" not in source assert ".toml" not in source assert "pyscript-config.js" not in source - host = (GALLERY / "gallery-host.js").read_text(encoding="utf-8") - assert "globalThis.__pydevicesPlan ?? location.search" in host - assert 'command: params.get("command")' in host - assert "`./packages/${manifest}.json`" in host - - -def test_dynamic_discovery_is_sorted_and_excludes_init(): - source = _source() - assert 'os.listdir("/utils/gui/demos")' in source - assert 'filename != "__init__.py"' in source - assert "names.sort()" in source - - -def test_demo_list_is_reused_across_fresh_interpreter_reloads(): - source = _source() - assert "'peterhinch-demos-' + gui" in source - assert "window.sessionStorage.getItem(cacheKey)" in source - assert 'window.sessionStorage.setItem("peterhinch-demos-" + gui, signature)' in source - assert 'if _gui_value("__hinchDemoSignature") != "\\n".join(names):' in source - - -def test_display_size_is_overridden_before_setup_import(): - source = _source() - width = source.index('env_set("PYDEVICES_WIDTH", 320)') - height = source.index('env_set("PYDEVICES_HEIGHT", 240)') - setup_import = source.index("__import__(setup)") - assert width < setup_import - assert height < setup_import - - -def test_micro_gui_has_visible_keyboard_hint(): - source = _source() - assert "Use the arrow keys to navigate and adjust. Press Space to select." in source - assert "document.getElementById('control-hint').hidden = gui !== 'micro';" in source - - -def test_gui_name_links_to_its_upstream_repository(): - source = _source() - assert 'id="package-link"' in source - assert "https://github.com/peterhinch/micropython-nano-gui" in source - assert "https://github.com/peterhinch/micropython-micro-gui" in source - assert "https://github.com/peterhinch/micropython-touch" in source - assert "packageLink.textContent = labels[gui];" in source - assert "packageLink.href = repositories[gui];" in source - - -def test_gui_picker_is_ordered_touch_micro_nano(): - source = _source() - touch = source.index('data-gui="touch"') - micro = source.index('data-gui="micro"') - nano = source.index('data-gui="nano"') - assert touch < micro < nano - - -def test_console_stacks_below_canvas_and_cards_are_synchronized(): - source = _source() - assert "grid-template-columns: max-content max-content;" in source - assert "justify-content: center;" in source - assert ".hinch-stage .play-area > .console-panel" in source - assert "grid-row: 3;" in source - assert "stage.style.width = width;" in source - assert "panel.style.width = width;" in source - assert "demoPanel.style.width = width;" in source - assert "demoPanel.style.maxWidth = width;" in source - assert "panel.style.height = rect.height + 'px';" in source - assert "demoPanel.style.height = consoleBottom - demoTop + 'px';" in source - - -def test_console_output_is_the_hosts_and_is_not_written_twice(): - """PyScript needed the page to rebind ``print`` to reach the console panel. - ``gallery-host.js`` already pipes stdout and stderr into ``#log``, so a - second writer would double every line.""" - source = _source() - assert '
document.getElementById("log");' in host
- assert 'log(line, "stdout")' in host
- assert 'log(line, "stderr")' in host
-
-
-def test_known_incompatible_demos_are_filtered():
- assert _excluded("nano") == {
- "aclock",
- "aclock_large",
- "aclock_ttgo",
- "alevel",
- "asnano",
- "asnano_sync",
- "clock_batt",
- "clocktest",
- "color15",
- "color96",
- "fpt",
- "mono_test",
- "sharptest",
- }
- assert _excluded("micro") == {"audio", "bitmap", "date", "qrcode"}
- assert _excluded("touch") == {"audio", "bitmap", "date", "qrcode"}
-
-
-def test_selected_demo_must_be_discovered_and_supported():
- source = _source()
- assert 'if not selected:\n _set_status("Discovering demos…")' in source
- assert "'Starting ' + demo + '…'" in source
- assert "if selected not in names:" in source
- assert "if selected in discovered:" in source
- assert "Demo is not compatible with the browser interpreter:" in source
- assert '__import__("gui.demos." + selected)' in source
-
-
-def test_valid_demo_scrolls_panel_below_sticky_header():
- source = _source()
- validation = source.index("if selected not in names:")
- scroll = source.index("_scroll_to_demo_panel()", validation)
- demo_import = source.index('__import__("gui.demos." + selected)')
- assert validation < scroll < demo_import
- assert 'document.querySelector(".demo-panel")' in source
- assert 'document.querySelector(".site-header")' in source
- assert "window.scrollTo(0, max(0, int(target)))" in source
-
-
-def test_gallery_regeneration_preserves_page():
- generator = (ROOT / "scripts" / "gallery_generator.py").read_text(encoding="utf-8")
- keep_html = generator.split("KEEP_HTML =", 1)[1].split("ARROW =", 1)[0]
- assert '"peterhinch"' in keep_html
+ assert "pyodide" not in source.lower()
+
+ def test_page_loads_the_shared_gallery_chrome_and_styles(self):
+ """The COI shim the PyScript page carried is obsolete here: the direct host
+ runs MicroPython on the page's own main thread and never needs
+ SharedArrayBuffer. What survives is that the page wears the same chrome and
+ stylesheets as its sibling runtime page rather than forking them."""
+ source = _source()
+ sibling = (GALLERY / "micropython.html").read_text(encoding="utf-8")
+ for link in (
+ '',
+ '',
+ '',
+ ):
+ assert link in source
+ assert link in sibling
+ assert "mini-coi" not in source
+ assert "mini-coi" not in sibling
+ assert '' in source
+ assert '' in source
+ assert 'id="pydevices-site-header"' in source
+ assert 'id="pydevices-site-footer"' in source
+
+ def test_bare_url_defaults_to_touch_gui(self):
+ source = _source()
+ assert "Bare URL (or only ?demo=…): same as ?touch" in source
+ assert "gui = 'touch';" in source
+ assert "selectors.length === 0 && unknownBare.length === 0" in source
+
+ def test_gui_files_come_from_per_gui_mip_manifests(self):
+ """Each GUI's files come from its own manifest, sourced straight from
+ peterhinch's upstream repository — and nothing shared rides along in it.
+ ``pydevices-desktop`` (board_config, displaydev) is the host's job."""
+ source = _source()
+ packages = {
+ "nano": "micropython-nano-gui",
+ "micro": "micropython-micro-gui",
+ "touch": "micropython-touch",
+ }
+ for gui, package in packages.items():
+ assert f"{gui}: '{package}'" in source
+ manifest = json.loads((ROOT / "packages" / f"{package}.json").read_text())
+ destinations = {destination for destination, _ in manifest["urls"]}
+ assert destinations
+ assert not any(name.endswith("board_config.py") for name in destinations)
+ assert all(name.startswith("gui/") for name in destinations)
+ assert any(name.startswith("gui/demos/") for name in destinations)
+ for destination, origin in manifest["urls"]:
+ assert origin == f"github:peterhinch/{package}/{destination}"
+
+ def test_gallery_pages_share_one_direct_wasm_host(self):
+ """The modular ``.toml`` config chain retired with PyScript. The gallery's
+ loader pages now compose by sharing one host module instead."""
+ for filename in ("micropython.html", "mp.html", "peterhinch.html"):
+ source = (GALLERY / filename).read_text(encoding="utf-8")
+ assert '' in source
+ assert ".toml" not in source
+ assert "pyscript-config.js" not in source
+ host = (GALLERY / "gallery-host.js").read_text(encoding="utf-8")
+ assert "globalThis.__pydevicesPlan ?? location.search" in host
+ assert 'command: params.get("command")' in host
+ assert "`./packages/${manifest}.json`" in host
+
+ def test_dynamic_discovery_is_sorted_and_excludes_init(self):
+ source = _source()
+ assert 'os.listdir("/utils/gui/demos")' in source
+ assert 'filename != "__init__.py"' in source
+ assert "names.sort()" in source
+
+ def test_demo_list_is_reused_across_fresh_interpreter_reloads(self):
+ source = _source()
+ assert "'peterhinch-demos-' + gui" in source
+ assert "window.sessionStorage.getItem(cacheKey)" in source
+ assert 'window.sessionStorage.setItem("peterhinch-demos-" + gui, signature)' in source
+ assert 'if _gui_value("__hinchDemoSignature") != "\\n".join(names):' in source
+
+ def test_display_size_is_overridden_before_setup_import(self):
+ source = _source()
+ width = source.index('env_set("PYDEVICES_WIDTH", 320)')
+ height = source.index('env_set("PYDEVICES_HEIGHT", 240)')
+ setup_import = source.index("__import__(setup)")
+ assert width < setup_import
+ assert height < setup_import
+
+ def test_micro_gui_has_visible_keyboard_hint(self):
+ source = _source()
+ assert "Use the arrow keys to navigate and adjust. Press Space to select." in source
+ assert "document.getElementById('control-hint').hidden = gui !== 'micro';" in source
+
+ def test_gui_name_links_to_its_upstream_repository(self):
+ source = _source()
+ assert 'id="package-link"' in source
+ assert "https://github.com/peterhinch/micropython-nano-gui" in source
+ assert "https://github.com/peterhinch/micropython-micro-gui" in source
+ assert "https://github.com/peterhinch/micropython-touch" in source
+ assert "packageLink.textContent = labels[gui];" in source
+ assert "packageLink.href = repositories[gui];" in source
+
+ def test_gui_picker_is_ordered_touch_micro_nano(self):
+ source = _source()
+ touch = source.index('data-gui="touch"')
+ micro = source.index('data-gui="micro"')
+ nano = source.index('data-gui="nano"')
+ assert touch < micro < nano
+
+ def test_console_stacks_below_canvas_and_cards_are_synchronized(self):
+ source = _source()
+ assert "grid-template-columns: max-content max-content;" in source
+ assert "justify-content: center;" in source
+ assert ".hinch-stage .play-area > .console-panel" in source
+ assert "grid-row: 3;" in source
+ assert "stage.style.width = width;" in source
+ assert "panel.style.width = width;" in source
+ assert "demoPanel.style.width = width;" in source
+ assert "demoPanel.style.maxWidth = width;" in source
+ assert "panel.style.height = rect.height + 'px';" in source
+ assert "demoPanel.style.height = consoleBottom - demoTop + 'px';" in source
+
+ def test_console_output_is_the_hosts_and_is_not_written_twice(self):
+ """PyScript needed the page to rebind ``print`` to reach the console panel.
+ ``gallery-host.js`` already pipes stdout and stderr into ``#log``, so a
+ second writer would double every line."""
+ source = _source()
+ assert ' document.getElementById("log");' in host
+ assert 'log(line, "stdout")' in host
+ assert 'log(line, "stderr")' in host
+
+ def test_known_incompatible_demos_are_filtered(self):
+ assert _excluded("nano") == {
+ "aclock",
+ "aclock_large",
+ "aclock_ttgo",
+ "alevel",
+ "asnano",
+ "asnano_sync",
+ "clock_batt",
+ "clocktest",
+ "color15",
+ "color96",
+ "fpt",
+ "mono_test",
+ "sharptest",
+ }
+ assert _excluded("micro") == {"audio", "bitmap", "date", "qrcode"}
+ assert _excluded("touch") == {"audio", "bitmap", "date", "qrcode"}
+
+ def test_selected_demo_must_be_discovered_and_supported(self):
+ source = _source()
+ assert 'if not selected:\n _set_status("Discovering demos…")' in source
+ assert "'Starting ' + demo + '…'" in source
+ assert "if selected not in names:" in source
+ assert "if selected in discovered:" in source
+ assert "Demo is not compatible with the browser interpreter:" in source
+ assert '__import__("gui.demos." + selected)' in source
+
+ def test_valid_demo_scrolls_panel_below_sticky_header(self):
+ source = _source()
+ validation = source.index("if selected not in names:")
+ scroll = source.index("_scroll_to_demo_panel()", validation)
+ demo_import = source.index('__import__("gui.demos." + selected)')
+ assert validation < scroll < demo_import
+ assert 'document.querySelector(".demo-panel")' in source
+ assert 'document.querySelector(".site-header")' in source
+ assert "window.scrollTo(0, max(0, int(target)))" in source
+
+ def test_gallery_regeneration_preserves_page(self):
+ generator = (ROOT / "scripts" / "gallery_generator.py").read_text(encoding="utf-8")
+ keep_html = generator.split("KEEP_HTML =", 1)[1].split("ARROW =", 1)[0]
+ assert '"peterhinch"' in keep_html
+
+
+if __name__ == "__main__":
+ unittest.main()