diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 98392bde2..ac48a4261 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Added:*** + +- Add the `--all`/`-a` flag to the `build` command to build every workspace member defined by the selected environment, consolidating artifacts in the workspace root's `dist` directory by default ***Fixed:*** diff --git a/docs/how-to/environment/workspace.md b/docs/how-to/environment/workspace.md index 495746a90..25084bcbc 100644 --- a/docs/how-to/environment/workspace.md +++ b/docs/how-to/environment/workspace.md @@ -71,6 +71,23 @@ workspace.members = [ ] ``` +## Building all members + +Build an `sdist` and `wheel` for every workspace member with the [`build`](../../cli/reference.md#hatch-build) command's `--all` flag: + +``` +hatch build --all +``` + +Members are discovered from the selected environment's `workspace.members` option and artifacts are consolidated in the workspace root's `dist` directory by default, ready for e.g. `twine upload dist/*`. Pass a location argument to write artifacts elsewhere: + +``` +hatch build --all out/artifacts +``` + +!!! note + The workspace root project itself is not built unless it is listed as a member. + ## Test matrices with workspaces Combine workspace configuration with test matrices: diff --git a/src/hatch/cli/build/__init__.py b/src/hatch/cli/build/__init__.py index 66dd9babb..a1e19d858 100644 --- a/src/hatch/cli/build/__init__.py +++ b/src/hatch/cli/build/__init__.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from hatch.cli.application import Application + from hatch.project.core import Project @click.command(short_help="Build a project") @@ -19,6 +20,16 @@ "The target to build, overriding project defaults. This may be selected multiple times e.g. `-t sdist -t wheel`" ), ) +@click.option( + "--all", + "-a", + "build_all", + is_flag=True, + help=( + "Whether or not to build every workspace member defined by the selected environment. " + "Artifacts are written to the workspace root's `dist` directory by default" + ), +) @click.option( "--hooks-only", is_flag=True, help="Whether or not to only execute build hooks [env var: `HATCH_BUILD_HOOKS_ONLY`]" ) @@ -49,18 +60,16 @@ ) @click.option("--clean-only", is_flag=True, hidden=True) @click.pass_obj -def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, clean_hooks_after, clean_only): +def build( + app: Application, location, targets, build_all, hooks_only, no_hooks, ext, clean, clean_hooks_after, clean_only +): """Build a project.""" app.ensure_environment_plugin_dependencies() from hatch.config.constants import AppEnvVars - from hatch.project.config import env_var_enabled - from hatch.project.constants import BUILD_BACKEND, DEFAULT_BUILD_DIRECTORY, BuildEnvVars + from hatch.project.constants import DEFAULT_BUILD_DIRECTORY from hatch.utils.fs import Path - from hatch.utils.runner import ExecutionContext - from hatch.utils.structures import EnvVars - build_dir = Path(location).resolve() if location else None if ext: hooks_only = True targets = ("wheel",) @@ -73,11 +82,76 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, elif app.quiet: env_vars[AppEnvVars.QUIET] = str(abs(app.verbosity)) + if not build_all: + _build_project( + app, + app.project, + location, + targets, + hooks_only=hooks_only, + no_hooks=no_hooks, + clean=clean, + clean_hooks_after=clean_hooks_after, + clean_only=clean_only, + env_vars=env_vars, + ) + return + + environment = app.project.get_environment() + members = environment.workspace.members + if not members: + app.abort( + f"The `--all` flag requires workspace members to be defined in field " + f"`tool.hatch.envs.{environment.name}.workspace.members`" + ) + + # Artifacts from every member are consolidated in a single directory, defaulting to the + # workspace root. The location must be absolute because each member builds from its own path + build_directory = str(Path(location).resolve() if location else app.project.location / DEFAULT_BUILD_DIRECTORY) + for member in members: + if not clean_only: + app.display_header(member.name) + + _build_project( + app, + member.project, + build_directory, + targets, + hooks_only=hooks_only, + no_hooks=no_hooks, + clean=clean, + clean_hooks_after=clean_hooks_after, + clean_only=clean_only, + env_vars=env_vars, + ) + + +def _build_project( + app: Application, + project: Project, + location, + targets, + *, + hooks_only, + no_hooks, + clean, + clean_hooks_after, + clean_only, + env_vars, +): + from hatch.project.config import env_var_enabled + from hatch.project.constants import BUILD_BACKEND, DEFAULT_BUILD_DIRECTORY, BuildEnvVars + from hatch.utils.fs import Path + from hatch.utils.runner import ExecutionContext + from hatch.utils.structures import EnvVars + + build_dir = Path(location).resolve() if location else None + with EnvVars(env_vars): - app.project.prepare_build_environment(targets=[target.split(":")[0] for target in targets]) + project.prepare_build_environment(targets=[target.split(":")[0] for target in targets]) - build_backend = app.project.metadata.build.build_backend - with app.project.location.as_cwd(), app.project.build_env.get_env_vars(): + build_backend = project.metadata.build.build_backend + with project.location.as_cwd(), project.build_env.get_env_vars(): for target in targets: target_name, _, _ = target.partition(":") if not clean_only: @@ -85,19 +159,19 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, if build_backend != BUILD_BACKEND: if target_name == "sdist": - directory = build_dir or app.project.location / DEFAULT_BUILD_DIRECTORY + directory = build_dir or project.location / DEFAULT_BUILD_DIRECTORY directory.ensure_dir_exists() - artifact_path = app.project.build_frontend.build_sdist(directory) + artifact_path = project.build_frontend.build_sdist(directory) elif target_name == "wheel": - directory = build_dir or app.project.location / DEFAULT_BUILD_DIRECTORY + directory = build_dir or project.location / DEFAULT_BUILD_DIRECTORY directory.ensure_dir_exists() - artifact_path = app.project.build_frontend.build_wheel(directory) + artifact_path = project.build_frontend.build_wheel(directory) else: app.abort(f"Target `{target_name}` is not supported by `{build_backend}`") app.display_info( - str(artifact_path.relative_to(app.project.location)) - if app.project.location in artifact_path.parents + str(artifact_path.relative_to(project.location)) + if project.location in artifact_path.parents else str(artifact_path) ) else: @@ -106,7 +180,7 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, # We deliberately pass the location unchanged so that absolute paths may be non-local # and reflect wherever builds actually take place if location: - command.extend(("--directory", location)) + command.extend(("--directory", str(location))) if hooks_only or env_var_enabled(BuildEnvVars.HOOKS_ONLY): command.append("--hooks-only") @@ -123,7 +197,7 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, if clean_only: command.append("--clean-only") - context = ExecutionContext(app.project.build_env) + context = ExecutionContext(project.build_env) context.add_shell_command(command) context.env_vars.update(env_vars) app.execute_context(context) diff --git a/tests/cli/build/test_build.py b/tests/cli/build/test_build.py index da5aa85af..f03db936a 100644 --- a/tests/cli/build/test_build.py +++ b/tests/cli/build/test_build.py @@ -1420,3 +1420,104 @@ def test_plugin_dependencies_unmet(hatch, temp_dir, helpers, mock_plugin_install """ ) helpers.assert_plugin_installation(mock_plugin_installation, [dependency]) + + +class TestBuildAll: + def _create_workspace(self, hatch, temp_dir): + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + (workspace_root / "pyproject.toml").write_text( + """\ +[project] +name = "workspace-root" +version = "0.1.0" + +[tool.hatch.envs.default] +workspace.members = ["packages/*"] +""" + ) + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + with packages_dir.as_cwd(): + for project_name in ("member1", "member2"): + result = hatch("new", project_name) + assert result.exit_code == 0, result.output + + return workspace_root + + def test_no_workspace_members(self, hatch, temp_dir, helpers): + project_name = "My.App" + + with temp_dir.as_cwd(): + result = hatch("new", project_name) + assert result.exit_code == 0, result.output + + path = temp_dir / "my-app" + + with path.as_cwd(): + result = hatch("build", "--all") + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + """ + The `--all` flag requires workspace members to be defined in field `tool.hatch.envs.default.workspace.members` + """ + ) + + def test_default(self, hatch, temp_dir): + workspace_root = self._create_workspace(hatch, temp_dir) + + with workspace_root.as_cwd(): + result = hatch("build", "--all") + assert result.exit_code == 0, result.output + + build_directory = workspace_root / "dist" + assert build_directory.is_dir() + + artifacts = sorted(artifact.name for artifact in build_directory.iterdir()) + assert artifacts == [ + "member1-0.0.1-py3-none-any.whl", + "member1-0.0.1.tar.gz", + "member2-0.0.1-py3-none-any.whl", + "member2-0.0.1.tar.gz", + ] + + for member_name in ("member1", "member2"): + assert f" {member_name} " in result.output + assert not (workspace_root / "packages" / member_name / "dist").is_dir() + + def test_explicit_targets(self, hatch, temp_dir): + workspace_root = self._create_workspace(hatch, temp_dir) + + with workspace_root.as_cwd(): + result = hatch("build", "--all", "-t", "wheel") + assert result.exit_code == 0, result.output + + build_directory = workspace_root / "dist" + assert build_directory.is_dir() + + artifacts = sorted(artifact.name for artifact in build_directory.iterdir()) + assert artifacts == [ + "member1-0.0.1-py3-none-any.whl", + "member2-0.0.1-py3-none-any.whl", + ] + + def test_explicit_directory(self, hatch, temp_dir): + workspace_root = self._create_workspace(hatch, temp_dir) + build_directory = temp_dir / "artifacts" + + with workspace_root.as_cwd(): + result = hatch("build", "--all", str(build_directory)) + assert result.exit_code == 0, result.output + + assert build_directory.is_dir() + + artifacts = sorted(artifact.name for artifact in build_directory.iterdir()) + assert artifacts == [ + "member1-0.0.1-py3-none-any.whl", + "member1-0.0.1.tar.gz", + "member2-0.0.1-py3-none-any.whl", + "member2-0.0.1.tar.gz", + ] + assert not (workspace_root / "dist").is_dir()