Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/history/hatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:***

Expand Down
17 changes: 17 additions & 0 deletions docs/how-to/environment/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
108 changes: 91 additions & 17 deletions src/hatch/cli/build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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`]"
)
Expand Down Expand Up @@ -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",)
Expand All @@ -73,31 +82,96 @@ 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:
app.display_header(target_name)

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:
Expand All @@ -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")
Expand All @@ -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)
101 changes: 101 additions & 0 deletions tests/cli/build/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading