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()