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
1 change: 1 addition & 0 deletions docs/history/hatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Skip injection of `ruff` extend if a key already exists in `ruff.toml`.
- Pass `--no-header` to `uv pip compile` in the `uv` locker so that generated lockfiles are deterministic.
- Fix env-dependency extras being dropped when a metadata hook is configured and resolve workspace-member extras from the member's own metadata.
- Clarify the error when an environment's configured Python version does not satisfy `requires-python`.


## [1.17.0](https://github.com/pypa/hatch/releases/tag/hatch-v1.17.0) - 2026-05-31 ## {: #hatch-v1.17.0 }
Expand Down
27 changes: 22 additions & 5 deletions src/hatch/env/virtual.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,11 +335,13 @@ def check_compatibility(self):
):
return

message = (
f"cannot locate Python: {python_version}"
if python_version
else "no compatible Python distribution available"
)
if self._explicit_python_violates_constraint(python_version):
message = f"Python {python_version} does not satisfy project requires-python: {self._python_constraint}"
elif python_version:
message = f"cannot locate Python: {python_version}"
else:
message = "no compatible Python distribution available"

raise OSError(message)

@cached_property
Expand Down Expand Up @@ -478,6 +480,21 @@ def _get_available_distribution(self, python_version: str = "") -> str | None:

return None

def _explicit_python_violates_constraint(self, python_version: str) -> bool:
if not (python_version and str(self._python_constraint)):
return False

from packaging.version import InvalidVersion

candidate_version = python_version.removeprefix("pypy").rstrip("t")
if "." not in candidate_version:
return False

try:
return not self._python_constraint.contains(candidate_version)
except InvalidVersion:
return False

def _is_stable_path(self, executable: str) -> bool:
path = Path(executable).resolve()
parents = path.parents
Expand Down
37 changes: 37 additions & 0 deletions tests/cli/env/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,43 @@ def test_incompatible_single(hatch, helpers, temp_dir, config_file):
assert not env_data_path.is_dir()


def test_incompatible_python_constraint(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()

project_name = "My.App"

with temp_dir.as_cwd():
result = hatch("new", project_name)

assert result.exit_code == 0, result.output

project_path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()

project = Project(project_path)
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
python_constraint = f"<{python_version}"
config = dict(project.raw_config)
config["project"]["requires-python"] = python_constraint
project.save_config(config)
helpers.update_project_environment(project, "test", {"python": python_version})

with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")

assert result.exit_code == 1
assert result.output == helpers.dedent(
f"""
Environment `test` is incompatible: Python {python_version} does not satisfy project requires-python: {python_constraint}
"""
)

env_data_path = data_path / "env" / "virtual"
assert not env_data_path.is_dir()


def test_incompatible_matrix_full(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
Expand Down