Use project-pinned uv and check min version in prek hooks (#65531)

* Prefer project-pinned uv and enforce its minimum version in prek hooks

Prek hooks that invoke uv (directly or via breeze) now prefer
`.venv/bin/uv` from the project's main virtualenv over PATH uv so the uv
version is pinned by the project, and verify it satisfies the minimum
declared in `[tool.uv] required-version` in the root pyproject.toml. If
the main-venv uv is missing, the mypy local runner falls back to PATH uv
with a warning. An outdated uv fails fast with upgrade instructions
instead of emitting confusing downstream errors.

* Extend breeze environment checks with uv version verification

`breeze`'s `perform_environment_checks` already validates Docker and
docker-compose versions before running any command; extend it with a
matching `check_uv_version` that reads `[tool.uv] required-version` from
the root `pyproject.toml` and confirms the `uv` on PATH satisfies it,
matching the pattern the prek hooks now use. Users get the same clear
"upgrade uv to X.Y.Z" error from `breeze` commands that they'd see from
a standalone prek hook, instead of a confusing failure deeper in the
shell bootstrap.

* Document that [tool.uv] required-version is not auto-upgraded

`[tool.uv] required-version` is the minimum uv contributors must have
installed, not the exact uv version CI pins to. Bumping it on every uv
release would force every contributor to upgrade uv in lockstep, which
is far more churn than the floor check is worth.

Leave an explicit comment both at the declaration site and in
`scripts/ci/prek/upgrade_important_versions.py` (where `UV_PATTERNS`
could be tempted to pick it up) so a future contributor doesn't wire it
into the automated bump by accident. The breeze/prek uv version checks
already read it dynamically and tolerate a stale floor.

* Move uv version check out of docker utils into environment_check

The uv version check is not docker-specific — it applies to any breeze
entry point that invokes uv, including flows that never talk to Docker.
Move `check_uv_version` and `_read_required_uv_version` from
`docker_command_utils` into the generic `environment_check` module, use
`packaging.version.parse` directly instead of docker-scoped
`compare_version`, and move the unit tests alongside.

`perform_environment_checks` continues to call it after the Docker
checks so the behavior is unchanged.

* Propagate manual [tool.uv] required-version bumps into test fixtures

`[tool.uv] required-version` is bumped manually (by design — see the
comment next to it in pyproject.toml). When it moves, any hard-coded uv
version in tests that represents that floor needs to move too or the
fixtures silently drift.

Add a `sync-uv-min-version-markers` prek hook that reads the current
`required-version` and rewrites any line tagged with a
`# sync-uv-min-version` marker to match. The hook fails (prek
convention) when it makes a change, so the contributor just re-stages.

The existing `check_uv_version` tests carry the marker on their mocked
floor constant so a future bump to `required-version` will propagate
through the marker and keep the success-path assertions in sync.
This commit is contained in:
Jarek Potiuk authored and GitHub committed 2026-04-21 01:02:34 +02:00
1 parent 26e2e786f4
commit 0a67541ba0
12 files changed
+520 -3

No files matched your search

+12
View File
@@ -235,6 +235,18 @@ repos:
^pyproject\.toml$
pass_filenames: false
require_serial: true
- id: sync-uv-min-version-markers
name: Sync `# sync-uv-min-version` markers with [tool.uv] required-version
entry: ./scripts/ci/prek/sync_uv_min_version_markers.py
language: python
# Hook reads pyproject.toml itself; the file list selects places that may carry
# the `# sync-uv-min-version` marker. Any new test/file using the marker should
# be added here.
files: >
(?x)
^pyproject\.toml$|
^dev/breeze/tests/test_environment_check\.py$
require_serial: true
- id: check-distribution-gitignore
name: Check distribution .gitignore files have *.iml
entry: ./scripts/ci/prek/check_distribution_gitignore.py
+1 -1
View File
@@ -30,7 +30,7 @@
- **Run other suites of tests** `breeze testing <test_group>` (test groups: `airflow-ctl-tests`, `docker-compose-tests`, `task-sdk-tests`)
- **Run scripts tests:** `uv run --project scripts pytest scripts/tests/ -xvs`
- **Run Airflow CLI:** `breeze run airflow dags list`
- **Type-check (non-providers):** run the prek hook — `prek run mypy-<project> --all-files` (e.g. `mypy-airflow-core`, `mypy-task-sdk`, `mypy-shared-logging`; each `shared/<dist>` workspace member has its own `mypy-shared-<dist>` hook). The hook uses a dedicated virtualenv and mypy cache under `.build/mypy-venvs/<hook>/` and `.build/mypy-caches/<hook>/`; mypy itself is installed from `uv.lock` via the `mypy` dependency group (`uv sync --group mypy`), so it never mutates your project `.venv`. Clear with `breeze down --cleanup-mypy-cache`.
- **Type-check (non-providers):** run the prek hook — `prek run mypy-<project> --all-files` (e.g. `mypy-airflow-core`, `mypy-task-sdk`, `mypy-shared-logging`; each `shared/<dist>` workspace member has its own `mypy-shared-<dist>` hook). The hook uses a dedicated virtualenv and mypy cache under `.build/mypy-venvs/<hook>/` and `.build/mypy-caches/<hook>/`; mypy itself is installed from `uv.lock` via the `mypy` dependency group (`uv sync --group mypy`), so it never mutates your project `.venv`. The hook prefers `uv` from the project's main `.venv/bin/uv` (installed by `uv sync` — `uv` is part of the `dev` dependency group via the `all` extras) for a project-pinned uv version; it falls back to `uv` on `$PATH` with a warning if that binary is missing. Clear with `breeze down --cleanup-mypy-cache`.
- **Type-check (providers):** `breeze run mypy path/to/code`
- **Lint with ruff only:** `prek run ruff --from-ref <target_branch>`
- **Format with ruff only:** `prek run ruff-format --from-ref <target_branch>`
@@ -299,6 +299,20 @@ dependency set:
- virtualenvs: ``.build/mypy-venvs/<hook-name>/``
- mypy caches: ``.build/mypy-caches/<hook-name>/``
The hook prefers ``uv`` from your project's main ``.venv/bin/uv`` — so the uv version used to sync
and run mypy is pinned by the project rather than whatever ``uv`` is on your ``PATH``. If that
binary is missing, the hook falls back to ``uv`` on ``PATH`` and prints a warning. ``uv`` is part
of the ``dev`` dependency group via the ``all`` extras, so a plain sync installs it:
.. code-block:: bash
uv sync
Prek hooks that invoke ``uv`` (directly or via ``breeze``) also verify that the ``uv`` they are
about to run is at least the version pinned in ``[tool.uv] required-version`` in the root
``pyproject.toml``. If your ``uv`` is older, the hook fails fast with an instruction to run
``uv self update`` (or ``uv sync`` to refresh the project-pinned uv).
Adding a new shared library
~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -58,6 +58,7 @@ from airflow_breeze.global_constants import (
MIN_DOCKER_VERSION,
)
from airflow_breeze.utils.console import Output, console_print, get_console
from airflow_breeze.utils.environment_check import check_uv_version
from airflow_breeze.utils.run_utils import (
RunCommandResult,
check_if_buildx_plugin_installed,
@@ -609,6 +610,7 @@ def perform_environment_checks(quiet: bool = False):
check_docker_compose_version(quiet)
check_windows_filesystem_mount(quiet)
check_executable_entrypoint_permissions(quiet)
check_uv_version(quiet)
if not quiet:
console_print(f"[success]Host python version is {sys.version}[/]")
@@ -17,8 +17,98 @@
from __future__ import annotations
import os
import re
import sys
from airflow_breeze.utils.console import console_print
from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
from airflow_breeze.utils.run_utils import run_command
try:
from packaging import version
except ImportError:
# Autocomplete can run with only click installed; defer the ImportError until a
# caller actually needs version comparison.
version = None # type: ignore[assignment]
def is_ci_environment():
"""Check if running in CI environment."""
return os.environ.get("CI", "").lower() in ("true", "1", "yes")
def _read_required_uv_version() -> str | None:
"""Parse ``[tool.uv] required-version = ">=X.Y.Z"`` from the root ``pyproject.toml``.
Returns the raw ``X.Y.Z`` string, or ``None`` if the declaration is missing or
unparsable — in which case the caller should skip the check rather than fail the
environment bootstrap.
"""
pyproject = (AIRFLOW_ROOT_PATH / "pyproject.toml").read_text()
# Narrow to the [tool.uv] section so we don't pick up an unrelated required-version.
section = re.search(r"^\[tool\.uv\]\s*$(?P<body>.*?)(?=^\[|\Z)", pyproject, re.MULTILINE | re.DOTALL)
if not section:
return None
match = re.search(
r"""^\s*required-version\s*=\s*["']\s*>=\s*(?P<ver>\d+(?:\.\d+){0,2})\s*["']""",
section.group("body"),
re.MULTILINE,
)
return match.group("ver") if match else None
def check_uv_version(quiet: bool = False):
"""Fail the environment check if ``uv`` on PATH is older than the project minimum.
The minimum is declared as ``[tool.uv] required-version`` in the root
``pyproject.toml``. Lives here (not in ``docker_command_utils``) because the check
is not docker-specific — it applies to any breeze entry point that invokes ``uv``,
including flows that never talk to Docker.
"""
required_uv_version = _read_required_uv_version()
if required_uv_version is None:
if not quiet:
console_print(
"[warning]Could not read `[tool.uv] required-version` from pyproject.toml — "
"skipping uv version check.[/]"
)
return
uv_version_result = run_command(
["uv", "--version"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
check=False,
dry_run_override=False,
)
if uv_version_result.returncode != 0:
console_print(
f"""
[error]`uv` is not installed or not on PATH.[/]
[warning]Breeze needs uv >= {required_uv_version} to manage its environments.[/]
See https://docs.astral.sh/uv/getting-started/installation/ for installation instructions.
"""
)
sys.exit(1)
match = re.search(r"\b(\d+\.\d+(?:\.\d+)?)", uv_version_result.stdout)
if not match:
if not quiet:
console_print(
f"[warning]Unexpected `uv --version` output: {uv_version_result.stdout!r} — "
"skipping uv version check.[/]"
)
return
uv_version = match.group(1)
if version.parse(uv_version) >= version.parse(required_uv_version):
if not quiet:
console_print(f"[success]Good version of uv: {uv_version}.[/]")
else:
console_print(
f"""
[error]Your version of uv is too old: {uv_version}.\n[/]
[warning]Please upgrade to at least {required_uv_version}.\n[/]
Run `uv self update` (or reinstall following
https://docs.astral.sh/uv/getting-started/installation/).
"""
)
sys.exit(1)
@@ -0,0 +1,86 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from unittest import mock
import pytest
from airflow_breeze.utils.environment_check import check_uv_version
# Kept in sync with `[tool.uv] required-version` in the root pyproject.toml by the
# `sync-uv-min-version-markers` prek hook. Any line tagged with the
# `# sync-uv-min-version` marker is auto-rewritten when required-version is bumped
# manually. Tests that exercise the success path set both the mocked floor AND the
# mocked "actual uv" to this value so the ok-case keeps passing after a bump.
_MIN_UV = "0.9.17" # sync-uv-min-version
@mock.patch("airflow_breeze.utils.environment_check._read_required_uv_version")
@mock.patch("airflow_breeze.utils.environment_check.run_command")
@mock.patch("airflow_breeze.utils.environment_check.console_print")
def test_check_uv_version_ok(mock_console_print, mock_run_command, mock_required_version):
mock_required_version.return_value = _MIN_UV
mock_run_command.return_value.returncode = 0
mock_run_command.return_value.stdout = f"uv {_MIN_UV} (abc 2026-03-26 aarch64-apple-darwin)"
check_uv_version()
mock_run_command.assert_called_with(
["uv", "--version"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
check=False,
dry_run_override=False,
)
mock_console_print.assert_called_with(f"[success]Good version of uv: {_MIN_UV}.[/]")
@mock.patch("airflow_breeze.utils.environment_check._read_required_uv_version")
@mock.patch("airflow_breeze.utils.environment_check.run_command")
@mock.patch("airflow_breeze.utils.environment_check.console_print")
def test_check_uv_version_too_low(mock_console_print, mock_run_command, mock_required_version):
mock_required_version.return_value = _MIN_UV
mock_run_command.return_value.returncode = 0
# Deliberately far below any plausible `required-version` so this test stays valid
# without needing the sync marker.
mock_run_command.return_value.stdout = "uv 0.0.1 (fake)"
with pytest.raises(SystemExit) as e:
check_uv_version()
assert e.value.code == 1
@mock.patch("airflow_breeze.utils.environment_check._read_required_uv_version")
@mock.patch("airflow_breeze.utils.environment_check.run_command")
@mock.patch("airflow_breeze.utils.environment_check.console_print")
def test_check_uv_version_not_installed(mock_console_print, mock_run_command, mock_required_version):
mock_required_version.return_value = _MIN_UV
mock_run_command.return_value.returncode = 1
mock_run_command.return_value.stdout = ""
with pytest.raises(SystemExit) as e:
check_uv_version()
assert e.value.code == 1
@mock.patch("airflow_breeze.utils.environment_check._read_required_uv_version")
@mock.patch("airflow_breeze.utils.environment_check.run_command")
@mock.patch("airflow_breeze.utils.environment_check.console_print")
def test_check_uv_version_missing_declaration_skips(
mock_console_print, mock_run_command, mock_required_version
):
mock_required_version.return_value = None
check_uv_version()
mock_run_command.assert_not_called()
+5
View File
@@ -1349,6 +1349,11 @@ leveldb = [
]
[tool.uv]
# Bump this only when the project actually relies on a newer uv feature/fix. It is a
# minimum contributors must install, NOT the uv CI pins to — keeping it in lockstep
# with AIRFLOW_UV_VERSION would force everyone to upgrade uv on every release. The
# breeze/prek uv version checks read this value dynamically and tolerate a stale
# floor; `scripts/ci/prek/upgrade_important_versions.py` deliberately skips it.
required-version = ">=0.9.17"
no-build-isolation-package = ["sphinx-redoc"]
# Synchroonize with scripts/ci/prek/upgrade_important_versions.py
+92
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import ast
import difflib
import os
import re
import shlex
import shutil
import subprocess
@@ -202,6 +203,95 @@ def insert_documentation(
return False
_UV_REQUIRED_VERSION_RE = re.compile(
r"""^\s*required-version\s*=\s*["']\s*>=\s*(?P<ver>\d+(?:\.\d+){0,2})\s*["']""",
re.MULTILINE,
)
def _parse_version(ver: str) -> tuple[int, ...]:
"""Turn "0.9.17" into (0, 9, 17). Extra pre-release/build suffixes are ignored."""
match = re.match(r"^(\d+(?:\.\d+)*)", ver.strip())
if not match:
raise ValueError(f"Cannot parse version: {ver!r}")
return tuple(int(part) for part in match.group(1).split("."))
def read_uv_required_min_version() -> tuple[str, tuple[int, ...]]:
"""Read the minimum uv version from the root ``pyproject.toml``.
Parses ``[tool.uv] required-version = ">=X.Y.Z"`` and returns ``(raw, tuple)``.
We parse by regex to avoid pulling a TOML dep into every prek script.
"""
pyproject = (AIRFLOW_ROOT_PATH / "pyproject.toml").read_text()
# Narrow to the [tool.uv] section so we don't match a different required-version.
match = re.search(r"^\[tool\.uv\]\s*$(?P<body>.*?)(?=^\[|\Z)", pyproject, re.MULTILINE | re.DOTALL)
if not match:
raise RuntimeError("`[tool.uv]` section not found in root pyproject.toml")
ver_match = _UV_REQUIRED_VERSION_RE.search(match.group("body"))
if not ver_match:
raise RuntimeError('`required-version = ">=X.Y.Z"` not found under `[tool.uv]` in pyproject.toml')
raw = ver_match.group("ver")
return raw, _parse_version(raw)
def check_uv_version(uv_bin: str = "uv") -> None:
"""Fail the hook if ``uv_bin`` is older than ``[tool.uv] required-version``.
Called manually by prek hooks that invoke ``uv`` (directly or via breeze) so a
contributor with an outdated uv sees a clear error before the hook spends time
running and emits a confusing downstream failure.
"""
try:
raw_min, min_tuple = read_uv_required_min_version()
except Exception as exc:
# Don't block hooks on parse bugs — warn and continue.
message = f"Could not determine required uv version from pyproject.toml: {exc}"
if console:
console.print(f"[yellow]WARNING: {message}")
else:
print(f"WARNING: {message}")
return
try:
output = subprocess.check_output([uv_bin, "--version"], text=True).strip()
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
message = (
f"Could not run `{uv_bin} --version` to verify uv version (required: >= {raw_min}). Error: {exc}"
)
if console:
console.print(f"[red]{message}")
else:
print(message)
sys.exit(1)
match = re.search(r"\b(\d+\.\d+(?:\.\d+)?)", output)
if not match:
message = f"Unexpected `uv --version` output: {output!r}"
if console:
console.print(f"[yellow]WARNING: {message}")
else:
print(f"WARNING: {message}")
return
actual_raw = match.group(1)
actual_tuple = _parse_version(actual_raw)
if actual_tuple < min_tuple:
message = (
f"uv {actual_raw} at `{uv_bin}` is older than the project-required "
f">= {raw_min} (see `[tool.uv] required-version` in pyproject.toml). "
"Upgrade uv before running this hook, e.g.:\n"
" uv self update\n"
"or, to refresh the project-pinned uv in the main venv (included in the "
"`dev` dependency group via the `all` extras):\n"
" uv sync\n"
)
if console:
console.print(f"[red]{message}")
else:
print(message)
sys.exit(1)
def initialize_breeze_prek(name: str, file: str):
if name not in ("__main__", "__mp_main__"):
raise SystemExit(
@@ -212,6 +302,8 @@ def initialize_breeze_prek(name: str, file: str):
if os.environ.get("SKIP_BREEZE_PREK_HOOKS"):
console.print("[yellow]Skipping breeze prek hooks as SKIP_BREEZE_PREK_HOOKS is set")
sys.exit(0)
# Breeze itself runs under uv, so enforce the project's minimum uv version up front.
check_uv_version()
if shutil.which("breeze") is None:
console.print(
"[red]The `breeze` command is not on path.[/]\n\n"
@@ -39,6 +39,7 @@ from pathlib import Path
from common_prek_utils import (
AIRFLOW_ROOT_PATH,
check_uv_version,
)
CI = os.environ.get("CI")
@@ -182,13 +183,37 @@ def run_local_mypy(project: str, hook_name: str, files: list[str], config_file:
mypy_venv_dir.parent.mkdir(parents=True, exist_ok=True)
mypy_cache_dir.parent.mkdir(parents=True, exist_ok=True)
# Prefer the uv binary installed in the project's main .venv so the uv version used
# to sync and run mypy is pinned by the project rather than whatever uv happens to
# be on the contributor's PATH. Fall back to PATH `uv` with a warning if missing.
main_venv_uv = AIRFLOW_ROOT_PATH / ".venv" / "bin" / "uv"
if main_venv_uv.exists():
uv_bin = str(main_venv_uv)
else:
uv_bin = "uv"
warning = (
f"uv not found in the main project virtualenv at {main_venv_uv}; falling back "
"to `uv` on PATH. The project-pinned uv gives deterministic sync/run results — "
"sync the main venv to get it (uv is included in the `dev` dependency group via "
"the `all` extras):\n"
" uv sync\n"
)
if console:
console.print(f"[yellow]WARNING: {warning}")
else:
print(f"WARNING: {warning}")
# Verify the uv we're about to invoke meets the project-required minimum (from
# `[tool.uv] required-version` in the root pyproject.toml).
check_uv_version(uv_bin)
run_env = {
**os.environ,
"TERM": "ansi",
"UV_PROJECT_ENVIRONMENT": str(mypy_venv_dir),
}
sync_cmd = ["uv", "sync", "--frozen", "--project", project, "--group", "mypy"]
sync_cmd = [uv_bin, "sync", "--frozen", "--project", project, "--group", "mypy"]
if console:
console.print(
f"[magenta]Syncing dedicated mypy virtualenv ({mypy_venv_dir}) "
@@ -226,7 +251,7 @@ def run_local_mypy(project: str, hook_name: str, files: list[str], config_file:
# same file across hooks because each hook's virtualenv installs a different dependency
# set that influences type inference.
cmd = [
"uv",
uv_bin,
"run",
"--frozen",
"--project",
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# /// script
# requires-python = ">=3.10,<3.11"
# dependencies = [
# "rich>=13.6.0",
# ]
# ///
"""Keep ``# sync-uv-min-version`` markers in lockstep with ``[tool.uv] required-version``.
``[tool.uv] required-version`` in the root ``pyproject.toml`` is a deliberate, manual
bump — we intentionally do not wire it into ``upgrade_important_versions.py`` (see the
comment next to ``UV_PATTERNS`` there). But when a contributor does bump it, every
hard-coded uv version in tests that is meant to track that floor needs to move too,
or test fixtures silently drift out of sync.
This hook finds lines of the form::
foo = "X.Y.Z" # sync-uv-min-version
and rewrites the ``X.Y.Z`` to match the current ``required-version``. Mark any line
whose string value should equal the current minimum uv version — typically mocked
``_read_required_uv_version`` return values and matching "actual uv" mocks in tests
that exercise the equality/success path.
Exit code ``0`` means nothing changed; exit code ``1`` means the hook updated a file
and the contributor should re-stage it (prek convention).
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from common_prek_utils import (
console,
read_uv_required_min_version,
)
MARKER_RE = re.compile(
r"""(?P<prefix>["'])(?P<version>\d+(?:\.\d+){0,2})(?P<suffix>["']\s*)#\s*sync-uv-min-version\b""",
)
def sync_file(path: Path, required_version: str) -> bool:
"""Rewrite any ``# sync-uv-min-version``-marked version in ``path`` to ``required_version``.
Returns ``True`` if the file was modified.
"""
original = path.read_text()
def _replace(match: re.Match[str]) -> str:
if match.group("version") == required_version:
return match.group(0)
return f"{match.group('prefix')}{required_version}{match.group('suffix')}# sync-uv-min-version"
updated = MARKER_RE.sub(_replace, original)
if updated == original:
return False
path.write_text(updated)
return True
def main(argv: list[str]) -> int:
files = [Path(arg) for arg in argv[1:]]
if not files:
return 0
try:
required_version, _ = read_uv_required_min_version()
except Exception as exc:
console.print(
f"[red]ERROR: could not read `[tool.uv] required-version` from pyproject.toml: {exc}[/]"
)
return 1
modified: list[Path] = []
for file in files:
if sync_file(file, required_version):
modified.append(file)
if modified:
console.print(
f"[yellow]Synced `# sync-uv-min-version` markers to uv {required_version} "
"in the following files:[/]"
)
for file in modified:
console.print(f" - {file}")
console.print(
"[yellow]Please review the changes and re-stage the files.[/]\n"
"[info]Tip: every line carrying `# sync-uv-min-version` is kept equal to "
r"`\[tool.uv] required-version` in the root pyproject.toml. Bump that "
"first, then let this hook propagate the value into the marked test "
"fixtures.[/]"
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
@@ -408,6 +408,13 @@ UV_PATTERNS: list[tuple[re.Pattern, Quoting]] = [
(re.compile(r"^(\s*UV_VERSION = )(\"[0-9.abrc]+\")", re.MULTILINE), Quoting.DOUBLE_QUOTED),
(re.compile(r"^(\s*UV_VERSION=)(\"[0-9.abrc]+\")", re.MULTILINE), Quoting.DOUBLE_QUOTED),
(re.compile(r"(\| *`AIRFLOW_UV_VERSION` *\| *)(`[0-9.abrd]+`)( *\|)"), Quoting.REVERSE_SINGLE_QUOTED),
# Intentionally NOT matching `[tool.uv] required-version = ">=X.Y.Z"` in the root
# pyproject.toml. That value is a hard minimum contributors must have installed —
# not the exact uv version CI ships with. Bumping it on every uv release would force
# the whole contributor base to upgrade uv in lockstep, which is far more churn than
# the check is worth. `required-version` stays a deliberate, manual bump only. If
# you're tempted to auto-track it here, don't — the breeze/prek uv version check
# reads it dynamically and tolerates a stale floor.
(
re.compile(
r"(\")([0-9.abrc]+)(\" {2}# Keep this comment to "
@@ -0,0 +1,70 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from ci.prek.sync_uv_min_version_markers import MARKER_RE, sync_file
class TestSyncFile:
def test_updates_marked_version(self, tmp_path):
path = tmp_path / "fixture.py"
path.write_text(
'_MIN_UV = "0.9.17" # sync-uv-min-version\n'
'_other = "0.9.17"\n'
'stdout = f"uv {_MIN_UV} (fake)" # no marker — unchanged\n'
)
assert sync_file(path, "0.12.0") is True
contents = path.read_text()
assert '_MIN_UV = "0.12.0" # sync-uv-min-version\n' in contents
# Non-marked literal stays untouched.
assert '_other = "0.9.17"\n' in contents
def test_noop_when_already_synced(self, tmp_path):
path = tmp_path / "fixture.py"
original = '_MIN_UV = "0.12.0" # sync-uv-min-version\n'
path.write_text(original)
assert sync_file(path, "0.12.0") is False
assert path.read_text() == original
def test_handles_multiple_markers(self, tmp_path):
path = tmp_path / "fixture.py"
path.write_text(
'_FLOOR = "0.9.17" # sync-uv-min-version\n'
'_MATCHING = "0.9.17" # sync-uv-min-version (matching actual uv)\n'
)
assert sync_file(path, "0.12.0") is True
contents = path.read_text()
assert contents.count('"0.12.0" # sync-uv-min-version') == 2
def test_supports_single_quotes(self, tmp_path):
path = tmp_path / "fixture.py"
path.write_text("_MIN_UV = '0.9.17' # sync-uv-min-version\n")
assert sync_file(path, "0.12.0") is True
assert "_MIN_UV = '0.12.0' # sync-uv-min-version\n" in path.read_text()
class TestMarkerRegex:
def test_matches_marker_with_double_quotes(self):
match = MARKER_RE.search('x = "0.9.17" # sync-uv-min-version')
assert match is not None
assert match.group("version") == "0.9.17"
def test_does_not_match_unmarked_version(self):
assert MARKER_RE.search('x = "0.9.17"') is None
def test_does_not_match_other_markers(self):
assert MARKER_RE.search('x = "0.9.17" # Keep this comment to allow ...') is None