Bladeren bron

chore(deps): pin what ships in requirements.lock and install it everywhere

requirements.txt only had minimum versions, so every Docker build, install
and update took whatever was newest that day. requirements.lock (universal,
compiled with uv) pins them; Docker, the test image, install.sh, the update
scripts, the SpoolBuddy full installer, the Windows build and CI install it,
and pip-audit audits it. The type-check lock is derived from it. Guard tests
fail when requirements.txt adds or raises a dependency without a regenerated
lock, or when the lock is not universal.
maziggy 1 week geleden
bovenliggende
commit
56c131ef01

+ 4 - 3
.github/workflows/ci.yml

@@ -109,7 +109,7 @@ jobs:
           # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
           # A fix exists, so we upgrade rather than --ignore-vuln.
           python -m pip install --upgrade pip setuptools
-          pip install -r requirements.txt
+          pip install -r requirements.lock
           pip install pip-audit
 
       - name: Run pip-audit
@@ -150,14 +150,15 @@ jobs:
         uses: actions/cache@v5
         with:
           path: ~/.cache/pip
-          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
+          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.lock', 'requirements-dev.txt') }}
           restore-keys: |
             ${{ runner.os }}-pip-
 
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install -r requirements.txt
+          # The pinned versions that ship (Docker, installers), not whatever is newest.
+          pip install -r requirements.lock
           pip install -r requirements-dev.txt
 
       - name: Run tests (shard ${{ matrix.shard }}/4)

+ 3 - 1
.github/workflows/security.yml

@@ -12,6 +12,7 @@ on:
       - 'Dockerfile'
       - 'docker-compose*.yml'
       - 'requirements.txt'
+      - 'requirements.lock'
       - 'frontend/package*.json'
       - '.github/workflows/security.yml'
   pull_request:
@@ -22,6 +23,7 @@ on:
       - 'Dockerfile'
       - 'docker-compose*.yml'
       - 'requirements.txt'
+      - 'requirements.lock'
       - 'frontend/package*.json'
       - '.github/workflows/security.yml'
   workflow_dispatch:
@@ -141,7 +143,7 @@ jobs:
           # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
           # A fix exists, so we upgrade rather than --ignore-vuln.
           python -m pip install --upgrade pip setuptools
-          pip install -r requirements.txt
+          pip install -r requirements.lock
           pip install pip-audit
 
       - name: Run pip-audit

+ 1 - 0
CHANGELOG.md

