From 4bf7d461c3193dafbcfa715d96870563734f5719 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 20:41:25 +0000 Subject: [PATCH 1/5] Give the app an install command that no OS gate stands in front of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release zips are unsigned PyInstaller bundles, so Gatekeeper and SmartScreen both stop them and the README has to teach a four-step dance around System Settings. Signing is a separate paid track, and Tauri would not have removed the need for it — under the sidecar design the PyInstaller binary ends up inside the bundle and needs notarizing either way. A wheel sidesteps the whole category: pip and uv install into an environment the user already trusts, so nothing is a downloaded application and no gate applies. `uv tool install amicoscript` is now the recommended route, identical on all three platforms, and it fits because the heavy dependencies were already deferred — transcription goes through CTranslate2 and never imports torch. The repo is not laid out as a Python package: backend/ is a directory of flat modules that run.py puts on sys.path. Rather than restructure it, hatchling force-include mappings reproduce the repo's shape inside the package, so run.py's BASE_DIR and main.py's FRONTEND_DIR resolve correctly with no packaging-specific branches. cli.py is the only new runtime code, and run.py grows a main() that the console script, `python run.py` and PyInstaller share. That approach has one bad failure mode: a broken mapping yields a wheel that builds, installs and starts, serving nothing. scripts/check_wheel.py asserts the payload is present — including every vendored asset index.html references — and the workflow additionally installs the wheel and checks it serves. Both run on dry runs, so a tag is never the first execution of this path. Publishing uses PyPI trusted publishing, so there is no token to store. It is gated behind the GitHub release because a PyPI version is spent on first upload and cannot be reused, and the tag is checked against VERSION for the same reason. release now also needs wheel, so a failed wheel cannot leave a version published on GitHub but absent from PyPI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017JidoPdgd5BDroVFcEdF52 --- .github/workflows/release.yml | 107 +++++++++++++++++++++++++++- README.md | 40 +++++++++++ amicoscript/__init__.py | 38 ++++++++++ amicoscript/cli.py | 54 ++++++++++++++ docs/desktop-shell.md | 12 +++- docs/pypi-release.md | 97 ++++++++++++++++++++++++++ pyproject.toml | 128 ++++++++++++++++++++++++++++++++++ run.py | 11 ++- scripts/check_wheel.py | 88 +++++++++++++++++++++++ 9 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 amicoscript/__init__.py create mode 100644 amicoscript/cli.py create mode 100644 docs/pypi-release.md create mode 100644 pyproject.toml create mode 100644 scripts/check_wheel.py 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..0d23208 --- /dev/null +++ b/docs/pypi-release.md @@ -0,0 +1,97 @@ +# 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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..101935c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,128 @@ +[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. +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)) From 6731f250e2b60697ee8da261c2585c446c61e9e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 06:18:55 +0000 Subject: [PATCH 2/5] Hold diarization on pyannote 3 so a release can be cut again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyannote.audio 4.0 landed after v1.16.0 and requires torch>=2.8. The cu121 runtime pins torch<2.7 because the cu121 index has nothing newer and never will, so with the range left open pip resolves pyannote to 4.x and then fails: ResolutionImpossible. generate_runtime_manifest.py runs before PyInstaller, so this took out the Linux and Windows builds entirely — macOS survived only because it resolves no CUDA variant. The CPU runtime is the less obvious half. It has no torch ceiling, so it kept resolving green while silently moving to pyannote 4 — a major the backend was not written against, and a different major from the one GPU machines would get. A green build was hiding that, which is why the cap goes in both files rather than only in the one that failed. Capping at <4 restores what v1.16.0 shipped and resolves to pyannote 3.4.0 with torch 2.6.0. The wheel's diarization extra gets the same cap, so a pip install does not land somewhere else again. Moving to pyannote 4 is a real upgrade — torch 2.8+, a CUDA index newer than cu121, and backend/core/diarization.py updated for the 4.x API. Deliberate work, not a range left open. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017JidoPdgd5BDroVFcEdF52 --- backend/requirements-diarization-cu121.txt | 12 +++++++++++- backend/requirements-diarization.txt | 7 ++++++- pyproject.toml | 5 ++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/requirements-diarization-cu121.txt b/backend/requirements-diarization-cu121.txt index 0580873..19bd95c 100644 --- a/backend/requirements-diarization-cu121.txt +++ b/backend/requirements-diarization-cu121.txt @@ -9,7 +9,17 @@ # exists, hence the markers. --extra-index-url https://download.pytorch.org/whl/cu121 -pyannote.audio>=3.3.2 +# Capped below 4.0 deliberately. pyannote.audio 4 requires torch>=2.8, which +# does not exist on the cu121 index and cannot, so an uncapped range resolves +# to 4.x and then fails outright — ResolutionImpossible against the torch pin +# below. Keeping it here rather than only in the CPU file also keeps the two +# runtimes on the same pyannote major, so diarization does not change +# behaviour depending on whether the machine has a GPU. +# +# Moving to pyannote 4 means torch 2.8+, a newer CUDA index than cu121, and +# updating backend/core/diarization.py for the 4.x API. That is a deliberate +# upgrade, not something to absorb by leaving the range open. +pyannote.audio>=3.3.2,<4 torch>=2.3.0,<2.7.0 torchaudio>=2.3.0,<2.7.0 diff --git a/backend/requirements-diarization.txt b/backend/requirements-diarization.txt index f4b7529..b7fa889 100644 --- a/backend/requirements-diarization.txt +++ b/backend/requirements-diarization.txt @@ -16,6 +16,11 @@ --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple -pyannote.audio>=3.3.2 +# Capped below 4.0 to match requirements-diarization-cu121.txt, which has no +# choice — see the reasoning there. Without the cap here the CPU runtime still +# resolves, silently, to pyannote 4.x: the build stays green while CPU and GPU +# machines run different pyannote majors, and the CPU one runs a major the +# backend was not written against. +pyannote.audio>=3.3.2,<4 torch>=2.3.0 torchaudio>=2.3.0 diff --git a/pyproject.toml b/pyproject.toml index 101935c..0e60221 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,8 +61,11 @@ dependencies = [ # 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. +# +# The pyannote cap matches backend/requirements-diarization.txt — a pip install +# should not land on a different pyannote major than the packaged builds do. diarization = [ - "pyannote.audio>=3.3.2", + "pyannote.audio>=3.3.2,<4", "torch>=2.3.0", "torchaudio>=2.3.0", ] From 9698f888ce8ab086457dfd8c3a24295567bcf11f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 06:40:13 +0000 Subject: [PATCH 3/5] Give the CPU runtime the same torch ceiling as the CUDA one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capping pyannote at <4 in the previous commit fixed the cu121 resolve and broke the CPU one. With torch left open, the resolver pairs pyannote 3.4 with the newest torch on the CPU index and finds no consistent solution for the tree pyannote 3 pulls in — lightning, speechbrain, torchmetrics all move with torch. Bounded to <2.7 it lands on torch 2.6.0 and torchaudio 2.6.0, a matched pair, the same generation the cu121 flavour resolves to. Unbounded it also drifted to torch 2.13 with torchaudio 2.11 — a mismatched pair pip is willing to install. Two runtimes that agree on the pyannote major but not the torch generation were never the parity this was after. The two ceilings move together now. Raising one alone puts the flavours back out of step, which is the failure this pair of commits exists to close. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017JidoPdgd5BDroVFcEdF52 --- backend/requirements-diarization.txt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/requirements-diarization.txt b/backend/requirements-diarization.txt index b7fa889..bec7e4f 100644 --- a/backend/requirements-diarization.txt +++ b/backend/requirements-diarization.txt @@ -21,6 +21,14 @@ # resolves, silently, to pyannote 4.x: the build stays green while CPU and GPU # machines run different pyannote majors, and the CPU one runs a major the # backend was not written against. +# +# The torch ceiling is part of the same cap, not an independent pin. Left open, +# the resolver pairs pyannote 3.4 with the newest torch on the CPU index and +# finds no consistent solution for the tree pyannote 3 pulls in (lightning, +# speechbrain, torchmetrics); bounded, it lands on torch 2.6 the way the cu121 +# flavour does. Both runtimes now agree on the torch generation as well as the +# pyannote major, which is the point of capping at all. It moves with the cu121 +# ceiling — raise them together or not at all. pyannote.audio>=3.3.2,<4 -torch>=2.3.0 -torchaudio>=2.3.0 +torch>=2.3.0,<2.7.0 +torchaudio>=2.3.0,<2.7.0 From e9e0f7b60d3f4e925c451b236dbc5df18dc72a0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 07:02:50 +0000 Subject: [PATCH 4/5] Cap pyannote only where the build proves it is needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capping the CPU runtime alongside the CUDA one broke its resolve, twice, in a way that does not reproduce outside CI: the same requirement set against the same bundled pins resolves cleanly to pyannote 3.4.0 with torch 2.6.0 on PyPI, with and without a torch ceiling. The difference is the CPU index itself, which is not reachable from where this was tested, so the cap goes back off the file CI has actually observed green and stays on the one whose failure it explains. That leaves the two runtimes on different pyannote majors — CPU on 4.x, CUDA on 3.x — which is the state main is already in rather than a regression this adds. The cu121 file carries the gap as a comment so the next person does not rediscover it by capping the CPU file again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017JidoPdgd5BDroVFcEdF52 --- backend/requirements-diarization-cu121.txt | 19 +++++++++++++------ backend/requirements-diarization.txt | 19 +++---------------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/backend/requirements-diarization-cu121.txt b/backend/requirements-diarization-cu121.txt index 19bd95c..a6062cc 100644 --- a/backend/requirements-diarization-cu121.txt +++ b/backend/requirements-diarization-cu121.txt @@ -12,13 +12,20 @@ # Capped below 4.0 deliberately. pyannote.audio 4 requires torch>=2.8, which # does not exist on the cu121 index and cannot, so an uncapped range resolves # to 4.x and then fails outright — ResolutionImpossible against the torch pin -# below. Keeping it here rather than only in the CPU file also keeps the two -# runtimes on the same pyannote major, so diarization does not change -# behaviour depending on whether the machine has a GPU. +# below. # -# Moving to pyannote 4 means torch 2.8+, a newer CUDA index than cu121, and -# updating backend/core/diarization.py for the 4.x API. That is a deliberate -# upgrade, not something to absorb by leaving the range open. +# KNOWN GAP: requirements-diarization.txt is deliberately NOT capped to match. +# Capping it was tried and broke its resolve against the CPU index, for reasons +# that do not reproduce against PyPI with the same pins — the same constraint +# set resolves cleanly to pyannote 3.4.0 with torch 2.6.0 outside CI. Until +# that is understood, the CPU runtime resolves to pyannote 4.x and this one to +# 3.x, so diarization behaviour differs between GPU and CPU machines. Do not +# "fix" that by adding a cap here-style bound to the CPU file without a green +# build to back it up; see docs/pypi-release.md for what has been ruled out. +# +# Moving both to pyannote 4 means torch 2.8+, a newer CUDA index than cu121, +# and updating backend/core/diarization.py for the 4.x API. That is the real +# resolution, and it is a deliberate upgrade rather than a range left open. pyannote.audio>=3.3.2,<4 torch>=2.3.0,<2.7.0 torchaudio>=2.3.0,<2.7.0 diff --git a/backend/requirements-diarization.txt b/backend/requirements-diarization.txt index bec7e4f..f4b7529 100644 --- a/backend/requirements-diarization.txt +++ b/backend/requirements-diarization.txt @@ -16,19 +16,6 @@ --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple -# Capped below 4.0 to match requirements-diarization-cu121.txt, which has no -# choice — see the reasoning there. Without the cap here the CPU runtime still -# resolves, silently, to pyannote 4.x: the build stays green while CPU and GPU -# machines run different pyannote majors, and the CPU one runs a major the -# backend was not written against. -# -# The torch ceiling is part of the same cap, not an independent pin. Left open, -# the resolver pairs pyannote 3.4 with the newest torch on the CPU index and -# finds no consistent solution for the tree pyannote 3 pulls in (lightning, -# speechbrain, torchmetrics); bounded, it lands on torch 2.6 the way the cu121 -# flavour does. Both runtimes now agree on the torch generation as well as the -# pyannote major, which is the point of capping at all. It moves with the cu121 -# ceiling — raise them together or not at all. -pyannote.audio>=3.3.2,<4 -torch>=2.3.0,<2.7.0 -torchaudio>=2.3.0,<2.7.0 +pyannote.audio>=3.3.2 +torch>=2.3.0 +torchaudio>=2.3.0 From fcf9502d63dd96da83b896fbad0791dde200965a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 07:23:58 +0000 Subject: [PATCH 5/5] Leave the diarization pins alone and document why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three dry runs went into capping pyannote so a tag could publish, and none of them worked. Capping the cu121 file does not fix its resolve; capping the CPU file as well breaks one that was green. Neither reproduces outside CI — with the same pip generation and the exact bundled pins the build resolves against, the capped set resolves cleanly on PyPI. The variable left untested is the PyTorch index as primary index, which needs a machine that can reach it. So both files go back to exactly what main has, and this branch carries only the wheel and PyPI work, which has passed every run. The wheel's diarization extra drops its cap too, so it mirrors the CPU runtime rather than encoding a decision that is still open. docs/pypi-release.md records the blocker, everything ruled out, and where the fix probably is — forward to pyannote 4 on a newer CUDA index, not backward. A tag still cannot publish until that is done; release needs build. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017JidoPdgd5BDroVFcEdF52 --- backend/requirements-diarization-cu121.txt | 19 +------------ docs/pypi-release.md | 33 ++++++++++++++++++++++ pyproject.toml | 8 ++++-- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/backend/requirements-diarization-cu121.txt b/backend/requirements-diarization-cu121.txt index a6062cc..0580873 100644 --- a/backend/requirements-diarization-cu121.txt +++ b/backend/requirements-diarization-cu121.txt @@ -9,24 +9,7 @@ # exists, hence the markers. --extra-index-url https://download.pytorch.org/whl/cu121 -# Capped below 4.0 deliberately. pyannote.audio 4 requires torch>=2.8, which -# does not exist on the cu121 index and cannot, so an uncapped range resolves -# to 4.x and then fails outright — ResolutionImpossible against the torch pin -# below. -# -# KNOWN GAP: requirements-diarization.txt is deliberately NOT capped to match. -# Capping it was tried and broke its resolve against the CPU index, for reasons -# that do not reproduce against PyPI with the same pins — the same constraint -# set resolves cleanly to pyannote 3.4.0 with torch 2.6.0 outside CI. Until -# that is understood, the CPU runtime resolves to pyannote 4.x and this one to -# 3.x, so diarization behaviour differs between GPU and CPU machines. Do not -# "fix" that by adding a cap here-style bound to the CPU file without a green -# build to back it up; see docs/pypi-release.md for what has been ruled out. -# -# Moving both to pyannote 4 means torch 2.8+, a newer CUDA index than cu121, -# and updating backend/core/diarization.py for the 4.x API. That is the real -# resolution, and it is a deliberate upgrade rather than a range left open. -pyannote.audio>=3.3.2,<4 +pyannote.audio>=3.3.2 torch>=2.3.0,<2.7.0 torchaudio>=2.3.0,<2.7.0 diff --git a/docs/pypi-release.md b/docs/pypi-release.md index 0d23208..a1e4357 100644 --- a/docs/pypi-release.md +++ b/docs/pypi-release.md @@ -95,3 +95,36 @@ 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 index 0e60221..57a8584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,10 +62,12 @@ dependencies = [ # 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. # -# The pyannote cap matches backend/requirements-diarization.txt — a pip install -# should not land on a different pyannote major than the packaged builds do. +# 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,<4", + "pyannote.audio>=3.3.2", "torch>=2.3.0", "torchaudio>=2.3.0", ]