diff --git a/docs/.hooks/render_default_test_env.py b/docs/.hooks/render_default_test_env.py index 7813d6fbf..4999d16c2 100644 --- a/docs/.hooks/render_default_test_env.py +++ b/docs/.hooks/render_default_test_env.py @@ -1,7 +1,7 @@ from __future__ import annotations +import ast import os -from ast import literal_eval from functools import cache import tomlkit @@ -11,6 +11,26 @@ MARKER_MATRIX = "" MARKER_SCRIPTS = "" +# `installer` defaults to `default_installer()` at runtime, which returns `uv` when +# `uv` is available and `pip` otherwise (see `hatch.env.internal.default_installer`). +# That call is not a literal, so render it as its documented default for the docs. +DEFAULT_INSTALLER = "uv" + + +class _ResolveDefaultInstaller(ast.NodeTransformer): + """Replace the `default_installer()` call with its documented default literal. + + The default test environment config holds `"installer": default_installer()`, a + runtime call that `ast.literal_eval` cannot evaluate. The installer value is not + rendered in the docs anyway (only dependencies, matrix and scripts are), so swap + the call for its default string to keep the config parseable. + """ + + def visit_Call(self, node: ast.Call) -> ast.AST: + if isinstance(node.func, ast.Name) and node.func.id == "default_installer": + return ast.copy_location(ast.Constant(value=DEFAULT_INSTALLER), node) + return self.generic_visit(node) + @cache def test_env_config(): @@ -19,7 +39,8 @@ def test_env_config(): contents = f.read() value = "".join(contents.split(" return ")[1].strip().splitlines()) - return literal_eval(value) + tree = ast.fix_missing_locations(_ResolveDefaultInstaller().visit(ast.parse(value, mode="eval"))) + return ast.literal_eval(tree) @cache diff --git a/docs/history/hatch.md b/docs/history/hatch.md index ca257fed2..e710a43b9 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Changed:*** + +- Make `uv` an optional dependency. The internal environments (build, static analysis, test and type checking) now detect whether `uv` is available and fall back to `pip` when it is not, so Hatch works without `uv` installed. Install the `uv` extra (`hatch[uv]`) to bundle it. + ## [1.17.0](https://github.com/pypa/hatch/releases/tag/hatch-v1.17.0) - 2026-05-31 ## {: #hatch-v1.17.0 } ***Changed:*** diff --git a/pyproject.toml b/pyproject.toml index 055ad4aff..2f515edfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,12 +55,16 @@ dependencies = [ "tomli-w>=1.0", "tomlkit>=0.11.1", "userpath~=1.7", - "uv>=0.5.23", "virtualenv>=21", "backports.zstd>=1.0.0 ; python_version<'3.14'", ] dynamic = ["version"] +[project.optional-dependencies] +uv = [ + "uv>=0.5.23", +] + [project.urls] Homepage = "https://hatch.pypa.io/latest/" Sponsor = "https://github.com/sponsors/ofek" diff --git a/src/hatch/env/internal/__init__.py b/src/hatch/env/internal/__init__.py index bcc4d9776..7113b2f4d 100644 --- a/src/hatch/env/internal/__init__.py +++ b/src/hatch/env/internal/__init__.py @@ -1,10 +1,35 @@ from __future__ import annotations +import os +import shutil +import sysconfig +from functools import cache from typing import Any from hatch.env.utils import ensure_valid_environment +@cache +def uv_available() -> bool: + # `uv` is an optional dependency, so detect whether it is actually installed before defaulting + # the internal environments to it. This mirrors the standalone detection used by the virtual + # environment type (see `hatch.env.virtual.VirtualEnvironment.uv_path`): when `uv` is installed + # as a Python package it lands in the install environment's scripts directory, which is not + # necessarily on the running process's `PATH`, so that directory is prepended before searching. + # + # The result is cached because `default_installer` is called by every internal environment config + # producer, and a fresh `PATH` scan on every config build is wasteful. Tests that toggle `uv` + # availability must clear this cache (`uv_available.cache_clear()`). + scripts_dir = sysconfig.get_path("scripts") + old_path = os.environ.get("PATH", os.defpath) + new_path = f"{scripts_dir}{os.pathsep}{old_path}" + return shutil.which("uv", path=new_path) is not None + + +def default_installer() -> str: + return "uv" if uv_available() else "pip" + + def get_internal_env_config() -> dict[str, Any]: from hatch.env.internal import build, static_analysis, test, type_check, uv diff --git a/src/hatch/env/internal/build.py b/src/hatch/env/internal/build.py index aac6eac3b..7c6cdc3ba 100644 --- a/src/hatch/env/internal/build.py +++ b/src/hatch/env/internal/build.py @@ -4,8 +4,10 @@ def get_default_config() -> dict[str, Any]: + from hatch.env.internal import default_installer + return { "skip-install": True, "builder": True, - "installer": "uv", + "installer": default_installer(), } diff --git a/src/hatch/env/internal/static_analysis.py b/src/hatch/env/internal/static_analysis.py index 98c16495d..1b8458fbc 100644 --- a/src/hatch/env/internal/static_analysis.py +++ b/src/hatch/env/internal/static_analysis.py @@ -4,9 +4,11 @@ def get_default_config() -> dict[str, Any]: + from hatch.env.internal import default_installer + return { "skip-install": True, - "installer": "uv", + "installer": default_installer(), "dependencies": [f"ruff=={RUFF_DEFAULT_VERSION}"], "scripts": { "format-check": "ruff format{env:HATCH_FMT_ARGS:}{env:HATCH_CHECK_FMT_ARGS:} --check --diff {args:.}", @@ -18,9 +20,11 @@ def get_default_config() -> dict[str, Any]: def get_check_code_config() -> dict[str, Any]: + from hatch.env.internal import default_installer + return { "skip-install": True, - "installer": "uv", + "installer": default_installer(), "dependencies": [f"ruff=={RUFF_DEFAULT_VERSION}"], "scripts": { "lint-check": "ruff check{env:HATCH_CHECK_CODE_ARGS:} {args:.}", @@ -30,9 +34,11 @@ def get_check_code_config() -> dict[str, Any]: def get_check_fmt_config() -> dict[str, Any]: + from hatch.env.internal import default_installer + return { "skip-install": True, - "installer": "uv", + "installer": default_installer(), "dependencies": [f"ruff=={RUFF_DEFAULT_VERSION}"], "scripts": { "format-check": "ruff format{env:HATCH_CHECK_FMT_ARGS:} --check --diff {args:.}", diff --git a/src/hatch/env/internal/test.py b/src/hatch/env/internal/test.py index 779117ae9..846ef40e8 100644 --- a/src/hatch/env/internal/test.py +++ b/src/hatch/env/internal/test.py @@ -4,8 +4,10 @@ def get_default_config() -> dict[str, Any]: + from hatch.env.internal import default_installer + return { - "installer": "uv", + "installer": default_installer(), "dependencies": [ "coverage-enable-subprocess==1.0", "coverage[toml]~=7.11", diff --git a/src/hatch/env/internal/type_check.py b/src/hatch/env/internal/type_check.py index ca1f82de0..2d94a54c8 100644 --- a/src/hatch/env/internal/type_check.py +++ b/src/hatch/env/internal/type_check.py @@ -4,13 +4,14 @@ def get_default_config() -> dict[str, Any]: + from hatch.env.internal import default_installer from hatch.env.internal.test import get_default_config as get_test_config test_config = get_test_config() test_deps = test_config.get("dependencies", []) return { - "installer": "uv", + "installer": default_installer(), "dependencies": [f"pyrefly=={PYREFLY_DEFAULT_VERSION}", *test_deps], "scripts": { "check": "pyrefly check{env:HATCH_CHECK_TYPES_ARGS:} {args}", diff --git a/src/hatch/env/internal/uv.py b/src/hatch/env/internal/uv.py index fc41cc0ee..ad0e3fe3e 100644 --- a/src/hatch/env/internal/uv.py +++ b/src/hatch/env/internal/uv.py @@ -4,7 +4,9 @@ def get_default_config() -> dict[str, Any]: + from hatch.env.internal import default_installer + return { "skip-install": True, - "installer": "uv", + "installer": default_installer(), } diff --git a/tests/env/internal/__init__.py b/tests/env/internal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/env/internal/test_default_installer.py b/tests/env/internal/test_default_installer.py new file mode 100644 index 000000000..c454b6ad9 --- /dev/null +++ b/tests/env/internal/test_default_installer.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import os +import shutil + +import pytest + +from hatch.env.internal import ( + build, + default_installer, + get_internal_env_config, + static_analysis, + test, + type_check, + uv, + uv_available, +) + +# Every internal environment config that resolves an installer, paired with the +# config-producing callable. The ``hatch-uv`` environment is included because it +# must also fall back to ``pip`` so that ``uv`` can be bootstrapped on a machine +# where it is not yet available. +INSTALLER_CONFIG_FACTORIES = [ + pytest.param(build.get_default_config, id="hatch-build"), + pytest.param(static_analysis.get_default_config, id="hatch-static-analysis"), + pytest.param(static_analysis.get_check_code_config, id="hatch-check-code"), + pytest.param(static_analysis.get_check_fmt_config, id="hatch-check-fmt"), + pytest.param(type_check.get_default_config, id="hatch-check-types"), + pytest.param(test.get_default_config, id="hatch-test"), + pytest.param(uv.get_default_config, id="hatch-uv"), +] + + +@pytest.fixture(autouse=True) +def _clear_uv_available_cache(): + # ``uv_available`` is cached so the per-build ``PATH`` scan only runs once. Each test toggles + # ``uv`` availability, so clear the cache before and after to keep results independent. + uv_available.cache_clear() + yield + uv_available.cache_clear() + + +def mock_uv(mocker, *, available): + """Mirror ``VirtualEnvironment.uv_path``'s standalone detection in tests. + + ``uv_available`` augments ``PATH`` with the interpreter's scripts directory and then calls + ``shutil.which("uv", path=...)``. Patch that lookup directly so the test does not depend on + whether ``uv`` happens to be installed in the test environment. + """ + + return mocker.patch( + "hatch.env.internal.shutil.which", + return_value="/usr/bin/uv" if available else None, + ) + + +class TestUvAvailableDetection: + def test_augments_path_with_scripts_dir(self, mocker, tmp_path): + # Mirror the scripts-dir augmentation used by ``VirtualEnvironment.uv_path``: the scripts + # directory must be prepended to ``PATH`` before the lookup. + scripts_dir = str(tmp_path) + mocker.patch("hatch.env.internal.sysconfig.get_path", return_value=scripts_dir) + which = mocker.patch("hatch.env.internal.shutil.which", return_value=f"{scripts_dir}/uv") + mocker.patch.dict(os.environ, {"PATH": "/usr/bin"}) + + assert uv_available() is True + + which.assert_called_once_with("uv", path=f"{scripts_dir}{os.pathsep}/usr/bin") + + def test_detects_uv_in_scripts_dir_not_on_bare_path(self, mocker, tmp_path): + # The exact scenario from the review: ``uv`` is installed as a package and lives in the + # scripts directory, but that directory is not on the running process's ``PATH``. A bare + # ``shutil.which("uv")`` would miss it; the augmented lookup must find it. + scripts_dir = tmp_path + uv_name = "uv.exe" if os.name == "nt" else "uv" + uv_binary = scripts_dir / uv_name + uv_binary.write_text("") + uv_binary.chmod(0o755) + + mocker.patch("hatch.env.internal.sysconfig.get_path", return_value=str(scripts_dir)) + # Bare PATH does not contain the scripts dir, so unaugmented detection fails... + mocker.patch.dict(os.environ, {"PATH": str(tmp_path / "nowhere")}) + assert shutil.which("uv") is None + + # ...but ``uv_available`` augments PATH with the scripts dir and therefore finds it. + assert uv_available() is True + + def test_returns_false_when_uv_missing(self, mocker, tmp_path): + mocker.patch("hatch.env.internal.sysconfig.get_path", return_value=str(tmp_path)) + mocker.patch("hatch.env.internal.shutil.which", return_value=None) + + assert uv_available() is False + + def test_result_is_cached_across_calls(self, mocker, tmp_path): + mocker.patch("hatch.env.internal.sysconfig.get_path", return_value=str(tmp_path)) + which = mocker.patch("hatch.env.internal.shutil.which", return_value="/usr/bin/uv") + + assert uv_available() is True + assert uv_available() is True + # A single PATH scan, even though ``default_installer`` is invoked for every config build. + which.assert_called_once() + + +class TestDefaultInstaller: + def test_uv_available(self, mocker): + mock_uv(mocker, available=True) + assert default_installer() == "uv" + + def test_uv_unavailable(self, mocker): + mock_uv(mocker, available=False) + assert default_installer() == "pip" + + +@pytest.mark.parametrize("get_config", INSTALLER_CONFIG_FACTORIES) +class TestInternalEnvInstallerFallback: + def test_installer_is_uv_when_uv_available(self, get_config, mocker): + mock_uv(mocker, available=True) + assert get_config()["installer"] == "uv" + + def test_installer_is_pip_when_uv_unavailable(self, get_config, mocker): + mock_uv(mocker, available=False) + assert get_config()["installer"] == "pip" + + +class TestInternalEnvConfigFallback: + def test_all_internal_envs_resolve_pip_without_uv(self, mocker): + mock_uv(mocker, available=False) + internal_config = get_internal_env_config() + for env_name, env_config in internal_config.items(): + if "installer" in env_config: + assert env_config["installer"] == "pip", env_name + + def test_all_internal_envs_resolve_uv_with_uv(self, mocker): + mock_uv(mocker, available=True) + internal_config = get_internal_env_config() + for env_name, env_config in internal_config.items(): + if "installer" in env_config: + assert env_config["installer"] == "uv", env_name