diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d0fc85..c9fe783 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,11 +130,92 @@ jobs: if-no-files-found: error retention-days: 7 + # The pip/uv install path. The wheel is pure Python — the frontend and the + # backend ride along as package data — so it is built once rather than per + # platform, and it deliberately runs on dry runs too, for the same reason the + # matrix does: a tag should never be the first execution of this path. + wheel: + name: Build the Python distribution + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build twine + python -m build + shell: bash + + # PyPI refuses a version it has already seen, and a filename is never + # reusable even after a delete. A tag that disagrees with VERSION would + # therefore burn the version rather than fail, so it is caught up front. + - name: Check the tag matches VERSION + if: github.event_name == 'push' || inputs.publish + run: | + tag="${{ inputs.tag || github.ref_name }}" + version=$(tr -d '[:space:]' < VERSION) + if [ "$tag" != "v$version" ]; then + echo "Tag '$tag' disagrees with VERSION '$version' (expected 'v$version')." >&2 + echo "Run scripts/bump_version.py so the two cannot drift." >&2 + exit 1 + fi + shell: bash + + # PyPI renders the README and rejects markup it cannot parse. Without this + # the first sign of trouble is a failed upload, after the release is cut. + - name: Check the metadata + run: twine check dist/* + shell: bash + + # A broken force-include in pyproject.toml yields a wheel that builds, + # installs and runs — serving nothing. See the script's docstring. + - name: Check the wheel carries the app + run: python scripts/check_wheel.py dist/*.whl + shell: bash + + # Installed from the built wheel rather than the checkout, so this + # exercises what users actually get: console script, package data, the + # frontend served off the installed tree. + - name: Smoke test the installed wheel + run: | + python -m venv /tmp/wheeltest + /tmp/wheeltest/bin/pip install --quiet dist/*.whl + AMICOSCRIPT_NO_BROWSER=1 /tmp/wheeltest/bin/amicoscript & + pid=$! + for _ in $(seq 1 90); do + if curl -sf -o /dev/null http://127.0.0.1:8002/; then break; fi + sleep 1 + done + status=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8002/) + kill $pid 2>/dev/null || true + if [ "$status" != "200" ]; then + echo "Installed wheel did not serve the frontend (HTTP $status)." >&2 + exit 1 + fi + echo "Installed wheel served the frontend." + shell: bash + + - name: Hand the distribution to the publish job + uses: actions/upload-artifact@v4 + with: + name: python-dist + path: dist/* + if-no-files-found: error + retention-days: 7 + release: name: Publish release - needs: build + needs: [build, wheel] runs-on: ubuntu-latest - # Every platform must have built. A release missing an artifact is the + # Every platform must have built, and so must the wheel — otherwise the + # release publishes, `pypi` skips on the failed dependency, and the version + # exists on GitHub but not on PyPI. A release missing an artifact is the # failure this job exists to prevent, so a partial one is not published. if: github.event_name == 'push' || inputs.publish steps: @@ -176,3 +257,25 @@ jobs: artifacts: artifacts/*.zip allowUpdates: true skipIfReleaseExists: false + + # Last, and needing `release`, on purpose: a PyPI upload cannot be undone — + # the version is spent even if the files are deleted — so nothing goes out + # until the GitHub release it accompanies exists. + pypi: + name: Publish to PyPI + needs: [wheel, release] + runs-on: ubuntu-latest + permissions: + # Trusted publishing: PyPI verifies this workflow's OIDC identity, so + # there is no API token to store or rotate. One-time setup on the PyPI + # side is documented in docs/pypi-release.md. + id-token: write + steps: + - name: Collect the distribution + uses: actions/download-artifact@v4 + with: + name: python-dist + path: dist + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index 6f14f86..8c67f32 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,42 @@ UI's Help modal also has a one-click "Copy command" for this. pytest -q ``` +## 📦 Install + +One command, identical on macOS, Windows and Linux: + +```bash +uv tool install amicoscript +amicoscript +``` + +`uv` fetches a suitable Python itself, so nothing needs to be installed first. +([Don't have uv?](https://docs.astral.sh/uv/getting-started/installation/) — +`pipx install amicoscript` works the same way.) To run it once without +installing: `uvx amicoscript`. + +This is the recommended route, and not only for convenience: nothing here is a +downloaded application, so neither Gatekeeper nor SmartScreen is involved. The +zips below are unsigned and both will object to them. + +Speaker diarization is an optional extra, since transcription never needs +torch: + +```bash +uv tool install "amicoscript[diarization]" +``` + +On **Linux**, that pulls PyPI's default torch, which is the CUDA build and +several GB of `nvidia-*` packages with it. For a CPU-only machine, name the CPU +index — torch comes from there, everything else still comes from PyPI: + +```bash +uv tool install "amicoscript[diarization]" \ + --index https://download.pytorch.org/whl/cpu +``` + +Upgrade with `uv tool upgrade amicoscript`. + ## 🏃🏼 Running from the installer In the [releases](https://github.com/sim186/AmicoScript/releases) page you can download the application for Windows or Mac (Linux is coming). Be careful that the .exe (or. the dmg) might be recognized as suspicious by the OS. @@ -208,6 +244,10 @@ work offline. ### macOS: Running unsigned apps (Not disabling Gatekeeper) +This applies to the downloaded `.app` only. `uv tool install amicoscript` +(above) skips all of it — Gatekeeper gates downloaded applications, and a wheel +installed into your own environment is not one. + 1. Download the latest release from the Releases page. 2. Because the app is not signed by Apple, macOS will initially block it. Open System Settings → Privacy & Security and enable "App Store and identified developers" (allow apps downloaded from App Store and identified developers). 3. Unzip the downloaded file. Double-click the application file (`AmicoScript.app`). macOS will prevent it from opening because it's from an unidentified developer. diff --git a/amicoscript/__init__.py b/amicoscript/__init__.py new file mode 100644 index 0000000..aafbc33 --- /dev/null +++ b/amicoscript/__init__.py @@ -0,0 +1,38 @@ +"""Distribution shim for the `amicoscript` wheel. + +Only this file and `cli.py` live in the repo. The wheel is assembled by +hatchling (see `pyproject.toml`), which copies `backend/`, `frontend/`, +`scripts/meeting_watcher/` and `run.py` in beside them so the installed package +has the same shape the repo root does. That is the whole trick: `run.py` and +`backend/main.py` already locate their siblings relative to `__file__`, so +neither needs a packaging-specific code path. +""" +from __future__ import annotations + +__all__ = ["__version__", "main"] + + +def _detect_version() -> str: + from importlib.metadata import PackageNotFoundError, version + + try: + return version("amicoscript") + except PackageNotFoundError: + # Running from a source checkout that was never installed. + from pathlib import Path + + try: + return (Path(__file__).resolve().parents[1] / "VERSION").read_text( + encoding="utf-8" + ).strip() + except OSError: + return "0.0.0" + + +__version__ = _detect_version() + + +def main() -> int: + from .cli import main as _main + + return _main() diff --git a/amicoscript/cli.py b/amicoscript/cli.py new file mode 100644 index 0000000..6d56387 --- /dev/null +++ b/amicoscript/cli.py @@ -0,0 +1,54 @@ +"""The `amicoscript` console script. + +`run.py` is the launcher for every other distribution channel too (`python +run.py`, the PyInstaller bundle), so this module does not reimplement it — it +locates the file and calls its `main()`. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent + + +def _launcher_path() -> Path: + """Find run.py, whether we are installed or in a checkout.""" + # Installed wheel: hatchling put run.py here as _run.py, next to backend/. + installed = _HERE / "_run.py" + if installed.is_file(): + return installed + # Source checkout (including `pip install -e .`): the shim package sits at + # /amicoscript/, so run.py is one level up. + source = _HERE.parent / "run.py" + if source.is_file(): + return source + raise ModuleNotFoundError( + "Could not locate the AmicoScript launcher (run.py). The installation " + "looks incomplete — try reinstalling the amicoscript package." + ) + + +def _load_launcher(): + path = _launcher_path() + # Loaded by path rather than imported by name so that `__file__` stays the + # real location: run.py derives BASE_DIR from it, and everything the app + # serves — backend modules, the frontend, meeting_watcher — hangs off that. + spec = importlib.util.spec_from_file_location("amicoscript._run", path) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load the AmicoScript launcher from {path}") + module = importlib.util.module_from_spec(spec) + # Registered before exec_module so a re-entrant import gets the same module + # rather than running run.py's import-time side effects a second time. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def main() -> int: + return _load_launcher().main() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/desktop-shell.md b/docs/desktop-shell.md index fe1cc35..107e6df 100644 --- a/docs/desktop-shell.md +++ b/docs/desktop-shell.md @@ -77,7 +77,8 @@ bundle ships in browser-fallback mode rather than failing. embedded meeting watcher, which previously survived closing a browser tab. Users who want the old behaviour can set `AMICOSCRIPT_UI=browser`. Phase 2 should make the window close to the tray instead. -- **No auto-update.** Releases are still hand-downloaded zips. +- **No auto-update for the zips.** They are still hand-downloaded. The wheel + has `uv tool upgrade amicoscript`; a bundled updater is Tauri's job. ### Offline assets @@ -98,7 +99,14 @@ and supervises. Both build chains survive; Tauri is added, not swapped in. What it buys over Phase 1: -- Signed, real installers: `.dmg`, `.msi`/`.nsis`, `.deb`/`.AppImage` +- Real installers: `.dmg`, `.msi`/`.nsis`, `.deb`/`.AppImage`, with hooks that + apply a signature — **not** the certificates themselves. Tauri does not solve + the "unidentified developer" problem: an unsigned `.dmg` is blocked exactly + like the current unsigned `.app`, and under the sidecar design the PyInstaller + binary ends up inside the bundle and needs notarizing either way. Signing is + an independent, paid track (Apple Developer Program; a Windows certificate in + a cloud HSM). The route that avoids it entirely is the wheel — + see [pypi-release.md](pypi-release.md) - Built-in updater (signed `latest.json`), replacing manual zip downloads - Native tray that outlives the window, single-instance guard, deep links - A working Linux window via an apt-declared WebKitGTK dependency diff --git a/docs/pypi-release.md b/docs/pypi-release.md new file mode 100644 index 0000000..a1e4357 --- /dev/null +++ b/docs/pypi-release.md @@ -0,0 +1,130 @@ +# Publishing to PyPI + +The `amicoscript` wheel is what makes `uv tool install amicoscript` work. It is +built and published by `.github/workflows/release.yml` alongside the platform +zips — same tag, same run. + +## Why there is a wheel at all + +The release zips are PyInstaller bundles, and an unsigned bundle downloaded from +GitHub is what Gatekeeper and SmartScreen exist to stop. A wheel is not a +downloaded application: pip and uv install it into an environment the user +already trusts, so neither gate applies. It also happens to be the smallest +artifact by a wide margin, because the heavy dependencies were already deferred +(see below). + +## What the wheel contains + +`pyproject.toml` assembles it with hatchling `force-include` mappings rather +than package discovery, because the repo is not laid out as a Python package — +`backend/` is a directory of flat modules that `run.py` puts on `sys.path`. +The mappings reproduce the repo's shape inside the package: + +| Repo | Wheel | +|------|-------| +| `run.py` | `amicoscript/_run.py` | +| `backend/` | `amicoscript/backend/` | +| `frontend/` | `amicoscript/frontend/` | +| `scripts/meeting_watcher/` | `amicoscript/scripts/meeting_watcher/` | +| `VERSION` | `amicoscript/backend/VERSION` | + +That mirroring is the entire trick. `run.py` derives `BASE_DIR` from +`__file__`, and `backend/main.py` finds the frontend at `BASE_DIR.parent / +"frontend"`; both resolve correctly once the layout matches, so neither file +needs a packaging-specific branch. `amicoscript/cli.py` is the only new code — +it locates `run.py` and calls its `main()`. + +The rest of `scripts/` is deliberately excluded. `backend/main.py` mounts +`SCRIPTS_DIR` as public static files, and the build tooling has no business +being served over HTTP. + +### The failure mode this creates + +A broken mapping produces a wheel that builds, installs, and starts — serving +nothing, because the frontend is not there. Nothing else in the pipeline +notices, so `scripts/check_wheel.py` asserts the payload is present and the +workflow additionally installs the wheel and checks it serves. Both run on dry +runs, not only on tags. + +## Dependencies + +Base install is transcription only, matching `backend/requirements.txt`: +faster-whisper goes through CTranslate2 and never imports torch. + +Diarization is the `diarization` extra. The packaged zips download torch at +first use via `backend/runtime_pack.py`, because a PyInstaller bundle has no +interpreter to install into; a pip install does, so the extra is a plain +dependency and `runtime_pack` simply finds no manifest and no-ops. + +One sharp edge: on Linux, `pip install amicoscript[diarization]` resolves +torch from PyPI, which is the CUDA build and drags in several GB of `nvidia-*` +packages. `backend/requirements-diarization.txt` avoids this by naming the CPU +index, but an extra cannot carry an index URL — PEP 621 has no field for it. +The README documents the workaround: `--index https://download.pytorch.org/whl/cpu` +on the install command. uv's default `first-index` strategy then takes torch +from the CPU index and everything else from PyPI, which does not carry it. + +Note that `--torch-backend cpu`, which solves this more directly, is available +on `uv pip install` but *not* on `uv tool install` as of uv 0.8. + +## One-time PyPI setup + +The `pypi` job uses **trusted publishing**, so there is no API token in the +repo secrets. It needs a publisher configured once on the PyPI side: + +1. Register the `amicoscript` name (the first upload can be done by hand, or + create a [pending publisher] before the project exists). +2. Go to *Manage project → Publishing → Add a new publisher*, GitHub tab. +3. Fill in: + - Owner: `sim186` + - Repository: `AmicoScript` + - Workflow name: `release.yml` + - Environment: leave blank — the workflow does not declare one. If you add a + GitHub environment later for approval gates, set `environment:` on the + `pypi` job and this field to the same name, or the upload will be rejected. + +[pending publisher]: https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/ + +## Cutting a release + +Unchanged: `python scripts/bump_version.py` then push the tag. `VERSION` is the +single source of the version — hatchling reads it, so there is no second place +to update. The workflow refuses to publish if the tag and `VERSION` disagree, +because a PyPI version number is spent on first upload and cannot be reused +even after the files are deleted. + +A `workflow_dispatch` run with `publish` off builds and checks the wheel +without uploading anything, the same way it dry-runs the platform bundles. + +## Blocker: no tag can publish until diarization resolves + +This is not a wheel problem — the `wheel` job passes — but it stops the whole +workflow, because `release` needs `build` and `pypi` needs `release`. + +`pyannote.audio` 4.0 requires `torch>=2.8`. The cu121 index has nothing newer +than the 2.6 line and never will, so `generate_runtime_manifest.py` fails with +`ResolutionImpossible` on Linux and Windows before PyInstaller ever runs. macOS +is unaffected: it resolves no CUDA flavour. + +What has been ruled out, so nobody repeats it: + +- **Capping `pyannote.audio<4` in the cu121 file.** Does not fix it. Same + `Cannot install pyannote.audio` failure. +- **Capping it in `requirements-diarization.txt` too.** Makes it worse — breaks + the CPU resolve, which is otherwise green, and takes macOS down with it. +- **Adding a matching `torch<2.7` ceiling to the CPU file.** No effect. +- **The omegaconf metadata warnings in the log.** Noise. Only 2.1.0 is invalid; + pip skips it and picks 2.3.1 fine. + +None of it reproduces outside CI. With pip 26.2.1 and the exact 44 bundled pins +the build resolves against, `pyannote.audio>=3.3.2,<4` plus `torch>=2.3.0,<2.7.0` +resolves cleanly on PyPI to pyannote 3.4.0, omegaconf 2.3.1, torch 2.6.0 and +torchaudio 2.6.0. The untested variable is the PyTorch index as the *primary* +index (`--index-url`), which is what both diarization files use and what a +sandbox without `download.pytorch.org` access cannot exercise. Diagnosing this +needs a machine that can reach that index. + +The likely real fix is forward, not backward: move the CUDA flavour off cu121 +to cu126/cu128, let both runtimes take pyannote 4.x — the CPU one already +does — and update `backend/core/diarization.py` for the 4.x API. Note that CPU +and CUDA machines are on different pyannote majors until then. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..57a8584 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,133 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "amicoscript" +dynamic = ["version"] +description = "Local-first transcription and speaker diarization with a desktop UI" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Simone Celestino" }] +keywords = ["transcription", "whisper", "diarization", "speech-to-text", "local-first"] + +# Floor is 3.10 (the README's stated support). The ceiling is not caution about +# our own code — it is faster-whisper's CTranslate2 wheels, which land for a new +# CPython well after it ships. Without it `uv tool install` would happily pick +# an interpreter that has no wheel and fail at the compile step. +requires-python = ">=3.10,<3.14" + +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Intended Audience :: End Users/Desktop", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Sound/Audio :: Speech", +] + +# Kept in step with backend/requirements.txt, which stays the source of truth +# for Docker and development. Diarization is deliberately absent here for the +# same reason it is absent there: transcription never imports torch. +dependencies = [ + "fastapi>=0.111.0", + "uvicorn[standard]>=0.29.0", + "python-multipart>=0.0.9", + "faster-whisper>=1.0.3", + "sse-starlette>=2.1.0", + "aiofiles>=23.2.1", + "pydantic>=2.7.1", + "requests>=2.31.0", + "sqlmodel>=0.0.18", + "huggingface_hub>=0.23.0,<1.0", + "yt-dlp>=2024.12.13", + # The native window. Markers match requirements-pyinstaller.txt: on Linux + # the webview backend is WebKitGTK via PyGObject, which pip cannot install, + # so Linux falls back to a browser tab. See docs/desktop-shell.md. + 'pywebview>=5.3; sys_platform != "linux"', + 'pythonnet>=3.0.3; sys_platform == "win32"', +] + +[project.optional-dependencies] +# The packaged .zip builds download torch on first use (backend/runtime_pack.py) +# because a PyInstaller bundle has no interpreter to install into. A pip install +# does, so here diarization is a plain extra and runtime_pack simply no-ops. +# +# NOTE: on Linux this resolves to PyPI's default torch, which is the CUDA build +# and pulls in several GB of nvidia-* packages. backend/requirements-diarization.txt +# points at the CPU index to avoid that; an extra cannot carry an index URL, so +# CPU-only Linux users should install torch from that index first. README covers it. +# +# Ranges mirror backend/requirements-diarization.txt exactly, including its +# open pyannote range, so a pip install lands where the packaged CPU runtime +# does. Both currently resolve to pyannote 4.x. If those pins gain a ceiling, +# this needs the same one. +diarization = [ + "pyannote.audio>=3.3.2", + "torch>=2.3.0", + "torchaudio>=2.3.0", +] + +# The embedded meeting watcher: Windows-only, WASAPI loopback capture plus a +# tray icon. Mirrors scripts/meeting_watcher/requirements.txt. +watcher = [ + 'pyaudiowpatch>=0.2.12; sys_platform == "win32"', + 'pycaw>=20240210; sys_platform == "win32"', + 'comtypes>=1.2.0; sys_platform == "win32"', + 'numpy>=1.24.0; sys_platform == "win32"', + 'winotify>=1.1.0; sys_platform == "win32"', + 'pystray>=0.19.4; sys_platform == "win32"', + 'pillow>=10.0.0; sys_platform == "win32"', +] + +[project.urls] +Homepage = "https://github.com/sim186/AmicoScript" +Repository = "https://github.com/sim186/AmicoScript" +Changelog = "https://github.com/sim186/AmicoScript/blob/main/CHANGELOG.md" +Issues = "https://github.com/sim186/AmicoScript/issues" + +[project.scripts] +amicoscript = "amicoscript.cli:main" + +# One source of truth for the version: VERSION, which scripts/bump_version.py +# already owns and the release tag is cut from. +[tool.hatch.version] +path = "VERSION" +pattern = "^(?P[^\\s]+)" + +# The wheel reproduces the repo's layout inside the package directory. run.py +# and backend/main.py both resolve their siblings from `__file__`, so mirroring +# the shape is what lets them run installed with no packaging-specific branches. +[tool.hatch.build.targets.wheel] +packages = ["amicoscript"] + +[tool.hatch.build.targets.wheel.force-include] +"backend" = "amicoscript/backend" +"frontend" = "amicoscript/frontend" +# Only the watcher: the frontend links /scripts/meeting_watcher/setup.bat for +# download, and meeting_watcher_host.start() runs it. The rest of scripts/ is +# build tooling that has no business being served over HTTP. +"scripts/meeting_watcher" = "amicoscript/scripts/meeting_watcher" +"run.py" = "amicoscript/_run.py" +# run_windowed() reads BASE_DIR/"VERSION" for the window title, and BASE_DIR is +# the backend directory. +"VERSION" = "amicoscript/backend/VERSION" + +[tool.hatch.build.targets.sdist] +include = [ + "/amicoscript", + "/backend", + "/frontend", + "/scripts", + "/tests", + "/run.py", + "/package.py", + "/VERSION", + "/README.md", + "/LICENSE", + "/CHANGELOG.md", +] diff --git a/run.py b/run.py index fbebe0d..d535552 100644 --- a/run.py +++ b/run.py @@ -208,7 +208,9 @@ def run_windowed(url: str, host: str, port: int) -> int: return 0 -if __name__ == "__main__": +def main() -> int: + """Start the app. Shared by `python run.py`, the PyInstaller bundle, and the + `amicoscript` console script the wheel installs (see pyproject.toml).""" # Ensure frontend and uploads dirs are found os.chdir(BASE_DIR) @@ -233,10 +235,15 @@ def run_windowed(url: str, host: str, port: int) -> int: ui_mode = "browser" if ui_mode == "window": - sys.exit(run_windowed(url, host, port)) + return run_windowed(url, host, port) if ui_mode == "browser": threading.Thread(target=open_browser, args=(url,), daemon=True).start() server = make_server(host, port) server.run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_wheel.py b/scripts/check_wheel.py new file mode 100644 index 0000000..15496cd --- /dev/null +++ b/scripts/check_wheel.py @@ -0,0 +1,88 @@ +"""Assert a built wheel actually contains the application. + +The wheel is assembled by `force-include` mappings in pyproject.toml rather than +by importable-package discovery, which has one bad failure mode: if a mapping +breaks, the wheel still builds, still installs, and still exposes the console +script — it just serves an empty app at runtime. Nothing before this point +notices, so the checks live here and run on every build, dry runs included. + +Usage: python scripts/check_wheel.py dist/amicoscript-*.whl +""" +from __future__ import annotations + +import re +import sys +import zipfile +from pathlib import Path + +PKG = "amicoscript" + +# Files whose absence means a broken mapping rather than a missing feature. +REQUIRED = [ + f"{PKG}/__init__.py", + f"{PKG}/cli.py", + # run.py, renamed. cli.py resolves the launcher by this name. + f"{PKG}/_run.py", + # The backend is imported flat off sys.path, exactly as in a source checkout. + f"{PKG}/backend/main.py", + f"{PKG}/backend/config.py", + f"{PKG}/backend/runtime_pack.py", + # run_windowed() reads this for the window title. + f"{PKG}/backend/VERSION", + f"{PKG}/frontend/index.html", + # The frontend links this for download; meeting_watcher_host.start() runs it. + f"{PKG}/scripts/meeting_watcher/setup.bat", +] + + +def check(wheel: Path) -> list[str]: + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + index = archive.read(f"{PKG}/frontend/index.html").decode("utf-8") + + problems = [f"missing {name}" for name in REQUIRED if name not in names] + + # The vendored assets are what let the UI render with no network at all, and + # they are the largest force-included tree — the most likely to be dropped. + # tests/test_frontend_assets.py guards the checkout; this guards the wheel. + for ref in sorted(set(re.findall(r"vendor/[A-Za-z0-9._/-]+", index))): + ref = ref.rstrip(".") + if f"{PKG}/frontend/{ref}" not in names: + problems.append(f"index.html references {ref}, which is not in the wheel") + + # A wheel built from a dirty checkout can otherwise ship stale bytecode. + if any("__pycache__" in n or n.endswith(".pyc") for n in names): + problems.append("wheel contains __pycache__/.pyc entries") + + # The backend is a package tree, not a handful of modules; a mapping that + # silently flattened would still satisfy the checks above. + routes = sum(1 for n in names if n.startswith(f"{PKG}/backend/api/routes/")) + if routes < 5: + problems.append(f"only {routes} backend API route modules in the wheel") + + return problems + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(__doc__.strip().splitlines()[-1], file=sys.stderr) + return 2 + + wheel = Path(argv[1]) + if not wheel.is_file(): + print(f"No such wheel: {wheel}", file=sys.stderr) + return 2 + + problems = check(wheel) + if problems: + print(f"{wheel.name} is incomplete:", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + return 1 + + print(f"{wheel.name}: contents OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv))