From 16d4727291403c6a895d9bf7cf52394cfcea9ccb Mon Sep 17 00:00:00 2001 From: cjames23 Date: Fri, 6 Feb 2026 15:17:05 -0500 Subject: [PATCH 1/5] Add env lock command to generate PEP compliant lock files. --- src/hatch/cli/env/__init__.py | 2 + src/hatch/cli/env/lock.py | 82 ++++++++ src/hatch/env/lock.py | 106 +++++++++++ src/hatch/env/virtual.py | 9 +- tests/cli/env/test_lock.py | 344 ++++++++++++++++++++++++++++++++++ 5 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 src/hatch/cli/env/lock.py create mode 100644 src/hatch/env/lock.py create mode 100644 tests/cli/env/test_lock.py 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..1506b3b4a --- /dev/null +++ b/src/hatch/cli/env/lock.py @@ -0,0 +1,82 @@ +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", default="default") +@click.option("--all", "lock_all", is_flag=True, help="Lock all environments") +@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("--output", "-o", type=click.Path(), default=None, help="Output file path") +@click.option("--check", is_flag=True, help="Check if lockfile is up-to-date") +@click.pass_obj +def lock( + app: Application, + env_name: str, + *, + lock_all: bool, + upgrade: bool, + upgrade_package: tuple[str, ...], + output: str | None, + check: bool, +): + """Generate lockfiles for environments.""" + app.ensure_environment_plugin_dependencies() + + if lock_all: + env_names = list(app.project.config.envs) + else: + env_names = app.project.expand_environments(env_name) + if not env_names: + app.abort(f"Environment `{env_name}` is not defined by project config") + + from hatch.env.lock import generate_lockfile, resolve_output_path + + incompatible = {} + for env in env_names: + environment = app.project.get_environment(env) + + try: + environment.check_compatibility() + except Exception as e: # noqa: BLE001 + if env_name in app.project.config.matrices: + incompatible[env] = str(e) + continue + + app.abort(f"Environment `{env}` is incompatible: {e}") + + output_path = resolve_output_path(environment, output) + + 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 + + with app.status(f"Locking environment: {env}"): + generate_lockfile( + environment, + output_path, + upgrade=upgrade, + upgrade_packages=upgrade_package, + ) + + 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..2506687eb --- /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, ...] = (), +) -> None: + deps = environment.dependencies + if not deps: + return + + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) 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_output_path(environment: EnvironmentInterface, custom_output: str | None = None) -> Path: + if custom_output: + return Path(custom_output) + + lock_filename = environment.config.get("lock-filename") + if lock_filename: + return environment.root / lock_filename + + if environment.name == "default": + return environment.root / "pylock.toml" + + return environment.root / f"pylock.{environment.name}.toml" diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index 646554fe1..441c213de 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -144,7 +144,14 @@ def missing_dependencies(self) -> list[Dependency]: @staticmethod def get_option_types() -> dict: - return {"system-packages": bool, "path": str, "python-sources": list, "installer": str, "uv-path": str} + return { + "system-packages": bool, + "path": str, + "python-sources": list, + "installer": str, + "uv-path": str, + "lock-filename": str, + } def activate(self): self.virtual_env.activate() diff --git a/tests/cli/env/test_lock.py b/tests/cli/env/test_lock.py new file mode 100644 index 000000000..2a7ad4e0c --- /dev/null +++ b/tests/cli/env/test_lock.py @@ -0,0 +1,344 @@ +import os + +from hatch.config.constants import ConfigEnvVars +from hatch.project.core import Project + + +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_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, **project.config.envs["default"]}) + + with project_path.as_cwd(): + result = hatch("env", "lock") + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Environment `default` has no dependencies to lock + """ + ) + + +def test_default_env(hatch, helpers, temp_dir, config_file, env_run): + 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"]}, + ) + + 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 + + +def test_named_env(hatch, helpers, temp_dir, config_file, env_run): + 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"]}) + + 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 + + +def test_check_missing(hatch, helpers, temp_dir, config_file, env_run): + 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"]}, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch("env", "lock", "--check") + + assert result.exit_code == 1 + assert "Lockfile does not exist" in result.output + + +def test_check_exists(hatch, helpers, temp_dir, config_file, env_run): + 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"]}, + ) + + # 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", "--check") + + assert result.exit_code == 0, result.output + assert "Lockfile exists" in result.output + + +def test_custom_lock_filename(hatch, helpers, temp_dir, config_file, env_run): + 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"], + "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") + + assert result.exit_code == 0, result.output + assert f"Wrote lockfile: {project_path / 'locks' / 'default.toml'}" in result.output + + +def test_output_option(hatch, helpers, temp_dir, config_file, env_run): + 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", "-o", custom_output) + + assert result.exit_code == 0, result.output + assert f"Wrote lockfile: {custom_output}" in result.output + + +def test_matrix(hatch, helpers, temp_dir, config_file, env_run): + 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"], + "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 + + +def test_matrix_incompatible(hatch, helpers, temp_dir, config_file, env_run): + 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"], + "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 + + +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, **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") + + assert result.exit_code == 0, result.output + assert "Syncing environment plugin requirements" in result.output + helpers.assert_plugin_installation(mock_plugin_installation, [dependency]) From d13708e1503f1c33de114be5bfbcbd02706086b0 Mon Sep 17 00:00:00 2001 From: cjames23 Date: Fri, 6 Feb 2026 15:40:51 -0500 Subject: [PATCH 2/5] Fix formatting issues --- src/hatch/env/lock.py | 2 +- tests/cli/env/test_lock.py | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/hatch/env/lock.py b/src/hatch/env/lock.py index 2506687eb..e40c1d647 100644 --- a/src/hatch/env/lock.py +++ b/src/hatch/env/lock.py @@ -22,7 +22,7 @@ def generate_lockfile( if not deps: return - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + 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) diff --git a/tests/cli/env/test_lock.py b/tests/cli/env/test_lock.py index 2a7ad4e0c..e77f37fec 100644 --- a/tests/cli/env/test_lock.py +++ b/tests/cli/env/test_lock.py @@ -1,5 +1,7 @@ import os +import pytest + from hatch.config.constants import ConfigEnvVars from hatch.project.core import Project @@ -55,7 +57,8 @@ def test_no_dependencies(hatch, helpers, temp_dir, config_file): ) -def test_default_env(hatch, helpers, temp_dir, config_file, env_run): +@pytest.mark.usefixtures("env_run") +def test_default_env(hatch, helpers, temp_dir, config_file): config_file.model.template.plugins["default"]["tests"] = False config_file.save() @@ -85,7 +88,8 @@ def test_default_env(hatch, helpers, temp_dir, config_file, env_run): assert f"Wrote lockfile: {project_path / 'pylock.toml'}" in result.output -def test_named_env(hatch, helpers, temp_dir, config_file, env_run): +@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() @@ -112,7 +116,8 @@ def test_named_env(hatch, helpers, temp_dir, config_file, env_run): assert f"Wrote lockfile: {project_path / 'pylock.test.toml'}" in result.output -def test_check_missing(hatch, helpers, temp_dir, config_file, env_run): +@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() @@ -141,7 +146,8 @@ def test_check_missing(hatch, helpers, temp_dir, config_file, env_run): assert "Lockfile does not exist" in result.output -def test_check_exists(hatch, helpers, temp_dir, config_file, env_run): +@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() @@ -173,7 +179,8 @@ def test_check_exists(hatch, helpers, temp_dir, config_file, env_run): assert "Lockfile exists" in result.output -def test_custom_lock_filename(hatch, helpers, temp_dir, config_file, env_run): +@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() @@ -207,7 +214,8 @@ def test_custom_lock_filename(hatch, helpers, temp_dir, config_file, env_run): assert f"Wrote lockfile: {project_path / 'locks' / 'default.toml'}" in result.output -def test_output_option(hatch, helpers, temp_dir, config_file, env_run): +@pytest.mark.usefixtures("env_run") +def test_output_option(hatch, helpers, temp_dir, config_file): config_file.model.template.plugins["default"]["tests"] = False config_file.save() @@ -238,7 +246,8 @@ def test_output_option(hatch, helpers, temp_dir, config_file, env_run): assert f"Wrote lockfile: {custom_output}" in result.output -def test_matrix(hatch, helpers, temp_dir, config_file, env_run): +@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() @@ -272,7 +281,8 @@ def test_matrix(hatch, helpers, temp_dir, config_file, env_run): assert "Locking environment: test.42" in result.output -def test_matrix_incompatible(hatch, helpers, temp_dir, config_file, env_run): +@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() From 0dc652438a82b56a34c48f4cb11f6d471de59ebf Mon Sep 17 00:00:00 2001 From: cjames23 Date: Sun, 8 Feb 2026 20:37:50 -0800 Subject: [PATCH 3/5] Move to per environment config of lockfiles --- docs/config/environment/overview.md | 49 +++++++ docs/environment.md | 18 +++ docs/history/hatch.md | 5 + docs/how-to/environment/lockfiles.md | 109 +++++++++++++++ mkdocs.yml | 1 + src/hatch/cli/env/lock.py | 45 +++++-- src/hatch/env/lock.py | 9 +- src/hatch/env/plugin/interface.py | 16 +++ src/hatch/env/virtual.py | 9 +- src/hatch/project/config.py | 9 ++ src/hatch/project/core.py | 8 ++ src/hatch/project/env.py | 2 + tests/cli/env/test_lock.py | 193 +++++++++++++++++++++++++-- 13 files changed, 438 insertions(+), 35 deletions(-) create mode 100644 docs/how-to/environment/lockfiles.md 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..16e074aec 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -95,6 +95,24 @@ 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 any environment using the [`env lock`](cli/reference.md#hatch-env-lock) command: + +```console +$ hatch env lock default +Locking environment: default +Wrote lockfile: /path/to/project/pylock.toml +``` + +To lock all environments at once, use the `--all` flag: + +```console +$ hatch env lock --all +``` + +You can also configure environments to be [locked automatically](config/environment/overview.md#locking) whenever they are created or 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..cd90e7bc3 --- /dev/null +++ b/docs/how-to/environment/lockfiles.md @@ -0,0 +1,109 @@ +# 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. + +## Locking a specific environment + +Use the [`env lock`](../../cli/reference.md#hatch-env-lock) command with an environment name to generate a lockfile: + +```console +$ hatch env lock test +Locking environment: test +Wrote lockfile: /path/to/project/pylock.test.toml +``` + +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. + +## Locking all environments + +To lock every environment at once, use the `--all` flag: + +```console +$ hatch env lock --all +``` + +Environments that are incompatible with the current platform (e.g. a matrix variant requiring a Python version that is not installed) will be skipped with a warning. + +## Automatic locking + +You can configure environments to generate lockfiles automatically whenever they are created or their dependencies change. Set [`locked`](../../config/environment/overview.md#locked) to `true` on individual environments: + +```toml config-example +[tool.hatch.envs.test] +locked = true +dependencies = [ + "pytest", +] +``` + +Or enable it for all environments at once with the global [`lock-envs`](../../config/environment/overview.md#lock-envs) setting: + +```toml config-example +[tool.hatch] +lock-envs = true +``` + +Individual environments can opt out of the global setting: + +```toml config-example +[tool.hatch] +lock-envs = true + +[tool.hatch.envs.docs] +locked = false +``` + +When no explicit environment name is passed, `hatch env lock` will lock all environments that have `locked = true` (either directly or via the global setting). + +## 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 to a custom path + +By default, lockfiles are written to the project root. Use `--export` to write to a different location: + +```console +$ hatch env lock test --export locks/test.lock +``` + +## 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" +``` + +## 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/lock.py b/src/hatch/cli/env/lock.py index 1506b3b4a..e457ff504 100644 --- a/src/hatch/cli/env/lock.py +++ b/src/hatch/cli/env/lock.py @@ -9,34 +9,52 @@ @click.command(short_help="Generate lockfiles for environments") -@click.argument("env_name", default="default") -@click.option("--all", "lock_all", is_flag=True, help="Lock all environments") +@click.argument("env_name", required=False, default=None) +@click.option("--all", "lock_all", is_flag=True, help="Lock all environments regardless of config") @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("--output", "-o", type=click.Path(), default=None, help="Output file path") +@click.option("--export", "export_path", type=click.Path(), default=None, help="Export lockfile to a custom path") @click.option("--check", is_flag=True, help="Check if lockfile is up-to-date") @click.pass_obj def lock( app: Application, - env_name: str, *, + env_name: str | None, lock_all: bool, upgrade: bool, upgrade_package: tuple[str, ...], - output: str | None, + export_path: str | None, check: bool, ): - """Generate lockfiles for environments.""" + """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 + if lock_all: - env_names = list(app.project.config.envs) - else: + 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) - from hatch.env.lock import generate_lockfile, resolve_output_path + if not env_names: + app.abort("No environments are configured with `locked = true`") incompatible = {} for env in env_names: @@ -45,13 +63,18 @@ def lock( try: environment.check_compatibility() except Exception as e: # noqa: BLE001 - if env_name in app.project.config.matrices: + 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}") - output_path = resolve_output_path(environment, output) + if export_path: + from hatch.utils.fs import Path + + output_path = Path(export_path) + else: + output_path = resolve_lockfile_path(environment) if check: if not output_path.is_file(): diff --git a/src/hatch/env/lock.py b/src/hatch/env/lock.py index e40c1d647..5444f544d 100644 --- a/src/hatch/env/lock.py +++ b/src/hatch/env/lock.py @@ -92,10 +92,7 @@ def _lock_with_uv( environment.platform.check_command(command) -def resolve_output_path(environment: EnvironmentInterface, custom_output: str | None = None) -> Path: - if custom_output: - return Path(custom_output) - +def resolve_lockfile_path(environment: EnvironmentInterface) -> Path: lock_filename = environment.config.get("lock-filename") if lock_filename: return environment.root / lock_filename @@ -103,4 +100,6 @@ def resolve_output_path(environment: EnvironmentInterface, custom_output: str | if environment.name == "default": return environment.root / "pylock.toml" - return environment.root / f"pylock.{environment.name}.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/env/virtual.py b/src/hatch/env/virtual.py index 441c213de..646554fe1 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -144,14 +144,7 @@ def missing_dependencies(self) -> list[Dependency]: @staticmethod def get_option_types() -> dict: - return { - "system-packages": bool, - "path": str, - "python-sources": list, - "installer": str, - "uv-path": str, - "lock-filename": str, - } + return {"system-packages": bool, "path": str, "python-sources": list, "installer": str, "uv-path": str} def activate(self): self.virtual_env.activate() 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 index e77f37fec..76a2174e9 100644 --- a/tests/cli/env/test_lock.py +++ b/tests/cli/env/test_lock.py @@ -1,9 +1,11 @@ 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): @@ -30,6 +32,30 @@ def test_undefined(hatch, helpers, temp_dir, config_file): ) +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_no_dependencies(hatch, helpers, temp_dir, config_file): config_file.model.template.plugins["default"]["tests"] = False config_file.save() @@ -47,7 +73,7 @@ def test_no_dependencies(hatch, helpers, temp_dir, config_file): helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]}) with project_path.as_cwd(): - result = hatch("env", "lock") + result = hatch("env", "lock", "default") assert result.exit_code == 0, result.output assert result.output == helpers.dedent( @@ -58,7 +84,7 @@ def test_no_dependencies(hatch, helpers, temp_dir, config_file): @pytest.mark.usefixtures("env_run") -def test_default_env(hatch, helpers, temp_dir, config_file): +def test_explicit_env(hatch, helpers, temp_dir, config_file): config_file.model.template.plugins["default"]["tests"] = False config_file.save() @@ -80,6 +106,37 @@ def test_default_env(hatch, helpers, temp_dir, config_file): {"skip-install": True, "dependencies": ["requests"], **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") @@ -88,6 +145,88 @@ def test_default_env(hatch, helpers, temp_dir, config_file): 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 @@ -140,7 +279,7 @@ def test_check_missing(hatch, helpers, temp_dir, config_file): ) with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch("env", "lock", "--check") + result = hatch("env", "lock", "default", "--check") assert result.exit_code == 1 assert "Lockfile does not exist" in result.output @@ -173,12 +312,44 @@ def test_check_exists(hatch, helpers, temp_dir, config_file): (project_path / "pylock.toml").write_text("") with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch("env", "lock", "--check") + 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_custom_lock_filename(hatch, helpers, temp_dir, config_file): config_file.model.template.plugins["default"]["tests"] = False @@ -208,14 +379,14 @@ def test_custom_lock_filename(hatch, helpers, temp_dir, config_file): ) with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch("env", "lock") + 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_output_option(hatch, helpers, temp_dir, config_file): +def test_all_flag(hatch, helpers, temp_dir, config_file): config_file.model.template.plugins["default"]["tests"] = False config_file.save() @@ -236,14 +407,14 @@ def test_output_option(hatch, helpers, temp_dir, config_file): "default", {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, ) - - custom_output = str(temp_dir / "custom-lock.toml") + helpers.update_project_environment(project, "test", {"dependencies": ["pytest"]}) with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch("env", "lock", "-o", custom_output) + result = hatch("env", "lock", "--all") assert result.exit_code == 0, result.output - assert f"Wrote lockfile: {custom_output}" in result.output + assert "Locking environment: default" in result.output + assert "Locking environment: test" in result.output @pytest.mark.usefixtures("env_run") @@ -347,7 +518,7 @@ def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, config_file, mock_p ) with project_path.as_cwd(): - result = hatch("env", "lock") + result = hatch("env", "lock", "default") assert result.exit_code == 0, result.output assert "Syncing environment plugin requirements" in result.output From 4de406adfd13d8f902ce4f749db9e1e7eb7227a1 Mon Sep 17 00:00:00 2001 From: cjames23 Date: Tue, 10 Feb 2026 20:23:55 -0800 Subject: [PATCH 4/5] Move to export-all, adjust docs, dedupe based on lock-filename --- docs/environment.md | 12 +- docs/how-to/environment/lockfiles.md | 79 ++++++++----- src/hatch/cli/env/lock.py | 70 ++++++++++- src/hatch/env/lock.py | 3 +- tests/cli/env/test_lock.py | 171 +++++++++++++++++++++++---- 5 files changed, 269 insertions(+), 66 deletions(-) diff --git a/docs/environment.md b/docs/environment.md index 16e074aec..d5115b6f5 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -97,21 +97,15 @@ Syncing dependencies ## Locking -Hatch can generate [PEP 751](https://peps.python.org/pep-0751/) lockfiles (`pylock.toml`) for any environment using the [`env lock`](cli/reference.md#hatch-env-lock) command: +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 default +$ hatch env lock Locking environment: default Wrote lockfile: /path/to/project/pylock.toml ``` -To lock all environments at once, use the `--all` flag: - -```console -$ hatch env lock --all -``` - -You can also configure environments to be [locked automatically](config/environment/overview.md#locking) whenever they are created or their dependencies change. See the [lockfile how-to guide](how-to/environment/lockfiles.md) for more details. +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 diff --git a/docs/how-to/environment/lockfiles.md b/docs/how-to/environment/lockfiles.md index cd90e7bc3..d0140aa1c 100644 --- a/docs/how-to/environment/lockfiles.md +++ b/docs/how-to/environment/lockfiles.md @@ -4,31 +4,9 @@ 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. -## Locking a specific environment +## Configuring locked environments -Use the [`env lock`](../../cli/reference.md#hatch-env-lock) command with an environment name to generate a lockfile: - -```console -$ hatch env lock test -Locking environment: test -Wrote lockfile: /path/to/project/pylock.test.toml -``` - -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. - -## Locking all environments - -To lock every environment at once, use the `--all` flag: - -```console -$ hatch env lock --all -``` - -Environments that are incompatible with the current platform (e.g. a matrix variant requiring a Python version that is not installed) will be skipped with a warning. - -## Automatic locking - -You can configure environments to generate lockfiles automatically whenever they are created or their dependencies change. Set [`locked`](../../config/environment/overview.md#locked) to `true` on individual 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] @@ -38,14 +16,14 @@ dependencies = [ ] ``` -Or enable it for all environments at once with the global [`lock-envs`](../../config/environment/overview.md#lock-envs) setting: +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 of the global setting: +Individual environments can opt out: ```toml config-example [tool.hatch] @@ -55,7 +33,37 @@ lock-envs = true locked = false ``` -When no explicit environment name is passed, `hatch env lock` will lock all environments that have `locked = true` (either directly or via the global setting). +## 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 @@ -82,14 +90,23 @@ Lockfile exists: /path/to/project/pylock.test.toml This is useful in CI to ensure lockfiles have been committed. -## Exporting to a custom path +## 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`: -By default, lockfiles are written to the project root. Use `--export` to write to a different location: +```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 test --export locks/test.lock +$ 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: @@ -99,6 +116,8 @@ You can override the default filename for any environment with the [`lock-filena 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: diff --git a/src/hatch/cli/env/lock.py b/src/hatch/cli/env/lock.py index e457ff504..f14e7f249 100644 --- a/src/hatch/cli/env/lock.py +++ b/src/hatch/cli/env/lock.py @@ -10,20 +10,26 @@ @click.command(short_help="Generate lockfiles for environments") @click.argument("env_name", required=False, default=None) -@click.option("--all", "lock_all", is_flag=True, help="Lock all environments regardless of config") @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, - lock_all: bool, upgrade: bool, upgrade_package: tuple[str, ...], export_path: str | None, + export_all_path: str | None, check: bool, ): """Generate lockfiles for environments. @@ -34,8 +40,12 @@ def lock( app.ensure_environment_plugin_dependencies() from hatch.env.lock import generate_lockfile, resolve_lockfile_path + from hatch.utils.fs import Path - if lock_all: + 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) @@ -56,7 +66,10 @@ def lock( 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) @@ -69,10 +82,20 @@ def lock( app.abort(f"Environment `{env}` is incompatible: {e}") - if export_path: - from hatch.utils.fs import Path + # 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) @@ -86,12 +109,47 @@ def lock( app.display_warning(f"Environment `{env}` has no dependencies to lock") continue - with app.status(f"Locking environment: {env}"): + 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}") diff --git a/src/hatch/env/lock.py b/src/hatch/env/lock.py index 5444f544d..2a287f7dd 100644 --- a/src/hatch/env/lock.py +++ b/src/hatch/env/lock.py @@ -17,8 +17,9 @@ def generate_lockfile( *, upgrade: bool = False, upgrade_packages: tuple[str, ...] = (), + deps_override: list[str] | None = None, ) -> None: - deps = environment.dependencies + deps = deps_override if deps_override is not None else environment.dependencies if not deps: return diff --git a/tests/cli/env/test_lock.py b/tests/cli/env/test_lock.py index 76a2174e9..59a4abe9f 100644 --- a/tests/cli/env/test_lock.py +++ b/tests/cli/env/test_lock.py @@ -56,6 +56,34 @@ def test_no_locked_envs(hatch, helpers, temp_dir, config_file): ) +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() @@ -70,7 +98,9 @@ def test_no_dependencies(hatch, helpers, temp_dir, config_file): 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, "default", {"skip-install": True, "locked": True, **project.config.envs["default"]} + ) with project_path.as_cwd(): result = hatch("env", "lock", "default") @@ -103,7 +133,7 @@ def test_explicit_env(hatch, helpers, temp_dir, config_file): helpers.update_project_environment( project, "default", - {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, + {"skip-install": True, "dependencies": ["requests"], "locked": True, **project.config.envs["default"]}, ) with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): @@ -245,7 +275,7 @@ def test_named_env(hatch, helpers, temp_dir, config_file): 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"]}) + 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") @@ -275,7 +305,7 @@ def test_check_missing(hatch, helpers, temp_dir, config_file): helpers.update_project_environment( project, "default", - {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, + {"skip-install": True, "dependencies": ["requests"], "locked": True, **project.config.envs["default"]}, ) with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): @@ -305,7 +335,7 @@ def test_check_exists(hatch, helpers, temp_dir, config_file): helpers.update_project_environment( project, "default", - {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, + {"skip-install": True, "dependencies": ["requests"], "locked": True, **project.config.envs["default"]}, ) # Create a lockfile to check against @@ -350,6 +380,60 @@ def test_export(hatch, helpers, temp_dir, config_file): 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 + + +def test_export_and_export_all_mutually_exclusive(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", "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 @@ -373,6 +457,7 @@ def test_custom_lock_filename(hatch, helpers, temp_dir, config_file): { "skip-install": True, "dependencies": ["requests"], + "locked": True, "lock-filename": "locks/default.toml", **project.config.envs["default"], }, @@ -386,7 +471,7 @@ def test_custom_lock_filename(hatch, helpers, temp_dir, config_file): @pytest.mark.usefixtures("env_run") -def test_all_flag(hatch, helpers, temp_dir, config_file): +def test_matrix(hatch, helpers, temp_dir, config_file): config_file.model.template.plugins["default"]["tests"] = False config_file.save() @@ -402,23 +487,27 @@ def test_all_flag(hatch, helpers, temp_dir, config_file): 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, - "default", - {"skip-install": True, "dependencies": ["requests"], **project.config.envs["default"]}, + "test", + { + "dependencies": ["pytest"], + "locked": True, + "matrix": [{"version": ["9000", "42"]}], + }, ) - helpers.update_project_environment(project, "test", {"dependencies": ["pytest"]}) with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch("env", "lock", "--all") + result = hatch("env", "lock", "test") assert result.exit_code == 0, result.output - assert "Locking environment: default" in result.output - assert "Locking environment: test" in 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(hatch, helpers, temp_dir, config_file): +def test_matrix_incompatible(hatch, helpers, temp_dir, config_file): config_file.model.template.plugins["default"]["tests"] = False config_file.save() @@ -440,7 +529,9 @@ def test_matrix(hatch, helpers, temp_dir, config_file): "test", { "dependencies": ["pytest"], + "locked": True, "matrix": [{"version": ["9000", "42"]}], + "overrides": {"matrix": {"version": {"platforms": [{"value": "foo", "if": ["9000"]}]}}}, }, ) @@ -448,12 +539,14 @@ def test_matrix(hatch, helpers, temp_dir, config_file): 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 + assert "Skipped 1 incompatible environment:" in result.output + assert "test.9000 -> unsupported platform" in result.output @pytest.mark.usefixtures("env_run") -def test_matrix_incompatible(hatch, helpers, temp_dir, config_file): +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() @@ -475,8 +568,9 @@ def test_matrix_incompatible(hatch, helpers, temp_dir, config_file): "test", { "dependencies": ["pytest"], + "locked": True, + "lock-filename": "pylock.test.toml", "matrix": [{"version": ["9000", "42"]}], - "overrides": {"matrix": {"version": {"platforms": [{"value": "foo", "if": ["9000"]}]}}}, }, ) @@ -484,12 +578,13 @@ def test_matrix_incompatible(hatch, helpers, temp_dir, config_file): 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 + # 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_plugin_dependencies_unmet(hatch, helpers, temp_dir, config_file, mock_plugin_installation): +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() @@ -504,6 +599,42 @@ def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, config_file, mock_p 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 From 639804476de8238883d63510646587abb8baad6e Mon Sep 17 00:00:00 2001 From: cjames23 Date: Tue, 10 Feb 2026 20:26:20 -0800 Subject: [PATCH 5/5] Change to usefixture for helpers for lock tests --- tests/cli/env/test_lock.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/cli/env/test_lock.py b/tests/cli/env/test_lock.py index 59a4abe9f..c26e0353f 100644 --- a/tests/cli/env/test_lock.py +++ b/tests/cli/env/test_lock.py @@ -414,7 +414,8 @@ def test_export_all(hatch, helpers, temp_dir, config_file): assert "Locking environment: test" in result.output -def test_export_and_export_all_mutually_exclusive(hatch, helpers, temp_dir, config_file): +@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()