Skip to content
Draft
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
148 changes: 95 additions & 53 deletions src/hatch/cli/build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,57 +73,99 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean,
elif app.quiet:
env_vars[AppEnvVars.QUIET] = str(abs(app.verbosity))

with EnvVars(env_vars):
app.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():
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.ensure_dir_exists()
artifact_path = app.project.build_frontend.build_sdist(directory)
elif target_name == "wheel":
directory = build_dir or app.project.location / DEFAULT_BUILD_DIRECTORY
directory.ensure_dir_exists()
artifact_path = app.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
else str(artifact_path)
)
else:
command = ["python", "-u", "-m", "hatchling", "build", "--target", target]

# 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))

if hooks_only or env_var_enabled(BuildEnvVars.HOOKS_ONLY):
command.append("--hooks-only")

if no_hooks or env_var_enabled(BuildEnvVars.NO_HOOKS):
command.append("--no-hooks")

if clean or env_var_enabled(BuildEnvVars.CLEAN):
command.append("--clean")

if clean_hooks_after or env_var_enabled(BuildEnvVars.CLEAN_HOOKS_AFTER):
command.append("--clean-hooks-after")

if clean_only:
command.append("--clean-only")

context = ExecutionContext(app.project.build_env)
context.add_shell_command(command)
context.env_vars.update(env_vars)
app.execute_context(context)
target_names = [target.split(":")[0] for target in targets]
use_local_hatchling = (
build_backend == BUILD_BACKEND
and app.project.network_isolation_likely
and app.project.can_execute_hatchling_locally(targets=target_names)
)

if not use_local_hatchling:
with EnvVars(env_vars):
app.project.prepare_build_environment(targets=target_names)

with app.project.location.as_cwd():
if use_local_hatchling:
with EnvVars(env_vars), app.status("Inspecting build dependencies"):
for target in targets:
target_name, _, _ = target.partition(":")
if not clean_only:
app.display_header(target_name)

command = ["python", "-u", "-m", "hatchling", "build", "--target", target]

# 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))

if hooks_only or env_var_enabled(BuildEnvVars.HOOKS_ONLY):
command.append("--hooks-only")

if no_hooks or env_var_enabled(BuildEnvVars.NO_HOOKS):
command.append("--no-hooks")

if clean or env_var_enabled(BuildEnvVars.CLEAN):
command.append("--clean")

if clean_hooks_after or env_var_enabled(BuildEnvVars.CLEAN_HOOKS_AFTER):
command.append("--clean-hooks-after")

if clean_only:
command.append("--clean-only")

if app.verbose:
app.display_info(f"cmd [1] | {' '.join(command)}")
app.platform.check_command(command)
else:
with app.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.ensure_dir_exists()
artifact_path = app.project.build_frontend.build_sdist(directory)
elif target_name == "wheel":
directory = build_dir or app.project.location / DEFAULT_BUILD_DIRECTORY
directory.ensure_dir_exists()
artifact_path = app.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
else str(artifact_path)
)
else:
command = ["python", "-u", "-m", "hatchling", "build", "--target", target]

# 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))

if hooks_only or env_var_enabled(BuildEnvVars.HOOKS_ONLY):
command.append("--hooks-only")

if no_hooks or env_var_enabled(BuildEnvVars.NO_HOOKS):
command.append("--no-hooks")

if clean or env_var_enabled(BuildEnvVars.CLEAN):
command.append("--clean")

if clean_hooks_after or env_var_enabled(BuildEnvVars.CLEAN_HOOKS_AFTER):
command.append("--clean-hooks-after")

if clean_only:
command.append("--clean-only")

context = ExecutionContext(app.project.build_env)
context.add_shell_command(command)
context.env_vars.update(env_vars)
app.execute_context(context)
23 changes: 18 additions & 5 deletions src/hatch/cli/project/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,26 @@ def metadata(app: Application, field: str | None):

from hatch.project.constants import BUILD_BACKEND

app.project.prepare_build_environment()
build_backend = app.project.metadata.build.build_backend
with app.project.location.as_cwd(), app.project.build_env.get_env_vars():
if build_backend != BUILD_BACKEND:
project_metadata = app.project.build_frontend.get_core_metadata()
with app.project.location.as_cwd():
if (
build_backend == BUILD_BACKEND
and app.project.network_isolation_likely
and app.project.can_execute_hatchling_locally(targets=["wheel"])
):
command = ["python", "-u", "-m", "hatchling", "metadata", "--compact"]
with app.status("Inspecting build dependencies"):
if app.verbose:
app.display_info(f"cmd [1] | {' '.join(command)}")
output = app.platform.check_command_output(command)
project_metadata = json.loads(output)
else:
project_metadata = app.project.build_frontend.hatch.get_core_metadata()
app.project.prepare_build_environment()
with app.project.build_env.get_env_vars():
if build_backend != BUILD_BACKEND:
project_metadata = app.project.build_frontend.get_core_metadata()
else:
project_metadata = app.project.build_frontend.hatch.get_core_metadata()

if field:
if field not in project_metadata:
Expand Down
7 changes: 4 additions & 3 deletions src/hatch/cli/self/report.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

import click

Expand Down Expand Up @@ -80,12 +80,13 @@ def report(app: Application, *, no_open: bool) -> None:

# Retain the config that would be most useful
full_config = load_toml_data(app.config_file.read_scrubbed())
relevant_config = {}
relevant_config: dict[str, Any] = {}
for setting in ("mode", "shell"):
if setting in full_config:
relevant_config[setting] = full_config[setting]

