diff --git a/docs/config/environment/overview.md b/docs/config/environment/overview.md index 879de8291..b34c5624d 100644 --- a/docs/config/environment/overview.md +++ b/docs/config/environment/overview.md @@ -290,6 +290,55 @@ The following platforms are supported: If unspecified, the environment is assumed to be compatible with all platforms. +## Locking + +Hatch can generate [PEP 751](https://peps.python.org/pep-0751/) lockfiles (`pylock.toml`) for environments. Lockfiles capture the exact versions of all resolved dependencies, ensuring reproducible installations. + +### Locked + +Set `locked` to `true` to enable automatic lockfile generation for an environment. When enabled, a lockfile will be generated whenever the environment is created or its dependencies change. + +```toml config-example +[tool.hatch.envs.test] +locked = true +dependencies = [ + "pytest", + "pytest-cov", +] +``` + +The default value is `false` unless overridden by the global [`lock-envs`](#lock-envs) setting. + +### Lock filename ### {: #lock-filename } + +By default, lockfiles are named following the [PEP 751](https://peps.python.org/pep-0751/) convention: `pylock.toml` for the `default` environment and `pylock..toml` for all others. You can override this with the `lock-filename` option: + +```toml config-example +[tool.hatch.envs.test] +lock-filename = "locks/test-requirements.lock" +``` + +### Global lock-envs ### {: #lock-envs } + +You can enable locking for all environments at once by setting `lock-envs` to `true` at the top level of your Hatch configuration: + +```toml config-example +[tool.hatch] +lock-envs = true +``` + +This acts as the default value for each environment's [`locked`](#locked) option. Individual environments can still opt out by explicitly setting `locked = false`: + +```toml config-example +[tool.hatch] +lock-envs = true + +[tool.hatch.envs.docs] +locked = false +``` + +See the [lockfile how-to guide](../../how-to/environment/lockfiles.md) for practical usage examples. + ## Description The `description` option is purely informational and is displayed in the output of the [`env show`](../../cli/reference.md#hatch-env-show) command: diff --git a/docs/environment.md b/docs/environment.md index aff2c64b0..d5115b6f5 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -95,6 +95,18 @@ Syncing dependencies !!! note The `Syncing dependencies` status will display temporarily when Hatch updates environments in response to any dependency changes that you make. +## Locking + +Hatch can generate [PEP 751](https://peps.python.org/pep-0751/) lockfiles (`pylock.toml`) for environments. Configure environments with [`locked = true`](config/environment/overview.md#locked) and then use the [`env lock`](cli/reference.md#hatch-env-lock) command: + +```console +$ hatch env lock +Locking environment: default +Wrote lockfile: /path/to/project/pylock.toml +``` + +When called without arguments, all environments configured with `locked = true` will be locked. Environments are also locked automatically when created or when their dependencies change. See the [lockfile how-to guide](how-to/environment/lockfiles.md) for more details. + ## Selection You can select which environment to enter or run commands in by using the `-e`/`--env` [root option](cli/reference.md#hatch) or by setting the `HATCH_ENV` environment variable. diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 5b51533e2..57dfc7269 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Added:*** + +- Add `hatch env lock` command to generate [PEP 751](https://peps.python.org/pep-0751/) lockfiles (`pylock.toml`) for environments +- Add `locked` per-environment setting and `lock-envs` global setting for automatic lockfile generation + ## [1.16.3](https://github.com/pypa/hatch/releases/tag/hatch-v1.16.3) - 2026-01-20 ## {: #hatch-v1.16.3 } ***Added:*** diff --git a/docs/how-to/environment/lockfiles.md b/docs/how-to/environment/lockfiles.md new file mode 100644 index 000000000..d0140aa1c --- /dev/null +++ b/docs/how-to/environment/lockfiles.md @@ -0,0 +1,128 @@ +# How to use lockfiles + +----- + +Hatch can generate [PEP 751](https://peps.python.org/pep-0751/) lockfiles (`pylock.toml`) for your environments. Lockfiles capture the exact resolved versions and hashes of all dependencies, ensuring reproducible installations across machines and CI. + +## Configuring locked environments + +To use lockfiles, first configure which environments should be locked by setting [`locked = true`](../../config/environment/overview.md#locked): + +```toml config-example +[tool.hatch.envs.test] +locked = true +dependencies = [ + "pytest", +] +``` + +Or enable it globally for all environments with the [`lock-envs`](../../config/environment/overview.md#lock-envs) setting: + +```toml config-example +[tool.hatch] +lock-envs = true +``` + +Individual environments can opt out: + +```toml config-example +[tool.hatch] +lock-envs = true + +[tool.hatch.envs.docs] +locked = false +``` + +## Generating lockfiles + +Use the [`env lock`](../../cli/reference.md#hatch-env-lock) command to generate lockfiles. When called without arguments, it locks all environments configured with `locked = true`: + +```console +$ hatch env lock +Locking environment: default +Wrote lockfile: /path/to/project/pylock.toml +Locking environment: test +Wrote lockfile: /path/to/project/pylock.test.toml +``` + +You can also lock a specific environment by name: + +```console +$ hatch env lock test +Locking environment: test +Wrote lockfile: /path/to/project/pylock.test.toml +``` + +!!! note + When locking a specific environment by name, it must have `locked = true` configured. To generate a lockfile for an environment that is not configured as locked, use the `--export` flag. + +The `default` environment produces `pylock.toml`, while all other environments produce `pylock..toml`, following the [PEP 751](https://peps.python.org/pep-0751/) naming convention. + +## Automatic locking + +Environments with `locked = true` will have their lockfiles generated automatically during `hatch env create` or `hatch run` whenever: + +- The lockfile does not exist yet +- The environment's dependencies have changed + +## Updating locked dependencies + +To upgrade all locked packages to their latest allowed versions: + +```console +$ hatch env lock test --upgrade +``` + +To upgrade only specific packages: + +```console +$ hatch env lock test --upgrade-package requests --upgrade-package urllib3 +``` + +## Checking if a lockfile is up-to-date + +Use the `--check` flag to verify that a lockfile exists without regenerating it: + +```console +$ hatch env lock test --check +Lockfile exists: /path/to/project/pylock.test.toml +``` + +This is useful in CI to ensure lockfiles have been committed. + +## Exporting lockfiles + +To generate a lockfile for an environment that is not configured with `locked = true`, or to write to a custom location, use `--export`: + +```console +$ hatch env lock default --export locks/default.lock +``` + +To export lockfiles for all environments into a directory, use `--export-all`: + +```console +$ hatch env lock --export-all locks/ +``` + +!!! note + `--export` and `--export-all` are mutually exclusive. + +## Custom lock filenames + +You can override the default filename for any environment with the [`lock-filename`](../../config/environment/overview.md#lock-filename) option: + +```toml config-example +[tool.hatch.envs.test] +lock-filename = "requirements-test.lock" +``` + +When multiple matrix environments share the same `lock-filename`, Hatch will merge their dependencies and generate the lockfile once. + +## Installer integration + +Lockfile generation delegates to whichever installer your environment is configured to use: + +- **pip** (default): Uses `pip lock` (requires pip 25.1+) to produce a `pylock.toml` file directly. +- **UV**: Uses `uv pip compile` to resolve dependencies with hashes. + +See [How to select the installer](select-installer.md) for details on configuring UV. diff --git a/mkdocs.yml b/mkdocs.yml index 1e1a613cb..c1ec8bed0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -130,6 +130,7 @@ nav: - Environments: - Select installer: how-to/environment/select-installer.md - Dependency resolution: how-to/environment/dependency-resolution.md + - Lockfiles: how-to/environment/lockfiles.md - Workspace: how-to/environment/workspace.md - Static analysis: - Customize behavior: how-to/static-analysis/behavior.md diff --git a/src/hatch/cli/env/__init__.py b/src/hatch/cli/env/__init__.py index 901deeb0e..a6bf8d8c9 100644 --- a/src/hatch/cli/env/__init__.py +++ b/src/hatch/cli/env/__init__.py @@ -2,6 +2,7 @@ from hatch.cli.env.create import create from hatch.cli.env.find import find +from hatch.cli.env.lock import lock from hatch.cli.env.prune import prune from hatch.cli.env.remove import remove from hatch.cli.env.run import run @@ -15,6 +16,7 @@ def env(): env.add_command(create) env.add_command(find) +env.add_command(lock) env.add_command(prune) env.add_command(remove) env.add_command(run) diff --git a/src/hatch/cli/env/lock.py b/src/hatch/cli/env/lock.py new file mode 100644 index 000000000..f14e7f249 --- /dev/null +++ b/src/hatch/cli/env/lock.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from hatch.cli.application import Application + + +@click.command(short_help="Generate lockfiles for environments") +@click.argument("env_name", required=False, default=None) +@click.option("--upgrade", "-U", is_flag=True, help="Upgrade all packages") +@click.option("--upgrade-package", "-P", multiple=True, help="Upgrade specific package(s)") +@click.option("--export", "export_path", type=click.Path(), default=None, help="Export lockfile to a custom path") +@click.option( + "--export-all", + "export_all_path", + type=click.Path(), + default=None, + help="Export lockfiles for all environments to a directory", +) +@click.option("--check", is_flag=True, help="Check if lockfile is up-to-date") +@click.pass_obj +def lock( + app: Application, + *, + env_name: str | None, + upgrade: bool, + upgrade_package: tuple[str, ...], + export_path: str | None, + export_all_path: str | None, + check: bool, +): + """Generate lockfiles for environments. + + When called without arguments, locks all environments that have `locked = true` + in their configuration. When called with ENV_NAME, locks that specific environment. + """ + app.ensure_environment_plugin_dependencies() + + from hatch.env.lock import generate_lockfile, resolve_lockfile_path + from hatch.utils.fs import Path + + if export_path and export_all_path: + app.abort("Cannot use both `--export` and `--export-all`") + + if export_all_path: + env_names = [*app.project.config.envs, *app.project.config.internal_envs] + elif env_name is not None: + env_names = app.project.expand_environments(env_name) + if not env_names: + app.abort(f"Environment `{env_name}` is not defined by project config") + else: + # No argument: lock all environments with locked = true + env_names = [] + for name in app.project.config.envs: + environment = app.project.get_environment(name) + if environment.locked: + env_names.append(name) + for name in app.project.config.internal_envs: + environment = app.project.get_environment(name) + if environment.locked: + env_names.append(name) + + if not env_names: + app.abort("No environments are configured with `locked = true`") + + # Resolve environments, check compatibility, and determine output paths + incompatible = {} + lockfile_groups: dict[Path, list[str]] = {} + + for env in env_names: + environment = app.project.get_environment(env) + + try: + environment.check_compatibility() + except Exception as e: # noqa: BLE001 + if env_name is None or env_name in app.project.config.matrices: + incompatible[env] = str(e) + continue + + app.abort(f"Environment `{env}` is incompatible: {e}") + + # Require --export for non-locked environments when explicitly named + if env_name is not None and not environment.locked and not export_path and not export_all_path: + app.abort( + f"Environment `{env_name}` is not configured with `locked = true`. " + f"Use `--export ` to generate a lockfile, " + f"or set `locked = true` on the environment (or `lock-envs = true` globally)." + ) + + if export_path: + output_path = Path(export_path) + elif export_all_path: + safe_name = environment.name.replace(".", "-") + filename = "pylock.toml" if environment.name == "default" else f"pylock.{safe_name}.toml" + output_path = Path(export_all_path) / filename + else: + output_path = resolve_lockfile_path(environment) + + if check: + if not output_path.is_file(): + app.abort(f"Lockfile does not exist: {output_path}") + app.display(f"Lockfile exists: {output_path}") + continue + + if not environment.dependencies: + app.display_warning(f"Environment `{env}` has no dependencies to lock") + continue + + lockfile_groups.setdefault(output_path, []).append(env) + + # Generate lockfiles, deduplicating when multiple envs share a path + for output_path, envs in lockfile_groups.items(): + # Merge dependencies from all environments sharing this lockfile + if len(envs) == 1: + environment = app.project.get_environment(envs[0]) + merged_deps = None + display_name = envs[0] + else: + seen: set[str] = set() + merged: list[str] = [] + python_versions: set[str] = set() + for env in envs: + env_obj = app.project.get_environment(env) + for dep in env_obj.dependencies: + if dep not in seen: + seen.add(dep) + merged.append(dep) + python_version = env_obj.config.get("python", "") + python_versions.add(python_version) + + if len(python_versions) > 1: + versions_str = ", ".join(sorted(v for v in python_versions if v) or ["(default)"]) + app.abort( + f"Environments sharing lockfile `{output_path.name}` target different Python versions " + f"({versions_str}). A single lockfile cannot be valid across different Python versions. " + f"Use distinct `lock-filename` values for each Python version." + ) + + merged_deps = merged + environment = app.project.get_environment(envs[0]) + display_name = ", ".join(envs) + + with app.status(f"Locking environment: {display_name}"): + generate_lockfile( + environment, + output_path, + upgrade=upgrade, + upgrade_packages=upgrade_package, + deps_override=merged_deps, + ) + + app.display(f"Wrote lockfile: {output_path}") + + if incompatible: + num_incompatible = len(incompatible) + app.display_warning( + f"Skipped {num_incompatible} incompatible environment{'s' if num_incompatible > 1 else ''}:" + ) + for env, reason in incompatible.items(): + app.display_warning(f"{env} -> {reason}") diff --git a/src/hatch/env/lock.py b/src/hatch/env/lock.py new file mode 100644 index 000000000..2a287f7dd --- /dev/null +++ b/src/hatch/env/lock.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import tempfile +from typing import TYPE_CHECKING + +from hatch.env.utils import add_verbosity_flag +from hatch.utils.fs import Path + +if TYPE_CHECKING: + from hatch.env.plugin.interface import EnvironmentInterface + from hatch.env.virtual import VirtualEnvironment + + +def generate_lockfile( + environment: EnvironmentInterface, + output_path: Path, + *, + upgrade: bool = False, + upgrade_packages: tuple[str, ...] = (), + deps_override: list[str] | None = None, +) -> None: + deps = deps_override if deps_override is not None else environment.dependencies + if not deps: + return + + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f: + f.write("\n".join(deps)) + f.write("\n") + deps_file = Path(f.name) + + try: + from hatch.env.virtual import VirtualEnvironment + + if isinstance(environment, VirtualEnvironment) and environment.use_uv: + _lock_with_uv(environment, deps_file, output_path, upgrade=upgrade, upgrade_packages=upgrade_packages) + else: + _lock_with_pip(environment, deps_file, output_path, upgrade=upgrade, upgrade_packages=upgrade_packages) + finally: + deps_file.unlink() + + +def _lock_with_pip( + environment: EnvironmentInterface, + deps_file: Path, + output_path: Path, + *, + upgrade: bool = False, + upgrade_packages: tuple[str, ...] = (), +) -> None: + command = ["python", "-u", "-m", "pip", "lock", "-r", str(deps_file), "-o", str(output_path)] + + add_verbosity_flag(command, environment.verbosity, adjustment=-1) + + if upgrade: + command.append("--upgrade") + for pkg in upgrade_packages: + command.extend(["--upgrade-package", pkg]) + + with environment.command_context(): + environment.platform.check_command(command) + + +def _lock_with_uv( + environment: VirtualEnvironment, + deps_file: Path, + output_path: Path, + *, + upgrade: bool = False, + upgrade_packages: tuple[str, ...] = (), +) -> None: + command = [ + environment.uv_path, + "pip", + "compile", + str(deps_file), + "--generate-hashes", + "--output-file", + str(output_path), + ] + + add_verbosity_flag(command, environment.verbosity, adjustment=-1) + + if upgrade: + command.append("--upgrade") + for pkg in upgrade_packages: + command.extend(["--upgrade-package", pkg]) + + python_version = environment.config.get("python", "") + if python_version: + command.extend(["--python-version", python_version]) + + with environment.command_context(): + environment.platform.check_command(command) + + +def resolve_lockfile_path(environment: EnvironmentInterface) -> Path: + lock_filename = environment.config.get("lock-filename") + if lock_filename: + return environment.root / lock_filename + + if environment.name == "default": + return environment.root / "pylock.toml" + + # PEP 751 only allows one dot in the filename: pylock..toml + safe_name = environment.name.replace(".", "-") + return environment.root / f"pylock.{safe_name}.toml" diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 68a090064..a99355c13 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -459,6 +459,22 @@ def platforms(self) -> list[str]: return [platform.lower() for platform in platforms] + @cached_property + def locked(self) -> bool: + """ + ```toml config-example + [tool.hatch.envs.] + locked = ... + ``` + """ + global_default = self.app.project.config.lock_envs + locked = self.config.get("locked", global_default) + if not isinstance(locked, bool): + message = f"Field `tool.hatch.envs.{self.name}.locked` must be a boolean" + raise TypeError(message) + + return locked + @cached_property def skip_install(self) -> bool: """ diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 81f3e6b61..d605adfa4 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -44,6 +44,15 @@ def build(self): return BuildConfig(config) + @cached_property + def lock_envs(self) -> bool: + lock_envs = self.config.get("lock-envs", False) + if not isinstance(lock_envs, bool): + message = "Field `tool.hatch.lock-envs` must be a boolean" + raise TypeError(message) + + return lock_envs + @property def env(self): if self._env is None: diff --git a/src/hatch/project/core.py b/src/hatch/project/core.py index 4f3128442..ad56740af 100644 --- a/src/hatch/project/core.py +++ b/src/hatch/project/core.py @@ -247,6 +247,14 @@ def prepare_environment(self, environment: EnvironmentInterface, *, keep_env: bo self.env_metadata.update_dependency_hash(environment, new_dep_hash) + if environment.locked and environment.dependencies: + from hatch.env.lock import generate_lockfile, resolve_lockfile_path + + lockfile_path = resolve_lockfile_path(environment) + if not lockfile_path.is_file() or new_dep_hash != current_dep_hash: + with self.app.status(f"Locking environment: {environment.name}"): + generate_lockfile(environment, lockfile_path) + def prepare_build_environment(self, *, targets: list[str] | None = None, keep_env: bool = False) -> None: from hatch.project.constants import BUILD_BACKEND, BuildEnvVars from hatch.utils.structures import EnvVars diff --git a/src/hatch/project/env.py b/src/hatch/project/env.py index e05822bb0..823de4ff9 100644 --- a/src/hatch/project/env.py +++ b/src/hatch/project/env.py @@ -20,6 +20,8 @@ "env-include": list, "env-vars": dict, "features": list, + "lock-filename": str, + "locked": bool, "matrix-name-format": str, "platforms": list, "post-install-commands": list, diff --git a/tests/cli/env/test_lock.py b/tests/cli/env/test_lock.py new file mode 100644 index 000000000..c26e0353f --- /dev/null +++ b/tests/cli/env/test_lock.py @@ -0,0 +1,657 @@ +import os + +import pytest +import tomli_w + +from hatch.config.constants import ConfigEnvVars +from hatch.project.core import Project +from hatch.utils.toml import load_toml_file + + +def test_undefined(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" + + with project_path.as_cwd(): + result = hatch("env", "lock", "test") + + assert result.exit_code == 1 + assert result.output == helpers.dedent( + """ + Environment `test` is not defined by project config + """ + ) + + +def test_no_locked_envs(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" + + with project_path.as_cwd(): + result = hatch("env", "lock") + + assert result.exit_code == 1 + assert result.output == helpers.dedent( + """ + No environments are configured with `locked = true` + """ + ) + + +def test_non_locked_env_requires_export(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" + + project = Project(project_path) + helpers.update_project_environment( + project, + "default", + {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, + ) + + with project_path.as_cwd(): + result = hatch("env", "lock", "default") + + assert result.exit_code == 1 + assert "not configured with `locked = true`" in result.output + assert "--export" in result.output + + +def test_no_dependencies(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" + + project = Project(project_path) + helpers.update_project_environment( + project, "default", {"skip-install": True, "locked": True, **project.config.envs["default"]} + ) + + with project_path.as_cwd(): + result = hatch("env", "lock", "default") + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Environment `default` has no dependencies to lock + """ + ) + + +@pytest.mark.usefixtures("env_run") +def test_explicit_env(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) + helpers.update_project_environment( + project, + "default", + {"skip-install": True, "dependencies": ["requests"], "locked": True, **project.config.envs["default"]}, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "default") + + assert result.exit_code == 0, result.output + assert "Locking environment: default" in result.output + assert f"Wrote lockfile: {project_path / 'pylock.toml'}" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_locked_env_no_arg(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) + helpers.update_project_environment( + project, + "default", + {"skip-install": True, "dependencies": ["requests"], "locked": True, **project.config.envs["default"]}, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock") + + assert result.exit_code == 0, result.output + assert "Locking environment: default" in result.output + assert f"Wrote lockfile: {project_path / 'pylock.toml'}" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_global_lock_envs(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) + helpers.update_project_environment( + project, + "default", + {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, + ) + helpers.update_project_environment(project, "test", {"dependencies": ["pytest"]}) + + # Set global lock-envs = true in [tool.hatch] + project_file = project_path / "pyproject.toml" + raw_config = load_toml_file(str(project_file)) + raw_config.setdefault("tool", {}).setdefault("hatch", {})["lock-envs"] = True + + with open(str(project_file), "w", encoding="utf-8") as f: + f.write(tomli_w.dumps(raw_config)) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock") + + assert result.exit_code == 0, result.output + assert "Locking environment: default" in result.output + assert "Locking environment: test" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_per_env_override_global(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) + helpers.update_project_environment( + project, + "default", + {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, + ) + # test env explicitly opts out of locking + helpers.update_project_environment(project, "test", {"dependencies": ["pytest"], "locked": False}) + + # Set global lock-envs = true + project_file = project_path / "pyproject.toml" + raw_config = load_toml_file(str(project_file)) + raw_config.setdefault("tool", {}).setdefault("hatch", {})["lock-envs"] = True + + with open(str(project_file), "w", encoding="utf-8") as f: + f.write(tomli_w.dumps(raw_config)) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock") + + assert result.exit_code == 0, result.output + # default should be locked (global), test should NOT be locked (per-env override) + assert "Locking environment: default" in result.output + assert "Locking environment: test" not in result.output + + +@pytest.mark.usefixtures("env_run") +def test_named_env(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) + helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]}) + helpers.update_project_environment(project, "test", {"dependencies": ["pytest"], "locked": True}) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "test") + + assert result.exit_code == 0, result.output + assert "Locking environment: test" in result.output + assert f"Wrote lockfile: {project_path / 'pylock.test.toml'}" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_check_missing(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) + helpers.update_project_environment( + project, + "default", + {"skip-install": True, "dependencies": ["requests"], "locked": True, **project.config.envs["default"]}, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "default", "--check") + + assert result.exit_code == 1 + assert "Lockfile does not exist" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_check_exists(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) + helpers.update_project_environment( + project, + "default", + {"skip-install": True, "dependencies": ["requests"], "locked": True, **project.config.envs["default"]}, + ) + + # Create a lockfile to check against + (project_path / "pylock.toml").write_text("") + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "default", "--check") + + assert result.exit_code == 0, result.output + assert "Lockfile exists" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_export(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) + helpers.update_project_environment( + project, + "default", + {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, + ) + + custom_output = str(temp_dir / "custom-lock.toml") + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "default", "--export", custom_output) + + assert result.exit_code == 0, result.output + assert f"Wrote lockfile: {custom_output}" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_export_all(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) + helpers.update_project_environment( + project, + "default", + {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, + ) + helpers.update_project_environment(project, "test", {"dependencies": ["pytest"]}) + + export_dir = str(temp_dir / "locks") + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "--export-all", export_dir) + + assert result.exit_code == 0, result.output + assert "Locking environment: default" in result.output + assert "Locking environment: test" in result.output + + +@pytest.mark.usefixtures("helpers") +def test_export_and_export_all_mutually_exclusive(hatch, 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" + + with project_path.as_cwd(): + result = hatch("env", "lock", "default", "--export", "a.toml", "--export-all", "locks/") + + assert result.exit_code == 1 + assert "Cannot use both" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_custom_lock_filename(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) + helpers.update_project_environment( + project, + "default", + { + "skip-install": True, + "dependencies": ["requests"], + "locked": True, + "lock-filename": "locks/default.toml", + **project.config.envs["default"], + }, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "default") + + assert result.exit_code == 0, result.output + assert f"Wrote lockfile: {project_path / 'locks' / 'default.toml'}" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_matrix(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) + helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]}) + helpers.update_project_environment( + project, + "test", + { + "dependencies": ["pytest"], + "locked": True, + "matrix": [{"version": ["9000", "42"]}], + }, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "test") + + assert result.exit_code == 0, result.output + assert "Locking environment: test.9000" in result.output + assert "Locking environment: test.42" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_matrix_incompatible(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) + helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]}) + helpers.update_project_environment( + project, + "test", + { + "dependencies": ["pytest"], + "locked": True, + "matrix": [{"version": ["9000", "42"]}], + "overrides": {"matrix": {"version": {"platforms": [{"value": "foo", "if": ["9000"]}]}}}, + }, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "test") + + assert result.exit_code == 0, result.output + assert "Locking environment: test.42" in result.output + assert "Skipped 1 incompatible environment:" in result.output + assert "test.9000 -> unsupported platform" in result.output + + +@pytest.mark.usefixtures("env_run") +def test_shared_lock_filename_dedup(hatch, helpers, temp_dir, config_file): + """When multiple matrix envs share the same lock-filename, generate once with merged deps.""" + 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) + helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]}) + helpers.update_project_environment( + project, + "test", + { + "dependencies": ["pytest"], + "locked": True, + "lock-filename": "pylock.test.toml", + "matrix": [{"version": ["9000", "42"]}], + }, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "test") + + assert result.exit_code == 0, result.output + # Both envs share pylock.test.toml, so only one lockfile write should happen + assert result.output.count("Wrote lockfile:") == 1 + assert f"Wrote lockfile: {project_path / 'pylock.test.toml'}" in result.output + + +def test_shared_lock_filename_different_python_errors(hatch, helpers, temp_dir, config_file): + """When grouped envs have different Python versions, abort rather than generate an invalid lockfile.""" + 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" + + project = Project(project_path) + helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]}) + helpers.update_project_environment( + project, + "test", + { + "dependencies": ["pytest"], + "locked": True, + "lock-filename": "pylock.test.toml", + "matrix": [{"python": ["3.12", "3.10"]}], + }, + ) + + with project_path.as_cwd(): + result = hatch("env", "lock", "test") + + assert result.exit_code == 1 + assert "target different Python versions" in result.output + assert "lock-filename" in result.output + + +def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, config_file, mock_plugin_installation): + 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" + + project = Project(project_path) + helpers.update_project_environment( + project, "default", {"skip-install": True, "locked": True, **project.config.envs["default"]} + ) + + dependency = os.urandom(16).hex() + from hatchling.utils.constants import DEFAULT_CONFIG_FILE + + (project_path / DEFAULT_CONFIG_FILE).write_text( + helpers.dedent( + f""" + [env] + requires = ["{dependency}"] + """ + ) + ) + + with project_path.as_cwd(): + result = hatch("env", "lock", "default") + + assert result.exit_code == 0, result.output + assert "Syncing environment plugin requirements" in result.output + helpers.assert_plugin_installation(mock_plugin_installation, [dependency])