diff --git a/backend/src/hatchling/builders/binary.py b/backend/src/hatchling/builders/binary.py index af345c9f2..161f9d6de 100644 --- a/backend/src/hatchling/builders/binary.py +++ b/backend/src/hatchling/builders/binary.py @@ -2,11 +2,15 @@ import os import sys -from typing import Any, Callable +from functools import cached_property +from typing import TYPE_CHECKING, Any, Callable from hatchling.builders.config import BuilderConfig from hatchling.builders.plugin.interface import BuilderInterface +if TYPE_CHECKING: + from types import TracebackType + class BinaryBuilderConfig(BuilderConfig): SUPPORTED_VERSIONS = ('3.12', '3.11', '3.10', '3.9', '3.8', '3.7') @@ -78,6 +82,55 @@ def pyapp_version(self) -> str: return self.__pyapp_version + @cached_property + def env_vars(self) -> dict: + """ + ```toml config-example + [tool.hatch.build.targets.binary.env-vars] + ``` + """ + env_vars = self.target_config.get('env-vars', {}) + if not isinstance(env_vars, dict): + message = f'Field `tool.hatch.envs.{self.plugin_name}.env-vars` must be a mapping' + raise TypeError(message) + + for key, value in env_vars.items(): + if not isinstance(value, str): + message = f'Environment variable `{key}` of field `tool.hatch.envs.{self.plugin_name}.env-vars` must be a string' + raise TypeError(message) + + return env_vars + + @cached_property + def outputs(self) -> list[dict[str, Any]]: + """ + Allows specifying multiple build targets, each with its own options/environment variables. + + This extends the previously non-customizable script build targets by full control over what is built. + """ + outputs = self.target_config.get('outputs') + + if not outputs: # None or empty array + # Fill in the default build targets. + # First check the scripts section, if it is empty, fall-back to the default build target. + if not self.scripts: + # the default if nothing is defined - at least one empty table must be defined + return [{}] + + return [ + { + 'exe-stem': f'{script}-{{version}}', # version will be interpolated later + 'env-vars': {'PYAPP_EXEC_SPEC': self.builder.metadata.core.scripts[script]}, + } + for script in self.scripts + ] + + if isinstance(outputs, list): + return outputs + + message = f'Field `tool.hatch.build.targets.{self.plugin_name}.outputs` must be an array of tables' + raise TypeError(message) + class BinaryBuilder(BuilderInterface): """ @@ -111,74 +164,69 @@ def build_bootstrap( import shutil import tempfile - cargo_path = os.environ.get('CARGO', '') - if not cargo_path: - if not shutil.which('cargo'): - message = 'Executable `cargo` could not be found on PATH' - raise OSError(message) - - cargo_path = 'cargo' - app_dir = os.path.join(directory, self.PLUGIN_NAME) if not os.path.isdir(app_dir): os.makedirs(app_dir) - on_windows = sys.platform == 'win32' - base_env = dict(os.environ) - base_env['PYAPP_PROJECT_NAME'] = self.metadata.name - base_env['PYAPP_PROJECT_VERSION'] = self.metadata.version - - if self.config.python_version: - base_env['PYAPP_PYTHON_VERSION'] = self.config.python_version + for output_spec in self.config.outputs: + env_vars = { # merge options from the parent options table and the build target options table + **self.config.env_vars, + **output_spec.get('env-vars', {}), + } + # Format values with context + context = self.metadata.context + env_vars = {k: context.format(v) for k, v in env_vars.items()} + + with EnvVars(env_vars): + cargo_path = os.environ.get('CARGO', '') + if not cargo_path: + if not shutil.which('cargo'): + message = 'Executable `cargo` could not be found on PATH' + raise OSError(message) + + cargo_path = 'cargo' + + on_windows = sys.platform == 'win32' + base_env = dict(os.environ) + base_env['PYAPP_PROJECT_NAME'] = self.metadata.name + base_env['PYAPP_PROJECT_VERSION'] = self.metadata.version + + if self.config.python_version: + base_env['PYAPP_PYTHON_VERSION'] = self.config.python_version + + # https://doc.rust-lang.org/cargo/reference/config.html#buildtarget + build_target = os.environ.get('CARGO_BUILD_TARGET', '') + + # This will determine whether we install from crates.io or build locally and is currently required for + # cross compilation: https://github.com/cross-rs/cross/issues/1215 + repo_path = os.environ.get('PYAPP_REPO', '') + + with tempfile.TemporaryDirectory() as temp_dir: + exe_name = 'pyapp.exe' if on_windows else 'pyapp' + if repo_path: + context_dir = repo_path + target_dir = os.path.join(temp_dir, 'build') + if build_target: + temp_exe_path = os.path.join(target_dir, build_target, 'release', exe_name) + else: + temp_exe_path = os.path.join(target_dir, 'release', exe_name) + install_command = [cargo_path, 'build', '--release', '--target-dir', target_dir] + else: + context_dir = temp_dir + temp_exe_path = os.path.join(temp_dir, 'bin', exe_name) + install_command = [cargo_path, 'install', 'pyapp', '--force', '--root', temp_dir] + if self.config.pyapp_version: + install_command.extend(['--version', self.config.pyapp_version]) + + self.cargo_build(install_command, cwd=context_dir, env=base_env) + + exe_stem_template = output_spec.get('exe-stem', '{name}-{version}') + exe_stem = exe_stem_template.format(name=self.metadata.name, version=self.metadata.version) + if build_target: + exe_stem = f'{exe_stem}-{build_target}' - # https://doc.rust-lang.org/cargo/reference/config.html#buildtarget - build_target = os.environ.get('CARGO_BUILD_TARGET', '') - - # This will determine whether we install from crates.io or build locally and is currently required for - # cross compilation: https://github.com/cross-rs/cross/issues/1215 - repo_path = os.environ.get('PYAPP_REPO', '') - - with tempfile.TemporaryDirectory() as temp_dir: - exe_name = 'pyapp.exe' if on_windows else 'pyapp' - if repo_path: - context_dir = repo_path - target_dir = os.path.join(temp_dir, 'build') - if build_target: - temp_exe_path = os.path.join(target_dir, build_target, 'release', exe_name) - else: - temp_exe_path = os.path.join(target_dir, 'release', exe_name) - install_command = [cargo_path, 'build', '--release', '--target-dir', target_dir] - else: - context_dir = temp_dir - temp_exe_path = os.path.join(temp_dir, 'bin', exe_name) - install_command = [cargo_path, 'install', 'pyapp', '--force', '--root', temp_dir] - if self.config.pyapp_version: - install_command.extend(['--version', self.config.pyapp_version]) - - if self.config.scripts: - for script in self.config.scripts: - env = dict(base_env) - env['PYAPP_EXEC_SPEC'] = self.metadata.core.scripts[script] - - self.cargo_build(install_command, cwd=context_dir, env=env) - - exe_stem = ( - f'{script}-{self.metadata.version}-{build_target}' - if build_target - else f'{script}-{self.metadata.version}' - ) exe_path = os.path.join(app_dir, f'{exe_stem}.exe' if on_windows else exe_stem) shutil.move(temp_exe_path, exe_path) - else: - self.cargo_build(install_command, cwd=context_dir, env=base_env) - - exe_stem = ( - f'{self.metadata.name}-{self.metadata.version}-{build_target}' - if build_target - else f'{self.metadata.name}-{self.metadata.version}' - ) - exe_path = os.path.join(app_dir, f'{exe_stem}.exe' if on_windows else exe_stem) - shutil.move(temp_exe_path, exe_path) return app_dir @@ -197,3 +245,24 @@ def cargo_build(self, *args: Any, **kwargs: Any) -> None: @classmethod def get_config_class(cls) -> type[BinaryBuilderConfig]: return BinaryBuilderConfig + + +class EnvVars(dict): + """ + Context manager to temporarily set environment variables + """ + + def __init__(self, env_vars: dict) -> None: + super().__init__(os.environ) + self.old_env = dict(self) + self.update(env_vars) + + def __enter__(self) -> None: + os.environ.clear() + os.environ.update(self) + + def __exit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + os.environ.clear() + os.environ.update(self.old_env) diff --git a/docs/plugins/builder/binary.md b/docs/plugins/builder/binary.md index 0afa4b5bc..be8ff1816 100644 --- a/docs/plugins/builder/binary.md +++ b/docs/plugins/builder/binary.md @@ -5,7 +5,7 @@ This uses [PyApp](https://github.com/ofek/pyapp) to build an application that is able to bootstrap itself at runtime. !!! note - This requires an installation of [Rust](https://www.rust-lang.org). + This requires an installation of [Rust](https://www.rust-lang.org). After installing, make sure the `CARGO` environment variable is set. ## Configuration @@ -17,11 +17,14 @@ The builder plugin name is `binary`. ## Options -| Option | Default | Description | -| --- | --- | --- | -| `scripts` | all defined | An array of defined [script](../../config/metadata.md#cli) names to limit what gets built | -| `python-version` | latest compatible Python minor version | The [Python version ID](https://ofek.dev/pyapp/latest/config/#known) to use | -| `pyapp-version` | | The version of PyApp to use | +| Option | Default | Description | +|------------------|----------------------------------------|------------------------------------------------------------------------------------------------------------------| +| `scripts` | all defined (if empty list) | An array of defined [script](../../config/metadata.md#cli) names to limit what gets built | +| `python-version` | latest compatible Python minor version | The [Python version ID](https://ofek.dev/pyapp/latest/config/#known) to use | +| `pyapp-version` | | The version of PyApp to use | +| `env-vars` | | Environment variables to set during the build process. See [below](#build-customization). | +| `outputs` | | An array of tables that each define options for an executable to be built. See [below](#build-customization). | + ## Build behavior @@ -34,3 +37,61 @@ If the `CARGO` environment variable is set then that path will be used as the ex If the [`CARGO_BUILD_TARGET`](https://doc.rust-lang.org/cargo/reference/config.html#buildtarget) environment variable is set then its value will be appended to the file name stems. If the `PYAPP_REPO` environment variable is set then a local build will be performed inside that directory rather than installing from [crates.io](https://crates.io). Note that this is [required](https://github.com/cross-rs/cross/issues/1215) if the `CARGO` environment variable refers to [cross](https://github.com/cross-rs/cross). + + +## Build customization + +To customize how targets are built with the `binary` builder, you can define multiple outputs as an array of tables. + +Each output is defined as a table with the following options: + +| Option | Default | Description | +|------------------|----------------------|-----------------------------------------------------------------------------| +| `exe-stem` | `"{name}-{version}"` | The stem for the executable. `name` and `version` may be used as variables. | +| `env-vars` | | Environment variables to set during the build process | + +Additionally `env-vars` can also be defined at the top level to apply to all outputs. + +The following example demonstrates how to build multiple executables with different settings: + +```toml + +[project] +name = "myapp" +version = "1.0.0" + +[tool.hatch.build.targets.binary.env-vars] # (2)! +CARGO_TARGET_DIR = "{root}/.tmp/pyapp_cargo" # (1)! +PYAPP_DISTRIBUTION_EMBED = "false" +PYAPP_FULL_ISOLATION = "true" +PYAPP_PIP_EXTRA_ARGS = "--index-url ..." +PYAPP_UV_ENABLED = "true" +PYAPP_IS_GUI = "false" + +[[tool.hatch.build.targets.binary.outputs]] # (4)! +exe-stem = "{name}-cli" # (5)! +env-vars = { "PYAPP_EXEC_SPEC" = "myapp.cli:cli" } # (7)! + +[[tool.hatch.build.targets.binary.outputs]] +exe-stem = "{name}-{version}" # (6)! +env-vars = { "PYAPP_EXEC_SPEC" = "myapp.app:app", "PYAPP_IS_GUI" = "true" } # (3)! +``` + +1. Context formating is supported in all `env-vars` values. + In this case, the `CARGO_TARGET_DIR` environment variable is set to a local directory to speed up builds by caching. +2. The `env-vars` table at the top level is applied to all outputs. +3. The `env-vars` table in an output is applied only to that output and has precedence over the top-level `env-vars`. + In this case, we want the second outputs to be built as a GUI application. +4. The `outputs` table is an array of tables, each defining an output. +5. The `exe-stem` option is a format string that can use `name` and `version` as variables. On Windows + the executable would be named for example `myapp-cli.exe` +6. The second output will be named `myapp-1.0.0.exe` on Windows. +7. The `PYAPP_EXEC_SPEC` environment variable is used to specify the entry point for the executable. + In this case, the `cli` function in the `myapp.cli` module is used for the first output. + More info [here](https://ofek.dev/pyapp/latest/config/project/). + +!!! note + If no `outputs` array is defined but the `scripts` option is set, then the `outputs` table will be automatically + generated with the `exe-stem` set to `"-{version}"`. + + You cannot define `outputs` and `scripts` at the same time. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 91a00d8a6..fed89b7a6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,7 @@ theme: features: - content.action.edit - content.code.copy + - content.code.annotate - content.tabs.link - content.tooltips - navigation.expand diff --git a/tests/backend/builders/test_binary.py b/tests/backend/builders/test_binary.py index 410146a11..642b52bd1 100644 --- a/tests/backend/builders/test_binary.py +++ b/tests/backend/builders/test_binary.py @@ -299,7 +299,7 @@ def test_default_build_target(self, hatch, temp_dir, mocker): 'project': {'name': project_name, 'version': '0.1.0'}, 'tool': { 'hatch': { - 'build': {'targets': {'binary': {'versions': ['bootstrap']}}}, + 'build': {'targets': {'binary': {'versions': ['bootstrap'], 'env-vars': {'FOO': 'BAR'}}}}, }, }, } @@ -313,7 +313,7 @@ def test_default_build_target(self, hatch, temp_dir, mocker): subprocess_run.assert_called_once_with( ['cargo', 'install', 'pyapp', '--force', '--root', mocker.ANY], cwd=mocker.ANY, - env=ExpectedEnvVars({'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0'}), + env=ExpectedEnvVars({'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0', 'FOO': 'BAR'}), ) assert len(artifacts) == 1 @@ -341,7 +341,7 @@ def test_scripts(self, hatch, temp_dir, mocker): 'project': {'name': project_name, 'version': '0.1.0', 'scripts': {'foo': 'bar.baz:cli'}}, 'tool': { 'hatch': { - 'build': {'targets': {'binary': {'versions': ['bootstrap']}}}, + 'build': {'targets': {'binary': {'versions': ['bootstrap'], 'env-vars': {'FOO': 'BAR'}}}}, }, }, } @@ -359,6 +359,7 @@ def test_scripts(self, hatch, temp_dir, mocker): 'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0', 'PYAPP_EXEC_SPEC': 'bar.baz:cli', + 'FOO': 'BAR', }), ) @@ -385,7 +386,7 @@ def test_scripts_build_target(self, hatch, temp_dir, mocker): 'project': {'name': project_name, 'version': '0.1.0', 'scripts': {'foo': 'bar.baz:cli'}}, 'tool': { 'hatch': { - 'build': {'targets': {'binary': {'versions': ['bootstrap']}}}, + 'build': {'targets': {'binary': {'versions': ['bootstrap'], 'env-vars': {'FOO': 'BAR'}}}}, }, }, } @@ -403,6 +404,7 @@ def test_scripts_build_target(self, hatch, temp_dir, mocker): 'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0', 'PYAPP_EXEC_SPEC': 'bar.baz:cli', + 'FOO': 'BAR', }), ) @@ -727,3 +729,73 @@ def test_legacy(self, hatch, temp_dir, mocker): assert len(build_artifacts) == 1 assert expected_artifact == str(build_artifacts[0]) assert (build_path / 'app' / ('my-app-0.1.0.exe' if sys.platform == 'win32' else 'my-app-0.1.0')).is_file() + + def test_custom_build_targets(self, hatch, temp_dir, mocker): + subprocess_run = mocker.patch('subprocess.run', side_effect=cargo_install) + + 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' + + config = { + 'project': {'name': project_name, 'version': '0.1.0'}, + 'tool': { + 'hatch': { + 'build': { + 'targets': { + 'binary': { + 'versions': ['bootstrap'], + 'env-vars': { + 'PYAPP_DISTIBUTION_EMBED': 'true', + 'PYAPP_PIP_EXTRA_INDEX_ARGS': '--index-url foobar', + 'CARGO_TARGET_DIR': '{root}/pyapp_cargo', + }, + 'outputs': [ + { + 'exe-stem': '{name}-{version}-gui', + 'env-vars': { + 'PYAPP_IS_GUI': 'true', + 'PYAPP_EXEC_MODULE': 'myapp', + }, + }, + ], + }, + } + }, + }, + }, + } + + builder = BinaryBuilder(str(project_path), config=config) + + build_path = project_path / 'dist' + + with project_path.as_cwd(): + artifacts = list(builder.build()) + + subprocess_run.assert_called_once_with( + ['cargo', 'install', 'pyapp', '--force', '--root', mocker.ANY], + cwd=mocker.ANY, + env=ExpectedEnvVars({ + 'CARGO_TARGET_DIR': f'{project_path}/pyapp_cargo', + 'PYAPP_DISTIBUTION_EMBED': 'true', + 'PYAPP_EXEC_MODULE': 'myapp', + 'PYAPP_IS_GUI': 'true', + 'PYAPP_PIP_EXTRA_INDEX_ARGS': '--index-url foobar', + }), + ) + + assert len(artifacts) == 1 + expected_artifact = artifacts[0] + + build_artifacts = list(build_path.iterdir()) + assert len(build_artifacts) == 1 + assert expected_artifact == str(build_artifacts[0]) + assert ( + build_path / 'binary' / ('my-app-0.1.0-gui.exe' if sys.platform == 'win32' else 'my-app-0.1.0-gui') + ).is_file()