diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 6b15eb352..876736a4a 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -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 } diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index 4511db853..13b6a540f 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -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 @@ -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 diff --git a/tests/cli/env/test_create.py b/tests/cli/env/test_create.py index ccb7b6766..f4cf6d003 100644 --- a/tests/cli/env/test_create.py +++ b/tests/cli/env/test_create.py @@ -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()