From 8adb894c97e539345846694edfa7593ec9460656 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Sat, 6 Jul 2024 15:55:01 -0400 Subject: [PATCH] Improve builds --- .github/workflows/test.yml | 3 +- docs/history/hatch.md | 5 + docs/plugins/environment/reference.md | 2 +- hatch.toml | 2 + pyproject.toml | 2 + src/hatch/cli/__init__.py | 12 +- src/hatch/cli/application.py | 169 +---- src/hatch/cli/build/__init__.py | 102 +-- src/hatch/cli/dep/__init__.py | 28 +- src/hatch/cli/env/create.py | 6 +- src/hatch/cli/env/find.py | 4 +- src/hatch/cli/env/remove.py | 4 +- src/hatch/cli/env/show.py | 4 +- src/hatch/cli/project/metadata.py | 49 +- src/hatch/cli/shell/__init__.py | 4 +- src/hatch/cli/test/__init__.py | 2 +- src/hatch/cli/version/__init__.py | 48 +- src/hatch/env/internal/__init__.py | 11 +- src/hatch/env/internal/build.py | 7 +- src/hatch/env/plugin/interface.py | 89 ++- src/hatch/env/virtual.py | 4 +- src/hatch/project/config.py | 152 +++- src/hatch/project/constants.py | 14 + src/hatch/project/core.py | 211 +++++- src/hatch/project/env.py | 53 ++ src/hatch/project/frontend/__init__.py | 0 src/hatch/project/frontend/core.py | 347 +++++++++ src/hatch/project/frontend/script.py | 82 ++ .../project/frontend/scripts/build_deps.py | 40 + .../project/frontend/scripts/core_metadata.py | 29 + .../project/frontend/scripts/standard.py | 82 ++ src/hatch/utils/dep.py | 56 +- src/hatch/utils/fs.py | 10 + tests/cli/build/test_build.py | 274 ++++--- tests/cli/clean/test_clean.py | 5 +- tests/cli/dep/show/test_requirements.py | 30 +- tests/cli/dep/show/test_table.py | 30 +- tests/cli/dep/test_hash.py | 30 +- tests/cli/env/test_create.py | 8 +- tests/cli/project/test_metadata.py | 709 +++++++++--------- tests/cli/version/test_version.py | 169 +++-- tests/conftest.py | 28 +- tests/env/plugin/test_interface.py | 123 ++- tests/helpers/helpers.py | 11 +- tests/project/test_config.py | 245 ++++++ tests/project/test_frontend.py | 402 ++++++++++ 46 files changed, 2721 insertions(+), 976 deletions(-) create mode 100644 src/hatch/project/constants.py create mode 100644 src/hatch/project/frontend/__init__.py create mode 100644 src/hatch/project/frontend/core.py create mode 100644 src/hatch/project/frontend/script.py create mode 100644 src/hatch/project/frontend/scripts/build_deps.py create mode 100644 src/hatch/project/frontend/scripts/core_metadata.py create mode 100644 src/hatch/project/frontend/scripts/standard.py create mode 100644 tests/project/test_frontend.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49c5dfb66..89ba7a5ae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,7 +49,8 @@ jobs: run: hatch run types:check - name: Run tests - run: hatch test --python ${{ matrix.python-version }} --cover-quiet --randomize --parallel --retries 5 --retry-delay 3 + run: hatch test --python ${{ matrix.python-version }} tests/cli/build/test_build.py::TestOtherBackend::test_standard -vv + # run: hatch test --python ${{ matrix.python-version }} --cover-quiet --randomize --parallel --retries 5 --retry-delay 3 - name: Disambiguate coverage filename run: mv .coverage ".coverage.${{ matrix.os }}.${{ matrix.python-version }}" diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 1a98f8618..21786b7ec 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:*** + +- The `version` and `project metadata` commands now support projects that do not use Hatchling as the build backend +- Build environments can now be configured, the default build environment is now `hatch-build` + ## [1.12.0](https://github.com/pypa/hatch/releases/tag/hatch-v1.12.0) - 2024-05-28 ## {: #hatch-v1.12.0 } ***Changed:*** diff --git a/docs/plugins/environment/reference.md b/docs/plugins/environment/reference.md index c310a2b89..d8e932b4d 100644 --- a/docs/plugins/environment/reference.md +++ b/docs/plugins/environment/reference.md @@ -26,7 +26,7 @@ requires = [ Whenever an environment is used, the following logic is performed: -::: hatch.cli.application.Application.prepare_environment +::: hatch.project.Project.prepare_environment options: show_root_heading: false show_root_toc_entry: false diff --git a/hatch.toml b/hatch.toml index 06cb23460..51b950c92 100644 --- a/hatch.toml +++ b/hatch.toml @@ -10,6 +10,8 @@ post-install-commands = [ [envs.hatch-test] extra-dependencies = [ "filelock", + "flit-core", + "hatchling", "pyfakefs", "trustme", # Hatchling dynamic dependency diff --git a/pyproject.toml b/pyproject.toml index 2e38037d3..b142e00cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "packaging>=23.2", "pexpect~=4.8", "platformdirs>=2.5.0", + "pyproject-hooks", "rich>=11.2.0", "shellingham>=1.4.0", "tomli-w>=1.0", @@ -116,6 +117,7 @@ omit = [ "backend/src/hatchling/ouroboros.py", "src/hatch/__main__.py", "src/hatch/cli/new/migrate.py", + "src/hatch/project/frontend/scripts/*", "src/hatch/utils/shells.py", ] diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index e7a8b0fa1..242b7b626 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import os +from typing import cast import click @@ -159,13 +162,16 @@ def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interact app.cache_dir = Path(cache_dir or app.config.dirs.cache).expand() if project: - app.project = Project.from_config(app.config, project) - if app.project is None or app.project.root is None: + potential_project = Project.from_config(app.config, project) + if potential_project is None or potential_project.root is None: app.abort(f'Unable to locate project {project}') + app.project = cast(Project, potential_project) + app.project.set_app(app) return app.project = Project(Path.cwd()) + app.project.set_app(app) if app.config.mode == 'local': return @@ -182,6 +188,7 @@ def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interact else: app.project = possible_project + app.project.set_app(app) return if app.config.mode == 'aware' and app.project.root is None: @@ -195,6 +202,7 @@ def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interact else: app.project = possible_project + app.project.set_app(app) return diff --git a/src/hatch/cli/application.py b/src/hatch/cli/application.py index 5dcec8b4a..bdc77b8c2 100644 --- a/src/hatch/cli/application.py +++ b/src/hatch/cli/application.py @@ -3,7 +3,7 @@ import os import sys from functools import cached_property -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast from hatch.cli.terminal import Terminal from hatch.config.user import ConfigFile, RootConfig @@ -45,114 +45,11 @@ def plugins(self): def config(self) -> RootConfig: return self.config_file.model - def expand_environments(self, env_name: str) -> list[str]: - if env_name in self.project.config.internal_matrices: - return list(self.project.config.internal_matrices[env_name]['envs']) - - if env_name in self.project.config.matrices: - return list(self.project.config.matrices[env_name]['envs']) - - if env_name in self.project.config.internal_envs: - return [env_name] - - if env_name in self.project.config.envs: - return [env_name] - - return [] - def get_environment(self, env_name: str | None = None) -> EnvironmentInterface: - if env_name is None: - env_name = self.env - - if env_name in self.project.config.internal_envs: - config = self.project.config.internal_envs[env_name] - elif env_name in self.project.config.envs: - config = self.project.config.envs[env_name] - else: - self.abort(f'Unknown environment: {env_name}') - - environment_type = config['type'] - environment_class = self.plugins.environment.get(environment_type) - if environment_class is None: - self.abort(f'Environment `{env_name}` has unknown type: {environment_type}') - - from hatch.env.internal import is_isolated_environment + return self.project.get_environment(env_name) - if self.project.location.is_file(): - data_directory = isolated_data_directory = self.data_dir / 'env' / environment_type / '.scripts' - elif is_isolated_environment(env_name, config): - data_directory = isolated_data_directory = self.data_dir / 'env' / '.internal' / env_name - else: - data_directory = self.get_env_directory(environment_type) - isolated_data_directory = self.data_dir / 'env' / environment_type - - self.project.config.finalize_env_overrides(environment_class.get_option_types()) - - return environment_class( - self.project.location, - self.project.metadata, - env_name, - config, - self.project.config.matrix_variables.get(env_name, {}), - data_directory, - isolated_data_directory, - self.platform, - self.verbosity, - self, - ) - - # Ensure that this method is clearly written since it is - # used for documenting the life cycle of environments. def prepare_environment(self, environment: EnvironmentInterface): - if not environment.exists(): - self.env_metadata.reset(environment) - - with environment.app_status_creation(): - environment.create() - - if not environment.skip_install: - if environment.pre_install_commands: - with environment.app_status_pre_installation(): - self.run_shell_commands( - ExecutionContext( - environment, - shell_commands=environment.pre_install_commands, - source='pre-install', - show_code_on_error=True, - ) - ) - - with environment.app_status_project_installation(): - if environment.dev_mode: - environment.install_project_dev_mode() - else: - environment.install_project() - - if environment.post_install_commands: - with environment.app_status_post_installation(): - self.run_shell_commands( - ExecutionContext( - environment, - shell_commands=environment.post_install_commands, - source='post-install', - show_code_on_error=True, - ) - ) - - with environment.app_status_dependency_state_check(): - new_dep_hash = environment.dependency_hash() - - current_dep_hash = self.env_metadata.dependency_hash(environment) - if new_dep_hash != current_dep_hash: - with environment.app_status_dependency_installation_check(): - dependencies_in_sync = environment.dependencies_in_sync() - - if not dependencies_in_sync: - with environment.app_status_dependency_synchronization(): - environment.sync_dependencies() - new_dep_hash = environment.dependency_hash() - - self.env_metadata.update_dependency_hash(environment, new_dep_hash) + self.project.prepare_environment(environment) def run_shell_commands(self, context: ExecutionContext) -> None: with context.env.command_context(): @@ -194,8 +91,6 @@ def runner_context( ignore_compat: bool = False, display_header: bool = False, ) -> Generator[ExecutionContext, None, None]: - from hatch.utils.structures import EnvVars - if self.verbose or len(environments) > 1: display_header = True @@ -222,8 +117,7 @@ def runner_context( yield context self.prepare_environment(environment) - with EnvVars(context.env_vars): - self.run_shell_commands(context) + self.execute_context(context) if incompatible: num_incompatible = len(incompatible) @@ -234,6 +128,12 @@ def runner_context( for env_name, reason in incompatible.items(): self.display_warning(f'{env_name} -> {reason}') + def execute_context(self, context: ExecutionContext) -> None: + from hatch.utils.structures import EnvVars + + with EnvVars(context.env_vars): + self.run_shell_commands(context) + def ensure_environment_plugin_dependencies(self) -> None: self.ensure_plugin_dependencies( self.project.config.env_requires_complex, wait_message='Syncing environment plugin requirements' @@ -299,56 +199,7 @@ def shell_data(self) -> tuple[str, str]: return detect_shell(self.platform) - @cached_property - def env_metadata(self) -> EnvironmentMetadata: - return EnvironmentMetadata(self.data_dir / 'env' / '.metadata', self.project.location) - def abort(self, text='', code=1, **kwargs): if text: self.display_error(text, **kwargs) self.__exit_func(code) - - -class EnvironmentMetadata: - def __init__(self, data_dir: Path, project_path: Path): - self.__data_dir = data_dir - self.__project_path = project_path - - def dependency_hash(self, environment: EnvironmentInterface) -> str: - return self._read(environment).get('dependency_hash', '') - - def update_dependency_hash(self, environment: EnvironmentInterface, dependency_hash: str) -> None: - metadata = self._read(environment) - metadata['dependency_hash'] = dependency_hash - self._write(environment, metadata) - - def reset(self, environment: EnvironmentInterface) -> None: - self._metadata_file(environment).unlink(missing_ok=True) - - def _read(self, environment: EnvironmentInterface) -> dict[str, Any]: - import json - - metadata_file = self._metadata_file(environment) - if not metadata_file.is_file(): - return {} - - return json.loads(metadata_file.read_text()) - - def _write(self, environment: EnvironmentInterface, metadata: dict[str, Any]) -> None: - import json - - metadata_file = self._metadata_file(environment) - metadata_file.parent.ensure_dir_exists() - metadata_file.write_text(json.dumps(metadata)) - - def _metadata_file(self, environment: EnvironmentInterface) -> Path: - from hatch.env.internal import is_isolated_environment - - if is_isolated_environment(environment.name, environment.config): - return self.__data_dir / '.internal' / f'{environment.name}.json' - - return self._storage_dir / environment.config['type'] / f'{environment.name}.json' - - @cached_property - def _storage_dir(self) -> Path: - return self.__data_dir / self.__project_path.id diff --git a/src/hatch/cli/build/__init__.py b/src/hatch/cli/build/__init__.py index ffc922bc2..7b5c63f69 100644 --- a/src/hatch/cli/build/__init__.py +++ b/src/hatch/cli/build/__init__.py @@ -55,76 +55,76 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, 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.utils.fs import Path + from hatch.utils.runner import ExecutionContext from hatch.utils.structures import EnvVars - from hatchling.builders.constants import EDITABLES_REQUIREMENT, BuildEnvVars - from hatchling.builders.plugin.interface import BuilderInterface - - path = str(Path(location).resolve()) if location else None + build_dir = Path(location).resolve() if location else None if ext: hooks_only = True targets = ('wheel',) elif not targets: targets = ('sdist', 'wheel') - if app.project.metadata.build.build_backend != 'hatchling.build': - for context in app.runner_context(['hatch-build']): - context.add_shell_command( - 'build-sdist' if targets == ('sdist',) else 'build-wheel' if targets == ('wheel',) else 'build-all' - ) - - return - env_vars = {} - if no_hooks: - env_vars[BuildEnvVars.NO_HOOKS] = 'true' - if app.verbose: env_vars[AppEnvVars.VERBOSE] = str(app.verbosity) elif app.quiet: env_vars[AppEnvVars.QUIET] = str(abs(app.verbosity)) - class Builder(BuilderInterface): - def get_version_api(self): # noqa: PLR6301 - return {} - - with app.project.location.as_cwd(env_vars): - environment = app.get_environment() - if not environment.build_environment_exists(): - try: - environment.check_compatibility() - except Exception as e: # noqa: BLE001 - app.abort(f'Environment `{environment.name}` is incompatible: {e}') + 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(':') - builder = Builder(str(app.project.location)) - builder.PLUGIN_NAME = target_name - if not clean_only: app.display_header(target_name) - dependencies = list(app.project.metadata.build.requires) - # editables is needed for "hatch build -t wheel:editable". - dependencies.append(EDITABLES_REQUIREMENT) - with environment.get_env_vars(), EnvVars(env_vars): - dependencies.extend(builder.config.dependencies) - - with app.status_if( - 'Setting up build environment', condition=not environment.build_environment_exists() - ) as status, environment.build_environment(dependencies) as build_environment: - status.stop() - - process = environment.run_builder( - build_environment, - directory=path, - targets=(target,), - hooks_only=hooks_only, - no_hooks=no_hooks, - clean=clean, - clean_hooks_after=clean_hooks_after, - clean_only=clean_only, + 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) ) - if process.returncode: - app.abort(code=process.returncode) + 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) diff --git a/src/hatch/cli/dep/__init__.py b/src/hatch/cli/dep/__init__.py index 46278d55e..4202cb002 100644 --- a/src/hatch/cli/dep/__init__.py +++ b/src/hatch/cli/dep/__init__.py @@ -14,18 +14,20 @@ def hash_dependencies(app, project_only, env_only): """Output a hash of the currently defined dependencies.""" app.ensure_environment_plugin_dependencies() - from hatch.utils.dep import get_project_dependencies_complex, hash_dependencies + from hatch.utils.dep import get_complex_dependencies, hash_dependencies - environment = app.get_environment() + environment = app.project.get_environment() all_requirements = [] if project_only: - dependencies_complex, _ = get_project_dependencies_complex(environment) + dependencies, _ = app.project.get_dependencies() + dependencies_complex = get_complex_dependencies(dependencies) all_requirements.extend(dependencies_complex.values()) elif env_only: all_requirements.extend(environment.environment_dependencies_complex) else: - dependencies_complex, _ = get_project_dependencies_complex(environment) + dependencies, _ = app.project.get_dependencies() + dependencies_complex = get_complex_dependencies(dependencies) all_requirements.extend(dependencies_complex.values()) all_requirements.extend(environment.environment_dependencies_complex) @@ -49,19 +51,21 @@ def table(app, project_only, env_only, show_lines, force_ascii): from packaging.requirements import Requirement - from hatch.utils.dep import get_normalized_dependencies, get_project_dependencies_complex, normalize_marker_quoting + from hatch.utils.dep import get_complex_dependencies, get_normalized_dependencies, normalize_marker_quoting - environment = app.get_environment() + environment = app.project.get_environment() project_requirements = [] environment_requirements = [] if project_only: - dependencies_complex, _ = get_project_dependencies_complex(environment) + dependencies, _ = app.project.get_dependencies() + dependencies_complex = get_complex_dependencies(dependencies) project_requirements.extend(dependencies_complex.values()) elif env_only: environment_requirements.extend(environment.environment_dependencies_complex) else: - dependencies_complex, _ = get_project_dependencies_complex(environment) + dependencies, _ = app.project.get_dependencies() + dependencies_complex = get_complex_dependencies(dependencies) project_requirements.extend(dependencies_complex.values()) environment_requirements.extend(environment.environment_dependencies_complex) @@ -116,11 +120,13 @@ def requirements(app, project_only, env_only, features, all_features): """Enumerate dependencies as a list of requirements.""" app.ensure_environment_plugin_dependencies() - from hatch.utils.dep import get_normalized_dependencies, get_project_dependencies_complex + from hatch.utils.dep import get_complex_dependencies, get_complex_features, get_normalized_dependencies from hatchling.metadata.utils import normalize_project_name - environment = app.get_environment() - dependencies_complex, optional_dependencies_complex = get_project_dependencies_complex(environment) + environment = app.project.get_environment() + dependencies, optional_dependencies = app.project.get_dependencies() + dependencies_complex = get_complex_dependencies(dependencies) + optional_dependencies_complex = get_complex_features(optional_dependencies) all_requirements = [] if features: diff --git a/src/hatch/cli/env/create.py b/src/hatch/cli/env/create.py index ef271dfa8..2247c19a9 100644 --- a/src/hatch/cli/env/create.py +++ b/src/hatch/cli/env/create.py @@ -15,13 +15,13 @@ def create(app: Application, env_name: str): """Create environments.""" app.ensure_environment_plugin_dependencies() - environments = app.expand_environments(env_name) + environments = app.project.expand_environments(env_name) if not environments: app.abort(f'Environment `{env_name}` is not defined by project config') incompatible = {} for env in environments: - environment = app.get_environment(env) + environment = app.project.get_environment(env) if environment.exists(): app.display_warning(f'Environment `{env}` already exists') continue @@ -35,7 +35,7 @@ def create(app: Application, env_name: str): app.abort(f'Environment `{env}` is incompatible: {e}') - app.prepare_environment(environment) + app.project.prepare_environment(environment) if incompatible: num_incompatible = len(incompatible) diff --git a/src/hatch/cli/env/find.py b/src/hatch/cli/env/find.py index 262006b63..7dffdf30c 100644 --- a/src/hatch/cli/env/find.py +++ b/src/hatch/cli/env/find.py @@ -15,10 +15,10 @@ def find(app: Application, env_name: str): """Locate environments.""" app.ensure_environment_plugin_dependencies() - environments = app.expand_environments(env_name) + environments = app.project.expand_environments(env_name) if not environments: app.abort(f'Environment `{env_name}` is not defined by project config') for env in environments: - environment = app.get_environment(env) + environment = app.project.get_environment(env) app.display(environment.find()) diff --git a/src/hatch/cli/env/remove.py b/src/hatch/cli/env/remove.py index 3836ec17b..d0f578774 100644 --- a/src/hatch/cli/env/remove.py +++ b/src/hatch/cli/env/remove.py @@ -19,7 +19,7 @@ def remove(ctx: click.Context, env_name: str): if (parameter_source := ctx.get_parameter_source('env_name')) is not None and parameter_source.name == 'DEFAULT': env_name = app.env - environments = app.expand_environments(env_name) + environments = app.project.expand_environments(env_name) if not environments: app.abort(f'Environment `{env_name}` is not defined by project config') @@ -28,7 +28,7 @@ def remove(ctx: click.Context, env_name: str): app.abort(f'Cannot remove active environment: {env_name}') for env_name in environments: - environment = app.get_environment(env_name) + environment = app.project.get_environment(env_name) if environment.exists() or environment.build_environment_exists(): with app.status(f'Removing environment: {env_name}'): environment.remove() diff --git a/src/hatch/cli/env/show.py b/src/hatch/cli/env/show.py index 8ec89fec1..134368efb 100644 --- a/src/hatch/cli/env/show.py +++ b/src/hatch/cli/env/show.py @@ -35,7 +35,7 @@ def show( contextual_config = {} for environments in (app.project.config.envs, app.project.config.internal_envs): for env_name, config in environments.items(): - environment = app.get_environment(env_name) + environment = app.project.get_environment(env_name) new_config = contextual_config[env_name] = dict(config) env_vars = dict(environment.env_vars) @@ -165,7 +165,7 @@ def show( if env_names and env_name not in env_names: continue - environment = app.get_environment(env_name) + environment = app.project.get_environment(env_name) standalone_columns['Name'][i] = env_name standalone_columns['Type'][i] = config['type'] diff --git a/src/hatch/cli/project/metadata.py b/src/hatch/cli/project/metadata.py index 0088a7439..37912f693 100644 --- a/src/hatch/cli/project/metadata.py +++ b/src/hatch/cli/project/metadata.py @@ -1,10 +1,17 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + import click +if TYPE_CHECKING: + from hatch.cli.application import Application + @click.command(short_help='Display project metadata') @click.argument('field', required=False) @click.pass_obj -def metadata(app, field): +def metadata(app: Application, field: str | None): """ Display project metadata. @@ -16,39 +23,19 @@ def metadata(app, field): hatch project metadata | jq -r .readme ``` """ - import json - - from hatch.dep.sync import dependencies_in_sync + app.ensure_environment_plugin_dependencies() - if dependencies_in_sync(app.project.metadata.build.requires_complex): - from hatchling.metadata.utils import resolve_metadata_fields - - with app.project.location.as_cwd(): - project_metadata = resolve_metadata_fields(app.project.metadata) - else: - app.ensure_environment_plugin_dependencies() - - with app.project.location.as_cwd(): - environment = app.get_environment() - build_environment_exists = environment.build_environment_exists() - if not build_environment_exists: - try: - environment.check_compatibility() - except Exception as e: # noqa: BLE001 - app.abort(f'Environment `{environment.name}` is incompatible: {e}') + import json - with app.status_if( - 'Setting up build environment for missing dependencies', - condition=not build_environment_exists, - ) as status, environment.build_environment(app.project.metadata.build.requires): - status.stop() + from hatch.project.constants import BUILD_BACKEND - output = app.platform.check_command_output( - ['python', '-u', '-m', 'hatchling', 'metadata', '--compact'], - # Only capture stdout - stderr=app.platform.modules.subprocess.PIPE, - ) - project_metadata = json.loads(output) + 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() + else: + project_metadata = app.project.build_frontend.hatch.get_core_metadata() if field: if field not in project_metadata: diff --git a/src/hatch/cli/shell/__init__.py b/src/hatch/cli/shell/__init__.py index 75ce56e5f..182258a79 100644 --- a/src/hatch/cli/shell/__init__.py +++ b/src/hatch/cli/shell/__init__.py @@ -41,8 +41,8 @@ def shell(app: Application, env_name: str | None, name: str, path: str): # no c path, *args = app.platform.modules.shlex.split(path) with app.project.ensure_cwd(): - environment = app.get_environment(chosen_env) - app.prepare_environment(environment) + environment = app.project.get_environment(chosen_env) + app.project.prepare_environment(environment) first_run_indicator = app.cache_dir / 'shell' / 'first_run' if not first_run_indicator.is_file(): diff --git a/src/hatch/cli/test/__init__.py b/src/hatch/cli/test/__init__.py index 993e90675..48640636f 100644 --- a/src/hatch/cli/test/__init__.py +++ b/src/hatch/cli/test/__init__.py @@ -134,7 +134,7 @@ def test( candidate_envs: list[EnvironmentInterface] = [] for candidate_name in candidate_names: - environment = app.get_environment(candidate_name) + environment = app.project.get_environment(candidate_name) if environment.exists(): selected_envs.append(candidate_name) break diff --git a/src/hatch/cli/version/__init__.py b/src/hatch/cli/version/__init__.py index 6855e44ca..143ce60cc 100644 --- a/src/hatch/cli/version/__init__.py +++ b/src/hatch/cli/version/__init__.py @@ -26,12 +26,21 @@ def version(app: Application, desired_version: str | None): app.display(app.project.metadata.config['project']['version']) return - from hatch.dep.sync import dependencies_in_sync + from hatch.project.constants import BUILD_BACKEND with app.project.location.as_cwd(): - if not ( - 'version' in app.project.metadata.dynamic or app.project.metadata.hatch.metadata.hook_config - ) or dependencies_in_sync(app.project.metadata.build.requires_complex): + if app.project.metadata.build.build_backend != BUILD_BACKEND: + if desired_version: + app.abort('The version can only be set when Hatchling is the build backend') + + app.ensure_environment_plugin_dependencies() + app.project.prepare_build_environment() + + with app.project.location.as_cwd(), app.project.build_env.get_env_vars(): + project_metadata = app.project.build_frontend.get_core_metadata() + + app.display(project_metadata['version']) + elif 'version' not in app.project.metadata.dynamic: source = app.project.metadata.hatch.version.source version_data = source.get_version_data() @@ -49,26 +58,15 @@ def version(app: Application, desired_version: str | None): app.display_info(f'Old: {original_version}') app.display_info(f'New: {updated_version}') else: + from hatch.utils.runner import ExecutionContext + app.ensure_environment_plugin_dependencies() + app.project.prepare_build_environment() + + command = ['python', '-u', '-m', 'hatchling', 'version'] + if desired_version: + command.append(desired_version) - environment = app.get_environment() - build_environment_exists = environment.build_environment_exists() - if not build_environment_exists: - try: - environment.check_compatibility() - except Exception as e: # noqa: BLE001 - app.abort(f'Environment `{environment.name}` is incompatible: {e}') - - with app.status_if( - 'Setting up build environment for missing dependencies', - condition=not build_environment_exists, - ) as status, environment.build_environment(app.project.metadata.build.requires): - status.stop() - - command = ['python', '-u', '-m', 'hatchling', 'version'] - if desired_version: - command.append(desired_version) - - process = app.platform.run_command(command) - if process.returncode: - app.abort(code=process.returncode) + context = ExecutionContext(app.project.build_env) + context.add_shell_command(command) + app.execute_context(context) diff --git a/src/hatch/env/internal/__init__.py b/src/hatch/env/internal/__init__.py index dd6958b1f..7010f0b24 100644 --- a/src/hatch/env/internal/__init__.py +++ b/src/hatch/env/internal/__init__.py @@ -25,14 +25,19 @@ def get_internal_env_config() -> dict[str, Any]: def is_isolated_environment(env_name: str, config: dict[str, Any]) -> bool: # Provide super isolation and immunity to project-level environment removal only when the environment: # - # 1. Does not require the project being installed - # 2. The default configuration is used + # 1. Is not used for builds + # 2. Does not require the project being installed + # 3. The default configuration is used # # For example, the environment for static analysis depends only on Ruff at a specific default # version. This environment does not require the project and can be reused by every project to # improve responsiveness. However, if the user for some reason chooses to override the dependencies # to use a different version of Ruff, then the project would get its own environment. - return config.get('skip-install', False) and is_default_environment(env_name, config) + return ( + not config.get('builder', False) + and config.get('skip-install', False) + and is_default_environment(env_name, config) + ) def is_default_environment(env_name: str, config: dict[str, Any]) -> bool: diff --git a/src/hatch/env/internal/build.py b/src/hatch/env/internal/build.py index 3442d4566..58170efe7 100644 --- a/src/hatch/env/internal/build.py +++ b/src/hatch/env/internal/build.py @@ -6,11 +6,6 @@ def get_default_config() -> dict[str, Any]: return { 'skip-install': True, + 'builder': True, 'installer': 'uv', - 'dependencies': ['build[virtualenv]>=1.0.3'], - 'scripts': { - 'build-all': 'python -m build', - 'build-sdist': 'python -m build --sdist', - 'build-wheel': 'python -m build --wheel', - }, } diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 375aacbb5..e788197ec 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -6,7 +6,7 @@ from contextlib import contextmanager from functools import cached_property from os.path import isabs -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Generator from hatch.config.constants import AppEnvVars from hatch.env.utils import add_verbosity_flag, get_env_var_option @@ -16,6 +16,8 @@ if TYPE_CHECKING: from collections.abc import Iterable + from hatch.utils.fs import Path + class EnvironmentInterface(ABC): """ @@ -91,7 +93,7 @@ def verbosity(self): @property def root(self): """ - The root of the project tree as a path-like object. + The root of the local project tree as a path-like object. """ return self.__root @@ -139,6 +141,28 @@ def config(self) -> dict: """ return self.__config + @cached_property + def project_root(self) -> str: + """ + The root of the project tree as a path-like object. If the environment is not running locally, + this should be the remote path to the project. + """ + return str(self.root) + + @cached_property + def sep(self) -> str: + """ + The character used to separate directories in paths. By default, this is `\\` on Windows and `/` otherwise. + """ + return os.sep + + @cached_property + def pathsep(self) -> str: + """ + The character used to separate paths. By default, this is `;` on Windows and `:` otherwise. + """ + return os.pathsep + @cached_property def system_python(self): system_python = os.environ.get(AppEnvVars.PYTHON) @@ -258,13 +282,18 @@ def environment_dependencies(self) -> list[str]: @cached_property def dependencies_complex(self): all_dependencies_complex = list(self.environment_dependencies_complex) + if self.builder: + all_dependencies_complex.extend(self.metadata.build.requires_complex) + return all_dependencies_complex # Ensure these are checked last to speed up initial environment creation since # they will already be installed along with the project if (not self.skip_install and self.dev_mode) or self.features: - from hatch.utils.dep import get_project_dependencies_complex + from hatch.utils.dep import get_complex_dependencies, get_complex_features - dependencies_complex, optional_dependencies_complex = get_project_dependencies_complex(self) + dependencies, optional_dependencies = self.app.project.get_dependencies() + dependencies_complex = get_complex_dependencies(dependencies) + optional_dependencies_complex = get_complex_features(optional_dependencies) if not self.skip_install and self.dev_mode: all_dependencies_complex.extend(dependencies_complex.values()) @@ -344,6 +373,21 @@ def dev_mode(self) -> bool: return dev_mode + @cached_property + def builder(self) -> bool: + """ + ```toml config-example + [tool.hatch.envs.] + builder = ... + ``` + """ + builder = self.config.get('builder', False) + if not isinstance(builder, bool): + message = f'Field `tool.hatch.envs.{self.name}.builder` must be a boolean' + raise TypeError(message) + + return builder + @cached_property def features(self): from hatchling.metadata.utils import normalize_project_name @@ -693,6 +737,13 @@ def run_builder( """ return self.platform.run_command(self.construct_build_command(**kwargs)) + @contextmanager + def fs_context(self) -> Generator[FileSystemContext, None, None]: + from hatch.utils.fs import temp_directory + + with temp_directory() as temp_dir: + yield FileSystemContext(self, local_path=temp_dir, env_path=str(temp_dir)) + def build_environment_exists(self): # noqa: PLR6301 """ If the @@ -897,6 +948,36 @@ def __exit__(self, exc_type, exc_value, traceback): self.deactivate() +class FileSystemContext: + def __init__(self, env: EnvironmentInterface, *, local_path: Path, env_path: str): + self.__env = env + self.__local_path = local_path + self.__env_path = env_path + + @property + def local_path(self) -> Path: + return self.__local_path + + @property + def env_path(self) -> str: + return self.__env_path + + def join(self, relative_path: str): + local_path = self.local_path / relative_path + env_path = f'{self.env_path}{self.__env.sep.join(["", *os.path.normpath(relative_path).split(os.sep)])}' + return FileSystemContext(self.__env, local_path=local_path, env_path=env_path) + + def sync_env(self): + """ + Synchronizes the environment path with the local path as the source. + """ + + def sync_local(self): + """ + Synchronizes the local path with the environment path as the source. + """ + + def expand_script_commands(env_name, script_name, commands, config, seen, active): if script_name in seen: return seen[script_name] diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index 28d70a480..8393e30cd 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -115,8 +115,8 @@ def uv_path(self) -> str: # Only if dependencies have been set by the user or is_default_environment(env_name, self.app.project.config.internal_envs[env_name]) ): - uv_env = self.app.get_environment(env_name) - self.app.prepare_environment(uv_env) + uv_env = self.app.project.get_environment(env_name) + self.app.project.prepare_environment(uv_env) with uv_env: return self.platform.modules.shutil.which('uv') diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 90a8f0fbc..972a2f2d6 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -2,11 +2,13 @@ import re from copy import deepcopy +from functools import cached_property from itertools import product from os import environ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from hatch.env.utils import ensure_valid_environment +from hatch.project.constants import DEFAULT_BUILD_DIRECTORY, BuildEnvVars from hatch.project.env import apply_overrides from hatch.project.utils import format_script_commands, parse_script_command @@ -33,6 +35,15 @@ def __init__(self, root, config, plugin_manager=None): self._scripts = None self._cached_env_overrides = {} + @cached_property + def build(self): + config = self.config.get('build', {}) + if not isinstance(config, dict): + message = 'Field `tool.hatch.build` must be a table' + raise TypeError(message) + + return BuildConfig(config) + @property def env(self): if self._env is None: @@ -507,6 +518,121 @@ def finalize_env_overrides(self, option_types): self._cached_env_overrides.clear() +class BuildConfig: + def __init__(self, config: dict[str, Any]) -> None: + self.__config = config + self.__targets: dict[str, BuildTargetConfig] = config + + @cached_property + def directory(self) -> str: + directory = self.__config.get('directory', DEFAULT_BUILD_DIRECTORY) + if not isinstance(directory, str): + message = 'Field `tool.hatch.build.directory` must be a string' + raise TypeError(message) + + return directory + + @cached_property + def dependencies(self) -> list[str]: + dependencies: list[str] = self.__config.get('dependencies', []) + if not isinstance(dependencies, list): + message = 'Field `tool.hatch.build.dependencies` must be an array' + raise TypeError(message) + + for i, dependency in enumerate(dependencies, 1): + if not isinstance(dependency, str): + message = f'Dependency #{i} in field `tool.hatch.build.dependencies` must be a string' + raise TypeError(message) + + return list(dependencies) + + @cached_property + def hook_config(self) -> dict[str, dict[str, Any]]: + hook_config: dict[str, dict[str, Any]] = self.__config.get('hooks', {}) + if not isinstance(hook_config, dict): + message = 'Field `tool.hatch.build.hooks` must be a table' + raise TypeError(message) + + for hook_name, config in hook_config.items(): + if not isinstance(config, dict): + message = f'Field `tool.hatch.build.hooks.{hook_name}` must be a table' + raise TypeError(message) + + return finalize_hook_config(hook_config) + + @cached_property + def __target_config(self) -> dict[str, Any]: + config = self.__config.get('targets', {}) + if not isinstance(config, dict): + message = 'Field `tool.hatch.build.targets` must be a table' + raise TypeError(message) + + return config + + def target(self, target: str) -> BuildTargetConfig: + if target in self.__targets: + return self.__targets[target] + + config = self.__target_config.get(target, {}) + if not isinstance(config, dict): + message = f'Field `tool.hatch.build.targets.{target}` must be a table' + raise TypeError(message) + + target_config = BuildTargetConfig(target, config, self) + self.__targets[target] = target_config + return target_config + + +class BuildTargetConfig: + def __init__(self, name: str, config: dict[str, Any], global_config: BuildConfig) -> None: + self.__name = name + self.__config = config + self.__global_config = global_config + + @cached_property + def directory(self) -> str: + directory = self.__config.get('directory', self.__global_config.directory) + if not isinstance(directory, str): + message = f'Field `tool.hatch.build.targets.{self.__name}.directory` must be a string' + raise TypeError(message) + + return directory + + @cached_property + def dependencies(self) -> list[str]: + dependencies: list[str] = self.__config.get('dependencies', []) + if not isinstance(dependencies, list): + message = f'Field `tool.hatch.build.targets.{self.__name}.dependencies` must be an array' + raise TypeError(message) + + for i, dependency in enumerate(dependencies, 1): + if not isinstance(dependency, str): + message = ( + f'Dependency #{i} in field `tool.hatch.build.targets.{self.__name}.dependencies` must be a string' + ) + raise TypeError(message) + + all_dependencies = list(self.__global_config.dependencies) + all_dependencies.extend(dependencies) + return all_dependencies + + @cached_property + def hook_config(self) -> dict[str, dict[str, Any]]: + hook_config: dict[str, dict[str, Any]] = self.__config.get('hooks', {}) + if not isinstance(hook_config, dict): + message = f'Field `tool.hatch.build.targets.{self.__name}.hooks` must be a table' + raise TypeError(message) + + for hook_name, config in hook_config.items(): + if not isinstance(config, dict): + message = f'Field `tool.hatch.build.targets.{self.__name}.hooks.{hook_name}` must be a table' + raise TypeError(message) + + config = self.__global_config.hook_config.copy() + config.update(hook_config) + return finalize_hook_config(config) + + def expand_script_commands(script_name, commands, config, seen, active): if script_name in seen: return seen[script_name] @@ -583,3 +709,27 @@ def _populate_default_env_values(env_name, data, config, seen, active): seen.add(env_name) active.pop() + + +def finalize_hook_config(hook_config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + if env_var_enabled(BuildEnvVars.NO_HOOKS): + return {} + + all_hooks_enabled = env_var_enabled(BuildEnvVars.HOOKS_ENABLE) + final_hook_config: dict[str, dict[str, Any]] = { + hook_name: config + for hook_name, config in hook_config.items() + if ( + all_hooks_enabled + or config.get('enable-by-default', True) + or env_var_enabled(f'{BuildEnvVars.HOOK_ENABLE_PREFIX}{hook_name.upper()}') + ) + } + return final_hook_config + + +def env_var_enabled(env_var: str, *, default: bool = False) -> bool: + if env_var in environ: + return environ[env_var] in {'1', 'true'} + + return default diff --git a/src/hatch/project/constants.py b/src/hatch/project/constants.py new file mode 100644 index 000000000..6da1f339a --- /dev/null +++ b/src/hatch/project/constants.py @@ -0,0 +1,14 @@ +BUILD_BACKEND = 'hatchling.build' +DEFAULT_BUILD_DIRECTORY = 'dist' +DEFAULT_BUILD_SCRIPT = 'hatch_build.py' +DEFAULT_CONFIG_FILE = 'hatch.toml' + + +class BuildEnvVars: + LOCATION = 'HATCH_BUILD_LOCATION' + HOOKS_ONLY = 'HATCH_BUILD_HOOKS_ONLY' + NO_HOOKS = 'HATCH_BUILD_NO_HOOKS' + HOOKS_ENABLE = 'HATCH_BUILD_HOOKS_ENABLE' + HOOK_ENABLE_PREFIX = 'HATCH_BUILD_HOOK_ENABLE_' + CLEAN = 'HATCH_BUILD_CLEAN' + CLEAN_HOOKS_AFTER = 'HATCH_BUILD_CLEAN_HOOKS_AFTER' diff --git a/src/hatch/project/core.py b/src/hatch/project/core.py index 67015a445..6f4e7d407 100644 --- a/src/hatch/project/core.py +++ b/src/hatch/project/core.py @@ -2,12 +2,18 @@ import re from contextlib import contextmanager -from typing import TYPE_CHECKING, Generator +from functools import cached_property +from typing import TYPE_CHECKING, Generator, cast +from hatch.project.env import EnvironmentMetadata from hatch.utils.fs import Path +from hatch.utils.runner import ExecutionContext if TYPE_CHECKING: + from hatch.cli.application import Application from hatch.config.model import RootConfig + from hatch.env.plugin.interface import EnvironmentInterface + from hatch.project.frontend.core import BuildFrontend class Project: @@ -17,6 +23,9 @@ def __init__(self, path: Path, *, name: str | None = None, config=None): # From app config self.chosen_name = name + # Lazily attach the current app + self.__app: Application | None = None + # Location of pyproject.toml self._project_file_path: Path | None = None @@ -62,6 +71,206 @@ def location(self) -> Path: def set_path(self, path: Path) -> None: self._explicit_path = path + def set_app(self, app: Application) -> None: + self.__app = app + + @cached_property + def app(self) -> Application: + if self.__app is None: # no cov + message = 'The application has not been set' + raise RuntimeError(message) + + from hatch.cli.application import Application + + return cast(Application, self.__app) + + @cached_property + def build_env(self) -> EnvironmentInterface: + # Prevent the default environment from being used as a builder environment + environment = self.get_environment('hatch-build' if self.app.env == 'default' else self.app.env) + if not environment.builder: + self.app.abort(f'Environment `{environment.name}` is not a builder environment') + + return environment + + @cached_property + def build_frontend(self) -> BuildFrontend: + from hatch.project.frontend.core import BuildFrontend + + return BuildFrontend(self, self.build_env) + + @cached_property + def env_metadata(self) -> EnvironmentMetadata: + return EnvironmentMetadata(self.app.data_dir / 'env' / '.metadata', self.location) + + def get_environment(self, env_name: str | None = None) -> EnvironmentInterface: + if env_name is None: + env_name = self.app.env + + if env_name in self.config.internal_envs: + config = self.config.internal_envs[env_name] + elif env_name in self.config.envs: + config = self.config.envs[env_name] + else: + self.app.abort(f'Unknown environment: {env_name}') + + environment_type = config['type'] + environment_class = self.plugin_manager.environment.get(environment_type) + if environment_class is None: + self.app.abort(f'Environment `{env_name}` has unknown type: {environment_type}') + + from hatch.env.internal import is_isolated_environment + + if self.location.is_file(): + data_directory = isolated_data_directory = self.app.data_dir / 'env' / environment_type / '.scripts' + elif is_isolated_environment(env_name, config): + data_directory = isolated_data_directory = self.app.data_dir / 'env' / '.internal' / env_name + else: + data_directory = self.app.get_env_directory(environment_type) + isolated_data_directory = self.app.data_dir / 'env' / environment_type + + self.config.finalize_env_overrides(environment_class.get_option_types()) + + return environment_class( + self.location, + self.metadata, + env_name, + config, + self.config.matrix_variables.get(env_name, {}), + data_directory, + isolated_data_directory, + self.app.platform, + self.app.verbosity, + self.app, + ) + + # Ensure that this method is clearly written since it is + # used for documenting the life cycle of environments. + def prepare_environment(self, environment: EnvironmentInterface): + if not environment.exists(): + self.env_metadata.reset(environment) + + with environment.app_status_creation(): + environment.create() + + if not environment.skip_install: + if environment.pre_install_commands: + with environment.app_status_pre_installation(): + self.app.run_shell_commands( + ExecutionContext( + environment, + shell_commands=environment.pre_install_commands, + source='pre-install', + show_code_on_error=True, + ) + ) + + with environment.app_status_project_installation(): + if environment.dev_mode: + environment.install_project_dev_mode() + else: + environment.install_project() + + if environment.post_install_commands: + with environment.app_status_post_installation(): + self.app.run_shell_commands( + ExecutionContext( + environment, + shell_commands=environment.post_install_commands, + source='post-install', + show_code_on_error=True, + ) + ) + + with environment.app_status_dependency_state_check(): + new_dep_hash = environment.dependency_hash() + + current_dep_hash = self.env_metadata.dependency_hash(environment) + if new_dep_hash != current_dep_hash: + with environment.app_status_dependency_installation_check(): + dependencies_in_sync = environment.dependencies_in_sync() + + if not dependencies_in_sync: + with environment.app_status_dependency_synchronization(): + environment.sync_dependencies() + new_dep_hash = environment.dependency_hash() + + self.env_metadata.update_dependency_hash(environment, new_dep_hash) + + def prepare_build_environment(self, *, targets: list[str] | None = None) -> None: + from hatch.project.constants import BUILD_BACKEND + + if targets is None: + targets = ['wheel'] + + build_backend = self.metadata.build.build_backend + with self.location.as_cwd(), self.build_env.get_env_vars(): + if not self.build_env.exists(): + try: + self.build_env.check_compatibility() + except Exception as e: # noqa: BLE001 + self.app.abort(f'Environment `{self.build_env.name}` is incompatible: {e}') + + self.prepare_environment(self.build_env) + + extra_dependencies: list[str] = [] + with self.app.status('Inspecting build dependencies'): + if build_backend != BUILD_BACKEND: + for target in targets: + if target == 'sdist': + extra_dependencies.extend(self.build_frontend.get_requires('sdist')) + elif target == 'wheel': + extra_dependencies.extend(self.build_frontend.get_requires('wheel')) + else: + self.app.abort(f'Target `{target}` is not supported by `{build_backend}`') + else: + required_build_deps = self.build_frontend.hatch.get_required_build_deps(targets) + if required_build_deps: + with self.metadata.context.apply_context(self.build_env.context): + extra_dependencies.extend(self.metadata.context.format(dep) for dep in required_build_deps) + + if extra_dependencies: + self.build_env.dependencies.extend(extra_dependencies) + with self.build_env.app_status_dependency_synchronization(): + self.build_env.sync_dependencies() + + def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]: + dynamic_fields = {'dependencies', 'optional-dependencies'} + if not dynamic_fields.intersection(self.metadata.dynamic): + dependencies: list[str] = self.metadata.core_raw_metadata.get('dependencies', []) + features: dict[str, list[str]] = self.metadata.core_raw_metadata.get('optional-dependencies', {}) + return dependencies, features + + from hatch.project.constants import BUILD_BACKEND + + self.prepare_build_environment() + build_backend = self.metadata.build.build_backend + with self.location.as_cwd(), self.build_env.get_env_vars(): + if build_backend != BUILD_BACKEND: + project_metadata = self.build_frontend.get_core_metadata() + else: + project_metadata = self.build_frontend.hatch.get_core_metadata() + + dynamic_dependencies: list[str] = project_metadata.get('dependencies', []) + dynamic_features: dict[str, list[str]] = project_metadata.get('optional-dependencies', {}) + + return dynamic_dependencies, dynamic_features + + def expand_environments(self, env_name: str) -> list[str]: + if env_name in self.config.internal_matrices: + return list(self.config.internal_matrices[env_name]['envs']) + + if env_name in self.config.matrices: + return list(self.config.matrices[env_name]['envs']) + + if env_name in self.config.internal_envs: + return [env_name] + + if env_name in self.config.envs: + return [env_name] + + return [] + @classmethod def from_config(cls, config: RootConfig, project: str) -> Project | None: # Disallow empty strings diff --git a/src/hatch/project/env.py b/src/hatch/project/env.py index 6a75ca2cc..6bca5d87d 100644 --- a/src/hatch/project/env.py +++ b/src/hatch/project/env.py @@ -1,7 +1,15 @@ +from __future__ import annotations + +from functools import cached_property from os import environ +from typing import TYPE_CHECKING, Any from hatch.utils.platform import get_platform_name +if TYPE_CHECKING: + from hatch.env.plugin.interface import EnvironmentInterface + from hatch.utils.fs import Path + RESERVED_OPTIONS = { 'dependencies': list, 'extra-dependencies': list, @@ -364,3 +372,48 @@ def _resolve_condition(env_name, option, source, condition, condition_value, con str: _apply_override_to_string, bool: _apply_override_to_boolean, } + + +class EnvironmentMetadata: + def __init__(self, data_dir: Path, project_path: Path): + self.__data_dir = data_dir + self.__project_path = project_path + + def dependency_hash(self, environment: EnvironmentInterface) -> str: + return self._read(environment).get('dependency_hash', '') + + def update_dependency_hash(self, environment: EnvironmentInterface, dependency_hash: str) -> None: + metadata = self._read(environment) + metadata['dependency_hash'] = dependency_hash + self._write(environment, metadata) + + def reset(self, environment: EnvironmentInterface) -> None: + self._metadata_file(environment).unlink(missing_ok=True) + + def _read(self, environment: EnvironmentInterface) -> dict[str, Any]: + import json + + metadata_file = self._metadata_file(environment) + if not metadata_file.is_file(): + return {} + + return json.loads(metadata_file.read_text()) + + def _write(self, environment: EnvironmentInterface, metadata: dict[str, Any]) -> None: + import json + + metadata_file = self._metadata_file(environment) + metadata_file.parent.ensure_dir_exists() + metadata_file.write_text(json.dumps(metadata)) + + def _metadata_file(self, environment: EnvironmentInterface) -> Path: + from hatch.env.internal import is_isolated_environment + + if is_isolated_environment(environment.name, environment.config): + return self.__data_dir / '.internal' / f'{environment.name}.json' + + return self._storage_dir / environment.config['type'] / f'{environment.name}.json' + + @cached_property + def _storage_dir(self) -> Path: + return self.__data_dir / self.__project_path.id diff --git a/src/hatch/project/frontend/__init__.py b/src/hatch/project/frontend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/hatch/project/frontend/core.py b/src/hatch/project/frontend/core.py new file mode 100644 index 000000000..c184b1322 --- /dev/null +++ b/src/hatch/project/frontend/core.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import json +import sys +from functools import lru_cache +from typing import TYPE_CHECKING, Any, Literal + +from hatch.utils.fs import Path +from hatch.utils.runner import ExecutionContext + +if TYPE_CHECKING: + from hatch.env.plugin.interface import EnvironmentInterface + from hatch.project.core import Project + + +class BuildFrontend: + def __init__(self, project: Project, env: EnvironmentInterface) -> None: + self.__project = project + self.__env = env + self.__scripts = StandardBuildFrontendScripts(self.__project, self.__env) + self.__hatch = HatchBuildFrontend(self.__project, self.__env) + + @property + def scripts(self) -> StandardBuildFrontendScripts: + return self.__scripts + + @property + def hatch(self) -> HatchBuildFrontend: + return self.__hatch + + def build_sdist(self, directory: Path) -> Path: + with self.__env.fs_context() as fs_context: + output_context = fs_context.join('output') + output_context.local_path.ensure_dir_exists() + script = self.scripts.build_sdist(project_root=self.__env.project_root, output_dir=output_context.env_path) + + script_context = fs_context.join('build_sdist.py') + script_context.local_path.parent.ensure_dir_exists() + script_context.local_path.write_text(script) + script_context.sync_env() + + context = ExecutionContext(self.__env) + context.add_shell_command(['python', '-u', script_context.env_path]) + self.__env.app.execute_context(context) + output_context.sync_local() + + output_path = output_context.local_path / 'output.json' + output = json.loads(output_path.read_text()) + + work_dir = output_context.local_path / 'work' + artifact_path = Path(work_dir / output['return_val']) + artifact_path.move(directory) + return directory / artifact_path.name + + def build_wheel(self, directory: Path) -> Path: + with self.__env.fs_context() as fs_context: + output_context = fs_context.join('output') + output_context.local_path.ensure_dir_exists() + script = self.scripts.build_wheel(project_root=self.__env.project_root, output_dir=output_context.env_path) + + script_context = fs_context.join('build_wheel.py') + script_context.local_path.parent.ensure_dir_exists() + script_context.local_path.write_text(script) + script_context.sync_env() + + context = ExecutionContext(self.__env) + context.add_shell_command(['python', '-u', script_context.env_path]) + self.__env.app.execute_context(context) + output_context.sync_local() + + output_path = output_context.local_path / 'output.json' + output = json.loads(output_path.read_text()) + + work_dir = output_context.local_path / 'work' + artifact_path = Path(work_dir / output['return_val']) + artifact_path.move(directory) + return directory / artifact_path.name + + def get_requires(self, build: Literal['sdist', 'wheel', 'editable']) -> list[str]: + with self.__env.fs_context() as fs_context: + output_context = fs_context.join('output') + output_context.local_path.ensure_dir_exists() + script = self.scripts.get_requires( + project_root=self.__env.project_root, output_dir=output_context.env_path, build=build + ) + + script_context = fs_context.join(f'get_requires_{build}.py') + script_context.local_path.parent.ensure_dir_exists() + script_context.local_path.write_text(script) + script_context.sync_env() + + context = ExecutionContext(self.__env) + context.add_shell_command(['python', '-u', script_context.env_path]) + self.__env.app.execute_context(context) + output_context.sync_local() + + output_path = output_context.local_path / 'output.json' + output = json.loads(output_path.read_text()) + return output['return_val'] + + def get_core_metadata(self, *, editable: bool = False) -> dict[str, Any]: + from hatchling.metadata.spec import project_metadata_from_core_metadata + + with self.__env.fs_context() as fs_context: + output_context = fs_context.join('output') + output_context.local_path.ensure_dir_exists() + script = self.scripts.prepare_metadata( + project_root=self.__env.project_root, output_dir=output_context.env_path, editable=editable + ) + + script_context = fs_context.join('get_core_metadata.py') + script_context.local_path.parent.ensure_dir_exists() + script_context.local_path.write_text(script) + script_context.sync_env() + + context = ExecutionContext(self.__env) + context.add_shell_command(['python', '-u', script_context.env_path]) + self.__env.app.execute_context(context) + output_context.sync_local() + + output_path = output_context.local_path / 'output.json' + output = json.loads(output_path.read_text()) + + work_dir = output_context.local_path / 'work' + metadata_file = Path(work_dir) / output['return_val'] / 'METADATA' + return project_metadata_from_core_metadata(metadata_file.read_text()) + + +class HatchBuildFrontend: + def __init__(self, project: Project, env: EnvironmentInterface) -> None: + self.__project = project + self.__env = env + self.__scripts = HatchBuildFrontendScripts(self.__project, self.__env) + + @property + def scripts(self) -> HatchBuildFrontendScripts: + return self.__scripts + + def get_build_deps(self, targets: list[str]) -> list[str]: + with self.__env.fs_context() as fs_context: + output_context = fs_context.join('output') + output_context.local_path.ensure_dir_exists() + script = self.scripts.get_build_deps( + project_root=self.__env.project_root, output_dir=output_context.env_path, targets=targets + ) + + script_context = fs_context.join(f'get_build_deps_{"_".join(targets)}.py') + script_context.local_path.parent.ensure_dir_exists() + script_context.local_path.write_text(script) + script_context.sync_env() + + context = ExecutionContext(self.__env) + context.add_shell_command(['python', '-u', script_context.env_path]) + self.__env.app.execute_context(context) + output_context.sync_local() + + output_path = output_context.local_path / 'output.json' + output: list[str] = json.loads(output_path.read_text()) + return output + + def get_core_metadata(self) -> dict[str, Any]: + with self.__env.fs_context() as fs_context: + output_context = fs_context.join('output') + output_context.local_path.ensure_dir_exists() + script = self.scripts.get_core_metadata( + project_root=self.__env.project_root, output_dir=output_context.env_path + ) + + script_context = fs_context.join('get_core_metadata.py') + script_context.local_path.parent.ensure_dir_exists() + script_context.local_path.write_text(script) + script_context.sync_env() + + context = ExecutionContext(self.__env) + context.add_shell_command(['python', '-u', script_context.env_path]) + self.__env.app.execute_context(context) + output_context.sync_local() + + output_path = output_context.local_path / 'output.json' + output: dict[str, Any] = json.loads(output_path.read_text()) + return output + + def get_required_build_deps(self, targets: list[str]) -> list[str]: + target_dependencies: list[str] = [] + hooks: set[str] = set() + for target in targets: + target_config = self.__project.config.build.target(target) + target_dependencies.extend(target_config.dependencies) + hooks.update(target_config.hook_config) + + # Remove any build hooks that are known to not define any dependencies dynamically + hooks.difference_update(( + # Built-in + 'version', + # Popular third-party + 'vcs', + )) + + if hooks: + return self.get_build_deps(targets) + + return target_dependencies + + +class BuildFrontendScripts: + def __init__(self, project: Project, env: EnvironmentInterface) -> None: + self._project = project + self._env = env + + @staticmethod + def inject_data(script: str, data: dict[str, Any]) -> str: + # All scripts have a constant dictionary on top + return script.replace('{}', repr(data), 1) + + +class StandardBuildFrontendScripts(BuildFrontendScripts): + def get_runner_script( + self, + *, + project_root: str, + output_dir: str, + hook: str, + kwargs: dict[str, Any], + ) -> str: + return self.inject_data( + runner_script(), + { + 'project_root': project_root, + 'output_dir': output_dir, + 'hook': hook, + 'kwargs': kwargs, + 'backend': self._project.metadata.build.build_backend, + 'backend_path': self._env.pathsep.join(self._project.metadata.build.backend_path), + 'hook_caller_script': hook_caller_script(), + }, + ) + + def get_requires( + self, + *, + project_root: str, + output_dir: str, + build: Literal['sdist', 'wheel', 'editable'], + ) -> str: + return self.get_runner_script( + project_root=project_root, + output_dir=output_dir, + hook=f'get_requires_for_build_{build}', + kwargs={'config_settings': None}, + ) + + def prepare_metadata(self, *, output_dir: str, project_root: str, editable: bool = False) -> str: + return self.get_runner_script( + project_root=project_root, + output_dir=output_dir, + hook='prepare_metadata_for_build_editable' if editable else 'prepare_metadata_for_build_wheel', + kwargs={'work_dir': 'metadata_directory', 'config_settings': None, '_allow_fallback': True}, + ) + + def build_wheel(self, *, output_dir: str, project_root: str, editable: bool = False) -> str: + return self.get_runner_script( + project_root=project_root, + output_dir=output_dir, + hook='build_editable' if editable else 'build_wheel', + kwargs={'work_dir': 'wheel_directory', 'config_settings': None, 'metadata_directory': None}, + ) + + def build_sdist(self, *, output_dir: str, project_root: str) -> str: + return self.get_runner_script( + project_root=project_root, + output_dir=output_dir, + hook='build_sdist', + kwargs={'work_dir': 'sdist_directory', 'config_settings': None}, + ) + + +class HatchBuildFrontendScripts(BuildFrontendScripts): + def get_build_deps(self, *, output_dir: str, project_root: str, targets: list[str]) -> str: + return self.inject_data( + hatch_build_deps_script(), + { + 'project_root': project_root, + 'output_dir': output_dir, + 'targets': targets, + }, + ) + + def get_core_metadata(self, *, output_dir: str, project_root: str) -> str: + return self.inject_data( + hatch_core_metadata_script(), + { + 'project_root': project_root, + 'output_dir': output_dir, + }, + ) + + +@lru_cache(maxsize=None) +def hook_caller_script() -> str: + if sys.version_info[:2] < (3, 10): + from importlib.resources import read_text + + return read_text('pyproject_hooks._in_process', '_in_process.py') + + from importlib.resources import files + + script = files('pyproject_hooks._in_process') / '_in_process.py' + return script.read_text(encoding='utf-8') + + +@lru_cache(maxsize=None) +def runner_script() -> str: + if sys.version_info[:2] < (3, 10): + from importlib.resources import read_text + + return read_text('hatch.project.frontend.scripts', 'standard.py') + + from importlib.resources import files + + script = files('hatch.project.frontend.scripts') / 'standard.py' + return script.read_text(encoding='utf-8') + + +@lru_cache(maxsize=None) +def hatch_build_deps_script() -> str: + if sys.version_info[:2] < (3, 10): + from importlib.resources import read_text + + return read_text('hatch.project.frontend.scripts', 'build_deps.py') + + from importlib.resources import files + + script = files('hatch.project.frontend.scripts') / 'build_deps.py' + return script.read_text(encoding='utf-8') + + +@lru_cache(maxsize=None) +def hatch_core_metadata_script() -> str: + if sys.version_info[:2] < (3, 10): + from importlib.resources import read_text + + return read_text('hatch.project.frontend.scripts', 'core_metadata.py') + + from importlib.resources import files + + script = files('hatch.project.frontend.scripts') / 'core_metadata.py' + return script.read_text(encoding='utf-8') diff --git a/src/hatch/project/frontend/script.py b/src/hatch/project/frontend/script.py new file mode 100644 index 000000000..f63636d8a --- /dev/null +++ b/src/hatch/project/frontend/script.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from tempfile import TemporaryDirectory + +RUNNER: dict = {} + + +def main() -> int: + output_dir: str = RUNNER['output_dir'] + hook: str = RUNNER['hook'] + kwargs: dict[str, str] = RUNNER['kwargs'] + backend: str = RUNNER['backend'] + backend_path: str = RUNNER['backend_path'] + project_root: str = RUNNER['project_root'] + hook_caller_script: str = RUNNER['hook_caller_script'] + + with TemporaryDirectory() as d: + temp_dir = os.path.realpath(d) + control_dir = os.path.join(temp_dir, 'control') + os.mkdir(control_dir) + input_path = os.path.join(control_dir, 'input.json') + output_path = os.path.join(control_dir, 'output.json') + + env_vars = dict(os.environ) + env_vars['_PYPROJECT_HOOKS_BUILD_BACKEND'] = backend + if backend_path: + env_vars['_PYPROJECT_HOOKS_BACKEND_PATH'] = backend_path + + if 'work_dir' in kwargs: + work_dir = os.path.join(temp_dir, 'work') + os.mkdir(work_dir) + kwargs[kwargs.pop('work_dir')] = work_dir + else: + work_dir = '' + + with open(input_path, 'w', encoding='utf-8') as f: + f.write(json.dumps({'kwargs': kwargs})) + + script_path = os.path.join(temp_dir, 'script.py') + with open(script_path, 'w', encoding='utf-8') as f: + f.write(hook_caller_script) + + process = subprocess.run( + [sys.executable, script_path, hook, str(control_dir)], + cwd=project_root, + env=env_vars, + check=False, + ) + if process.returncode: + return process.returncode + + with open(output_path, encoding='utf-8') as f: + output = json.loads(f.read()) + + if output.get('no_backend', False): + sys.stderr.write(f'{output["traceback"]}\n{output["backend_error"]}\n') + return 1 + + if output.get('unsupported', False): + sys.stderr.write(output['traceback']) + return 1 + + if output.get('hook_missing', False): + sys.stderr.write(f'Build backend API `{backend}` is missing hook: {output["missing_hook_name"]}\n') + return 1 + + shutil.move(output_path, output_dir) + if work_dir: + shutil.move(work_dir, output_dir) + + return 0 + + +if __name__ == '__main__': + code = main() + sys.stderr.flush() + os._exit(code) diff --git a/src/hatch/project/frontend/scripts/build_deps.py b/src/hatch/project/frontend/scripts/build_deps.py new file mode 100644 index 000000000..296abdeb2 --- /dev/null +++ b/src/hatch/project/frontend/scripts/build_deps.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +import os + +from hatchling.bridge.app import Application +from hatchling.metadata.core import ProjectMetadata +from hatchling.plugin.manager import PluginManager + +RUNNER: dict = {} + + +def main() -> None: + project_root: str = RUNNER['project_root'] + output_dir: str = RUNNER['output_dir'] + targets: list[str] = RUNNER['targets'] + + app = Application() + plugin_manager = PluginManager() + metadata = ProjectMetadata(project_root, plugin_manager) + + dependencies: dict[str, None] = {} + for target_name in targets: + builder_class = plugin_manager.builder.get(target_name) + if builder_class is None: + continue + + builder = builder_class( + project_root, plugin_manager=plugin_manager, metadata=metadata, app=app.get_safe_application() + ) + for dependency in builder.config.dependencies: + dependencies[dependency] = None + + output = json.dumps(list(dependencies)) + with open(os.path.join(output_dir, 'output.json'), 'w', encoding='utf-8') as f: + f.write(output) + + +if __name__ == '__main__': + main() diff --git a/src/hatch/project/frontend/scripts/core_metadata.py b/src/hatch/project/frontend/scripts/core_metadata.py new file mode 100644 index 000000000..930d3731b --- /dev/null +++ b/src/hatch/project/frontend/scripts/core_metadata.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +import os + +from hatchling.metadata.core import ProjectMetadata +from hatchling.metadata.utils import resolve_metadata_fields +from hatchling.plugin.manager import PluginManager + +RUNNER: dict = {} + + +def main() -> None: + project_root: str = RUNNER['project_root'] + output_dir: str = RUNNER['output_dir'] + + project_metadata = ProjectMetadata(project_root, PluginManager()) + core_metadata = resolve_metadata_fields(project_metadata) + for key, value in list(core_metadata.items()): + if not value: + core_metadata.pop(key) + + output = json.dumps(core_metadata) + with open(os.path.join(output_dir, 'output.json'), 'w', encoding='utf-8') as f: + f.write(output) + + +if __name__ == '__main__': + main() diff --git a/src/hatch/project/frontend/scripts/standard.py b/src/hatch/project/frontend/scripts/standard.py new file mode 100644 index 000000000..3753b20f9 --- /dev/null +++ b/src/hatch/project/frontend/scripts/standard.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from tempfile import TemporaryDirectory + +RUNNER: dict = {} + + +def main() -> int: + project_root: str = RUNNER['project_root'] + output_dir: str = RUNNER['output_dir'] + hook: str = RUNNER['hook'] + kwargs: dict[str, str] = RUNNER['kwargs'] + backend: str = RUNNER['backend'] + backend_path: str = RUNNER['backend_path'] + hook_caller_script: str = RUNNER['hook_caller_script'] + + with TemporaryDirectory() as d: + temp_dir = os.path.realpath(d) + control_dir = os.path.join(temp_dir, 'control') + os.mkdir(control_dir) + input_file = os.path.join(control_dir, 'input.json') + output_file = os.path.join(control_dir, 'output.json') + + env_vars = dict(os.environ) + env_vars['_PYPROJECT_HOOKS_BUILD_BACKEND'] = backend + if backend_path: + env_vars['_PYPROJECT_HOOKS_BACKEND_PATH'] = backend_path + + if 'work_dir' in kwargs: + work_dir = os.path.join(temp_dir, 'work') + os.mkdir(work_dir) + kwargs[kwargs.pop('work_dir')] = work_dir + else: + work_dir = '' + + with open(input_file, 'w', encoding='utf-8') as f: + f.write(json.dumps({'kwargs': kwargs})) + + script_path = os.path.join(temp_dir, 'script.py') + with open(script_path, 'w', encoding='utf-8') as f: + f.write(hook_caller_script) + + process = subprocess.run( + [sys.executable, script_path, hook, str(control_dir)], + cwd=project_root, + env=env_vars, + check=False, + ) + if process.returncode: + return process.returncode + + with open(output_file, encoding='utf-8') as f: + output = json.loads(f.read()) + + if output.get('no_backend', False): + sys.stderr.write(f'{output["traceback"]}\n{output["backend_error"]}\n') + return 1 + + if output.get('unsupported', False): + sys.stderr.write(output['traceback']) + return 1 + + if output.get('hook_missing', False): + sys.stderr.write(f'Build backend API `{backend}` is missing hook: {output["missing_hook_name"]}\n') + return 1 + + shutil.move(output_file, output_dir) + if work_dir: + shutil.move(work_dir, output_dir) + + return 0 + + +if __name__ == '__main__': + code = main() + sys.stderr.flush() + os._exit(code) diff --git a/src/hatch/utils/dep.py b/src/hatch/utils/dep.py index 529abafa9..e9964a3bf 100644 --- a/src/hatch/utils/dep.py +++ b/src/hatch/utils/dep.py @@ -7,8 +7,6 @@ if TYPE_CHECKING: from packaging.requirements import Requirement - from hatch.env.plugin.interface import EnvironmentInterface - def normalize_marker_quoting(text: str) -> str: # All TOML writers use double quotes, so allow copy/pasting to avoid escaping @@ -34,45 +32,23 @@ def hash_dependencies(requirements: list[Requirement]) -> str: return sha256(data).hexdigest() -def get_project_dependencies_complex( - environment: EnvironmentInterface, -) -> tuple[dict[str, Requirement], dict[str, dict[str, Requirement]]]: - from hatch.dep.sync import dependencies_in_sync +def get_complex_dependencies(dependencies: list[str]) -> dict[str, Requirement]: + from packaging.requirements import Requirement dependencies_complex = {} + for dependency in dependencies: + dependencies_complex[dependency] = Requirement(dependency) + + return dependencies_complex + + +def get_complex_features(features: dict[str, list[str]]) -> dict[str, dict[str, Requirement]]: + from packaging.requirements import Requirement + optional_dependencies_complex = {} + for feature, optional_dependencies in features.items(): + optional_dependencies_complex[feature] = { + optional_dependency: Requirement(optional_dependency) for optional_dependency in optional_dependencies + } - if not environment.metadata.hatch.metadata.hook_config or dependencies_in_sync( - environment.metadata.build.requires_complex - ): - dependencies_complex.update(environment.metadata.core.dependencies_complex) - optional_dependencies_complex.update(environment.metadata.core.optional_dependencies_complex) - else: - try: - environment.check_compatibility() - except Exception as e: # noqa: BLE001 - environment.app.abort(f'Environment `{environment.name}` is incompatible: {e}') - - import json - - from packaging.requirements import Requirement - - with environment.root.as_cwd(), environment.build_environment(environment.metadata.build.requires): - command = ['python', '-u', '-W', 'ignore', '-m', 'hatchling', 'metadata', '--compact'] - output = environment.platform.check_command_output( - command, - # Only capture stdout - stderr=environment.platform.modules.subprocess.PIPE, - ) - project_metadata = json.loads(output) - - for dependency in project_metadata.get('dependencies', []): - dependencies_complex[dependency] = Requirement(dependency) - - for feature, optional_dependencies in project_metadata.get('optional-dependencies', {}).items(): - optional_dependencies_complex[feature] = { - optional_dependency: Requirement(optional_dependency) - for optional_dependency in optional_dependencies - } - - return dependencies_complex, optional_dependencies_complex + return optional_dependencies_complex diff --git a/src/hatch/utils/fs.py b/src/hatch/utils/fs.py index a823d7299..1e187df77 100644 --- a/src/hatch/utils/fs.py +++ b/src/hatch/utils/fs.py @@ -65,6 +65,16 @@ def remove(self) -> None: shutil.rmtree(self, ignore_errors=False) + def move(self, target: Path) -> None: + try: + self.replace(target) + # Happens when on different filesystems like /tmp or caused by layering in containers + except OSError: + import shutil + + shutil.copy2(self, target) + self.unlink() + def wait_for_dir_removed(self, timeout: int = 5) -> None: import shutil import time diff --git a/tests/cli/build/test_build.py b/tests/cli/build/test_build.py index 8c310ddc3..8ec689e77 100644 --- a/tests/cli/build/test_build.py +++ b/tests/cli/build/test_build.py @@ -1,11 +1,11 @@ import os +import re import pytest from hatch.config.constants import ConfigEnvVars +from hatch.project.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE, BuildEnvVars from hatch.project.core import Project -from hatchling.builders.constants import BuildEnvVars -from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE pytestmark = [pytest.mark.usefixtures('local_backend_process')] @@ -37,41 +37,66 @@ def test_standard(self, hatch, temp_dir, helpers): with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('build') + assert result.exit_code == 0, result.output + + assert build_directory.is_dir() + + artifacts = list(build_directory.iterdir()) + assert len(artifacts) == 2 + + wheel_path = build_directory / 'my_app-0.0.1-py3-none-any.whl' + assert wheel_path.is_file() + + sdist_path = build_directory / 'my_app-0.0.1.tar.gz' + assert sdist_path.is_file() assert result.exit_code == 0, result.output assert result.output == helpers.dedent( - """ + f""" Creating environment: hatch-build Checking dependencies Syncing dependencies + Inspecting build dependencies + ──────────────────────────────────── sdist ───────────────────────────────────── + {sdist_path.relative_to(path)} + ──────────────────────────────────── wheel ───────────────────────────────────── + {wheel_path.relative_to(path)} """ ) - assert build_directory.is_dir() - assert (build_directory / 'my_app-0.0.1-py3-none-any.whl').is_file() - assert (build_directory / 'my_app-0.0.1.tar.gz').is_file() - build_directory.remove() with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('build', '-t', 'wheel') assert result.exit_code == 0, result.output - assert not result.output + assert result.output == helpers.dedent( + f""" + Inspecting build dependencies + ──────────────────────────────────── wheel ───────────────────────────────────── + {wheel_path.relative_to(path)} + """ + ) assert build_directory.is_dir() - assert (build_directory / 'my_app-0.0.1-py3-none-any.whl').is_file() - assert not (build_directory / 'my_app-0.0.1.tar.gz').is_file() + assert wheel_path.is_file() + assert not sdist_path.is_file() build_directory.remove() with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('build', '-t', 'sdist') assert result.exit_code == 0, result.output - assert not result.output + assert result.output == helpers.dedent( + f""" + Inspecting build dependencies + ──────────────────────────────────── sdist ───────────────────────────────────── + {sdist_path.relative_to(path)} + """ + ) assert build_directory.is_dir() - assert not (build_directory / 'my_app-0.0.1-py3-none-any.whl').is_file() - assert (build_directory / 'my_app-0.0.1.tar.gz').is_file() + assert not wheel_path.is_file() + assert sdist_path.is_file() def test_legacy(self, hatch, temp_dir, helpers): path = temp_dir / 'tmp' @@ -97,29 +122,43 @@ def test_legacy(self, hatch, temp_dir, helpers): print("Hello World!") """ ) + (path / 'README.md').touch() build_directory = path / 'dist' assert not build_directory.is_dir() with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('build') + assert result.exit_code == 0, result.output + + assert build_directory.is_dir() + + artifacts = list(build_directory.iterdir()) + assert len(artifacts) == 2 + + wheel_path = build_directory / 'tmp-0.0.1-py3-none-any.whl' + assert wheel_path.is_file() + + sdist_path = build_directory / 'tmp-0.0.1.tar.gz' + assert sdist_path.is_file() assert result.exit_code == 0, result.output assert result.output == helpers.dedent( - """ + f""" Creating environment: hatch-build Checking dependencies Syncing dependencies + Inspecting build dependencies + ──────────────────────────────────── sdist ───────────────────────────────────── + {sdist_path.relative_to(path)} + ──────────────────────────────────── wheel ───────────────────────────────────── + {wheel_path.relative_to(path)} """ ) - assert build_directory.is_dir() - assert (build_directory / 'tmp-0.0.1-py3-none-any.whl').is_file() - assert (build_directory / 'tmp-0.0.1.tar.gz').is_file() - @pytest.mark.allow_backend_process -def test_incompatible_environment(hatch, temp_dir, helpers): +def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config): project_name = 'My.App' with temp_dir.as_cwd(): @@ -129,9 +168,7 @@ def test_incompatible_environment(hatch, temp_dir, helpers): path = temp_dir / 'my-app' project = Project(path) - helpers.update_project_environment( - project, 'default', {'skip-install': True, 'python': '9000', **project.config.envs['default']} - ) + helpers.update_project_environment(project, 'hatch-build', {'python': '9000', **build_env_config}) with path.as_cwd(): result = hatch('build') @@ -139,7 +176,7 @@ def test_incompatible_environment(hatch, temp_dir, helpers): assert result.exit_code == 1, result.output assert result.output == helpers.dedent( """ - Environment `default` is incompatible: cannot locate Python: 9000 + Environment `hatch-build` is incompatible: cannot locate Python: 9000 """ ) @@ -159,6 +196,7 @@ def test_no_compatibility_check_if_exists(hatch, temp_dir, helpers, mocker): with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('build') + assert result.exit_code == 0, result.output build_directory = project_path / 'dist' assert build_directory.is_dir() @@ -169,8 +207,11 @@ def test_no_compatibility_check_if_exists(hatch, temp_dir, helpers, mocker): assert result.exit_code == 0, result.output assert result.output == helpers.dedent( """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment ──────────────────────────────────── wheel ───────────────────────────────────── """ ) @@ -179,6 +220,7 @@ def test_no_compatibility_check_if_exists(hatch, temp_dir, helpers, mocker): mocker.patch('hatch.env.virtual.VirtualEnvironment.check_compatibility', side_effect=Exception('incompatible')) with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('build') + assert result.exit_code == 0, result.output artifacts = list(build_directory.iterdir()) assert len(artifacts) == 2 @@ -186,6 +228,7 @@ def test_no_compatibility_check_if_exists(hatch, temp_dir, helpers, mocker): assert result.exit_code == 0, result.output assert result.output == helpers.dedent( """ + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── ──────────────────────────────────── wheel ───────────────────────────────────── """ @@ -207,8 +250,11 @@ def test_unknown_targets(hatch, temp_dir, helpers): assert result.exit_code == 1, result.output assert result.output == helpers.dedent( """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ───────────────────────────────────── foo ────────────────────────────────────── - Setting up build environment Unknown build targets: foo """ ) @@ -229,8 +275,11 @@ def test_mutually_exclusive_hook_options(hatch, temp_dir, helpers): assert result.exit_code == 1, result.output assert result.output == helpers.dedent( """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment Cannot use both --hooks-only and --no-hooks together """ ) @@ -247,8 +296,7 @@ def test_default(hatch, temp_dir, helpers): with path.as_cwd(): result = hatch('build') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = path / 'dist' assert build_directory.is_dir() @@ -261,11 +309,13 @@ def test_default(hatch, temp_dir, helpers): assert result.output == helpers.dedent( f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment {sdist_path.relative_to(path)} ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path.relative_to(path)} """ ) @@ -282,8 +332,7 @@ def test_explicit_targets(hatch, temp_dir, helpers): with path.as_cwd(): result = hatch('build', '-t', 'wheel') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = path / 'dist' assert build_directory.is_dir() @@ -295,8 +344,11 @@ def test_explicit_targets(hatch, temp_dir, helpers): assert result.output == helpers.dedent( f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path.relative_to(path)} """ ) @@ -326,11 +378,13 @@ def test_explicit_directory(hatch, temp_dir, helpers): assert result.output == helpers.dedent( f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment {sdist_path} ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path} """ ) @@ -360,11 +414,13 @@ def test_explicit_directory_env_var(hatch, temp_dir, helpers): assert result.output == helpers.dedent( f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment {sdist_path} ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path} """ ) @@ -445,11 +501,10 @@ def initialize(self, version, build_data): assert result.output == helpers.dedent( f""" + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment {sdist_path.relative_to(path)} ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path.relative_to(path)} """ ) @@ -502,11 +557,10 @@ def test_clean_env_var(hatch, temp_dir, helpers): assert result.output == helpers.dedent( f""" + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment {sdist_path.relative_to(path)} ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path.relative_to(path)} """ ) @@ -573,8 +627,7 @@ def initialize(self, version, build_data): assert result.output == helpers.dedent( """ - Setting up build environment - Setting up build environment + Inspecting build dependencies """ ) @@ -640,8 +693,7 @@ def initialize(self, version, build_data): assert result.output == helpers.dedent( """ - Setting up build environment - Setting up build environment + Inspecting build dependencies """ ) @@ -699,11 +751,13 @@ def initialize(self, version, build_data): assert result.output == helpers.dedent( f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment {sdist_path.relative_to(path)} ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path.relative_to(path)} """ ) @@ -762,11 +816,13 @@ def initialize(self, version, build_data): assert result.output == helpers.dedent( f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment {sdist_path.relative_to(path)} ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path.relative_to(path)} """ ) @@ -833,8 +889,7 @@ def initialize(self, version, build_data): assert result.output == helpers.dedent( """ - Setting up build environment - Setting up build environment + Inspecting build dependencies """ ) @@ -874,8 +929,7 @@ def initialize(self, version, build_data): with path.as_cwd(): result = hatch('-v', 'build', '-t', 'wheel', '--hooks-only') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = path / 'dist' assert build_directory.is_dir() @@ -884,13 +938,19 @@ def initialize(self, version, build_data): assert len(artifacts) == 0 assert (path / 'my_app' / 'lib.so').is_file() - assert result.output == helpers.dedent( - """ + helpers.assert_output_match( + result.output, + r""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + cmd \[1\] \| python -u .+ ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment + cmd \[1\] \| python -u -m hatchling build --target wheel --hooks-only Building `wheel` version `standard` Only ran build hooks for `wheel` version `standard` - """ + """, ) @@ -929,8 +989,7 @@ def initialize(self, version, build_data): with path.as_cwd({BuildEnvVars.HOOKS_ONLY: 'true'}): result = hatch('-v', 'build', '-t', 'wheel') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = path / 'dist' assert build_directory.is_dir() @@ -939,13 +998,19 @@ def initialize(self, version, build_data): assert len(artifacts) == 0 assert (path / 'my_app' / 'lib.so').is_file() - assert result.output == helpers.dedent( - """ + helpers.assert_output_match( + result.output, + r""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + cmd \[1\] \| python -u .+ ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment + cmd \[1\] \| python -u -m hatchling build --target wheel --hooks-only Building `wheel` version `standard` Only ran build hooks for `wheel` version `standard` - """ + """, ) @@ -984,8 +1049,7 @@ def initialize(self, version, build_data): with path.as_cwd(): result = hatch('-v', 'build', '--ext') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = path / 'dist' assert build_directory.is_dir() @@ -994,13 +1058,19 @@ def initialize(self, version, build_data): assert len(artifacts) == 0 assert (path / 'my_app' / 'lib.so').is_file() - assert result.output == helpers.dedent( - """ + helpers.assert_output_match( + result.output, + r""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + cmd \[1\] \| python -u .+ ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment + cmd \[1\] \| python -u -m hatchling build --target wheel --hooks-only Building `wheel` version `standard` Only ran build hooks for `wheel` version `standard` - """ + """, ) @@ -1036,8 +1106,7 @@ def initialize(self, version, build_data): with path.as_cwd(): result = hatch('build', '-t', 'wheel', '--no-hooks') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = path / 'dist' assert build_directory.is_dir() @@ -1050,8 +1119,11 @@ def initialize(self, version, build_data): assert result.output == helpers.dedent( f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path.relative_to(path)} """ ) @@ -1089,8 +1161,7 @@ def initialize(self, version, build_data): with path.as_cwd({BuildEnvVars.NO_HOOKS: 'true'}): result = hatch('build', '-t', 'wheel') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = path / 'dist' assert build_directory.is_dir() @@ -1103,8 +1174,11 @@ def initialize(self, version, build_data): assert result.output == helpers.dedent( f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path.relative_to(path)} """ ) @@ -1121,8 +1195,7 @@ def test_debug_verbosity(hatch, temp_dir, helpers): with path.as_cwd(): result = hatch('-v', 'build', '-t', 'wheel:standard') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = path / 'dist' assert build_directory.is_dir() @@ -1132,13 +1205,18 @@ def test_debug_verbosity(hatch, temp_dir, helpers): wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith('.whl')) - assert result.output == helpers.dedent( - f""" + helpers.assert_output_match( + result.output, + rf""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment + cmd \[1\] \| python -u -m hatchling build --target wheel:standard Building `wheel` version `standard` - {wheel_path.relative_to(path)} - """ + {re.escape(str(wheel_path.relative_to(path)))} + """, ) @@ -1157,8 +1235,7 @@ def test_shipped(hatch, temp_dir, helpers): with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('build') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output env_data_path = data_path / 'env' / 'virtual' assert env_data_path.is_dir() @@ -1177,7 +1254,7 @@ def test_shipped(hatch, temp_dir, helpers): env_path = env_dirs[0] - assert env_path.name == f'{project_path.name}-build' + assert env_path.name == 'hatch-build' build_directory = project_path / 'dist' assert build_directory.is_dir() @@ -1187,20 +1264,23 @@ def test_shipped(hatch, temp_dir, helpers): assert result.output == helpers.dedent( """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment ──────────────────────────────────── wheel ───────────────────────────────────── """ ) # Test removal while we're here with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch('env', 'remove') + result = hatch('env', 'remove', 'hatch-build') assert result.exit_code == 0, result.output assert result.output == helpers.dedent( """ - Removing environment: default + Removing environment: hatch-build """ ) @@ -1249,8 +1329,7 @@ def build(self, **kwargs): with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('build', '-t', 'custom') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = project_path / 'dist' assert build_directory.is_dir() @@ -1265,8 +1344,12 @@ def build(self, **kwargs): assert result.output == helpers.dedent( """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + Syncing dependencies ──────────────────────────────────── custom ──────────────────────────────────── - Setting up build environment """ ) @@ -1292,8 +1375,7 @@ def test_plugin_dependencies_unmet(hatch, temp_dir, helpers, mock_plugin_install with path.as_cwd(): result = hatch('build') - - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output build_directory = path / 'dist' assert build_directory.is_dir() @@ -1307,11 +1389,13 @@ def test_plugin_dependencies_unmet(hatch, temp_dir, helpers, mock_plugin_install assert result.output == helpers.dedent( f""" Syncing environment plugin requirements + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies ──────────────────────────────────── sdist ───────────────────────────────────── - Setting up build environment {sdist_path.relative_to(path)} ──────────────────────────────────── wheel ───────────────────────────────────── - Setting up build environment {wheel_path.relative_to(path)} """ ) diff --git a/tests/cli/clean/test_clean.py b/tests/cli/clean/test_clean.py index 81bb75076..9047af401 100644 --- a/tests/cli/clean/test_clean.py +++ b/tests/cli/clean/test_clean.py @@ -80,8 +80,7 @@ def initialize(self, version, build_data): assert result.output == helpers.dedent( """ Syncing environment plugin requirements - Setting up build environment - Setting up build environment + Inspecting build dependencies """ ) - helpers.assert_plugin_installation(mock_plugin_installation, [dependency]) + helpers.assert_plugin_installation(mock_plugin_installation, [dependency], count=2) diff --git a/tests/cli/dep/show/test_requirements.py b/tests/cli/dep/show/test_requirements.py index 82c5cfabc..1471fdd15 100644 --- a/tests/cli/dep/show/test_requirements.py +++ b/tests/cli/dep/show/test_requirements.py @@ -1,10 +1,11 @@ import os +from hatch.config.constants import ConfigEnvVars from hatch.project.core import Project -from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE +from hatchling.utils.constants import DEFAULT_CONFIG_FILE -def test_incompatible_environment(hatch, temp_dir, helpers): +def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config): project_name = 'My.App' with temp_dir.as_cwd(): @@ -12,36 +13,23 @@ def test_incompatible_environment(hatch, temp_dir, helpers): assert result.exit_code == 0, result.output path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() project = Project(path) config = dict(project.raw_config) config['build-system']['requires'].append('foo') - config['tool']['hatch']['metadata'] = {'hooks': {'custom': {}}} + config['project']['dynamic'].append('dependencies') project.save_config(config) - helpers.update_project_environment( - project, 'default', {'skip-install': True, 'python': '9000', **project.config.envs['default']} - ) - - build_script = path / DEFAULT_BUILD_SCRIPT - build_script.write_text( - helpers.dedent( - """ - from hatchling.metadata.plugin.interface import MetadataHookInterface - - class CustomMetadataHook(MetadataHookInterface): - def update(self, metadata): - pass - """ - ) - ) + helpers.update_project_environment(project, 'hatch-build', {'python': '9000', **build_env_config}) - with path.as_cwd(): + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('dep', 'show', 'requirements') assert result.exit_code == 1, result.output assert result.output == helpers.dedent( """ - Environment `default` is incompatible: cannot locate Python: 9000 + Environment `hatch-build` is incompatible: cannot locate Python: 9000 """ ) diff --git a/tests/cli/dep/show/test_table.py b/tests/cli/dep/show/test_table.py index 027f9def0..9748d1a16 100644 --- a/tests/cli/dep/show/test_table.py +++ b/tests/cli/dep/show/test_table.py @@ -2,9 +2,10 @@ import pytest +from hatch.config.constants import ConfigEnvVars from hatch.project.core import Project from hatch.utils.structures import EnvVars -from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE +from hatchling.utils.constants import DEFAULT_CONFIG_FILE @pytest.fixture(scope='module', autouse=True) @@ -13,7 +14,7 @@ def _terminal_width(): yield -def test_incompatible_environment(hatch, temp_dir, helpers): +def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config): project_name = 'My.App' with temp_dir.as_cwd(): @@ -21,36 +22,23 @@ def test_incompatible_environment(hatch, temp_dir, helpers): assert result.exit_code == 0, result.output path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() project = Project(path) config = dict(project.raw_config) config['build-system']['requires'].append('foo') - config['tool']['hatch']['metadata'] = {'hooks': {'custom': {}}} + config['project']['dynamic'].append('dependencies') project.save_config(config) - helpers.update_project_environment( - project, 'default', {'skip-install': True, 'python': '9000', **project.config.envs['default']} - ) - - build_script = path / DEFAULT_BUILD_SCRIPT - build_script.write_text( - helpers.dedent( - """ - from hatchling.metadata.plugin.interface import MetadataHookInterface - - class CustomMetadataHook(MetadataHookInterface): - def update(self, metadata): - pass - """ - ) - ) + helpers.update_project_environment(project, 'hatch-build', {'python': '9000', **build_env_config}) - with path.as_cwd(): + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('dep', 'show', 'table') assert result.exit_code == 1, result.output assert result.output == helpers.dedent( """ - Environment `default` is incompatible: cannot locate Python: 9000 + Environment `hatch-build` is incompatible: cannot locate Python: 9000 """ ) diff --git a/tests/cli/dep/test_hash.py b/tests/cli/dep/test_hash.py index 88219a1de..19904a391 100644 --- a/tests/cli/dep/test_hash.py +++ b/tests/cli/dep/test_hash.py @@ -1,11 +1,12 @@ import os from hashlib import sha256 +from hatch.config.constants import ConfigEnvVars from hatch.project.core import Project -from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE +from hatchling.utils.constants import DEFAULT_CONFIG_FILE -def test_incompatible_environment(hatch, temp_dir, helpers): +def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config): project_name = 'My.App' with temp_dir.as_cwd(): @@ -13,36 +14,23 @@ def test_incompatible_environment(hatch, temp_dir, helpers): assert result.exit_code == 0, result.output path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() project = Project(path) config = dict(project.raw_config) config['build-system']['requires'].append('foo') - config['tool']['hatch']['metadata'] = {'hooks': {'custom': {}}} + config['project']['dynamic'].append('dependencies') project.save_config(config) - helpers.update_project_environment( - project, 'default', {'skip-install': True, 'python': '9000', **project.config.envs['default']} - ) - - build_script = path / DEFAULT_BUILD_SCRIPT - build_script.write_text( - helpers.dedent( - """ - from hatchling.metadata.plugin.interface import MetadataHookInterface - - class CustomMetadataHook(MetadataHookInterface): - def update(self, metadata): - pass - """ - ) - ) + helpers.update_project_environment(project, 'hatch-build', {'python': '9000', **build_env_config}) - with path.as_cwd(): + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('dep', 'hash') assert result.exit_code == 1, result.output assert result.output == helpers.dedent( """ - Environment `default` is incompatible: cannot locate Python: 9000 + Environment `hatch-build` is incompatible: cannot locate Python: 9000 """ ) diff --git a/tests/cli/env/test_create.py b/tests/cli/env/test_create.py index 465aa7def..7106f4479 100644 --- a/tests/cli/env/test_create.py +++ b/tests/cli/env/test_create.py @@ -1538,6 +1538,10 @@ def update(self, metadata): Installing project in development mode Running post-installation commands Polling dependency state + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies Checking dependencies Syncing dependencies """ @@ -1557,9 +1561,9 @@ def update(self, metadata): assert len(storage_path.name) == 8 env_dirs = list(storage_path.iterdir()) - assert len(env_dirs) == 1 + assert len(env_dirs) == 2 - env_path = env_dirs[0] + env_path = env_dirs[1] assert env_path.name == 'test' diff --git a/tests/cli/project/test_metadata.py b/tests/cli/project/test_metadata.py index aa43ac40e..42a39d369 100644 --- a/tests/cli/project/test_metadata.py +++ b/tests/cli/project/test_metadata.py @@ -13,410 +13,407 @@ def read_readme(project_dir): return repr((project_dir / 'README.txt').read_text())[1:-1] -class TestBuildDependenciesInstalled: - def test_default_all(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' - - (path / 'README.md').replace(path / 'README.txt') - - project = Project(path) - config = dict(project.raw_config) - config['project']['readme'] = 'README.txt' - project.save_config(config) - - with path.as_cwd(): - result = hatch('project', 'metadata') +def test_other_backend(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 - assert result.output == helpers.dedent( - f""" - {{ - "name": "my-app", - "version": "0.0.1", - "readme": {{ - "content-type": "text/plain", - "text": "{read_readme(path)}" - }}, - "requires-python": ">=3.8", - "license": "MIT", - "authors": [ - {{ - "name": "Foo Bar", - "email": "foo@bar.baz" - }} - ], - "classifiers": [ - "Development Status :: 4 - Beta", - "Programming Language :: Python", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy" - ], - "urls": {{ - "Documentation": "https://github.com/Foo Bar/my-app#readme", - "Issues": "https://github.com/Foo Bar/my-app/issues", - "Source": "https://github.com/Foo Bar/my-app" - }} - }} - """ - ) - - def test_field_readme(self, hatch, temp_dir): - 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' - (path / 'README.md').replace(path / 'README.txt') - - project = Project(path) - config = dict(project.raw_config) - config['project']['readme'] = 'README.txt' - project.save_config(config) - - with path.as_cwd(): - result = hatch('project', 'metadata', 'readme') - - assert result.exit_code == 0, result.output - assert result.output == ( - f"""\ -{(path / 'README.txt').read_text()} -""" - ) - - def test_field_string(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' + data_path = temp_dir / 'data' + data_path.mkdir() + + (path / 'README.md').replace(path / 'README.txt') + + project = Project(path) + config = dict(project.raw_config) + config['build-system']['requires'] = ['flit-core'] + config['build-system']['build-backend'] = 'flit_core.buildapi' + config['project']['version'] = '0.0.1' + config['project']['dynamic'] = [] + config['project']['readme'] = 'README.txt' + del config['project']['license'] + project.save_config(config) + + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + {{ + "name": "my-app", + "version": "0.0.1", + "readme": {{ + "content-type": "text/plain", + "text": "{read_readme(path)}\\n" + }}, + "keywords": [ + "" + ], + "classifiers": [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy" + ], + "urls": {{ + "Documentation": "https://github.com/Foo Bar/my-app#readme", + "Issues": "https://github.com/Foo Bar/my-app/issues", + "Source": "https://github.com/Foo Bar/my-app" + }}, + "authors": [ + {{ + "email": "foo@bar.baz", + "name": "Foo Bar" + }} + ], + "requires-python": ">=3.8" + }} + """ + ) - path = temp_dir / 'my-app' - with path.as_cwd(): - result = hatch('project', 'metadata', 'license') +def test_default_all(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 - assert result.output == helpers.dedent( - """ - MIT - """ - ) - - def test_field_complex(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('project', 'metadata', 'urls') - - assert result.exit_code == 0, result.output - assert result.output == helpers.dedent( - """ - { + path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + (path / 'README.md').replace(path / 'README.txt') + + project = Project(path) + config = dict(project.raw_config) + config['project']['readme'] = 'README.txt' + project.save_config(config) + + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + {{ + "name": "my-app", + "version": "0.0.1", + "readme": {{ + "content-type": "text/plain", + "text": "{read_readme(path)}" + }}, + "requires-python": ">=3.8", + "license": "MIT", + "authors": [ + {{ + "name": "Foo Bar", + "email": "foo@bar.baz" + }} + ], + "classifiers": [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy" + ], + "urls": {{ "Documentation": "https://github.com/Foo Bar/my-app#readme", "Issues": "https://github.com/Foo Bar/my-app/issues", "Source": "https://github.com/Foo Bar/my-app" - } - """ - ) - - -class TestBuildDependenciesMissing: - @pytest.mark.allow_backend_process - def test_incompatible_environment(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' - - project = Project(path) - config = dict(project.raw_config) - config['build-system']['requires'].append('foo') - project.save_config(config) - helpers.update_project_environment( - project, 'default', {'skip-install': True, 'python': '9000', **project.config.envs['default']} - ) - - with path.as_cwd(): - result = hatch('project', 'metadata') - - assert result.exit_code == 1, result.output - assert result.output == helpers.dedent( - """ - Environment `default` is incompatible: cannot locate Python: 9000 - """ - ) - - def test_default_all(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' - - (path / 'README.md').replace(path / 'README.txt') + }} + }} + """ + ) - project = Project(path) - config = dict(project.raw_config) - config['build-system']['requires'].append('foo') - config['project']['readme'] = 'README.txt' - project.save_config(config) - with path.as_cwd(): - result = hatch('project', 'metadata') +def test_field_readme(hatch, temp_dir): + project_name = 'My.App' + with temp_dir.as_cwd(): + result = hatch('new', project_name) assert result.exit_code == 0, result.output - assert result.output == helpers.dedent( - f""" - Setting up build environment for missing dependencies - {{ - "name": "my-app", - "version": "0.0.1", - "readme": {{ - "content-type": "text/plain", - "text": "{read_readme(path)}" - }}, - "requires-python": ">=3.8", - "license": "MIT", - "authors": [ - {{ - "name": "Foo Bar", - "email": "foo@bar.baz" - }} - ], - "classifiers": [ - "Development Status :: 4 - Beta", - "Programming Language :: Python", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy" - ], - "urls": {{ - "Documentation": "https://github.com/Foo Bar/my-app#readme", - "Issues": "https://github.com/Foo Bar/my-app/issues", - "Source": "https://github.com/Foo Bar/my-app" - }} - }} - """ - ) - - def test_field_readme(self, hatch, temp_dir): - 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' + path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() - (path / 'README.md').replace(path / 'README.txt') + (path / 'README.md').replace(path / 'README.txt') - project = Project(path) - config = dict(project.raw_config) - config['build-system']['requires'].append('foo') - config['project']['readme'] = 'README.txt' - project.save_config(config) + project = Project(path) + config = dict(project.raw_config) + config['project']['readme'] = 'README.txt' + project.save_config(config) - with path.as_cwd(): - result = hatch('project', 'metadata', 'readme') + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata', 'readme') - assert result.exit_code == 0, result.output - assert result.output == ( - f"""\ -Setting up build environment for missing dependencies + assert result.exit_code == 0, result.output + assert result.output == ( + f"""\ +Creating environment: hatch-build +Checking dependencies +Syncing dependencies +Inspecting build dependencies {(path / 'README.txt').read_text()} """ - ) - - def test_field_string(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' - - project = Project(path) - config = dict(project.raw_config) - config['build-system']['requires'].append('foo') - project.save_config(config) - - with path.as_cwd(): - result = hatch('project', 'metadata', 'license') +def test_field_string(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 - assert result.output == helpers.dedent( - """ - Setting up build environment for missing dependencies - MIT - """ - ) - def test_field_complex(self, hatch, temp_dir, helpers): - project_name = 'My.App' + path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() - with temp_dir.as_cwd(): - result = hatch('new', project_name) - assert result.exit_code == 0, result.output + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata', 'license') - path = temp_dir / 'my-app' + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + MIT + """ + ) - project = Project(path) - config = dict(project.raw_config) - config['build-system']['requires'].append('foo') - project.save_config(config) - with path.as_cwd(): - result = hatch('project', 'metadata', 'urls') +def test_field_complex(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 - assert result.output == helpers.dedent( - """ - Setting up build environment for missing dependencies - { - "Documentation": "https://github.com/Foo Bar/my-app#readme", - "Issues": "https://github.com/Foo Bar/my-app/issues", - "Source": "https://github.com/Foo Bar/my-app" - } - """ - ) - def test_plugin_dependencies_unmet(self, hatch, temp_dir, helpers, mock_plugin_installation): - project_name = 'My.App' + path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata', 'urls') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + { + "Documentation": "https://github.com/Foo Bar/my-app#readme", + "Issues": "https://github.com/Foo Bar/my-app/issues", + "Source": "https://github.com/Foo Bar/my-app" + } + """ + ) + + +@pytest.mark.allow_backend_process +def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config): + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + assert result.exit_code == 0, result.output - with temp_dir.as_cwd(): - result = hatch('new', project_name) - assert result.exit_code == 0, result.output + path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() - path = temp_dir / 'my-app' + project = Project(path) + helpers.update_project_environment(project, 'hatch-build', {'python': '9000', **build_env_config}) - dependency = os.urandom(16).hex() - (path / DEFAULT_CONFIG_FILE).write_text( - helpers.dedent( - f""" - [env] - requires = ["{dependency}"] - """ - ) - ) + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata') - (path / 'README.md').replace(path / 'README.txt') + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + """ + Environment `hatch-build` is incompatible: cannot locate Python: 9000 + """ + ) - project = Project(path) - config = dict(project.raw_config) - config['build-system']['requires'].append('foo') - config['project']['readme'] = 'README.txt' - project.save_config(config) - with path.as_cwd(): - result = hatch('project', 'metadata') +def test_plugin_dependencies_unmet(hatch, temp_dir, helpers, mock_plugin_installation): + project_name = 'My.App' + with temp_dir.as_cwd(): + result = hatch('new', project_name) assert result.exit_code == 0, result.output - assert result.output == helpers.dedent( + + path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + dependency = os.urandom(16).hex() + (path / DEFAULT_CONFIG_FILE).write_text( + helpers.dedent( f""" - Syncing environment plugin requirements - Setting up build environment for missing dependencies - {{ - "name": "my-app", - "version": "0.0.1", - "readme": {{ - "content-type": "text/plain", - "text": "{read_readme(path)}" - }}, - "requires-python": ">=3.8", - "license": "MIT", - "authors": [ - {{ - "name": "Foo Bar", - "email": "foo@bar.baz" - }} - ], - "classifiers": [ - "Development Status :: 4 - Beta", - "Programming Language :: Python", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy" - ], - "urls": {{ - "Documentation": "https://github.com/Foo Bar/my-app#readme", - "Issues": "https://github.com/Foo Bar/my-app/issues", - "Source": "https://github.com/Foo Bar/my-app" - }} - }} + [env] + requires = ["{dependency}"] """ ) - helpers.assert_plugin_installation(mock_plugin_installation, [dependency]) - - @pytest.mark.allow_backend_process - @pytest.mark.requires_internet - def test_no_compatibility_check_if_exists(self, hatch, temp_dir, helpers, mocker): - 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() + ) + + (path / 'README.md').replace(path / 'README.txt') + + project = Project(path) + config = dict(project.raw_config) + config['project']['readme'] = 'README.txt' + project.save_config(config) + + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Syncing environment plugin requirements + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + {{ + "name": "my-app", + "version": "0.0.1", + "readme": {{ + "content-type": "text/plain", + "text": "{read_readme(path)}" + }}, + "requires-python": ">=3.8", + "license": "MIT", + "authors": [ + {{ + "name": "Foo Bar", + "email": "foo@bar.baz" + }} + ], + "classifiers": [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy" + ], + "urls": {{ + "Documentation": "https://github.com/Foo Bar/my-app#readme", + "Issues": "https://github.com/Foo Bar/my-app/issues", + "Source": "https://github.com/Foo Bar/my-app" + }} + }} + """ + ) + helpers.assert_plugin_installation(mock_plugin_installation, [dependency]) - project = Project(project_path) - config = dict(project.raw_config) - config['build-system']['requires'].append('binary') - project.save_config(config) - with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch('project', 'metadata', 'license') +def test_build_dependencies_unmet(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 - assert result.output == helpers.dedent( - """ - Setting up build environment for missing dependencies - MIT - """ - ) - - mocker.patch('hatch.env.virtual.VirtualEnvironment.check_compatibility', side_effect=Exception('incompatible')) - with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch('project', 'metadata', 'license') + path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + (path / 'README.md').replace(path / 'README.txt') + + project = Project(path) + config = dict(project.raw_config) + config['project']['readme'] = 'README.txt' + config['tool']['hatch']['build'] = {'dependencies': ['binary']} + project.save_config(config) + + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata', 'license') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + Syncing dependencies + MIT + """ + ) + + +@pytest.mark.allow_backend_process +@pytest.mark.requires_internet +def test_no_compatibility_check_if_exists(hatch, temp_dir, helpers, mocker): + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) assert result.exit_code == 0, result.output - assert result.output == helpers.dedent( - """ - MIT - """ - ) + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + project = Project(project_path) + config = dict(project.raw_config) + config['build-system']['requires'].append('binary') + project.save_config(config) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata', 'license') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + MIT + """ + ) + + mocker.patch('hatch.env.virtual.VirtualEnvironment.check_compatibility', side_effect=Exception('incompatible')) + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('project', 'metadata', 'license') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Inspecting build dependencies + MIT + """ + ) diff --git a/tests/cli/version/test_version.py b/tests/cli/version/test_version.py index 7af10f775..b49149dba 100644 --- a/tests/cli/version/test_version.py +++ b/tests/cli/version/test_version.py @@ -4,7 +4,7 @@ from hatch.config.constants import ConfigEnvVars from hatch.project.core import Project -from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE +from hatchling.utils.constants import DEFAULT_CONFIG_FILE class TestNoProject: @@ -37,7 +37,7 @@ def test_configured_project(self, hatch, temp_dir, helpers, config_file): ) -def test_incompatible_environment(hatch, temp_dir, helpers): +def test_other_backend_show(hatch, temp_dir, helpers): project_name = 'My.App' with temp_dir.as_cwd(): @@ -45,76 +45,111 @@ def test_incompatible_environment(hatch, temp_dir, helpers): assert result.exit_code == 0, result.output path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + (path / 'src' / 'my_app' / '__init__.py').write_text('__version__ = "9000.42"') project = Project(path) config = dict(project.raw_config) - config['build-system']['requires'].append('foo') - config['tool']['hatch']['metadata'] = {'hooks': {'custom': {}}} + config['build-system']['requires'] = ['flit-core'] + config['build-system']['build-backend'] = 'flit_core.buildapi' + del config['project']['license'] project.save_config(config) - helpers.update_project_environment( - project, 'default', {'skip-install': True, 'python': '9000', **project.config.envs['default']} - ) - build_script = path / DEFAULT_BUILD_SCRIPT - build_script.write_text( - helpers.dedent( - """ - from hatchling.metadata.plugin.interface import MetadataHookInterface + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('version') - class CustomMetadataHook(MetadataHookInterface): - def update(self, metadata): - pass - """ - ) + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + 9000.42 + """ ) - with path.as_cwd(): - result = hatch('version') + +def test_other_backend_set(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' + data_path = temp_dir / 'data' + data_path.mkdir() + + project = Project(path) + config = dict(project.raw_config) + config['build-system']['requires'] = ['flit-core'] + config['build-system']['build-backend'] = 'flit_core.buildapi' + del config['project']['license'] + project.save_config(config) + + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('version', '1.0.0') assert result.exit_code == 1, result.output assert result.output == helpers.dedent( """ - Environment `default` is incompatible: cannot locate Python: 9000 + The version can only be set when Hatchling is the build backend """ ) -def test_show_dynamic(hatch, temp_dir): +def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config): project_name = 'My.App' with temp_dir.as_cwd(): - hatch('new', project_name) + result = hatch('new', project_name) + assert result.exit_code == 0, result.output path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() - with path.as_cwd(): + project = Project(path) + config = dict(project.raw_config) + config['build-system']['requires'].append('foo') + project.save_config(config) + helpers.update_project_environment(project, 'hatch-build', {'python': '9000', **build_env_config}) + + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('version') - assert result.exit_code == 0, result.output - assert result.output == '0.0.1\n' + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + """ + Environment `hatch-build` is incompatible: cannot locate Python: 9000 + """ + ) @pytest.mark.usefixtures('local_backend_process') -def test_show_dynamic_missing_build_dependencies(hatch, helpers, temp_dir): +def test_show_dynamic(hatch, helpers, temp_dir): project_name = 'My.App' with temp_dir.as_cwd(): hatch('new', project_name) path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() - project = Project(path) - config = dict(project.raw_config) - config['build-system']['requires'].append('foo') - project.save_config(config) - - with path.as_cwd(): + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('version') assert result.exit_code == 0, result.output assert result.output == helpers.dedent( """ - Setting up build environment for missing dependencies + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies 0.0.1 """ ) @@ -128,6 +163,8 @@ def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, mock_plugin_install hatch('new', project_name) path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() dependency = os.urandom(16).hex() (path / DEFAULT_CONFIG_FILE).write_text( @@ -139,19 +176,17 @@ def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, mock_plugin_install ) ) - project = Project(path) - config = dict(project.raw_config) - config['build-system']['requires'].append('foo') - project.save_config(config) - - with path.as_cwd(): + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('version') assert result.exit_code == 0, result.output assert result.output == helpers.dedent( """ Syncing environment plugin requirements - Setting up build environment for missing dependencies + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies 0.0.1 """ ) @@ -159,6 +194,7 @@ def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, mock_plugin_install @pytest.mark.requires_internet +@pytest.mark.usefixtures('local_backend_process') def test_no_compatibility_check_if_exists(hatch, helpers, temp_dir, mocker): project_name = 'My.App' @@ -180,7 +216,11 @@ def test_no_compatibility_check_if_exists(hatch, helpers, temp_dir, mocker): assert result.exit_code == 0, result.output assert result.output == helpers.dedent( """ - Setting up build environment for missing dependencies + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies + 0.0.1 """ ) @@ -189,9 +229,15 @@ def test_no_compatibility_check_if_exists(hatch, helpers, temp_dir, mocker): result = hatch('version') assert result.exit_code == 0, result.output - assert not result.output + assert result.output == helpers.dedent( + """ + Inspecting build dependencies + 0.0.1 + """ + ) +@pytest.mark.usefixtures('local_backend_process') def test_set_dynamic(hatch, helpers, temp_dir): project_name = 'My.App' @@ -199,41 +245,34 @@ def test_set_dynamic(hatch, helpers, temp_dir): hatch('new', project_name) path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() - project = Project(path) - config = dict(project.raw_config) - config['tool']['hatch']['metadata'] = {'hooks': {'custom': {}}} - project.save_config(config) - - build_script = path / DEFAULT_BUILD_SCRIPT - build_script.write_text( - helpers.dedent( - """ - from hatchling.metadata.plugin.interface import MetadataHookInterface - - class CustomMetadataHook(MetadataHookInterface): - def update(self, metadata): - pass - """ - ) - ) - - with path.as_cwd(): + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('version', 'minor,rc') assert result.exit_code == 0, result.output assert result.output == helpers.dedent( """ + Creating environment: hatch-build + Checking dependencies + Syncing dependencies + Inspecting build dependencies Old: 0.0.1 New: 0.1.0rc0 """ ) - with path.as_cwd(): + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('version') assert result.exit_code == 0, result.output - assert result.output == '0.1.0rc0\n' + assert result.output == helpers.dedent( + """ + Inspecting build dependencies + 0.1.0rc0 + """ + ) def test_show_static(hatch, temp_dir): @@ -265,6 +304,8 @@ def test_set_static(hatch, helpers, temp_dir): hatch('new', project_name) path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() project = Project(path) config = dict(project.raw_config) @@ -272,7 +313,7 @@ def test_set_static(hatch, helpers, temp_dir): config['project']['dynamic'].remove('version') project.save_config(config) - with path.as_cwd(): + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): result = hatch('version', 'minor,rc') assert result.exit_code == 1, result.output diff --git a/tests/conftest.py b/tests/conftest.py index 6e14d6645..fb6678bf0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ from hatch.config.constants import AppEnvVars, ConfigEnvVars, PublishEnvVars from hatch.config.user import ConfigFile +from hatch.env.internal import get_internal_env_config from hatch.env.utils import get_env_var from hatch.utils.ci import running_in_ci from hatch.utils.fs import Path, temp_directory @@ -221,6 +222,19 @@ def global_application(): return Application(sys.exit, verbosity=0, enable_color=False, interactive=False) +@pytest.fixture +def temp_application(): + # This is only required for the EnvironmentInterface constructor and will never be used + from hatch.cli.application import Application + + return Application(sys.exit, verbosity=0, enable_color=False, interactive=False) + + +@pytest.fixture +def build_env_config(): + return get_internal_env_config()['hatch-build'] + + @pytest.fixture(scope='session') def devpi(tmp_path_factory, worker_id): import platform @@ -319,9 +333,9 @@ def env_run(mocker) -> Generator[MagicMock, None, None]: return run -def is_hatchling_command(command) -> bool: - if not isinstance(command, list): - return False +def is_hatchling_command(command: list[str] | str) -> bool: + if isinstance(command, str): + command = command.split() if command[0] != 'python': return False @@ -339,10 +353,13 @@ def mock_backend_process(request, mocker): return def mock_process_api(api): - def mock_process(command, **kwargs): + def mock_process(command: list[str] | str, **kwargs): if not is_hatchling_command(command): # no cov return api(command, **kwargs) + if isinstance(command, str): + command = command.split() + original_args = sys.argv try: sys.argv = command[3:] @@ -381,6 +398,9 @@ def mock_process(command, **kwargs): if not is_hatchling_command(command): # no cov return api(command, **kwargs) + if isinstance(command, str): + command = command.split() + output_queue.clear() original_args = sys.argv try: diff --git a/tests/env/plugin/test_interface.py b/tests/env/plugin/test_interface.py index e35f11cd1..645f5090f 100644 --- a/tests/env/plugin/test_interface.py +++ b/tests/env/plugin/test_interface.py @@ -4,7 +4,6 @@ from hatch.env.plugin.interface import EnvironmentInterface from hatch.project.core import Project from hatch.utils.structures import EnvVars -from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT class MockEnvironment(EnvironmentInterface): # no cov @@ -551,6 +550,69 @@ def test_disable(self, isolation, isolated_data_dir, platform, global_applicatio assert environment.dev_mode is False +class TestBuilder: + def test_default(self, isolation, isolated_data_dir, platform, global_application): + config = {'project': {'name': 'my_app', 'version': '0.0.1'}} + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.builder is False + + def test_not_boolean(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'builder': 9000}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.builder` must be a boolean'): + _ = environment.builder + + def test_enable(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'builder': True}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.builder is True + + class TestFeatures: def test_default(self, isolation, isolated_data_dir, platform, global_application): config = {'project': {'name': 'my_app', 'version': '0.0.1'}} @@ -753,12 +815,14 @@ def test_correct(self, isolation, isolated_data_dir, platform, global_applicatio class TestDependencies: - def test_default(self, isolation, isolated_data_dir, platform, global_application): + def test_default(self, isolation, isolated_data_dir, platform, temp_application): config = { 'project': {'name': 'my_app', 'version': '0.0.1', 'dependencies': ['dep1']}, 'tool': {'hatch': {'envs': {'default': {'skip-install': False}}}}, } project = Project(isolation, config=config) + project.set_app(temp_application) + temp_application.project = project environment = MockEnvironment( isolation, project.metadata, @@ -769,7 +833,7 @@ def test_default(self, isolation, isolated_data_dir, platform, global_applicatio isolated_data_dir, platform, 0, - global_application, + temp_application, ) assert environment.dependencies == environment.dependencies == ['dep1'] @@ -915,7 +979,7 @@ def test_extra_invalid(self, isolation, isolated_data_dir, platform, global_appl ): _ = environment.dependencies - def test_full(self, isolation, isolated_data_dir, platform, global_application): + def test_full(self, isolation, isolated_data_dir, platform, temp_application): config = { 'project': {'name': 'my_app', 'version': '0.0.1', 'dependencies': ['dep1']}, 'tool': { @@ -927,6 +991,8 @@ def test_full(self, isolation, isolated_data_dir, platform, global_application): }, } project = Project(isolation, config=config) + project.set_app(temp_application) + temp_application.project = project environment = MockEnvironment( isolation, project.metadata, @@ -937,12 +1003,12 @@ def test_full(self, isolation, isolated_data_dir, platform, global_application): isolated_data_dir, platform, 0, - global_application, + temp_application, ) assert environment.dependencies == ['dep2', 'dep3', 'dep1'] - def test_context_formatting(self, isolation, isolated_data_dir, platform, global_application, uri_slash_prefix): + def test_context_formatting(self, isolation, isolated_data_dir, platform, temp_application, uri_slash_prefix): config = { 'project': {'name': 'my_app', 'version': '0.0.1', 'dependencies': ['dep1']}, 'tool': { @@ -958,6 +1024,8 @@ def test_context_formatting(self, isolation, isolated_data_dir, platform, global }, } project = Project(isolation, config=config) + project.set_app(temp_application) + temp_application.project = project environment = MockEnvironment( isolation, project.metadata, @@ -968,7 +1036,7 @@ def test_context_formatting(self, isolation, isolated_data_dir, platform, global isolated_data_dir, platform, 0, - global_application, + temp_application, ) normalized_path = str(isolation).replace('\\', '/') @@ -1001,7 +1069,7 @@ def test_full_skip_install(self, isolation, isolated_data_dir, platform, global_ assert environment.dependencies == ['dep2', 'dep3'] - def test_full_skip_install_and_features(self, isolation, isolated_data_dir, platform, global_application): + def test_full_skip_install_and_features(self, isolation, isolated_data_dir, platform, temp_application): config = { 'project': { 'name': 'my_app', @@ -1023,6 +1091,8 @@ def test_full_skip_install_and_features(self, isolation, isolated_data_dir, plat }, } project = Project(isolation, config=config) + project.set_app(temp_application) + temp_application.project = project environment = MockEnvironment( isolation, project.metadata, @@ -1033,7 +1103,7 @@ def test_full_skip_install_and_features(self, isolation, isolated_data_dir, plat isolated_data_dir, platform, 0, - global_application, + temp_application, ) assert environment.dependencies == ['dep2', 'dep3', 'dep4'] @@ -1063,19 +1133,17 @@ def test_full_dev_mode(self, isolation, isolated_data_dir, platform, global_appl assert environment.dependencies == ['dep2', 'dep3'] - def test_unknown_dynamic_feature(self, helpers, temp_dir, isolated_data_dir, platform, global_application): + def test_builder(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1', 'dynamic': ['optional-dependencies']}, + 'build-system': {'requires': ['dep2']}, + 'project': {'name': 'my_app', 'version': '0.0.1', 'dependencies': ['dep1']}, 'tool': { - 'hatch': { - 'metadata': {'hooks': {'custom': {}}}, - 'envs': {'default': {'skip-install': False, 'features': ['foo']}}, - }, + 'hatch': {'envs': {'default': {'skip-install': False, 'builder': True, 'dependencies': ['dep3']}}} }, } - project = Project(temp_dir, config=config) + project = Project(isolation, config=config) environment = MockEnvironment( - temp_dir, + isolation, project.metadata, 'default', project.config.envs['default'], @@ -1087,26 +1155,7 @@ def test_unknown_dynamic_feature(self, helpers, temp_dir, isolated_data_dir, pla global_application, ) - build_script = temp_dir / DEFAULT_BUILD_SCRIPT - build_script.write_text( - helpers.dedent( - """ - from hatchling.metadata.plugin.interface import MetadataHookInterface - class CustomHook(MetadataHookInterface): - def update(self, metadata): - metadata['optional-dependencies'] = {'bar': ['binary']} - """ - ) - ) - - with pytest.raises( - ValueError, - match=( - 'Feature `foo` of field `tool.hatch.envs.default.features` is not defined in the dynamic ' - 'field `project.optional-dependencies`' - ), - ): - _ = environment.dependencies + assert environment.dependencies == ['dep3', 'dep2'] class TestScripts: diff --git a/tests/helpers/helpers.py b/tests/helpers/helpers.py index 25a200815..01d1bade5 100644 --- a/tests/helpers/helpers.py +++ b/tests/helpers/helpers.py @@ -3,11 +3,13 @@ import importlib import json import os +import re import sys from datetime import datetime, timezone from functools import lru_cache from textwrap import dedent as _dedent from typing import TYPE_CHECKING +from unittest.mock import call import tomli_w @@ -45,7 +47,7 @@ def get_current_timestamp(): return datetime.now(timezone.utc).timestamp() -def assert_plugin_installation(subprocess_run, dependencies: list[str], *, verbosity=0): +def assert_plugin_installation(subprocess_run, dependencies: list[str], *, verbosity=0, count=1): command = [ sys.executable, '-u', @@ -58,7 +60,7 @@ def assert_plugin_installation(subprocess_run, dependencies: list[str], *, verbo add_verbosity_flag(command, verbosity, adjustment=-1) command.extend(dependencies) - subprocess_run.assert_called_once_with(command, shell=False) + assert subprocess_run.call_args_list == [call(command, shell=False)] * count def assert_files(directory, expected_files, *, check_contents=True): @@ -98,6 +100,11 @@ def assert_files(directory, expected_files, *, check_contents=True): assert not extra_files, f'Extra files: {", ".join(sorted(extra_files))}' +def assert_output_match(output: str, pattern: str, *, exact: bool = True): + flags = re.MULTILINE if exact else re.MULTILINE | re.DOTALL + assert re.search(dedent(pattern), output, flags=flags) is not None, output + + def get_template_files(template_name, project_name, **kwargs): kwargs['project_name'] = project_name kwargs['project_name_normalized'] = project_name.lower().replace('.', '-') diff --git a/tests/project/test_config.py b/tests/project/test_config.py index a395d80d2..1edd48d49 100644 --- a/tests/project/test_config.py +++ b/tests/project/test_config.py @@ -5,6 +5,7 @@ from hatch.plugin.constants import DEFAULT_CUSTOM_SCRIPT from hatch.plugin.manager import PluginManager from hatch.project.config import ProjectConfig +from hatch.project.constants import DEFAULT_BUILD_DIRECTORY, BuildEnvVars from hatch.project.env import RESERVED_OPTIONS from hatch.utils.structures import EnvVars @@ -2700,3 +2701,247 @@ def test_command_expansion_circular_inheritance(self, isolation): ValueError, match='Circular expansion detected for field `tool.hatch.scripts`: foo -> bar -> foo' ): _ = project_config.scripts + + +class TestBuild: + def test_not_table(self, isolation): + config = {'build': 9000} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build` must be a table'): + _ = project_config.build + + def test_targets_not_table(self, isolation): + config = {'build': {'targets': 9000}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.targets` must be a table'): + _ = project_config.build.target('foo') + + def test_target_not_table(self, isolation): + config = {'build': {'targets': {'foo': 9000}}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.targets.foo` must be a table'): + _ = project_config.build.target('foo') + + def test_directory_global_not_table(self, isolation): + config = {'build': {'directory': 9000}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.directory` must be a string'): + _ = project_config.build.target('foo').directory + + def test_directory_not_table(self, isolation): + config = {'build': {'targets': {'foo': {'directory': 9000}}}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.targets.foo.directory` must be a string'): + _ = project_config.build.target('foo').directory + + def test_directory_default(self, isolation): + project_config = ProjectConfig(isolation, {}) + + assert project_config.build.target('foo').directory == DEFAULT_BUILD_DIRECTORY + + def test_directory_global_correct(self, isolation): + config = {'build': {'directory': 'bar'}} + project_config = ProjectConfig(isolation, config) + + assert project_config.build.target('foo').directory == 'bar' + + def test_directory_target_override(self, isolation): + config = {'build': {'directory': 'bar', 'targets': {'foo': {'directory': 'baz'}}}} + project_config = ProjectConfig(isolation, config) + + assert project_config.build.target('foo').directory == 'baz' + + def test_dependencies_global_not_array(self, isolation): + config = {'build': {'dependencies': 9000}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.dependencies` must be an array'): + _ = project_config.build.target('foo').dependencies + + def test_dependencies_global_entry_not_string(self, isolation): + config = {'build': {'dependencies': [9000]}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Dependency #1 in field `tool.hatch.build.dependencies` must be a string'): + _ = project_config.build.target('foo').dependencies + + def test_dependencies_not_array(self, isolation): + config = {'build': {'targets': {'foo': {'dependencies': 9000}}}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.targets.foo.dependencies` must be an array'): + _ = project_config.build.target('foo').dependencies + + def test_dependencies_entry_not_string(self, isolation): + config = {'build': {'targets': {'foo': {'dependencies': [9000]}}}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises( + TypeError, match='Dependency #1 in field `tool.hatch.build.targets.foo.dependencies` must be a string' + ): + _ = project_config.build.target('foo').dependencies + + def test_dependencies_target_merge(self, isolation): + config = {'build': {'dependencies': ['baz'], 'targets': {'foo': {'dependencies': ['bar']}}}} + project_config = ProjectConfig(isolation, config) + + assert project_config.build.target('foo').dependencies == ['baz', 'bar'] + + def test_hooks_global_not_table(self, isolation): + config = {'build': {'hooks': 9000}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.hooks` must be a table'): + _ = project_config.build.target('foo').hook_config + + def test_hook_config_global_not_table(self, isolation): + config = {'build': {'hooks': {'hook1': 9000}}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.hooks.hook1` must be a table'): + _ = project_config.build.target('foo').hook_config + + def test_hooks_not_table(self, isolation): + config = {'build': {'targets': {'foo': {'hooks': 9000}}}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.targets.foo.hooks` must be a table'): + _ = project_config.build.target('foo').hook_config + + def test_hook_config_not_table(self, isolation): + config = {'build': {'targets': {'foo': {'hooks': {'hook1': 9000}}}}} + project_config = ProjectConfig(isolation, config) + + with pytest.raises(TypeError, match='Field `tool.hatch.build.targets.foo.hooks.hook1` must be a table'): + _ = project_config.build.target('foo').hook_config + + def test_hook_config_target_override(self, isolation): + config = { + 'build': { + 'hooks': { + 'hook1': {'foo': 'bar', 'enable-by-default': False}, + 'hook2': {'foo': 'bar'}, + 'hook3': {'foo': 'bar'}, + 'hook4': {'foo': 'bar'}, + }, + 'targets': { + 'foo': { + 'hooks': { + 'hook3': {'bar': 'foo'}, + 'hook4': {'bar': 'foo', 'enable-by-default': False}, + 'hook5': {'bar': 'foo'}, + 'hook6': {'bar': 'foo', 'enable-by-default': False}, + }, + }, + }, + } + } + project_config = ProjectConfig(isolation, config) + + hook_config = project_config.build.target('foo').hook_config + assert hook_config == { + 'hook2': {'foo': 'bar'}, + 'hook3': {'bar': 'foo'}, + 'hook5': {'bar': 'foo'}, + } + + def test_hook_config_all_enabled(self, isolation): + config = { + 'build': { + 'hooks': { + 'hook1': {'foo': 'bar', 'enable-by-default': False}, + 'hook2': {'foo': 'bar'}, + 'hook3': {'foo': 'bar'}, + 'hook4': {'foo': 'bar'}, + }, + 'targets': { + 'foo': { + 'hooks': { + 'hook3': {'bar': 'foo'}, + 'hook4': {'bar': 'foo', 'enable-by-default': False}, + 'hook5': {'bar': 'foo'}, + 'hook6': {'bar': 'foo', 'enable-by-default': False}, + }, + }, + }, + } + } + project_config = ProjectConfig(isolation, config) + + with EnvVars({BuildEnvVars.HOOKS_ENABLE: 'true'}): + hook_config = project_config.build.target('foo').hook_config + + assert hook_config == { + 'hook1': {'foo': 'bar', 'enable-by-default': False}, + 'hook2': {'foo': 'bar'}, + 'hook3': {'bar': 'foo'}, + 'hook4': {'bar': 'foo', 'enable-by-default': False}, + 'hook5': {'bar': 'foo'}, + 'hook6': {'bar': 'foo', 'enable-by-default': False}, + } + + def test_hook_config_all_disabled(self, isolation): + config = { + 'build': { + 'hooks': { + 'hook1': {'foo': 'bar', 'enable-by-default': False}, + 'hook2': {'foo': 'bar'}, + 'hook3': {'foo': 'bar'}, + 'hook4': {'foo': 'bar'}, + }, + 'targets': { + 'foo': { + 'hooks': { + 'hook3': {'bar': 'foo'}, + 'hook4': {'bar': 'foo', 'enable-by-default': False}, + 'hook5': {'bar': 'foo'}, + 'hook6': {'bar': 'foo', 'enable-by-default': False}, + }, + }, + }, + } + } + project_config = ProjectConfig(isolation, config) + + with EnvVars({BuildEnvVars.NO_HOOKS: 'true'}): + hook_config = project_config.build.target('foo').hook_config + + assert not hook_config + + def test_hook_config_specific_enabled(self, isolation): + config = { + 'build': { + 'hooks': { + 'hook1': {'foo': 'bar', 'enable-by-default': False}, + 'hook2': {'foo': 'bar'}, + 'hook3': {'foo': 'bar'}, + 'hook4': {'foo': 'bar'}, + }, + 'targets': { + 'foo': { + 'hooks': { + 'hook3': {'bar': 'foo'}, + 'hook4': {'bar': 'foo', 'enable-by-default': False}, + 'hook5': {'bar': 'foo'}, + 'hook6': {'bar': 'foo', 'enable-by-default': False}, + }, + }, + }, + } + } + project_config = ProjectConfig(isolation, config) + + with EnvVars({f'{BuildEnvVars.HOOK_ENABLE_PREFIX}HOOK6': 'true'}): + hook_config = project_config.build.target('foo').hook_config + + assert hook_config == { + 'hook2': {'foo': 'bar'}, + 'hook3': {'bar': 'foo'}, + 'hook5': {'bar': 'foo'}, + 'hook6': {'bar': 'foo', 'enable-by-default': False}, + } diff --git a/tests/project/test_frontend.py b/tests/project/test_frontend.py new file mode 100644 index 000000000..f7570856c --- /dev/null +++ b/tests/project/test_frontend.py @@ -0,0 +1,402 @@ +import json +import sys + +import pytest + +from hatch.env.plugin.interface import EnvironmentInterface +from hatch.project.core import Project +from hatchling.builders.constants import EDITABLES_REQUIREMENT +from hatchling.metadata.spec import project_metadata_from_core_metadata + +BACKENDS = [('hatchling', 'hatchling.build'), ('flit-core', 'flit_core.buildapi')] + + +class MockEnvironment(EnvironmentInterface): # no cov + PLUGIN_NAME = 'mock' + + def find(self): + pass + + def create(self): + pass + + def remove(self): + pass + + def exists(self): + pass + + def install_project(self): + pass + + def install_project_dev_mode(self): + pass + + def dependencies_in_sync(self): + pass + + def sync_dependencies(self): + pass + + +class TestPrepareMetadata: + @pytest.mark.parametrize( + ('backend_pkg', 'backend_api'), + [pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS], + ) + def test_wheel(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api): + project_dir = temp_dir / 'project' + project_dir.mkdir() + (project_dir / 'pyproject.toml').write_text( + f"""\ +[build-system] +requires = ["{backend_pkg}"] +build-backend = "{backend_api}" + +[project] +name = "foo" +version = "9000.42" +description = "text" +""" + ) + + package_dir = project_dir / 'foo' + package_dir.mkdir() + (package_dir / '__init__.py').touch() + + project = Project(project_dir) + project.build_env = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + temp_dir_data, + temp_dir_data, + platform, + 0, + global_application, + ) + + output_dir = temp_dir / 'output' + output_dir.mkdir() + script = project.build_frontend.scripts.prepare_metadata( + output_dir=str(output_dir), project_root=str(project_dir) + ) + platform.check_command([sys.executable, '-c', script]) + work_dir = output_dir / 'work' + output = json.loads((output_dir / 'output.json').read_text()) + metadata_file = work_dir / output['return_val'] / 'METADATA' + + assert project_metadata_from_core_metadata(metadata_file.read_text()) == { + 'name': 'foo', + 'version': '9000.42', + 'description': 'text', + } + + @pytest.mark.parametrize( + ('backend_pkg', 'backend_api'), + [pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS], + ) + def test_editable(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api): + project_dir = temp_dir / 'project' + project_dir.mkdir() + (project_dir / 'pyproject.toml').write_text( + f"""\ +[build-system] +requires = ["{backend_pkg}"] +build-backend = "{backend_api}" + +[project] +name = "foo" +version = "9000.42" +description = "text" +""" + ) + + package_dir = project_dir / 'foo' + package_dir.mkdir() + (package_dir / '__init__.py').touch() + + project = Project(project_dir) + project.build_env = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + temp_dir_data, + temp_dir_data, + platform, + 0, + global_application, + ) + + output_dir = temp_dir / 'output' + output_dir.mkdir() + script = project.build_frontend.scripts.prepare_metadata( + output_dir=str(output_dir), project_root=str(project_dir), editable=True + ) + platform.check_command([sys.executable, '-c', script]) + work_dir = output_dir / 'work' + output = json.loads((output_dir / 'output.json').read_text()) + metadata_file = work_dir / output['return_val'] / 'METADATA' + + assert project_metadata_from_core_metadata(metadata_file.read_text()) == { + 'name': 'foo', + 'version': '9000.42', + 'description': 'text', + } + + +class TestBuildWheel: + @pytest.mark.parametrize( + ('backend_pkg', 'backend_api'), + [pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS], + ) + def test_standard(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api): + project_dir = temp_dir / 'project' + project_dir.mkdir() + (project_dir / 'pyproject.toml').write_text( + f"""\ +[build-system] +requires = ["{backend_pkg}"] +build-backend = "{backend_api}" + +[project] +name = "foo" +version = "9000.42" +description = "text" +""" + ) + + package_dir = project_dir / 'foo' + package_dir.mkdir() + (package_dir / '__init__.py').touch() + + project = Project(project_dir) + project.build_env = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + temp_dir_data, + temp_dir_data, + platform, + 0, + global_application, + ) + + output_dir = temp_dir / 'output' + output_dir.mkdir() + script = project.build_frontend.scripts.build_wheel(output_dir=str(output_dir), project_root=str(project_dir)) + platform.check_command([sys.executable, '-c', script]) + work_dir = output_dir / 'work' + output = json.loads((output_dir / 'output.json').read_text()) + wheel_path = work_dir / output['return_val'] + + assert wheel_path.is_file() + assert wheel_path.name.startswith('foo-9000.42-') + assert wheel_path.name.endswith('.whl') + + @pytest.mark.parametrize( + ('backend_pkg', 'backend_api'), + [pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS], + ) + def test_editable(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api): + project_dir = temp_dir / 'project' + project_dir.mkdir() + (project_dir / 'pyproject.toml').write_text( + f"""\ +[build-system] +requires = ["{backend_pkg}"] +build-backend = "{backend_api}" + +[project] +name = "foo" +version = "9000.42" +description = "text" +""" + ) + + package_dir = project_dir / 'foo' + package_dir.mkdir() + (package_dir / '__init__.py').touch() + + project = Project(project_dir) + project.build_env = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + temp_dir_data, + temp_dir_data, + platform, + 0, + global_application, + ) + + output_dir = temp_dir / 'output' + output_dir.mkdir() + script = project.build_frontend.scripts.build_wheel( + output_dir=str(output_dir), project_root=str(project_dir), editable=True + ) + platform.check_command([sys.executable, '-c', script]) + work_dir = output_dir / 'work' + output = json.loads((output_dir / 'output.json').read_text()) + wheel_path = work_dir / output['return_val'] + + assert wheel_path.is_file() + assert wheel_path.name.startswith('foo-9000.42-') + assert wheel_path.name.endswith('.whl') + + +class TestSourceDistribution: + @pytest.mark.parametrize( + ('backend_pkg', 'backend_api'), + [pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS], + ) + def test_standard(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api): + project_dir = temp_dir / 'project' + project_dir.mkdir() + (project_dir / 'pyproject.toml').write_text( + f"""\ +[build-system] +requires = ["{backend_pkg}"] +build-backend = "{backend_api}" + +[project] +name = "foo" +version = "9000.42" +description = "text" +""" + ) + + package_dir = project_dir / 'foo' + package_dir.mkdir() + (package_dir / '__init__.py').touch() + + project = Project(project_dir) + project.build_env = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + temp_dir_data, + temp_dir_data, + platform, + 0, + global_application, + ) + + output_dir = temp_dir / 'output' + output_dir.mkdir() + script = project.build_frontend.scripts.build_sdist(output_dir=str(output_dir), project_root=str(project_dir)) + platform.check_command([sys.executable, '-c', script]) + work_dir = output_dir / 'work' + output = json.loads((output_dir / 'output.json').read_text()) + sdist_path = work_dir / output['return_val'] + + assert sdist_path.is_file() + assert sdist_path.name == 'foo-9000.42.tar.gz' + + +class TestGetRequires: + @pytest.mark.parametrize( + ('backend_pkg', 'backend_api'), + [pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS], + ) + @pytest.mark.parametrize('build', ['sdist', 'wheel', 'editable']) + def test_default(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api, build): + project_dir = temp_dir / 'project' + project_dir.mkdir() + (project_dir / 'pyproject.toml').write_text( + f"""\ +[build-system] +requires = ["{backend_pkg}"] +build-backend = "{backend_api}" + +[project] +name = "foo" +version = "9000.42" +description = "text" +""" + ) + + package_dir = project_dir / 'foo' + package_dir.mkdir() + (package_dir / '__init__.py').touch() + + project = Project(project_dir) + project.build_env = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + temp_dir_data, + temp_dir_data, + platform, + 0, + global_application, + ) + + output_dir = temp_dir / 'output' + output_dir.mkdir() + script = project.build_frontend.scripts.get_requires( + output_dir=str(output_dir), project_root=str(project_dir), build=build + ) + platform.check_command([sys.executable, '-c', script]) + output = json.loads((output_dir / 'output.json').read_text()) + + assert output['return_val'] == ( + [EDITABLES_REQUIREMENT] if backend_pkg == 'hatchling' and build == 'editable' else [] + ) + + +class TestHatchGetBuildDeps: + def test_default(self, temp_dir, temp_dir_data, platform, global_application): + project_dir = temp_dir / 'project' + project_dir.mkdir() + (project_dir / 'pyproject.toml').write_text( + """\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "foo" +version = "9000.42" +""" + ) + + package_dir = project_dir / 'foo' + package_dir.mkdir() + (package_dir / '__init__.py').touch() + + project = Project(project_dir) + project.build_env = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + temp_dir_data, + temp_dir_data, + platform, + 0, + global_application, + ) + + output_dir = temp_dir / 'output' + output_dir.mkdir() + script = project.build_frontend.hatch.scripts.get_build_deps( + output_dir=str(output_dir), project_root=str(project_dir), targets=['sdist', 'wheel'] + ) + platform.check_command([sys.executable, '-c', script]) + output = json.loads((output_dir / 'output.json').read_text()) + + assert output == []