if env_dirs := relevant_config.get("dirs", {}).get("envs"):
full_dirs = full_config.get("dirs")
if isinstance(full_dirs, dict) and (env_dirs := full_dirs.get("envs")):
relevant_config["dirs"] = {"envs": env_dirs}

# Try to determine how Hatch was installed
Expand Down
24 changes: 16 additions & 8 deletions src/hatch/cli/version/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def version(app: Application, *, desired_version: str | None, force: bool):

from hatch.config.constants import VersionEnvVars
from hatch.project.constants import BUILD_BACKEND
from hatch.utils.structures import EnvVars

with app.project.location.as_cwd():
if app.project.metadata.build.build_backend != BUILD_BACKEND:
Expand All @@ -48,17 +49,24 @@ def version(app: Application, *, desired_version: str | None, force: bool):

app.display(project_metadata["version"])
else:
from hatch.utils.runner import ExecutionContext

app.ensure_environment_plugin_dependencies()
app.project.prepare_build_environment()

context = ExecutionContext(app.project.build_env)
command = ["python", "-u", "-m", "hatchling", "version"]
command_env_vars: dict[str, str] = {}
if desired_version:
command.append(desired_version)
if force:
context.env_vars[VersionEnvVars.VALIDATE_BUMP] = "false"
command_env_vars[VersionEnvVars.VALIDATE_BUMP] = "false"

if app.project.network_isolation_likely and app.project.can_execute_hatchling_locally(targets=["wheel"]):
with EnvVars(command_env_vars), app.status("Inspecting build dependencies"):
if app.verbose:
app.display_info(f"cmd [1] | {' '.join(command)}")
app.platform.check_command(command)
else:
from hatch.utils.runner import ExecutionContext

context.add_shell_command(command)
app.execute_context(context)
app.project.prepare_build_environment()
context = ExecutionContext(app.project.build_env)
context.env_vars.update(command_env_vars)
context.add_shell_command(command)
app.execute_context(context)
66 changes: 66 additions & 0 deletions src/hatch/project/core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import re
from collections import defaultdict
from collections.abc import Generator
Expand Down Expand Up @@ -290,6 +291,71 @@ def prepare_build_environment(self, *, targets: list[str] | None = None, keep_en
with self.build_env.app_status_dependency_synchronization():
self.build_env.sync_dependencies()

def can_execute_hatchling_locally(self, *, targets: list[str] | None = None) -> bool:
"""
Return whether Hatchling commands can safely execute without bootstrapping ``hatch-build``.

This is intentionally conservative: if any dependency/hook requirement is uncertain,
callers should continue using :meth:`prepare_build_environment`.
"""
from hatch.project.constants import BUILD_BACKEND

if self.metadata.build.build_backend != BUILD_BACKEND:
return False

if targets is None:
targets = ["wheel"]

# Any non-static hook dependency means we must inspect via build environment.
hooks: set[str] = set()
for target in targets:
target_config = self.config.build.target(target)
hooks.update(target_config.hook_config)
hooks.difference_update(("version", "vcs"))
if hooks:
return False

build_system = self.raw_config.get("build-system", {})
build_requires = build_system.get("requires", [])
if not isinstance(build_requires, list) or any(not isinstance(dep, str) for dep in build_requires):
return False

static_target_dependencies: list[str] = []
for target in targets:
target_config = self.config.build.target(target)
static_target_dependencies.extend(target_config.dependencies)

from hatch.dep.core import Dependency, InvalidDependencyError
from hatch.dep.sync import InstalledDistributions

all_requirements: list[Dependency] = []
for requirement in [*build_requires, *static_target_dependencies]:
try:
all_requirements.append(Dependency(requirement))
except InvalidDependencyError:
return False

if not all_requirements:
return True

return InstalledDistributions().dependencies_in_sync(all_requirements)

@cached_property
def network_isolation_likely(self) -> bool:
if os.environ.get("UV_OFFLINE", "").lower() in {"1", "true"}:
return True
if os.environ.get("PIP_NO_INDEX", "").lower() in {"1", "true"}:
return True

import socket

try:
host = socket.gethostbyname("pypi.org")
with socket.create_connection((host, 443), timeout=1):
return False
except Exception: # noqa: BLE001
return True

def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]:
dynamic_fields = {"dependencies", "optional-dependencies"}
if not dynamic_fields.intersection(self.metadata.dynamic):
Expand Down
7 changes: 6 additions & 1 deletion tests/cli/self/test_report.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib
import os
import sys
from textwrap import indent
Expand Down Expand Up @@ -35,6 +36,8 @@ def assert_call(open_new_tab, expected_body):
class TestDefault:
def test_open(self, hatch, mocker, platform):
open_new_tab = mocker.patch("webbrowser.open_new_tab")
report_module = importlib.import_module("hatch.cli.self.report")
mocker.patch.object(report_module, "get_install_source", return_value="pip")
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "report")

assert result.exit_code == 0, result.output
Expand Down Expand Up @@ -64,7 +67,9 @@ def test_open(self, hatch, mocker, platform):

assert_call(open_new_tab, expected_body)

def test_no_open(self, hatch, platform):
def test_no_open(self, hatch, mocker, platform):
report_module = importlib.import_module("hatch.cli.self.report")
mocker.patch.object(report_module, "get_install_source", return_value="pip")
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "report", "--no-open")

assert result.exit_code == 0, result.output
Expand Down
Loading