diff --git a/README.md b/README.md index 31de0d5..c5549c8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A collection of best-practice guides for coding in Python and Rust, maintained b |-------|-------------| | [Python Guidelines](python/best-practices.md) | Common guidelines for writing clean, idiomatic Python | | [Python Logging](docs/logging.md) | Logging configuration, log levels, and structured logging | +| [uv.lock Guide](docs/uv-lock.md) | Lock file best practices: when to commit, how to update, CI/CD usage | | Rust Guidelines *(coming soon)* | Best practices for safe, performant Rust code | ## Contributing diff --git a/docs/uv-lock.md b/docs/uv-lock.md new file mode 100644 index 0000000..eab41a8 --- /dev/null +++ b/docs/uv-lock.md @@ -0,0 +1,305 @@ +# uv.lock Best Practices + +A concise guide to understanding and managing the `uv.lock` file in Python projects. + +--- + +## Table of Contents + +1. [What is `uv.lock`?](#1-what-is-uvlock) +2. [Why Lock Files Matter](#2-why-lock-files-matter) +3. [When to Commit `uv.lock`](#3-when-to-commit-uvlock) +4. [`uv lock` vs `uv sync`](#4-uv-lock-vs-uv-sync) +5. [Comparison with pip-tools and Poetry](#5-comparison-with-pip-tools-and-poetry) +6. [Updating Dependencies](#6-updating-dependencies) +7. [CI/CD Considerations](#7-cicd-considerations) + +--- + +## 1. What is `uv.lock`? + +`uv.lock` is the lock file generated by [uv](https://github.com/astral-sh/uv). It records the exact resolved versions — including transitive dependencies — of every package in your project's dependency graph. + +`pyproject.toml` expresses *constraints* ("I need `httpx` version 0.27 or later"). `uv.lock` records the *resolution* ("the full graph was solved and `httpx 0.28.1` was chosen, along with its exact transitive dependencies `httpcore 1.0.5`, `certifi 2024.12.14`, …"). Together they give you both flexibility and reproducibility. + +Key properties of `uv.lock`: + +- **Cross-platform.** A single `uv.lock` encodes wheels for all supported platforms and Python versions, so the same file works on macOS, Linux, and Windows. +- **Machine-managed.** The file is generated and updated by `uv` — never edit it by hand. +- **Human-readable.** It uses a TOML-like format you can diff and review in pull requests. +- **Comprehensive.** Records every package in the full dependency graph, not just your direct dependencies. + +``` +# uv.lock (excerpt — never edit manually) +version = 1 +requires-python = ">=3.11" + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +``` + +--- + +## 2. Why Lock Files Matter + +Without a lock file, `pip install -r requirements.txt` (or `uv sync` without a lock) re-resolves the dependency graph on every install. That means: + +- A new version of a transitive dependency released overnight can silently change your environment. +- Two developers cloning the same repo at different times may end up with different packages installed. +- A CI run on Tuesday may pass while a deploy on Wednesday fails — with no code changes in between. + +A lock file eliminates this class of problem by recording the exact versions that were tested and known to work: + +| Without lock file | With lock file | +|-------------------|----------------| +| Resolution happens at install time | Resolution happens once (`uv lock`), result is committed | +| Transitive deps float to latest | Every transitive dep is pinned exactly | +| Installs may differ across machines | All machines install the same graph | +| Bugs from unexpected upgrades are hard to trace | Upgrades are explicit, reviewable, and intentional | + +> **Rule of thumb:** A lock file is a contract between your code and its dependencies. Commit it, review changes to it, and update it deliberately. + +--- + +## 3. When to Commit `uv.lock` + +### Applications — always commit + +An *application* is anything you deploy or distribute as a runnable artefact: a web service, a CLI tool, a data pipeline, a script. For applications, commit `uv.lock` unconditionally. + +- Deployments are reproducible: the exact same packages go to staging and production. +- Developers, CI, and production all share one resolved graph. +- Dependency changes are visible in code review — a reviewer can see when a transitive package was silently upgraded. + +```bash +# For applications: always checked in +git add uv.lock +git commit -m "chore: update uv.lock" +``` + +### Libraries — usually do not commit + +A *library* is a package you publish to PyPI for others to depend on. For libraries, committing `uv.lock` is typically not useful and can cause confusion: + +- The lock file only affects your development environment; it is ignored by anyone who installs your library as a dependency. +- If you commit it, you may mislead contributors into thinking a specific resolution is "required" rather than just "what was convenient when you last ran `uv lock`". +- Lock files for libraries can become stale quickly and add noise to pull requests. + +The recommended approach for libraries is to commit `pyproject.toml` with loose version constraints and add `uv.lock` to `.gitignore`: + +```gitignore +# .gitignore for a library +uv.lock +``` + +**Exception:** If your library repository also contains integration tests, example applications, or a development environment that benefits from reproducibility, you may commit `uv.lock` as a *development convenience* — just make clear in your README that it is not meaningful for end-users. + +--- + +## 4. `uv lock` vs `uv sync` + +These two commands are often confused but serve distinct purposes: + +| Command | What it does | +|---------|-------------| +| `uv lock` | Resolves the dependency graph and **writes** (or updates) `uv.lock`. Does **not** install anything. | +| `uv sync` | Reads `uv.lock` and **installs** the exact pinned packages into the virtual environment. | + +```bash +# Resolve and record — run when you change pyproject.toml +uv lock + +# Install from the existing lock file — run to set up or refresh your environment +uv sync + +# Both at once: re-resolve AND install (common during development) +uv sync # uv sync always re-locks if pyproject.toml changed +``` + +**When to run each:** + +- Run `uv lock` after editing `pyproject.toml` (adding, removing, or changing dependency constraints) to update the lock file before committing. +- Run `uv sync` when setting up a fresh clone, after pulling changes, or in CI to install from the committed lock file. +- Use `uv sync --frozen` in CI or production deployments to install exactly what is in the committed lock file and fail if `pyproject.toml` and `uv.lock` are out of sync (see [CI/CD Considerations](#7-cicd-considerations)). + +--- + +## 5. Comparison with pip-tools and Poetry + +If you are coming from another tool, here is how `uv.lock` maps to what you already know: + +| Feature | pip-tools + `requirements.txt` | Poetry + `poetry.lock` | uv + `uv.lock` | +|---------|-------------------------------|------------------------|----------------| +| Lock format | Plain text `requirements.txt` | TOML (`poetry.lock`) | TOML-like (`uv.lock`) | +| Cross-platform single file | No — one file per platform/extras | Yes | Yes | +| Transitive deps pinned | Yes (compiled output) | Yes | Yes | +| Dev / optional groups | Manual (separate files) | Dependency groups | Dependency groups | +| Speed | Slow (pure Python) | Moderate | Very fast (Rust) | +| Editable installs | Requires `pip install -e` | Built-in | Built-in | +| `pyproject.toml` native | Partial (`pip-compile` reads it) | Yes | Yes | + +**Migrating from pip-tools:** + +```bash +# Before (pip-tools) +pip-compile pyproject.toml -o requirements.txt +pip install -r requirements.txt + +# After (uv) +uv lock # generates uv.lock +uv sync # installs from uv.lock +``` + +**Migrating from Poetry:** + +```bash +# Before (Poetry) +poetry lock +poetry install + +# After (uv) +uv lock +uv sync +``` + +If you have an existing `requirements.txt` you want to preserve for legacy tooling, `uv` can still export to that format: + +```bash +uv export --format requirements-txt > requirements.txt +``` + +--- + +## 6. Updating Dependencies + +The lock file should be updated deliberately, not silently. `uv` gives you fine-grained control over what gets updated. + +### Upgrade all packages + +```bash +uv lock --upgrade +uv sync +``` + +This re-resolves the entire dependency graph against the latest available versions that still satisfy your `pyproject.toml` constraints, then updates `uv.lock` with the result. + +### Upgrade a single package + +```bash +uv lock --upgrade-package httpx +uv sync +``` + +Only the specified package (and its transitive dependencies, if necessary) is upgraded. Everything else in the lock file stays pinned at its current version. This is the safest way to take a targeted security patch. + +### Add or remove a dependency + +```bash +# Adding a new dependency updates pyproject.toml AND uv.lock +uv add requests + +# Removing a dependency updates pyproject.toml AND uv.lock +uv remove requests +``` + +`uv add` and `uv remove` always keep `uv.lock` in sync automatically — no need to run `uv lock` separately afterwards. + +### Review what changed + +After any update, review the diff before committing: + +```bash +git diff uv.lock +``` + +Look for unexpected upgrades to transitive dependencies. A surprising change to a deep transitive package is worth investigating before merging. + +--- + +## 7. CI/CD Considerations + +### Enforce the lock file with `--frozen` + +In CI and production deployments, always install with `--frozen` to ensure the environment exactly matches the committed lock file and to catch any accidental drift: + +```bash +uv sync --frozen +``` + +`--frozen` fails with an error if `pyproject.toml` and `uv.lock` are out of sync (e.g., a developer added a dependency to `pyproject.toml` but forgot to commit the updated `uv.lock`). This turns a silent "works differently in CI" bug into an immediate, obvious failure. + +### Example GitHub Actions workflow + +```yaml +name: CI + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install + + - name: Install dependencies (frozen) + run: uv sync --frozen --all-extras + + - name: Run tests + run: uv run pytest tests/ -v +``` + +### Cache the virtual environment + +Caching `.venv` between CI runs significantly reduces install time, especially for large dependency graphs: + +```yaml + - name: Cache virtual environment + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }} + restore-keys: | + venv-${{ runner.os }}- + + - name: Install dependencies (frozen) + run: uv sync --frozen --all-extras +``` + +The cache key includes `uv.lock`'s hash, so the cache is automatically invalidated whenever the lock file changes. + +### Verify the lock file is up to date in CI + +Add a check to catch PRs where `pyproject.toml` was modified but `uv.lock` was not updated: + +```yaml + - name: Check lock file is up to date + run: uv lock --check +``` + +`uv lock --check` exits with a non-zero status if the current `uv.lock` does not reflect the current `pyproject.toml` constraints, without modifying any files. + +--- + +## Further Reading + +- [uv documentation — Lock files](https://docs.astral.sh/uv/concepts/projects/sync/) +- [uv documentation — `uv lock`](https://docs.astral.sh/uv/reference/cli/#uv-lock) +- [uv documentation — `uv sync`](https://docs.astral.sh/uv/reference/cli/#uv-sync) +- [astral-sh/setup-uv — GitHub Actions integration](https://github.com/astral-sh/setup-uv) +- [pip-tools documentation](https://pip-tools.readthedocs.io/) +- [Poetry documentation — Lock file](https://python-poetry.org/docs/basic-usage/#installing-with-poetrylock)