From 25e13e8c9430ee52040cf49eba244c4ef5a29bd7 Mon Sep 17 00:00:00 2001 From: Smoke Test Date: Wed, 10 Jun 2026 21:47:49 -0700 Subject: [PATCH] Make the uv bootstrap chain survive venv recreation A Homebrew Python upgrade (3.12.12 -> 3.12.13) made uv recreate .venv against the lockfile, which removed the unlocked pip and uv binaries: ./scripts/uv hard-requires .venv/bin/uv, and bootstrap.sh assumed .venv/bin/pip exists whenever .venv does, so its recovery path crashed at 'pip install uv' and the user was stuck. Declare pip and uv as dev dependencies so every sync keeps them in the venv, teach bootstrap.sh to rebuild a venv that has neither binary, and add a contract test so they cannot be dropped from the dev extras again. Verified end-to-end: bootstrap heals the broken venv, and an exact 'uv sync --extra dev' no longer strips either binary. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 6 ++++++ scripts/bootstrap.sh | 5 +++++ tests/test_dev_env_contract.py | 26 +++++++++++++++++++++++ uv.lock | 39 ++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 tests/test_dev_env_contract.py diff --git a/pyproject.toml b/pyproject.toml index 4b40271..01715b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,16 @@ dependencies = [ dev = [ "coverage[toml]>=7.6,<8.0", "mypy>=1.10,<2.0", + # pip and uv must stay declared here: ./scripts/uv requires .venv/bin/uv and + # ./scripts/bootstrap.sh requires .venv/bin/pip, but `uv sync` makes the venv + # exactly match the lockfile (and recreates it on interpreter upgrades) — + # undeclared, both binaries vanish and the bootstrap chain breaks. + "pip>=24.0,<26.0", "pre-commit>=4.0,<5.0", "pytest>=8.3,<9.0", "pytest-asyncio>=0.24,<1.0", "ruff>=0.6,<1.0", + "uv>=0.4,<1.0", ] [project.scripts] diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 3a05585..cda6263 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -144,6 +144,11 @@ PYTHON_BIN="$(find_python_312)" if [[ ! -d "${VENV_DIR}" ]]; then "${PYTHON_BIN}" -m venv "${VENV_DIR}" +elif [[ ! -x "${PIP_BIN}" && ! -x "${UV_BIN}" ]]; then + # A uv-recreated venv (e.g. after a Homebrew Python upgrade) contains + # neither pip nor uv; rebuild it so the pip -> uv -> sync chain below works. + echo "Existing .venv has neither pip nor uv; rebuilding it." >&2 + "${PYTHON_BIN}" -m venv --clear "${VENV_DIR}" fi if [[ ! -x "${UV_BIN}" ]]; then diff --git a/tests/test_dev_env_contract.py b/tests/test_dev_env_contract.py new file mode 100644 index 0000000..e088cc8 --- /dev/null +++ b/tests/test_dev_env_contract.py @@ -0,0 +1,26 @@ +"""Contract: the bootstrap chain depends on uv and pip living inside .venv. + +``uv sync`` makes the environment exactly match the lockfile, and uv +recreates the venv outright when the base interpreter changes (e.g. a +Homebrew Python upgrade). If ``uv`` and ``pip`` are not declared dev +dependencies they vanish on the next sync or recreation, breaking +``./scripts/uv`` (which requires ``.venv/bin/uv``) and +``./scripts/bootstrap.sh`` (which uses ``.venv/bin/pip`` to install uv). +""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +_PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" + + +def test_dev_extras_pin_uv_and_pip_for_the_bootstrap_chain() -> None: + with open(_PYPROJECT, "rb") as handle: + data = tomllib.load(handle) + dev = data["project"]["optional-dependencies"]["dev"] + names = {re.split(r"[><=\[ ]", item, maxsplit=1)[0] for item in dev} + assert "uv" in names, "uv must stay a dev dependency or syncs strip .venv/bin/uv" + assert "pip" in names, "pip must stay a dev dependency or bootstrap.sh cannot recover" diff --git a/uv.lock b/uv.lock index 34982e4..661981d 100644 --- a/uv.lock +++ b/uv.lock @@ -162,10 +162,12 @@ dependencies = [ dev = [ { name = "coverage" }, { name = "mypy" }, + { name = "pip" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, + { name = "uv" }, ] [package.metadata] @@ -175,6 +177,7 @@ requires-dist = [ { name = "keyring", specifier = ">=25.5,<26.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10,<2.0" }, { name = "pathspec", specifier = ">=1.0,<2.0" }, + { name = "pip", marker = "extra == 'dev'", specifier = ">=24.0,<26.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0,<5.0" }, { name = "prompt-toolkit", specifier = ">=3.0,<4.0" }, { name = "pydantic", specifier = ">=2.10,<3.0" }, @@ -184,6 +187,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.7,<14.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6,<1.0" }, { name = "typer", specifier = ">=0.12,<1.0" }, + { name = "uv", marker = "extra == 'dev'", specifier = ">=0.4,<1.0" }, ] provides-extras = ["dev"] @@ -373,6 +377,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pip" +version = "25.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, +] + [[package]] name = "platformdirs" version = "4.9.4" @@ -668,6 +681,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uv" +version = "0.11.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/09/c29c0b90bc9308cfa6f5d77ce9b38ce97852210fda17d79019c7bcf9c3a1/uv-0.11.20.tar.gz", hash = "sha256:a246f30931cbc93d0a39d0cfc75be045fddd45773a734ddf8afa869aabc46c63", size = 4237464, upload-time = "2026-06-10T17:20:05.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/8a/25fc4d94ad3896e636466ee1fb03c4077f7a151c19cb25c1e6a731fa9cbf/uv-0.11.20-py3-none-linux_armv6l.whl", hash = "sha256:f867fd0807e39653fd101e16f2292ff488de14b5ffbb86a5678a87a27aa58ea0", size = 23713010, upload-time = "2026-06-10T17:19:32.058Z" }, + { url = "https://files.pythonhosted.org/packages/02/7d/bbaad5f0c616f7824149a4ac0271db14107b859b36bd75d16db1486c495f/uv-0.11.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3bacc52778775cef671867ddab744b50c4183bc3cd6419a5fb4eb01a02f9526", size = 22918378, upload-time = "2026-06-10T17:19:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/e3d39361b95c262b43ccfe260f41184da71c536a08f39d4ad59ab962e459/uv-0.11.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c9f2062096e146b351fd5da3cd43e15a5c43bfa37166c509d884dab3dbb72f03", size = 21716975, upload-time = "2026-06-10T17:19:51.552Z" }, + { url = "https://files.pythonhosted.org/packages/24/30/9031204d7b592d1595322d7506944793728a74795a8c4a3c38bc4a8985f0/uv-0.11.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8fe143572a1f02d536c4e9c6994f0c89d9fa58ff6e5d91de55f61660658c694b", size = 23571826, upload-time = "2026-06-10T17:19:46.206Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/7f9f00c7a152e67d59ae5f25635d19ac4252929dd6ac454cdb1dee3119fa/uv-0.11.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3d00754be09a381030829526f7ff47c06d0e28ca90760c4439be48e71c1bf13a", size = 23249218, upload-time = "2026-06-10T17:19:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/35/a9/1ce58670a89c25d4a8b84532207183b5a97b3623d9b9151afe641499fbc8/uv-0.11.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:edb59d33e602fc462b6ed8fde66d404445294de41ad4de31039f7a2c41153601", size = 23302149, upload-time = "2026-06-10T17:20:01.111Z" }, + { url = "https://files.pythonhosted.org/packages/61/cf/3a498a315364f906bd655a94fa6b5f74f5240dc90875d6ab67ef28c081ec/uv-0.11.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14026b64ccbe0174e4fcf107f585f5d23f0f5b9f5b3e8b28394fe63fff3e60ff", size = 24652804, upload-time = "2026-06-10T17:19:16.692Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/68e4a805e3b49d834e95b3838e53a623d2e0ad956bb44e681ece54b3308b/uv-0.11.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73dcaf5543d1b24e4c6fa4c19af033ed015304171c132670ebe9ef01ddec3d17", size = 25660209, upload-time = "2026-06-10T17:20:08.156Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/e8e205a79b9a39454c5da5c2695e4911a42860a4404739e32802e599af0c/uv-0.11.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cac8ca5d187dc5040b1c22c83f6ed789195c3fc06b6a5a2b32c747c603f07820", size = 24866467, upload-time = "2026-06-10T17:19:37.981Z" }, + { url = "https://files.pythonhosted.org/packages/7e/38/f844d125db277d8ce0c921f0219078d292d0ee72d314d0c12f4cf510aa40/uv-0.11.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61588768f04a24b0b4d87b43f03b4521ba66d5eec3ab1aca37cb59b1d52337db", size = 24957448, upload-time = "2026-06-10T17:19:40.979Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7d/b0e28abfa41c424d2a3df83be2b00b2fbe3ad5795baf7361262c6d800a62/uv-0.11.20-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2d307d47b1a0cf8f76aa69bde850be407bca9482c13cff066142e586a5c57e77", size = 23671087, upload-time = "2026-06-10T17:19:54.595Z" }, + { url = "https://files.pythonhosted.org/packages/80/ff/92bce88101ce61d708e888db6ab7f5ebf4ccd61f54d3316e7e48a4be56a5/uv-0.11.20-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:bb5839cbb68d7469925fe1599dacd16cefdb7698a7adeaf9c8e4d8a7cd4122bf", size = 24324677, upload-time = "2026-06-10T17:19:43.534Z" }, + { url = "https://files.pythonhosted.org/packages/0f/90/d308bd88c7a53cae93f7fe13ce5c621895b8fb94e37911660a3de7283b67/uv-0.11.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:efefbd491ba443b326fdd344d7efc89c1de8a006cab5bc639a4d5ce9d1dd1ab9", size = 24429959, upload-time = "2026-06-10T17:19:29.352Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e6/b87c941b93b61dceaa11f4f8d02760de7aa7d1c58ea24a2298e7c5aafb4f/uv-0.11.20-py3-none-musllinux_1_1_i686.whl", hash = "sha256:daa41b97386699212b2266a80c178140c5396e3c444ff5553f68f79812334e41", size = 23880515, upload-time = "2026-06-10T17:19:23.733Z" }, + { url = "https://files.pythonhosted.org/packages/53/ab/11b07641f8387177889f4341a36318561208c18703837bc47163244aa11b/uv-0.11.20-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:37ca61eddb940d1c698fce46d63789adfda3033344dabab0e18e2958e1a69771", size = 25171603, upload-time = "2026-06-10T17:19:34.848Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7d/62af57509c7007600ce11cd5222b2cb84d5cd8f80f427a499d1b44ef3601/uv-0.11.20-py3-none-win32.whl", hash = "sha256:32893ee9f94657fbf89e22638ac88850db4529d454f102bfa7ceea5fc9fe8d77", size = 22570328, upload-time = "2026-06-10T17:19:48.808Z" }, + { url = "https://files.pythonhosted.org/packages/66/35/c9ee48cdce11f5cdac9e2be41a5446bae7382cde18d2ce1050bcd81dc05a/uv-0.11.20-py3-none-win_amd64.whl", hash = "sha256:4836044213bb23a3be1f5550db340d3a19babe1dfc3ca1313544e8b614085ce9", size = 25228859, upload-time = "2026-06-10T17:19:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/8b/8d/00a382c2f8f44b328cf98f734a3fcd72957698c30bed2392bd241b573384/uv-0.11.20-py3-none-win_arm64.whl", hash = "sha256:442ae26f47bf6e58b072e99dbfd6d5296ab90308574b2c02adff1dace051008d", size = 23664943, upload-time = "2026-06-10T17:20:03.774Z" }, +] + [[package]] name = "virtualenv" version = "21.2.0"