Skip to content

Commit

Permalink
Support build backends other than Hatchling (#1018)
Browse files Browse the repository at this point in the history
  • Loading branch information
ofek authored Oct 29, 2023
1 parent 2ec7c3b commit 02eb803
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 34 deletions.
1 change: 1 addition & 0 deletions docs/history/hatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 32 additions & 2 deletions src/hatch/cli/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 11 additions & 9 deletions src/hatch/cli/build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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'
Expand Down
16 changes: 16 additions & 0 deletions src/hatch/env/internal/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions src/hatch/env/internal/build.py
Original file line number Diff line number Diff line change
@@ -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',
},
}
17 changes: 17 additions & 0 deletions src/hatch/env/internal/interface.py
Original file line number Diff line number Diff line change
@@ -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 {}
59 changes: 36 additions & 23 deletions tests/cli/build/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,54 +10,67 @@
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():
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']['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
Expand Down

0 comments on commit 02eb803

Please sign in to comment.