diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e558aa5..8fbe7b1 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,7 +1,7 @@ # This repository's own CI: lint the workflows it publishes to the rest of # the org, smoke-test that every reusable workflow is valid YAML, prove the # site generator's output matches what data/repos_db.json currently says it -# should be, and lint scripts/. +# should be, lint scripts/, and run publishing-script unit tests. name: Checks on: @@ -110,4 +110,16 @@ jobs: - run: python3 -m pip install --disable-pip-version-check ruff - - run: ruff check scripts/ + - run: ruff check scripts/ tests/ + + unittest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Run publishing-script unit tests + run: python3 -m unittest discover -s tests -v diff --git a/.github/workflows/reusable-synchronize-mip-package.yml b/.github/workflows/reusable-synchronize-mip-package.yml index 47e6aaa..868d371 100644 --- a/.github/workflows/reusable-synchronize-mip-package.yml +++ b/.github/workflows/reusable-synchronize-mip-package.yml @@ -90,10 +90,15 @@ jobs: profile = os.environ["PUBLICATION_PROFILE"] if profile not in lock: raise SystemExit(f"{profile!r} is not in {path}; add it before publishing") - lock[profile] = { - "repository": os.environ["SOURCE_REPOSITORY"], - "ref": os.environ["SOURCE_REF"], - } + entry = lock[profile] + expected = entry["repository"] + got = os.environ["SOURCE_REPOSITORY"] + if expected != got: + raise SystemExit( + f"profile {profile!r} is locked to {expected}, not {got}; " + f"edit pydevices-lock.json on the PyDevices branch to move the profile" + ) + entry["ref"] = os.environ["SOURCE_REF"] path.write_text(json.dumps(lock, indent=2) + "\n") print(f"{profile} -> {lock[profile]['ref']}") PY diff --git a/scripts/build_pydevices_python_distributions.py b/scripts/build_pydevices_python_distributions.py index 9230a53..78a5d1d 100755 --- a/scripts/build_pydevices_python_distributions.py +++ b/scripts/build_pydevices_python_distributions.py @@ -9,6 +9,8 @@ import sys from pathlib import Path +from pydevices_package_metadata import PYDEVICES_DESCRIPTIONS + DEBRIS = {"__pycache__", "README.md", "build", "dist"} # No internal dependency table here: with lib/ shipped as one distribution the # graph between its components is internal imports, not package requirements. @@ -114,7 +116,7 @@ def build(root: Path, output: Path, version: str) -> None: meta, "pydevices", version, - "Portable display, audio, event, and timing foundations for PyDevices", + PYDEVICES_DESCRIPTIONS["pydevices"], [], ) stages.append(meta) @@ -129,7 +131,7 @@ def build(root: Path, output: Path, version: str) -> None: desktop, "pydevices-desktop", version, - "Complete PyDevices desktop stack and board configuration", + PYDEVICES_DESCRIPTIONS["pydevices-desktop"], [f"pydevices=={version}"], ) stages.append(desktop) diff --git a/scripts/pydevices_package_metadata.py b/scripts/pydevices_package_metadata.py new file mode 100644 index 0000000..911af57 --- /dev/null +++ b/scripts/pydevices_package_metadata.py @@ -0,0 +1,6 @@ +"""Descriptions shared by TestPyPI and MIP publications of pydevices packages.""" + +PYDEVICES_DESCRIPTIONS = { + "pydevices": "Portable display, audio, event, and timing foundations for PyDevices", + "pydevices-desktop": "Complete PyDevices desktop stack and board configuration", +} diff --git a/scripts/synchronize_mip_package.py b/scripts/synchronize_mip_package.py index 6f424fa..0c70782 100755 --- a/scripts/synchronize_mip_package.py +++ b/scripts/synchronize_mip_package.py @@ -4,10 +4,13 @@ from __future__ import annotations import argparse +import json import shutil from dataclasses import dataclass from pathlib import Path +from pydevices_package_metadata import PYDEVICES_DESCRIPTIONS + @dataclass(frozen=True) class Profile: @@ -76,16 +79,12 @@ class Profile: ), } -PROFILE_REPOSITORIES = { - "palettes": "PyDevices/palettes", - "pdwidgets": "PyDevices/pdwidgets", - "pygraphics": "PyDevices/pygraphics", - "pydevices": "PyDevices/pydevices", - # Moved from PyDevices/audioif in the audioif/audiocomponents split - # (audiocomponents#2, 2026-09-03); audioif publishes the core only now. - "audioinstruments": "PyDevices/audiocomponents", - "audioeffects": "PyDevices/audiocomponents", -} +# The source repository for each profile is pydevices-lock.json in the MIP +# checkout, not a second map in this script. reusable-synchronize-mip-package.yml +# already keeps that lockfile on the runner; a hardcoded PROFILE_REPOSITORIES +# table disagreed with it after the audioif/audiocomponents split and blocked +# every publication until a new publishing-tools tag (#35). +LOCKFILE_NAME = "pydevices-lock.json" # No internal dependency table: lib/ ships as a single MIP package, so the graph # between its components is imports rather than package requirements. It was @@ -98,6 +97,26 @@ class Profile: PYDEVICES_DESKTOP_DIR = "board_configs/desktop" +def lockfile_repository(mip_root: Path, profile: str) -> str: + """Return the GitHub repository the MIP lockfile names for *profile*. + + A new profile is added to the lockfile deliberately, not auto-created. + """ + lockfile = mip_root / LOCKFILE_NAME + if not lockfile.is_file(): + raise SystemExit(f"{lockfile} is missing; add {profile!r} to {LOCKFILE_NAME} before publishing") + try: + lock = json.loads(lockfile.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise SystemExit(f"{lockfile} is not valid JSON: {exc}") from exc + if not isinstance(lock, dict) or profile not in lock: + raise SystemExit(f"{profile!r} is not in {lockfile}; add it before publishing") + entry = lock[profile] + if not isinstance(entry, dict) or not entry.get("repository"): + raise SystemExit(f"{profile!r} in {lockfile} has no repository") + return str(entry["repository"]) + + def ignore_debris(_directory: str, names: list[str]) -> set[str]: # publishable() gates the top level; this gates everything nested inside a # package directory, which copytree would otherwise take wholesale. That is @@ -155,9 +174,13 @@ def copy_component(source: Path, destination: Path) -> None: def render_pydevices_manifest(name: str, version: str, requirements: tuple[str, ...], payloads: tuple[str, ...] = ()) -> str: + try: + description = PYDEVICES_DESCRIPTIONS[name] + except KeyError: + raise SystemExit(f"no shared description for {name!r}") from None lines = [ "metadata(", - f' description="PyDevices {name}",', + f" description={description!r},", f' version="{version}",', ' author="Brad Barnett",', ' license="MIT",', @@ -223,15 +246,14 @@ def main() -> None: parser.add_argument("--version", required=True) args = parser.parse_args() - expected_repository = PROFILE_REPOSITORIES[args.profile] + source_repository = args.source_repository.resolve() + mip_root = args.mip_repository.resolve() + expected_repository = lockfile_repository(mip_root, args.profile) if args.source_repository_name != expected_repository: raise SystemExit( f"profile {args.profile!r} requires {expected_repository}, " f"not {args.source_repository_name}" ) - - source_repository = args.source_repository.resolve() - mip_root = args.mip_repository.resolve() if args.profile == "pydevices": synchronize_pydevices(source_repository, mip_root, args.version) return diff --git a/tests/test_synchronize_mip_package.py b/tests/test_synchronize_mip_package.py new file mode 100644 index 0000000..369d5e1 --- /dev/null +++ b/tests/test_synchronize_mip_package.py @@ -0,0 +1,345 @@ +"""Tests for scripts/synchronize_mip_package.py.""" + +from __future__ import annotations + +import importlib +import json +import os +import subprocess +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SCRIPTS = REPO / "scripts" +SYNC_SCRIPT = SCRIPTS / "synchronize_mip_package.py" +SYNC_WORKFLOW = REPO / ".github/workflows/reusable-synchronize-mip-package.yml" + + +def _load_module(name: str): + sys.path.insert(0, str(SCRIPTS)) + return importlib.import_module(name) + + +sync = _load_module("synchronize_mip_package") +build = _load_module("build_pydevices_python_distributions") +metadata = _load_module("pydevices_package_metadata") + + +def write_lockfile(mip: Path, mapping: dict[str, str]) -> None: + mip.mkdir(parents=True, exist_ok=True) + payload = { + profile: {"repository": repository, "ref": "v0.2.0"} + for profile, repository in mapping.items() + } + (mip / "pydevices-lock.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def write_lib_package(source: Path, package: str) -> None: + pkg = source / "lib" / package + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text(f"# {package}\n", encoding="utf-8") + + +def write_pydevices_source(source: Path) -> None: + write_lib_package(source, "displaydev") + utils = source / "utils" + utils.mkdir(parents=True) + (utils / "host.py").write_text("# host\n", encoding="utf-8") + desktop = source / "board_configs" / "desktop" + desktop.mkdir(parents=True) + (desktop / "board.py").write_text("# board\n", encoding="utf-8") + + +def run_sync( + source: Path, + mip: Path, + *, + source_name: str, + profile: str, + version: str = "0.2.0", +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SYNC_SCRIPT), + "--source-repository", + str(source), + "--source-repository-name", + source_name, + "--mip-repository", + str(mip), + "--profile", + profile, + "--version", + version, + ], + capture_output=True, + text=True, + check=False, + ) + + +class LockfileRepositoryTests(unittest.TestCase): + def test_audiocomponents_dispatch_matches_lockfile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + mip = root / "mip" + write_lib_package(source, "audioinstruments") + write_lockfile(mip, {"audioinstruments": "PyDevices/audiocomponents"}) + result = run_sync( + source, + mip, + source_name="PyDevices/audiocomponents", + profile="audioinstruments", + ) + self.assertEqual(result.returncode, 0, result.stderr) + manifest = (mip / "micropython" / "audioinstruments" / "manifest.py").read_text( + encoding="utf-8" + ) + self.assertIn('package("audioinstruments")', manifest) + self.assertTrue( + (mip / "micropython" / "audioinstruments" / "audioinstruments" / "__init__.py").is_file() + ) + + def test_audioeffects_dispatch_matches_lockfile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + mip = root / "mip" + write_lib_package(source, "audioeffects") + write_lockfile(mip, {"audioeffects": "PyDevices/audiocomponents"}) + result = run_sync( + source, + mip, + source_name="PyDevices/audiocomponents", + profile="audioeffects", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((mip / "micropython" / "audioeffects" / "manifest.py").is_file()) + + def test_script_in_isolation_rejects_source_repository_name_that_disagrees_with_lockfile(self) -> None: + # Defence in depth only. The publication workflow never presents this + # mismatch: Record this release refuses a repository move before the + # sync loop runs synchronize_mip_package.py. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + mip = root / "mip" + write_lib_package(source, "audioinstruments") + write_lockfile(mip, {"audioinstruments": "PyDevices/audiocomponents"}) + result = run_sync( + source, + mip, + source_name="PyDevices/audioif", + profile="audioinstruments", + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn( + "profile 'audioinstruments' requires PyDevices/audiocomponents, not PyDevices/audioif", + result.stderr, + ) + + def test_profile_missing_from_lockfile_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + mip = root / "mip" + write_lib_package(source, "audioinstruments") + write_lockfile(mip, {"palettes": "PyDevices/palettes"}) + result = run_sync( + source, + mip, + source_name="PyDevices/audiocomponents", + profile="audioinstruments", + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("is not in", result.stderr) + self.assertIn("add it before publishing", result.stderr) + + def test_missing_lockfile_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + mip = root / "mip" + mip.mkdir() + write_lib_package(source, "audioinstruments") + result = run_sync( + source, + mip, + source_name="PyDevices/audiocomponents", + profile="audioinstruments", + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("pydevices-lock.json", result.stderr) + self.assertIn("audioinstruments", result.stderr) + + def test_no_hardcoded_profile_repositories_map(self) -> None: + self.assertFalse(hasattr(sync, "PROFILE_REPOSITORIES")) + + def test_lockfile_repository_reads_named_entry(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + mip = Path(tmp) + write_lockfile( + mip, + { + "audioinstruments": "PyDevices/audiocomponents", + "pydevices": "PyDevices/pydevices", + }, + ) + self.assertEqual( + sync.lockfile_repository(mip, "audioinstruments"), + "PyDevices/audiocomponents", + ) + + +def record_release_python() -> str: + text = SYNC_WORKFLOW.read_text(encoding="utf-8") + start = text.index("- name: Record this release in the lockfile") + block = text[start:] + begin = block.index("python3 - <<'PY'\n") + len("python3 - <<'PY'\n") + end = block.index("\n PY\n", begin) + return textwrap.dedent(block[begin:end]) + + +def run_record_step( + lockfile: Path, + *, + profile: str, + repository: str, + ref: str, +) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "record_release.py" + script.write_text(record_release_python(), encoding="utf-8") + env = os.environ.copy() + env.update( + { + "LOCKFILE": str(lockfile), + "PUBLICATION_PROFILE": profile, + "SOURCE_REPOSITORY": repository, + "SOURCE_REF": ref, + } + ) + return subprocess.run( + [sys.executable, str(script)], + capture_output=True, + text=True, + env=env, + check=False, + ) + + +class RecordLockfileReleaseTests(unittest.TestCase): + def test_matching_repository_updates_ref_only(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + mip = Path(tmp) + write_lockfile(mip, {"audioinstruments": "PyDevices/audiocomponents"}) + lockfile = mip / "pydevices-lock.json" + before = json.loads(lockfile.read_text(encoding="utf-8")) + result = run_record_step( + lockfile, + profile="audioinstruments", + repository="PyDevices/audiocomponents", + ref="v0.3.0", + ) + self.assertEqual(result.returncode, 0, result.stderr) + after = json.loads(lockfile.read_text(encoding="utf-8")) + self.assertEqual(after["audioinstruments"]["repository"], "PyDevices/audiocomponents") + self.assertEqual(after["audioinstruments"]["ref"], "v0.3.0") + self.assertEqual(before["audioinstruments"]["repository"], after["audioinstruments"]["repository"]) + + def test_mismatched_repository_fails_without_writing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + mip = Path(tmp) + write_lockfile(mip, {"audioinstruments": "PyDevices/audiocomponents"}) + lockfile = mip / "pydevices-lock.json" + before = lockfile.read_text(encoding="utf-8") + result = run_record_step( + lockfile, + profile="audioinstruments", + repository="PyDevices/audioif", + ref="v0.3.0", + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("PyDevices/audiocomponents", result.stderr) + self.assertIn("PyDevices/audioif", result.stderr) + self.assertIn("pydevices-lock.json", result.stderr) + self.assertIn("PyDevices branch", result.stderr) + self.assertEqual(lockfile.read_text(encoding="utf-8"), before) + + +class SharedDescriptionTests(unittest.TestCase): + def test_pydevices_manifest_uses_shared_description(self) -> None: + text = sync.render_pydevices_manifest("pydevices", "1.2.3", ()) + description = metadata.PYDEVICES_DESCRIPTIONS["pydevices"] + self.assertIn(description, text) + self.assertNotIn("PyDevices pydevices", text) + + def test_pydevices_desktop_manifest_uses_shared_description(self) -> None: + text = sync.render_pydevices_manifest("pydevices-desktop", "1.2.3", ("pydevices",)) + description = metadata.PYDEVICES_DESCRIPTIONS["pydevices-desktop"] + self.assertIn(description, text) + self.assertNotIn("PyDevices pydevices-desktop", text) + self.assertIn('require("pydevices")', text) + + def test_builder_uses_the_same_descriptions(self) -> None: + self.assertEqual(build.PYDEVICES_DESCRIPTIONS, metadata.PYDEVICES_DESCRIPTIONS) + builder = (SCRIPTS / "build_pydevices_python_distributions.py").read_text(encoding="utf-8") + self.assertIn('PYDEVICES_DESCRIPTIONS["pydevices"]', builder) + self.assertIn('PYDEVICES_DESCRIPTIONS["pydevices-desktop"]', builder) + pydevices = build.project_text( + "pydevices", + "1.2.3", + metadata.PYDEVICES_DESCRIPTIONS["pydevices"], + [], + Path("."), + ) + desktop = build.project_text( + "pydevices-desktop", + "1.2.3", + metadata.PYDEVICES_DESCRIPTIONS["pydevices-desktop"], + ["pydevices==1.2.3"], + Path("."), + ) + self.assertIn( + 'description = "Portable display, audio, event, and timing foundations for PyDevices"', + pydevices, + ) + self.assertIn( + 'description = "Complete PyDevices desktop stack and board configuration"', + desktop, + ) + + def test_pydevices_profile_writes_shared_descriptions(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + mip = root / "mip" + write_pydevices_source(source) + write_lockfile(mip, {"pydevices": "PyDevices/pydevices"}) + result = run_sync( + source, + mip, + source_name="PyDevices/pydevices", + profile="pydevices", + version="1.2.3", + ) + self.assertEqual(result.returncode, 0, result.stderr) + pydevices = ( + mip / "micropython" / "pydevices" / "pydevices" / "manifest.py" + ).read_text(encoding="utf-8") + desktop = ( + mip / "micropython" / "pydevices" / "pydevices-desktop" / "manifest.py" + ).read_text(encoding="utf-8") + self.assertIn(metadata.PYDEVICES_DESCRIPTIONS["pydevices"], pydevices) + self.assertIn(metadata.PYDEVICES_DESCRIPTIONS["pydevices-desktop"], desktop) + self.assertNotIn("PyDevices pydevices", pydevices) + self.assertNotIn("PyDevices pydevices-desktop", desktop) + + +if __name__ == "__main__": + unittest.main()