Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions docs/.hooks/render_default_test_env.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import ast
import os
from ast import literal_eval
from functools import cache

import tomlkit
Expand All @@ -11,6 +11,26 @@
MARKER_MATRIX = "<HATCH_TEST_ENV_MATRIX>"
MARKER_SCRIPTS = "<HATCH_TEST_ENV_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():
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/history/hatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:***
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions src/hatch/env/internal/__init__.py
Original file line number Diff line number Diff line change
@@ -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()`).
Comment on lines +14 to +22

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This level of verbosity of a comment is not needed here.

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

Expand Down
4 changes: 3 additions & 1 deletion src/hatch/env/internal/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
12 changes: 9 additions & 3 deletions src/hatch/env/internal/static_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:.}",
Expand All @@ -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:.}",
Expand All @@ -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:.}",
Expand Down
4 changes: 3 additions & 1 deletion src/hatch/env/internal/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/hatch/env/internal/type_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
4 changes: 3 additions & 1 deletion src/hatch/env/internal/uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Empty file added tests/env/internal/__init__.py
Empty file.
138 changes: 138 additions & 0 deletions tests/env/internal/test_default_installer.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not reference PR reviews in comments

# 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
Loading