diff --git a/core/tests/test_update.py b/core/tests/test_update.py index 8bdf1ba4..d77055ec 100644 --- a/core/tests/test_update.py +++ b/core/tests/test_update.py @@ -229,7 +229,7 @@ def test_a_missing_requirement_is_installed_by_its_whole_line_so_a_pin_or_url_su monkeypatch.setattr(update, "_installed_version", lambda n: "1.0" if n == "numpy" else None) ran = [] monkeypatch.setattr(update.subprocess, "run", lambda cmd, **k: ran.append(cmd) or subprocess.CompletedProcess(cmd, 0, "", "")) - monkeypatch.setattr(update, "_pip_freeze", lambda: {}) + monkeypatch.setattr(update, "_pip_freeze", lambda: {"numpy": "1.26.4"}) assert update.install_requirements()["ok"] assert [c[-1] for c in ran] == ["zz_absent_pkg>=3.8,<3.9", "zz_model @ https://example.com/zz_model.whl"] @@ -244,7 +244,7 @@ def test_one_package_that_cannot_install_does_not_stop_the_others(repo, monkeypa return subprocess.CompletedProcess(cmd, 1 if "zz_bad_url" in cmd[-1] else 0, "", "404 not found") monkeypatch.setattr(update.subprocess, "run", fake_run) - monkeypatch.setattr(update, "_pip_freeze", lambda: {}) + monkeypatch.setattr(update, "_pip_freeze", lambda: {"numpy": "1.26.4"}) out = update.install_requirements() assert ran == ["zz_bad_url @ https://example.com/missing.whl", "zz_good_pkg"], "the second still installs" assert out["ok"] is False and "zz_bad_url" in out["detail"] and "404 not found" in out["detail"] @@ -269,6 +269,32 @@ def test_a_spacy_model_that_does_not_load_is_downloaded_on_the_switch(repo, monk ran = [] monkeypatch.setattr(update, "_spacy_model_loads", lambda: bool(ran) and ran[-1] == ["spacy", "download", "en_core_web_sm"]) monkeypatch.setattr(update.subprocess, "run", lambda cmd, **k: ran.append(cmd[-3:]) or subprocess.CompletedProcess(cmd, 0, "", "")) - monkeypatch.setattr(update, "_pip_freeze", lambda: {}) + monkeypatch.setattr(update, "_pip_freeze", lambda: {"numpy": "1.26.4"}) assert update.install_requirements()["ok"] assert ran[-1] == ["spacy", "download", "en_core_web_sm"] + + +def test_an_install_pins_every_installed_package_so_nothing_already_here_moves(repo, monkeypatch): + (repo / "requirements.txt").write_text("zz_new_pkg\n") + monkeypatch.setattr(update, "_installed_version", lambda n: None) + seen = {} + + def fake_run(cmd, **k): + if "-c" in cmd: + seen["constraints"] = open(cmd[cmd.index("-c") + 1]).read() + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr(update.subprocess, "run", fake_run) + monkeypatch.setattr(update, "_pip_freeze", lambda: {"numpy": "1.26.4", "torch": "2.9.0"}) + assert update.install_requirements()["ok"] + assert "numpy==1.26.4" in seen["constraints"] and "torch==2.9.0" in seen["constraints"] + + +def test_an_unreadable_installed_list_installs_nothing(repo, monkeypatch): + (repo / "requirements.txt").write_text("zz_new_pkg\n") + monkeypatch.setattr(update, "_installed_version", lambda n: None) + ran = [] + monkeypatch.setattr(update.subprocess, "run", lambda cmd, **k: ran.append(cmd) or subprocess.CompletedProcess(cmd, 0, "", "")) + monkeypatch.setattr(update, "_pip_freeze", lambda: {}) + out = update.install_requirements() + assert out["ok"] is False and ran == [] diff --git a/core/update.py b/core/update.py index e1a502b6..a8afffef 100644 --- a/core/update.py +++ b/core/update.py @@ -12,10 +12,12 @@ feature: no module can announce the thing that updates every module. """ from __future__ import annotations +import os import re import shutil import subprocess import sys +import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -426,9 +428,25 @@ def install_requirements(timeout: int = 900) -> dict: # stop every other one from installing too. Dependencies are resolved normally: a new package # with nothing under it is not installed, it is broken. The freeze diff says whether anything # already present moved as a result. + if not before: + return {"ran": False, "ok": False, + "detail": "Could not read the installed packages, so nothing is installed (an install could move them). " + f"Run it yourself: {sys.executable} -m pip install "} + # Constraints: every installed package stays at its version, so a new package's dependencies can be added + # but never bump one that is already here. A dependency that needs a newer one fails, and says so. + constraints = tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") + try: + constraints.write("\n".join(f"{n}=={v}" for n, v in before.items()) + "\n") + constraints.close() + return _install_each(status, constraints.name, before, floor_note, timeout, model_broken) + finally: + os.unlink(constraints.name) + + +def _install_each(status, constraints_path, before, floor_note, timeout, model_broken): failed, done = [], [] for line in status.get("install", []): - cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check", line] + cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check", "-c", constraints_path, line] try: proc = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: @@ -442,7 +460,7 @@ def install_requirements(timeout: int = 900) -> dict: else: done.append(line) if model_broken: - cmd = [sys.executable, "-m", "spacy", "download", "en_core_web_sm"] + cmd = [sys.executable, "-m", "spacy", "download", "en_core_web_sm"] # the model is data, not a dependency: no constraints try: proc = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout) if proc.returncode != 0 or not _spacy_model_loads():