# Replacing the bundled Python runtime with uv ## Status Design + implementation plan. Investigation complete; decisions locked. Target: iTerm2 3.7.x (post-beta) or 3.8. ## Build process (per phase - follow strictly, in phase order) Each phase is executed with TDD using this loop: 1. Write tests (failing first). 2. Implement. 3. Run tests; iterate until the code is correct and tests pass. 4. Launch a subagent to review the code. 5. Address its findings. Go to step 4 until no meaningful issues remain. 6. Proceed to the next phase. Phases run in order: Phase 0 (notice beta) -> 1 (provisioner) -> 2 (provision/launch) -> 3 (migration) -> 4 (target bump + enable) -> 5 (delete legacy). ### Development uv source (temporary) Until the signed universal binary is hosted on iterm2.com with the min/max-macOS manifest (a release-infra task owned by the author, needed before the Phase 4 beta), use this URL for the uv download during development: https://releases.astral.sh/github/uv/releases/download/0.12.0/uv-aarch64-apple-darwin.tar.gz Notes: this is an arch-specific (aarch64) `.tar.gz`, not the eventual `lipo`'d universal binary, and it is served from Astral's mirror, not iterm2.com. Signature verification and the manifest are stubbed/relaxed against this dev source and wired up for real when hosting moves to iterm2.com. ## Motivation iTerm2 ships a hand-built Python runtime (multiple CPython versions, the `iterm2` module, and pyobjc as an optional extra) as an on-demand optional component. It is built outside this repo by two sibling projects: - `~/git/iterm2-pyenv` (orchestration scripts + built zips) - `~/git/pyenv` (a fork of pyenv whose `python-build` compiles CPython and every C dependency from source, statically, to avoid depending on Homebrew/system libs) Problems: - The build is a pile of hacks: static readline 8.3 built privately, gdbm without readline, static openssl/sqlite/xz/zlib/zstd/gettext, `fdupes` hardlink dedupe, `templatize.sh` path rewriting (`__ITERM2_ENV__` / `__ITERM2_PYENV__`), an interactive `read`-gated pip dance run twice "to beat pip into submission," RSA-signed delta zips. - It is single-arch. The pyenv fork already flipped to arm64-only at runtime build 80, and the app already splits manifests by OS (`manifest.json` vs `manifest-new.json`, `iTermAdvancedSettingsModel.m:940-948`). - macOS 27 (September 2026) drops Rosetta 2, so an Intel binary can no longer run on Apple Silicon. Intel users (on macOS <=26) must still be supported, which today means maintaining the hack pipeline for that arch too. The reason the runtime exists in the first place remains true: we cannot assume the user has build tools or Homebrew. ## Why uv solves it uv provisions Python from **python-build-standalone**, which ships **native x86_64 and native arm64** macOS binaries (deployment floors 10.9 / 11.0), with **no compiler required** on the user's machine. That is exactly the constraint that motivated the custom runtime, satisfied by prebuilt binaries. uv itself is a single standalone Rust binary; it does not need Python to run. Result: native Intel Python without Rosetta, native ARM Python from the same tool, and the entire compile pipeline is deleted. Key facts: - uv env vars we rely on: `UV_PYTHON_INSTALL_DIR`, `UV_CACHE_DIR`, `UV_PYTHON_PREFERENCE=only-managed`, `UV_NO_CONFIG`, `UV_PYTHON_INSTALL_MIRROR` (deferred; see below), `UV_PYTHON_DOWNLOADS`. - python-build-standalone offers CPython ~3.8 through ~3.14. 3.7 is not available. - uv's own supported macOS floor is 13 (Ventura). ## Locked decisions 1. **uv delivery: download on demand.** A signed, `lipo`'d universal uv binary hosted on iterm2.com, fetched via the existing signature-verified optional-component downloader on first Python-scripting use. Not bundled in the app. 2. **Python source: upstream python-build-standalone** (uv's default). A self-hosted mirror via `UV_PYTHON_INSTALL_MIRROR` is a drop-in fallback for later; no architectural change needed to add it. Pin a known-good uv version rather than always pulling latest. 3. **Deployment target: bump the app from macOS 12 to macOS 13**, but only after a farewell-notice beta warns macOS 12 users (Phase 0), and the eventual 13-only build is gated by Sparkle `minimumSystemVersion` so 12 users are never offered an unlaunchable update. The bump itself is deferred to Phase 4 (paired with enabling uv). This meets uv's supported floor head-on and removes the need to verify uv on 12. (The target was set to 12 in commit `8963d0574`, 2025-07-02.) 4. **Per-script metadata: keep `setup.cfg`.** Reuse `iTermSetupCfgParser` unchanged; drive `uv venv` + `uv pip install` from it. No pyproject.toml migration now. 5. **uv only at provision time; launch is a bare exec of the venv Python.** No uv process and no network on any script launch. uv (and the network) are involved only when creating, importing, migrating, upgrading, or editing dependencies. This makes "basic-script launch is offline" an architectural guarantee. 6. **Do not conflate uv artifacts with the legacy `iterm2env` names.** Old iTerm2 builds locate a runtime by the exact strings `iterm2env` / `saved-iterm2env` and read `iterm2env-metadata.json`, expecting the pyenv tree layout. uv artifacts use new names so an old build sees "no runtime I recognize" and declines gracefully instead of misreading a flat venv. 7. **Forced Python-version remap only for 3.7.** python-build-standalone has no 3.7. Everything 3.8+ keeps its pinned minor. 3.7 is remapped to **3.9** (last release before the 3.10 `collections.abc` alias removal; negligibly riskier than 3.8 and avoids the one genuinely disruptive removal). Any forced minor bump is announced with a suppressible modal warning, not just a Script Console line. 8. **Drop `aioconsole`/`apython`.** stdlib `python -m asyncio` provides top-level `await` (since 3.8), which was apython's only real benefit. 9. **Gate the whole change behind an advanced setting; keep both code paths during development.** A boolean `iTermAdvancedSettingsModel` setting (proposed `pythonRuntimeUsesUV`, default NO) selects the runtime backend. Default NO means shipping `master` behaves exactly as today, so development happens on `master` without a long-lived branch. This is a genuine behavior toggle the user sets intentionally, so it is a normal (syncable) advanced setting, not `NoSync`. The legacy path and the uv path coexist until Phase 5 removes the setting and the legacy code together. ## Development gating (transitional) The setting has an asymmetric effect, which is what makes both paths safe to coexist: - **Provisioning branches on the setting.** Creating, importing, migrating, or editing dependencies of a script uses uv iff the setting is ON. With it OFF, all of these behave exactly as today (download the bundled runtime, pyenv tree layout, no auto-migration). uv is downloaded lazily on first provisioning, so users who never enable it never fetch it. - **Launch branches on what is on disk, NOT on the setting.** A script with `.venv` + `python-runtime.json` launches via `exec .venv/bin/python`; a script with a legacy `iterm2env` launches the legacy way. So toggling the setting never strands an already-provisioned script - a uv-provisioned script keeps working even after the setting is turned back off (its launch is just a bare python exec and needs no uv), and a legacy script keeps working with the setting on until it is actually migrated. Lifecycle of the default (there is no telemetry; rollout is by flipping the default, not by measuring adoption): 1. Development: default NO, deployment target still macOS 12. `master` ships today's behavior; the author flips the gate on locally (on macOS 13+) to test. A separate Phase 0 notice beta warns macOS 12 users of the coming drop. 2. Beta: when ready, bump the deployment target to macOS 13 (Phase 4) and in the same beta flip the default to YES for all beta users. Both code paths still exist, so the gate remains an escape hatch - a user hitting a problem can turn it off and fall back to the legacy runtime. macOS 12 users are not offered this build (appcast `minimumSystemVersion`). 3. Stable: keep the default YES in stable releases. 4. Only after uv has shipped as the default through a beta cycle and into stable does Phase 5 remove the setting and the legacy path. ## What does NOT change (runtime swap, not a protocol change) - The API transport and auth: unix socket at `.../private/socket`, TCP `localhost:1912` fallback, `api.iterm2.com` subprotocol, and the `ITERM2_COOKIE`/`ITERM2_KEY` cookie-and-key AppleScript handshake. `it2cli` and every existing `iterm2` client keep working. No wire break. - The `iterm2` PyPI module (vendored at `api/library/python/iterm2/`) and its minimum library version gate 0.24 (`iTermWebSocketConnection.m:31`). - The spaceless AppSupport symlink (`NSFileManager+iTerm.m:183`) — kept; venv shebangs still dislike spaces. - `iTermSignatureVerifier` + `rsa_pub.pem` — repurposed to verify the uv binary. ## Target on-disk layout All uv artifacts live under the spaceless Application Support symlink (venv shebangs and interpreter symlinks dislike spaces), which the provisioner resolves via `spacelessAppSupportCreatingLink()`: - uv binary: `/uv/bin/uv` - uv-managed interpreters: `/uv/python/` (`UV_PYTHON_INSTALL_DIR`) - uv cache: `/uv/cache/` (`UV_CACHE_DIR`) - shared basic-script venvs: `/uv/venvs//` - per full-env script venv: `