From 02eb8037fde7c7a9f59c332aa9ff091f3b65f748 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Sun, 29 Oct 2023 11:02:46 -0400 Subject: [PATCH] Support build backends other than Hatchling (#1018) --- docs/history/hatch.md | 1 + src/hatch/cli/application.py | 34 ++++++++++++++++- src/hatch/cli/build/__init__.py | 20 +++++----- src/hatch/env/internal/__init__.py | 16 ++++++++ src/hatch/env/internal/build.py | 16 ++++++++ src/hatch/env/internal/interface.py | 17 +++++++++ tests/cli/build/test_build.py | 59 ++++++++++++++++++----------- 7 files changed, 129 insertions(+), 34 deletions(-) create mode 100644 src/hatch/env/internal/__init__.py create mode 100644 src/hatch/env/internal/build.py create mode 100644 src/hatch/env/internal/interface.py diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 483c4b22d..32c721e80 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - The `virtual` environment type can now automatically download requested versions of Python that are not installed - Add `dependency_hash` method to the `environment` interface - The state of installed dependencies for environments is saved as metadata so if dependency definitions have not changed then no checking is performed, which can be computationally expensive +- The `build` command now supports backends other than Hatchling - For new project templates rely only on `requires-python` for configuring the target version Ruff and Black - The default is now `__TOKEN__` when prompting for a username for the `publish` command - Bump the minimum supported version of Hatchling to 1.17.1 diff --git a/src/hatch/cli/application.py b/src/hatch/cli/application.py index 1fdb53d6f..d60dba859 100644 --- a/src/hatch/cli/application.py +++ b/src/hatch/cli/application.py @@ -42,7 +42,7 @@ def plugins(self): def config(self) -> RootConfig: return self.config_file.model - def get_environment(self, env_name=None): + def get_environment(self, env_name: str | None = None) -> EnvironmentInterface: if env_name is None: env_name = self.env @@ -70,6 +70,31 @@ def get_environment(self, env_name=None): self.get_safe_application(), ) + def prepare_internal_environment(self, env_name: str, config: dict[str, Any] | None = None) -> EnvironmentInterface: + from hatch.env.internal import get_internal_environment_class + + environment_class = get_internal_environment_class(env_name) + storage_dir = self.data_dir / 'env' / '.internal' / env_name + environment = environment_class( + self.project.location, + self.project.metadata, + env_name, + config or {}, + {}, + storage_dir, + storage_dir, + self.platform, + self.verbosity, + self.get_safe_application(), + ) + try: + environment.check_compatibility() + except Exception as e: + self.abort(f'Internal environment `{env_name}` is incompatible: {e}') + + self.prepare_environment(environment) + return environment + # Ensure that this method is clearly written since it is # used for documenting the life cycle of environments. def prepare_environment(self, environment: EnvironmentInterface): @@ -311,7 +336,12 @@ def _write(self, environment: EnvironmentInterface, metadata: dict[str, Any]) -> metadata_file.write_text(json.dumps(metadata)) def _metadata_file(self, environment: EnvironmentInterface) -> Path: - return self._storage_dir / environment.config['type'] / f'{environment.name}.json' + from hatch.env.internal.interface import InternalEnvironment + + if isinstance(environment, InternalEnvironment) and environment.config.get('skip-install'): + return self.__data_dir / '.internal' / f'{environment.name}.json' + else: + return self._storage_dir / environment.config['type'] / f'{environment.name}.json' @cached_property def _storage_dir(self) -> Path: diff --git a/src/hatch/cli/build/__init__.py b/src/hatch/cli/build/__init__.py index f8ebf1fe5..437351761 100644 --- a/src/hatch/cli/build/__init__.py +++ b/src/hatch/cli/build/__init__.py @@ -60,15 +60,6 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, from hatch.utils.fs import Path from hatch.utils.structures import EnvVars - if app.project.metadata.build.build_backend != 'hatchling.build': - app.abort('Field `build-system.build-backend` must be set to `hatchling.build`') - - for requirement in app.project.metadata.build.requires_complex: - if requirement.name == 'hatchling': - break - else: - app.abort('Field `build-system.requires` must specify `hatchling` as a requirement') - if location: path = str(Path(location).resolve()) else: @@ -80,6 +71,17 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, elif not targets: targets = ('sdist', 'wheel') + if app.project.metadata.build.build_backend != 'hatchling.build': + script = 'build-sdist' if targets == ('sdist',) else 'build-wheel' if targets == ('wheel',) else 'build-all' + environment = app.prepare_internal_environment('build') + app.run_shell_commands( + environment, + [environment.join_command_args([script])], + show_code_on_error=False, + ) + + return + env_vars = {} if no_hooks: env_vars[BuildEnvVars.NO_HOOKS] = 'true' diff --git a/src/hatch/env/internal/__init__.py b/src/hatch/env/internal/__init__.py new file mode 100644 index 000000000..21b66b0f1 --- /dev/null +++ b/src/hatch/env/internal/__init__.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from hatch.env.internal.interface import InternalEnvironment + + +def get_internal_environment_class(env_name: str) -> type[InternalEnvironment]: + if env_name == 'build': + from hatch.env.internal.build import InternalBuildEnvironment + + return InternalBuildEnvironment + else: # no cov + message = f'Unknown internal environment: {env_name}' + raise ValueError(message) diff --git a/src/hatch/env/internal/build.py b/src/hatch/env/internal/build.py new file mode 100644 index 000000000..44862a582 --- /dev/null +++ b/src/hatch/env/internal/build.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from hatch.env.internal.interface import InternalEnvironment + + +class InternalBuildEnvironment(InternalEnvironment): + def get_base_config(self) -> dict: + return { + 'skip-install': True, + '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/internal/interface.py b/src/hatch/env/internal/interface.py new file mode 100644 index 000000000..f7f6ef34b --- /dev/null +++ b/src/hatch/env/internal/interface.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from functools import cached_property + +from hatch.env.virtual import VirtualEnvironment + + +class InternalEnvironment(VirtualEnvironment): + @cached_property + def config(self) -> dict: + config = {'type': 'virtual', 'template': self.name} + config.update(self.get_base_config()) + config.update(super().config) + return config + + def get_base_config(self) -> dict: + return {} diff --git a/tests/cli/build/test_build.py b/tests/cli/build/test_build.py index 2f8240ad8..8b6bc730c 100644 --- a/tests/cli/build/test_build.py +++ b/tests/cli/build/test_build.py @@ -10,7 +10,8 @@ pytestmark = [pytest.mark.usefixtures('local_builder')] -def test_backend_not_build_system(hatch, temp_dir, helpers): +@pytest.mark.requires_internet +def test_other_backend(hatch, temp_dir, helpers): project_name = 'My.App' with temp_dir.as_cwd(): @@ -18,46 +19,58 @@ def test_backend_not_build_system(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']['build-backend'] = 'foo' + config['build-system']['requires'] = ['flit-core'] + config['build-system']['build-backend'] = 'flit_core.buildapi' + config['project']['version'] = '0.0.1' + config['project']['dynamic'] = [] + del config['project']['license'] project.save_config(config) - with path.as_cwd(): + 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 == 1, result.output + assert result.exit_code == 0, result.output assert result.output == helpers.dedent( """ - Field `build-system.build-backend` must be set to `hatchling.build` + Creating environment: build + Checking dependencies + Syncing dependencies """ ) + 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() -def test_backend_not_build_dependency(hatch, temp_dir, helpers): - project_name = 'My.App' + build_directory.remove() + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('build', '-t', 'wheel') - with temp_dir.as_cwd(): - result = hatch('new', project_name) - assert result.exit_code == 0, result.output + assert result.exit_code == 0, result.output + assert not result.output - path = temp_dir / 'my-app' + 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() - project = Project(path) - config = dict(project.raw_config) - config['build-system']['requires'] = [] - project.save_config(config) + build_directory.remove() + with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('build', '-t', 'sdist') - with path.as_cwd(): - result = hatch('build') + assert result.exit_code == 0, result.output + assert not result.output - assert result.exit_code == 1, result.output - assert result.output == helpers.dedent( - """ - Field `build-system.requires` must specify `hatchling` as a requirement - """ - ) + 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() @pytest.mark.allow_backend_process