@@ -161,6 +161,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Every install now gets the dependency versions that were tested** — Bambuddy's Python dependencies only had minimum versions, so each Docker build, install and update took whatever was newest that day: a fresh install could run library versions nobody had tested Bambuddy with (SQLAlchemy 2.1 arrived that way). They are now pinned in `requirements.lock`, which Docker, the installers, the update scripts and the tests all use, and newer versions are taken on deliberately, after testing. Nothing changes for an install today: the pins are the versions a fresh install gets now. A native install moves to the pinned versions with the update after the one that brings this change, since an update still runs the previous version of the update script.
 - **Native installs need Python 3.11 or newer** — Since v0.2.4.8 Bambuddy hasn't started on Python 3.10, while the installer, README and wiki still said 3.10 was enough. The requirement now says 3.11. The installer uses the system's `python3` when it is new enough and otherwise looks for a newer Python next to it: on Ubuntu 22.04, whose `python3` is 3.10, it installs and uses the `python3.11` package. An environment left from an older Python is rebuilt. The update scripts check the install's Python first and, if it is too old, stop before changing anything and link to the steps for moving it to 3.11. Docker and the Windows installer ship Python 3.13 and are not affected. Wiki updated.
 - **The webhook endpoints listed in Settings didn't exist** — **Settings → API Keys → Webhook Endpoints** listed endpoints for the status of all printers, pausing and resuming that Bambuddy doesn't have, and two others under the wrong path. It now lists the real ones: add to the queue, start the next queued job, stop, cancel, printer status and queue status. Wiki updated.
 - **A valid API key could be refused as invalid during a brief database problem** — When checking an API key hit a database error (a busy database, or saving the key's last-used time while it was locked), the request was answered "Invalid API key", so integrations such as Home Assistant or scripts were told their key was wrong. It now answers "Authentication service temporarily unavailable" (503), which a client can retry, and the error is logged. A wrong or expired key is refused as before.

+ 19 - 3
CONTRIBUTING.md

@@ -109,8 +109,8 @@ Both docs repos can be edited directly in the browser, no `git clone` required:
 python3 -m venv venv
 source venv/bin/activate  # On Windows: venv\Scripts\activate
 
-# Install dependencies
-pip install -r requirements.txt
+# Install dependencies: the pinned versions that ship (see "Dependencies" below)
+pip install -r requirements.lock
 pip install -r requirements-dev.txt  # Dev/test dependencies (pytest, ruff, bandit, etc.)
 
 # Install pre-commit hooks
@@ -204,9 +204,25 @@ python3 -m venv .typecheck-venv          # Python 3.11 or newer
 ```
 
 - **Fixed an error from the baseline?** A local run without new errors removes it from `baseline.json`; commit that change with your fix. (With a new error in the same run, the baseline is left as it is until that error is fixed.) CI never rewrites the baseline and fails if it still lists an error that is gone.
-- **Added or changed a dependency in `requirements.txt`?** Add it to the lock in the same PR, with the command in `requirements-typecheck.txt` (needs [uv](https://docs.astral.sh/uv/): `pip install uv`). The check fails if the lock does not cover `requirements.txt`. If that moves the pin of a package already in the lock and the check then reports errors in code you did not touch, regenerate the baseline in the same commit (see `requirements-typecheck.txt`).
+- **Added or changed a dependency in `requirements.txt`?** Regenerate `requirements.lock` (see [Dependencies](#dependencies)) and then the type-check lock, with the command in `requirements-typecheck.txt`, in the same PR. If that moves the pin of a package already in the lock and the check then reports errors in code you did not touch, regenerate the baseline in the same commit (see `requirements-typecheck.txt`).
 - **Moved or edited code that has a baselined error?** The baseline matches each file's errors in order by rule and column span, not by line number. Editing such a line, re-indenting it (wrapping it in `if`/`try`/`with`) or reordering functions that hold baselined errors can bring them back as new ones. Fix them while you are there.
 
+### Dependencies
+
+`requirements.txt` lists what Bambuddy needs, with lower bounds. `requirements.lock` pins the exact versions, for every supported Python (3.11+) and platform, and it is what Docker, the installers, the update scripts and CI install: everyone runs the versions that were tested. Both files change together, and a test (`test_requirements_lock.py`) fails if the lock doesn't cover `requirements.txt`.
+
+Regenerating needs [uv](https://docs.astral.sh/uv/) (`pip install uv`):
+
+```bash
+# After adding or changing a dependency (keeps every other pin)
+uv pip compile requirements.txt --universal --python-version 3.11 -o requirements.lock
+
+# Moving every package to its newest version, on purpose (e.g. before a release)
+uv pip compile requirements.txt --universal --python-version 3.11 --upgrade -o requirements.lock
+```
+
+Then regenerate the type-check lock (command in `requirements-typecheck.txt`), install the new lock into your venv, and run the full test suite before committing.
+
 ### Frontend (TypeScript/React)
 
 We use ESLint for linting and TypeScript for type checking:

+ 3 - 2
Dockerfile

@@ -52,10 +52,11 @@ RUN setcap cap_net_bind_service=+ep "$(readlink -f /usr/local/bin/python3)"
 # pip is upgraded to >=26.1 first to close CVE-2026-6357 — the python:3.13-slim
 # base image ships pip 26.0.1, which runs its self-update check after installing
 # wheels (so a hostile wheel could hijack stdlib imports during install).
-COPY requirements.txt ./
+# requirements.lock pins the exact versions that were tested (see CONTRIBUTING.md).
+COPY requirements.txt requirements.lock ./
 RUN --mount=type=cache,target=/root/.cache/pip \
     pip install --root-user-action=ignore --upgrade 'pip>=26.1.2' \
- && pip install --root-user-action=ignore -r requirements.txt
+ && pip install --root-user-action=ignore -r requirements.lock
 
 # Copy backend
 COPY backend/ ./backend/

+ 5 - 2
Dockerfile.test

@@ -14,10 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
 # cache so the second build only does install work — the slow ~60-90s
 # fetch step from the first build becomes ~5s. Requires DOCKER_BUILDKIT=1
 # (already set in test_docker.sh).
-COPY requirements.txt ./
+COPY requirements.txt requirements.lock ./
 COPY requirements-dev.txt ./
+# Two installs, as in CI: the lock pins the newest ruff (requirements.txt only
+# has a floor) and requirements-dev.txt pins the one CI enforces, so a single
+# install would have two exact pins for ruff and fail. The second one wins.
 RUN --mount=type=cache,target=/root/.cache/pip \
-    pip install -r requirements.txt -r requirements-dev.txt
+    pip install -r requirements.lock && pip install -r requirements-dev.txt
 
 # Copy backend code
 COPY backend/ ./backend/

+ 1 - 1
README.md

@@ -757,7 +757,7 @@ git clone https://github.com/maziggy/bambuddy.git
 cd bambuddy
 python3 -m venv venv
 source venv/bin/activate
-pip install -r requirements.txt
+pip install -r requirements.lock
 
 # Run (--loop asyncio avoids a uvloop TLS bug that can truncate VP FTP uploads)
 uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --loop asyncio

+ 1 - 1
UPDATING.md

@@ -58,7 +58,7 @@ cd /opt/bambuddy
 sudo systemctl stop bambuddy
 sudo -u bambuddy git fetch origin
 sudo -u bambuddy git reset --hard origin/main
-sudo -u bambuddy venv/bin/pip install -r requirements.txt
+sudo -u bambuddy venv/bin/pip install -r requirements.lock
 sudo systemctl start bambuddy
 ```
 

+ 4 - 4
backend/app/main.py

@@ -215,9 +215,9 @@ def _start_error_server(missing_packages: list):
         <h2>Missing Python packages</h2>
         <div class="packages"><ul>{packages_html}</ul></div>
         <p>To fix, run this command on your server:</p>
-        <div class="command">pip install -r requirements.txt</div>
+        <div class="command">pip install -r requirements.lock</div>
         <p>Or if using a virtual environment:</p>
-        <div class="command">./venv/bin/pip install -r requirements.txt</div>
+        <div class="command">./venv/bin/pip install -r requirements.lock</div>
         <p class="note">After installing, restart Bambuddy:<br>
         <code>sudo systemctl restart bambuddy</code></p>
     </div>
@@ -277,9 +277,9 @@ def check_dependencies():
         print("=" * 60)
         print(f"\nMissing packages: {', '.join(missing)}")
         print("\nTo fix, run:")
-        print("  pip install -r requirements.txt")
+        print("  pip install -r requirements.lock")
         print("\nOr if using a virtual environment:")
-        print("  ./venv/bin/pip install -r requirements.txt")
+        print("  ./venv/bin/pip install -r requirements.lock")
         print("=" * 60 + "\n")
         _start_error_server(missing)
 

+ 67 - 0
backend/tests/unit/test_requirements_lock.py

@@ -0,0 +1,67 @@
+"""requirements.lock covers requirements.txt.
+
+Docker, the installers and CI install the pinned versions in requirements.lock;
+requirements.txt only holds the lower bounds it is compiled from. A dependency
+added or raised in requirements.txt without regenerating the lock would ship
+without it. Checked here statically (no network), so it fails in a local test
+run too, not only in CI.
+"""
+
+import re
+from pathlib import Path
+
+from packaging.markers import Marker
+from packaging.requirements import Requirement
+from packaging.utils import canonicalize_name
+from packaging.version import Version
+
+REPO = Path(__file__).resolve().parents[3]
+
+
+def _requirements() -> list[Requirement]:
+    reqs = []
+    for line in (REPO / "requirements.txt").read_text(encoding="utf-8").splitlines():
+        line = line.split("#", 1)[0].strip()
+        if line and not line.startswith("-"):
+            reqs.append(Requirement(line))
+    return reqs
+
+
+def _pins() -> dict[str, list[tuple[Version, Marker | None]]]:
+    pins: dict[str, list[tuple[Version, Marker | None]]] = {}
+    for line in (REPO / "requirements.lock").read_text(encoding="utf-8").splitlines():
+        m = re.match(r"^([A-Za-z0-9_.\[\]-]+)==([^\s;]+)\s*(?:;\s*(.+))?$", line)
+        if m:
+            name, version, marker = m.groups()
+            name = name.split("[", 1)[0]
+            pins.setdefault(canonicalize_name(name), []).append((Version(version), Marker(marker) if marker else None))
+    return pins
+
+
+def test_every_requirement_is_pinned_in_the_lock():
+    pins = _pins()
+    problems = []
+    for req in _requirements():
+        if req.marker is not None and not req.marker.evaluate():
+            continue  # not for this platform/Python
+        candidates = [
+            v for v, marker in pins.get(canonicalize_name(req.name), []) if marker is None or marker.evaluate()
+        ]
+        if not candidates:
+            problems.append(f"{req.name}: not in requirements.lock")
+        elif not any(req.specifier.contains(v, prereleases=True) for v in candidates):
+            problems.append(f"{req}: lock pins {', '.join(map(str, candidates))}")
+    assert not problems, (
+        "requirements.lock does not cover requirements.txt; regenerate it (see CONTRIBUTING.md):\n  "
+        + "\n  ".join(problems)
+    )
+
+
+def test_the_lock_is_universal():
+    """The check above only evaluates markers for the machine it runs on. A lock
+    compiled for Linux alone passes it but has no tzdata and an unconditional
+    uvloop, which breaks the Windows installer, a build only release tags run."""
+    header = (REPO / "requirements.lock").read_text(encoding="utf-8").split("\n", 3)[:3]
+    assert any("uv pip compile" in line and "--universal" in line for line in header), (
+        "requirements.lock was not compiled with --universal; regenerate it (see CONTRIBUTING.md)"
+    )

+ 3 - 3
install/install.sh

@@ -428,14 +428,14 @@ setup_virtualenv() {
     if [[ "$OS_TYPE" == "macos" ]]; then
         $PYTHON_CMD -m venv $venv_args venv
         "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
-        "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
+        "$INSTALL_PATH/venv/bin/pip" install -r requirements.lock
     else
         # Venv is owned by the service user, so pip must also run as that user —
         # otherwise `pip install --upgrade pip` fails trying to rewrite its own
         # binary inside the venv it doesn't own.
         sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv $venv_args venv 2>/dev/null || $PYTHON_CMD -m venv $venv_args venv
         sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
-        sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
+        sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install -r requirements.lock
     fi
 
     log_success "Virtual environment configured"
@@ -1128,7 +1128,7 @@ main() {
     echo ""
     echo -e "  ${BOLD}Update BamBuddy:${NC}"
     echo -e "    cd $INSTALL_PATH && git pull && source venv/bin/activate"
-    echo -e "    pip install -r requirements.txt && cd frontend && npm ci && npm run build"
+    echo -e "    pip install -r requirements.lock && cd frontend && npm ci && npm run build"
     if [[ "$OS_TYPE" != "macos" ]]; then
         echo -e "    sudo systemctl restart bambuddy"
     fi

+ 8 - 4
install/update.sh

@@ -293,11 +293,15 @@ log "Updating code to origin/$BRANCH"
 git reset --hard "origin/$BRANCH"
 CODE_UPDATED=1
 
-if [ -x "$VENV_PIP" ] && [ -f requirements.txt ]; then
-  log "Updating Python dependencies"
-  "$VENV_PIP" install -r requirements.txt
+# requirements.lock holds the exact, tested versions; requirements.txt only
+# their lower bounds (kept as the fallback for a checkout without the lock).
+REQUIREMENTS=requirements.lock
+[ -f "$REQUIREMENTS" ] || REQUIREMENTS=requirements.txt
+if [ -x "$VENV_PIP" ] && [ -f "$REQUIREMENTS" ]; then
+  log "Updating Python dependencies ($REQUIREMENTS)"
+  "$VENV_PIP" install -r "$REQUIREMENTS"
 else
-  warn "Skipping Python dependency update (venv pip or requirements.txt missing)."
+  warn "Skipping Python dependency update (venv pip or requirements file missing)."
 fi
 
 if [ -f "$FRONTEND_DIR/package.json" ]; then

+ 8 - 4
install/update_macos.sh

@@ -302,11 +302,15 @@ log "Updating code to origin/$BRANCH"
 git reset --hard "origin/$BRANCH"
 CODE_UPDATED=1
 
-if [ -x "$VENV_PIP" ] && [ -f requirements.txt ]; then
-  log "Updating Python dependencies"
-  "$VENV_PIP" install -r requirements.txt
+# requirements.lock holds the exact, tested versions; requirements.txt only
+# their lower bounds (kept as the fallback for a checkout without the lock).
+REQUIREMENTS=requirements.lock
+[ -f "$REQUIREMENTS" ] || REQUIREMENTS=requirements.txt
+if [ -x "$VENV_PIP" ] && [ -f "$REQUIREMENTS" ]; then
+  log "Updating Python dependencies ($REQUIREMENTS)"
+  "$VENV_PIP" install -r "$REQUIREMENTS"
 else
-  warn "Skipping Python dependency update (venv pip or requirements.txt missing)."
+  warn "Skipping Python dependency update (venv pip or requirements file missing)."
 fi
 
 if [ -f "$FRONTEND_DIR/package.json" ]; then

+ 5 - 5
installers/windows/build.py

@@ -9,7 +9,7 @@ Steps:
     1. Download python.org embeddable distribution for Windows x64
     2. Configure embedded Python (allow site-packages)
     3. Bootstrap pip into the embedded distribution
-    4. Install ``requirements.txt`` into the embedded Python
+    4. Install ``requirements.lock`` into the embedded Python
     5. Build the React frontend (``frontend/npm run build``)
     6. Stage backend source + frontend bundle
     7. Download NSSM
@@ -185,10 +185,10 @@ def stage_vcruntime(python_dir: Path) -> None:
 
 
 def install_requirements(python_dir: Path) -> None:
-    """Install Bambuddy's requirements.txt into the embedded Python."""
+    """Install Bambuddy's pinned dependencies (requirements.lock) into the embedded Python."""
     py = python_dir / "python.exe"
-    requirements = REPO_ROOT / "requirements.txt"
-    log(f"installing requirements.txt into {python_dir}")
+    requirements = REPO_ROOT / "requirements.lock"
+    log(f"installing requirements.lock into {python_dir}")
     subprocess.run(
         [
             str(py),
@@ -378,7 +378,7 @@ def main() -> int:
         log("ERROR: this build script must run on Windows.")
         log("")
         log("It downloads a Windows embeddable Python distribution and")
-        log("pip-installs Bambuddy's requirements.txt against it — both")
+        log("pip-installs Bambuddy's requirements.lock against it — both")
         log("require executing python.exe, which only runs on Windows.")
         log("")
         log("Supported build paths:")

+ 129 - 76
requirements-typecheck.lock

@@ -1,207 +1,257 @@
 # This file was autogenerated by uv via the following command:
 #    uv pip compile requirements-typecheck.txt --python-version 3.11 --python-platform x86_64-unknown-linux-gnu -o requirements-typecheck.lock
 aiofiles==25.1.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 aioftp==0.28.3
-    # via -r requirements.txt
+    # via -r requirements.lock
 aiohappyeyeballs==2.7.1
-    # via aiohttp
+    # via
+    #   -r requirements.lock
+    #   aiohttp
 aiohttp==3.14.4
-    # via -r requirements.txt
+    # via -r requirements.lock
 aiosignal==1.4.0
-    # via aiohttp
+    # via
+    #   -r requirements.lock
+    #   aiohttp
 aiosqlite==0.22.1
-    # via -r requirements.txt
+    # via -r requirements.lock
 annotated-doc==0.0.5
-    # via fastapi
+    # via
+    #   -r requirements.lock
+    #   fastapi
 annotated-types==0.8.0
-    # via pydantic
+    # via
+    #   -r requirements.lock
+    #   pydantic
 anyio==4.15.1
     # via
+    #   -r requirements.lock
     #   httpx
     #   starlette
     #   watchfiles
 asyncpg==0.32.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 asyncssh==2.24.1
-    # via -r requirements.txt
+    # via -r requirements.lock
 attrs==26.1.0
-    # via aiohttp
+    # via
+    #   -r requirements.lock
+    #   aiohttp
 basedpyright==1.40.2
     # via -r requirements-typecheck.txt
 bcrypt==5.0.0
-    # via passlib
+    # via -r requirements.lock
 certifi==2026.7.22
     # via
-    #   -r requirements.txt
+    #   -r requirements.lock
     #   curl-cffi
     #   httpcore
     #   httpx
 cffi==2.1.1
     # via
+    #   -r requirements.lock
     #   cryptography
     #   curl-cffi
 charset-normalizer==3.5.2
-    # via reportlab
+    # via
+    #   -r requirements.lock
+    #   reportlab
 click==8.5.0
-    # via uvicorn
+    # via
+    #   -r requirements.lock
+    #   uvicorn
 contourpy==1.3.3
-    # via matplotlib
+    # via
+    #   -r requirements.lock
+    #   matplotlib
 cryptography==50.0.2
     # via
-    #   -r requirements.txt
+    #   -r requirements.lock
     #   asyncssh
     #   pyopenssl
 curl-cffi==0.16.3
-    # via -r requirements.txt
+    # via -r requirements.lock
 cycler==0.12.1
-    # via matplotlib
+    # via
+    #   -r requirements.lock
+    #   matplotlib
 defusedxml==0.7.1
-    # via -r requirements.txt
+    # via -r requirements.lock
 et-xmlfile==2.0.0
-    # via openpyxl
+    # via
+    #   -r requirements.lock
+    #   openpyxl
 fast-simplification==0.2.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 fastapi==0.135.4
-    # via -r requirements.txt
+    # via -r requirements.lock
 fonttools==4.66.1
-    # via matplotlib
+    # via
+    #   -r requirements.lock
+    #   matplotlib
 frozenlist==1.8.0
     # via
+    #   -r requirements.lock
     #   aiohttp
     #   aiosignal
 greenlet==3.5.6
-    # via -r requirements.txt
+    # via -r requirements.lock
 h11==0.16.0
     # via
+    #   -r requirements.lock
     #   httpcore
     #   uvicorn
 httpcore==1.0.9
-    # via httpx
+    # via
+    #   -r requirements.lock
+    #   httpx
 httptools==0.9.0
-    # via uvicorn
+    # via -r requirements.lock
 httpx==0.28.1
-    # via -r requirements.txt
+    # via -r requirements.lock
 idna==3.20
     # via
-    #   -r requirements.txt
+    #   -r requirements.lock
     #   anyio
     #   httpx
     #   yarl
 iniconfig==2.3.1
-    # via pytest
+    # via
+    #   -r requirements.lock
+    #   pytest
 kiwisolver==1.5.1
-    # via matplotlib
+    # via
+    #   -r requirements.lock
+    #   matplotlib
 ldap3==2.9.1
-    # via -r requirements.txt
+    # via -r requirements.lock
 lxml==6.1.3
-    # via -r requirements.txt
+    # via -r requirements.lock
 matplotlib==3.11.2
-    # via -r requirements.txt
+    # via -r requirements.lock
 multidict==6.9.1
     # via
+    #   -r requirements.lock
     #   aiohttp
     #   yarl
 networkx==3.6.1
-    # via -r requirements.txt
+    # via -r requirements.lock
 nodejs-wheel-binaries==24.19.0
     # via basedpyright
 numpy==2.4.6
     # via
-    #   -r requirements.txt
+    #   -r requirements.lock
     #   contourpy
     #   fast-simplification
     #   matplotlib
     #   opencv-python-headless
     #   trimesh
 opencv-python-headless==5.0.0.93
-    # via -r requirements.txt
+    # via -r requirements.lock
 openpyxl==3.1.5
-    # via -r requirements.txt
+    # via -r requirements.lock
 packaging==26.3
     # via
+    #   -r requirements.lock
     #   matplotlib
     #   pytest
 paho-mqtt==2.1.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 passlib==1.7.4
-    # via -r requirements.txt
+    # via -r requirements.lock
 pillow==12.3.0
     # via
-    #   -r requirements.txt
+    #   -r requirements.lock
     #   matplotlib
-    #   qrcode
     #   reportlab
 pluggy==1.6.0
-    # via pytest
+    # via
+    #   -r requirements.lock
+    #   pytest
 propcache==0.5.4
     # via
+    #   -r requirements.lock
     #   aiohttp
     #   yarl
 psutil==7.2.2
-    # via -r requirements.txt
+    # via -r requirements.lock
 pyasn1==0.6.4
-    # via ldap3
+    # via
+    #   -r requirements.lock
+    #   ldap3
 pycparser==3.11
-    # via cffi
+    # via
+    #   -r requirements.lock
+    #   cffi
 pydantic==2.14.0
     # via
-    #   -r requirements.txt
+    #   -r requirements.lock
     #   fastapi
     #   pydantic-settings
 pydantic-core==2.50.0
-    # via pydantic
+    # via
+    #   -r requirements.lock
+    #   pydantic
 pydantic-settings==2.15.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 pyftpdlib==2.2.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 pygments==2.21.0
-    # via pytest
+    # via
+    #   -r requirements.lock
+    #   pytest
 pyjwt==2.15.1
-    # via -r requirements.txt
+    # via -r requirements.lock
 pyopenssl==26.4.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 pyotp==2.10.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 pyparsing==3.3.3
-    # via matplotlib
+    # via
+    #   -r requirements.lock
+    #   matplotlib
 pypdfium2==5.14.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 pytest==9.1.1
     # via
-    #   -r requirements.txt
+    #   -r requirements.lock
     #   pytest-asyncio
 pytest-asyncio==1.4.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 python-dateutil==2.9.0.post0
-    # via matplotlib
+    # via
+    #   -r requirements.lock
+    #   matplotlib
 python-dotenv==1.2.4
     # via
-    #   -r requirements.txt
+    #   -r requirements.lock
     #   pydantic-settings
-    #   uvicorn
 python-multipart==0.0.32
-    # via -r requirements.txt
+    # via -r requirements.lock
 pyyaml==6.0.3
-    # via uvicorn
+    # via -r requirements.lock
 qrcode==8.2
-    # via -r requirements.txt
+    # via -r requirements.lock
 reportlab==5.0.1
-    # via -r requirements.txt
+    # via -r requirements.lock
 ruff==0.17.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 six==1.17.0
-    # via python-dateutil
+    # via
+    #   -r requirements.lock
+    #   python-dateutil
 sqlalchemy==2.1.4
-    # via -r requirements.txt
+    # via -r requirements.lock
 starlette==1.7.0
     # via
-    #   -r requirements.txt
+    #   -r requirements.lock
     #   fastapi
 trimesh==5.1.1
-    # via -r requirements.txt
+    # via -r requirements.lock
 typing-extensions==4.16.0
     # via
+    #   -r requirements.lock
     #   aiohttp
     #   aiosignal
     #   anyio
@@ -216,18 +266,21 @@ typing-extensions==4.16.0
     #   typing-inspection
 typing-inspection==0.4.4
     # via
+    #   -r requirements.lock
     #   fastapi
     #   pydantic
     #   pydantic-settings
 urllib3==2.8.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 uvicorn==0.54.0
-    # via -r requirements.txt
+    # via -r requirements.lock
 uvloop==0.23.0
-    # via uvicorn
+    # via -r requirements.lock
 watchfiles==1.3.0
-    # via uvicorn
+    # via -r requirements.lock
 websockets==17.2
-    # via uvicorn
+    # via -r requirements.lock
 yarl==1.25.1
-    # via aiohttp
+    # via
+    #   -r requirements.lock
+    #   aiohttp

+ 4 - 3
requirements-typecheck.txt

@@ -6,8 +6,8 @@
 # reports. The baseline in .basedpyright/baseline.json is only valid for the
 # exact versions in the lock, so CI installs the lock, never requirements.txt.
 #
-# Added or changed a dependency in requirements.txt? Add it to the lock, keeping
-# every other pin:
+# Added or changed a dependency in requirements.txt? Regenerate requirements.lock
+# first (see CONTRIBUTING.md), then this lock, keeping every other pin:
 #   uv pip compile requirements-typecheck.txt --python-version 3.11 \
 #       --python-platform x86_64-unknown-linux-gnu -o requirements-typecheck.lock
 # If that moves the pin of a package already in the lock and the type check
@@ -19,7 +19,8 @@
 #   basedpyright --pythonpath <venv>/bin/python --writebaseline
 # and commit lock and baseline together.
 
--r requirements.txt
+# The same versions that ship (see requirements.lock).
+-r requirements.lock
 
 # Exact pin: the baseline is only valid for one checker version.
 basedpyright==1.40.2

+ 253 - 0
requirements.lock

@@ -0,0 +1,253 @@
+# This file was autogenerated by uv via the following command:
+#    uv pip compile requirements.txt --universal --python-version 3.11 -o requirements.lock
+aiofiles==25.1.0
+    # via -r requirements.txt
+aioftp==0.28.3
+    # via -r requirements.txt
+aiohappyeyeballs==2.7.1
+    # via aiohttp
+aiohttp==3.14.4
+    # via -r requirements.txt
+aiosignal==1.4.0
+    # via aiohttp
+aiosqlite==0.22.1
+    # via -r requirements.txt
+annotated-doc==0.0.5
+    # via fastapi
+annotated-types==0.8.0
+    # via pydantic
+anyio==4.15.1
+    # via
+    #   httpx
+    #   starlette
+    #   watchfiles
+asyncpg==0.32.0
+    # via -r requirements.txt
+asyncssh==2.24.1
+    # via -r requirements.txt
+attrs==26.1.0
+    # via aiohttp
+bcrypt==5.0.0
+    # via passlib
+certifi==2026.7.22
+    # via
+    #   -r requirements.txt
+    #   curl-cffi
+    #   httpcore
+    #   httpx
+cffi==2.1.1
+    # via
+    #   cryptography
+    #   curl-cffi
+charset-normalizer==3.5.2
+    # via reportlab
+click==8.5.0
+    # via uvicorn
+colorama==0.4.6 ; sys_platform == 'win32'
+    # via
+    #   pytest
+    #   qrcode
+contourpy==1.3.3 ; python_full_version < '3.12'
+    # via matplotlib
+contourpy==1.4.0 ; python_full_version >= '3.12'
+    # via matplotlib
+cryptography==50.0.2
+    # via
+    #   -r requirements.txt
+    #   asyncssh
+    #   pyopenssl
+curl-cffi==0.16.3
+    # via -r requirements.txt
+cycler==0.12.1
+    # via matplotlib
+defusedxml==0.7.1
+    # via -r requirements.txt
+et-xmlfile==2.0.0
+    # via openpyxl
+fast-simplification==0.2.0
+    # via -r requirements.txt
+fastapi==0.135.4
+    # via -r requirements.txt
+fonttools==4.66.1
+    # via matplotlib
+frozenlist==1.8.0
+    # via
+    #   aiohttp
+    #   aiosignal
+greenlet==3.5.6
+    # via -r requirements.txt
+h11==0.16.0
+    # via
+    #   httpcore
+    #   uvicorn
+httpcore==1.0.9
+    # via httpx
+httptools==0.9.0
+    # via uvicorn
+httpx==0.28.1
+    # via -r requirements.txt
+idna==3.20
+    # via
+    #   -r requirements.txt
+    #   anyio
+    #   httpx
+    #   yarl
+iniconfig==2.3.1
+    # via pytest
+kiwisolver==1.5.1
+    # via matplotlib
+ldap3==2.9.1
+    # via -r requirements.txt
+lxml==6.1.3
+    # via -r requirements.txt
+matplotlib==3.11.2
+    # via -r requirements.txt
+multidict==6.9.1
+    # via
+    #   aiohttp
+    #   yarl
+networkx==3.6.1 ; python_full_version < '3.12'
+    # via -r requirements.txt
+networkx==3.7 ; python_full_version >= '3.12'
+    # via -r requirements.txt
+numpy==2.4.6 ; python_full_version < '3.12'
+    # via
+    #   -r requirements.txt
+    #   contourpy
+    #   fast-simplification
+    #   matplotlib
+    #   opencv-python-headless
+    #   trimesh
+numpy==2.5.3 ; python_full_version >= '3.12'
+    # via
+    #   -r requirements.txt
+    #   contourpy
+    #   fast-simplification
+    #   matplotlib
+    #   opencv-python-headless
+    #   trimesh
+opencv-python-headless==5.0.0.93
+    # via -r requirements.txt
+openpyxl==3.1.5
+    # via -r requirements.txt
+packaging==26.3
+    # via
+    #   matplotlib
+    #   pytest
+paho-mqtt==2.1.0
+    # via -r requirements.txt
+passlib==1.7.4
+    # via -r requirements.txt
+pillow==12.3.0
+    # via
+    #   -r requirements.txt
+    #   matplotlib
+    #   qrcode
+    #   reportlab
+pluggy==1.6.0
+    # via pytest
+propcache==0.5.4
+    # via
+    #   aiohttp
+    #   yarl
+psutil==7.2.2
+    # via -r requirements.txt
+pyasn1==0.6.4
+    # via ldap3
+pyasynchat==1.0.5 ; python_full_version >= '3.12'
+    # via pyftpdlib
+pyasyncore==1.0.5 ; python_full_version >= '3.12'
+    # via
+    #   pyasynchat
+    #   pyftpdlib
+pycparser==3.11 ; implementation_name != 'PyPy'
+    # via cffi
+pydantic==2.14.0
+    # via
+    #   -r requirements.txt
+    #   fastapi
+    #   pydantic-settings
+pydantic-core==2.50.0
+    # via pydantic
+pydantic-settings==2.15.0
+    # via -r requirements.txt
+pyftpdlib==2.2.0
+    # via -r requirements.txt
+pygments==2.21.0
+    # via pytest
+pyjwt==2.15.1
+    # via -r requirements.txt
+pyopenssl==26.4.0
+    # via -r requirements.txt
+pyotp==2.10.0
+    # via -r requirements.txt
+pyparsing==3.3.3
+    # via matplotlib
+pypdfium2==5.14.0
+    # via -r requirements.txt
+pytest==9.1.1
+    # via
+    #   -r requirements.txt
+    #   pytest-asyncio
+pytest-asyncio==1.4.0
+    # via -r requirements.txt
+python-dateutil==2.9.0.post0
+    # via matplotlib
+python-dotenv==1.2.4
+    # via
+    #   -r requirements.txt
+    #   pydantic-settings
+    #   uvicorn
+python-multipart==0.0.32
+    # via -r requirements.txt
+pyyaml==6.0.3
+    # via uvicorn
+qrcode==8.2
+    # via -r requirements.txt
+reportlab==5.0.1
+    # via -r requirements.txt
+ruff==0.17.0
+    # via -r requirements.txt
+six==1.17.0
+    # via python-dateutil
+sqlalchemy==2.1.4
+    # via -r requirements.txt
+starlette==1.7.0
+    # via
+    #   -r requirements.txt
+    #   fastapi
+trimesh==5.1.1
+    # via -r requirements.txt
+typing-extensions==4.16.0
+    # via
+    #   aiohttp
+    #   aiosignal
+    #   anyio
+    #   asyncssh
+    #   fastapi
+    #   pydantic
+    #   pydantic-core
+    #   pyopenssl
+    #   pytest-asyncio
+    #   sqlalchemy
+    #   starlette
+    #   typing-inspection
+typing-inspection==0.4.4
+    # via
+    #   fastapi
+    #   pydantic
+    #   pydantic-settings
+tzdata==2026.5 ; sys_platform == 'win32'
+    # via -r requirements.txt
+urllib3==2.8.0
+    # via -r requirements.txt
+uvicorn==0.54.0
+    # via -r requirements.txt
+uvloop==0.23.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
+    # via uvicorn
+watchfiles==1.3.0
+    # via uvicorn
+websockets==17.2
+    # via uvicorn
+yarl==1.25.1
+    # via aiohttp

+ 4 - 1
spoolbuddy/install/install.sh

@@ -697,7 +697,10 @@ setup_bambuddy_venv() {
 
     run_with_progress "Creating Bambuddy venv" $PYTHON_CMD -m venv venv
     run_with_progress "Upgrading pip" "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
-    run_with_progress "Installing Bambuddy dependencies" "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
+    # The tested pins; requirements.txt only for a checkout from before the lock.
+    local requirements=requirements.lock
+    [ -f "$requirements" ] || requirements=requirements.txt
+    run_with_progress "Installing Bambuddy dependencies" "$INSTALL_PATH/venv/bin/pip" install -r "$requirements"
 
     chown -R "$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER" "$INSTALL_PATH/venv"
 }