From f1db91c6c116a5d9d0cf38c3be91f275fbf26b46 Mon Sep 17 00:00:00 2001 From: "Loibl Johannes (IFAG DES PTS TI EA DE)" Date: Mon, 3 Jun 2024 11:48:50 +0200 Subject: [PATCH 1/7] First implementation draft and test --- backend/src/hatchling/builders/binary.py | 186 +++++++++++++++-------- tests/backend/builders/test_binary.py | 65 ++++++++ 2 files changed, 190 insertions(+), 61 deletions(-) diff --git a/backend/src/hatchling/builders/binary.py b/backend/src/hatchling/builders/binary.py index af345c9f2..d3fdb4c5b 100644 --- a/backend/src/hatchling/builders/binary.py +++ b/backend/src/hatchling/builders/binary.py @@ -4,6 +4,7 @@ import sys from typing import Any, Callable +from hatch.utils.structures import EnvVars from hatchling.builders.config import BuilderConfig from hatchling.builders.plugin.interface import BuilderInterface @@ -17,6 +18,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.__scripts: list[str] | None = None self.__python_version: str | None = None self.__pyapp_version: str | None = None + self.__options: dict[str, str] | None = None + self.__build_targets: dict[str, dict[str, Any]] | None = None @property def scripts(self) -> list[str]: @@ -78,6 +81,56 @@ def pyapp_version(self) -> str: return self.__pyapp_version + @property + def options(self) -> dict[str, str]: + if self.__options is None: + options = self.target_config.get('options') + + if options is None: + self.__options = {} + elif isinstance(options, dict): + self.__options = options + else: + message = f'Field `tool.hatch.build.targets.{self.plugin_name}.options` must be a table' + raise TypeError(message) + + return self.__options + + @property + def build_targets(self) -> dict[str, 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. + """ + if self.__build_targets is None: + build_targets = self.target_config.get('build-targets') + + if not build_targets: # None or empty table + # Fill in the default build targets. + # First check the scripts section, if it is empty, fall-back to the default build target. + if self.scripts: + self.__build_targets = { + script: { + 'exe_stem': f'{script}-{{version}}', # version will be interpolated later + 'options': {'exec-spec': self.builder.metadata.core.scripts[script]}, + } + for script in self.scripts + } + else: # the default if nothing is defined + self.__build_targets = { + 'default': { + 'exe_stem': '{name}-{version}' # name & version will be interpolated later + } + } + elif isinstance(build_targets, dict): + self.__build_targets = build_targets + else: + message = f'Field `tool.hatch.build.targets.{self.plugin_name}.build_targets` must be a table' + raise TypeError(message) + + return self.__build_targets + class BinaryBuilder(BuilderInterface): """ @@ -111,74 +164,66 @@ 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 build_target_name, build_target_spec in self.config.build_targets.items(): + options = { # merge options from the parent options table and the build target options table + **options_to_env_vars(self.config.options), + **options_to_env_vars(build_target_spec.get('options', {})), + } + + with EnvVars(options): + 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 = build_target_spec.get('exe_stem', build_target_name) + 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 +242,22 @@ def cargo_build(self, *args: Any, **kwargs: Any) -> None: @classmethod def get_config_class(cls) -> type[BinaryBuilderConfig]: return BinaryBuilderConfig + + +def options_to_env_vars(options: dict[str, str]) -> dict[str, str]: + """ + Converts a dictionary of options to environment variables by uppercasing the keys and replacing dashes with + underscores. + Keys that no start with "CARGO_" will be prefixed with "PYAPP_". + + Examples: + + {'full-isolation': 'true', 'cargo-target-dir': 'tmp'} --> {'PYAPP_FULL_ISOLATION': 'true', 'CARGO_TARGET_DIR': 'tmp'} + """ + env_vars = {} + for key, value in options.items(): + sluggified_key = key.replace('-', '_').upper() + if not sluggified_key.startswith('CARGO_'): + sluggified_key = f'PYAPP_{sluggified_key}' + env_vars[sluggified_key] = str(value) + return env_vars diff --git a/tests/backend/builders/test_binary.py b/tests/backend/builders/test_binary.py index 410146a11..3179cea1d 100644 --- a/tests/backend/builders/test_binary.py +++ b/tests/backend/builders/test_binary.py @@ -727,3 +727,68 @@ 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'], + 'options': { + 'distibution-embed': 'true', + 'pip-extra-index-args': '--index-url foobar', + 'cargo-target-dir': (project_path / 'pyapp_cargo').as_posix(), + }, + 'build-targets': { + 'myapp-gui': { + 'exe_stem': '{name}-{version}-gui', + 'options': {'is-gui': 'true', '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': (temp_dir / 'my-app/pyapp_cargo').as_posix(), + '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() From 63705307f5cc344091b54853c6e531fa3fa77d2f Mon Sep 17 00:00:00 2001 From: "Loibl Johannes (IFAG DES PTS TI EA DE)" Date: Mon, 3 Jun 2024 20:55:29 +0200 Subject: [PATCH 2/7] Address review comments --- backend/src/hatchling/builders/binary.py | 104 +++++++++++------------ tests/backend/builders/test_binary.py | 34 +++++--- 2 files changed, 73 insertions(+), 65 deletions(-) diff --git a/backend/src/hatchling/builders/binary.py b/backend/src/hatchling/builders/binary.py index d3fdb4c5b..a80a50d39 100644 --- a/backend/src/hatchling/builders/binary.py +++ b/backend/src/hatchling/builders/binary.py @@ -2,12 +2,14 @@ import os import sys -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable -from hatch.utils.structures import EnvVars 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') @@ -18,8 +20,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.__scripts: list[str] | None = None self.__python_version: str | None = None self.__pyapp_version: str | None = None - self.__options: dict[str, str] | None = None - self.__build_targets: dict[str, dict[str, Any]] | None = None + self.__env_vars: dict[str, str] | None = None + self.__outputs: list[dict[str, Any]] | None = None @property def scripts(self) -> list[str]: @@ -82,54 +84,50 @@ def pyapp_version(self) -> str: return self.__pyapp_version @property - def options(self) -> dict[str, str]: - if self.__options is None: - options = self.target_config.get('options') - - if options is None: - self.__options = {} - elif isinstance(options, dict): - self.__options = options + def env_vars(self) -> dict[str, str]: + if self.__env_vars is None: + env_vars = self.target_config.get('env-vars') + + if env_vars is None: + self.__env_vars = {} + elif isinstance(env_vars, dict): + self.__env_vars = env_vars else: - message = f'Field `tool.hatch.build.targets.{self.plugin_name}.options` must be a table' + message = f'Field `tool.hatch.build.targets.{self.plugin_name}.env-vars` must be a table' raise TypeError(message) - return self.__options + return self.__env_vars @property - def build_targets(self) -> dict[str, dict[str, Any]]: + 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. """ - if self.__build_targets is None: - build_targets = self.target_config.get('build-targets') + if self.__outputs is None: + outputs = self.target_config.get('outputs') - if not build_targets: # None or empty table + if not outputs: # None or empty table # Fill in the default build targets. # First check the scripts section, if it is empty, fall-back to the default build target. if self.scripts: - self.__build_targets = { - script: { + self.__outputs = [ + { 'exe_stem': f'{script}-{{version}}', # version will be interpolated later - 'options': {'exec-spec': self.builder.metadata.core.scripts[script]}, + 'env-vars': {'PYAPP_EXEC_SPEC': self.builder.metadata.core.scripts[script]}, } for script in self.scripts - } - else: # the default if nothing is defined - self.__build_targets = { - 'default': { - 'exe_stem': '{name}-{version}' # name & version will be interpolated later - } - } - elif isinstance(build_targets, dict): - self.__build_targets = build_targets + ] + else: # the default if nothing is defined - at least one empty table must be defined + self.__outputs = [{}] + elif isinstance(outputs, list): + self.__outputs = outputs else: - message = f'Field `tool.hatch.build.targets.{self.plugin_name}.build_targets` must be a table' + message = f'Field `tool.hatch.build.targets.{self.plugin_name}.outputs` must be an array of tables' raise TypeError(message) - return self.__build_targets + return self.__outputs class BinaryBuilder(BuilderInterface): @@ -168,13 +166,13 @@ def build_bootstrap( if not os.path.isdir(app_dir): os.makedirs(app_dir) - for build_target_name, build_target_spec in self.config.build_targets.items(): - options = { # merge options from the parent options table and the build target options table - **options_to_env_vars(self.config.options), - **options_to_env_vars(build_target_spec.get('options', {})), + 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', {}), } - with EnvVars(options): + with EnvVars(env_vars): cargo_path = os.environ.get('CARGO', '') if not cargo_path: if not shutil.which('cargo'): @@ -217,7 +215,7 @@ def build_bootstrap( self.cargo_build(install_command, cwd=context_dir, env=base_env) - exe_stem_template = build_target_spec.get('exe_stem', build_target_name) + 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}' @@ -244,20 +242,22 @@ def get_config_class(cls) -> type[BinaryBuilderConfig]: return BinaryBuilderConfig -def options_to_env_vars(options: dict[str, str]) -> dict[str, str]: +class EnvVars(dict): + """ + Context manager to temporarily set environment variables """ - Converts a dictionary of options to environment variables by uppercasing the keys and replacing dashes with - underscores. - Keys that no start with "CARGO_" will be prefixed with "PYAPP_". - Examples: + def __init__(self, env_vars: dict) -> None: + super().__init__(os.environ) + self.old_env = dict(self) + self.update(env_vars) - {'full-isolation': 'true', 'cargo-target-dir': 'tmp'} --> {'PYAPP_FULL_ISOLATION': 'true', 'CARGO_TARGET_DIR': 'tmp'} - """ - env_vars = {} - for key, value in options.items(): - sluggified_key = key.replace('-', '_').upper() - if not sluggified_key.startswith('CARGO_'): - sluggified_key = f'PYAPP_{sluggified_key}' - env_vars[sluggified_key] = str(value) - return 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/tests/backend/builders/test_binary.py b/tests/backend/builders/test_binary.py index 3179cea1d..4852bace6 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', }), ) @@ -739,6 +741,7 @@ def test_custom_build_targets(self, hatch, temp_dir, mocker): assert result.exit_code == 0, result.output project_path = temp_dir / 'my-app' + config = { 'project': {'name': project_name, 'version': '0.1.0'}, 'tool': { @@ -747,23 +750,28 @@ def test_custom_build_targets(self, hatch, temp_dir, mocker): 'targets': { 'binary': { 'versions': ['bootstrap'], - 'options': { - 'distibution-embed': 'true', - 'pip-extra-index-args': '--index-url foobar', - 'cargo-target-dir': (project_path / 'pyapp_cargo').as_posix(), + 'env-vars': { + 'PYAPP_DISTIBUTION_EMBED': 'true', + 'PYAPP_PIP_EXTRA_INDEX_ARGS': '--index-url foobar', + 'CARGO_TARGET_DIR': (project_path / 'pyapp_cargo').as_posix(), }, - 'build-targets': { - 'myapp-gui': { + 'outputs': [ + { + 'name': 'myapp-gui', 'exe_stem': '{name}-{version}-gui', - 'options': {'is-gui': 'true', 'exec-module': 'myapp'}, + 'env-vars': { + 'PYAPP_IS_GUI': 'true', + 'PYAPP_EXEC_MODULE': 'myapp', + }, }, - }, - } + ], + }, } }, }, }, } + builder = BinaryBuilder(str(project_path), config=config) build_path = project_path / 'dist' From 09931cdeed4c615f7aef6d98de05d3fd03ac9ebc Mon Sep 17 00:00:00 2001 From: "Loibl Johannes (IFAG DES PTS TI EA DE)" Date: Mon, 3 Jun 2024 22:06:42 +0200 Subject: [PATCH 3/7] Fix typo in key name --- backend/src/hatchling/builders/binary.py | 2 +- tests/backend/builders/test_binary.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/src/hatchling/builders/binary.py b/backend/src/hatchling/builders/binary.py index a80a50d39..f5954ce97 100644 --- a/backend/src/hatchling/builders/binary.py +++ b/backend/src/hatchling/builders/binary.py @@ -215,7 +215,7 @@ def build_bootstrap( self.cargo_build(install_command, cwd=context_dir, env=base_env) - exe_stem_template = output_spec.get('exe_stem', '{name}-{version}') + 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}' diff --git a/tests/backend/builders/test_binary.py b/tests/backend/builders/test_binary.py index 4852bace6..7da9bd7d5 100644 --- a/tests/backend/builders/test_binary.py +++ b/tests/backend/builders/test_binary.py @@ -757,8 +757,7 @@ def test_custom_build_targets(self, hatch, temp_dir, mocker): }, 'outputs': [ { - 'name': 'myapp-gui', - 'exe_stem': '{name}-{version}-gui', + 'exe-stem': '{name}-{version}-gui', 'env-vars': { 'PYAPP_IS_GUI': 'true', 'PYAPP_EXEC_MODULE': 'myapp', From 8a5b56da8732691a6240f5754a4fba60478b200b Mon Sep 17 00:00:00 2001 From: "Loibl Johannes (IFAG DES PTS TI EA DE)" Date: Mon, 3 Jun 2024 22:16:03 +0200 Subject: [PATCH 4/7] Fix another typo --- backend/src/hatchling/builders/binary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/hatchling/builders/binary.py b/backend/src/hatchling/builders/binary.py index f5954ce97..41ec72ef1 100644 --- a/backend/src/hatchling/builders/binary.py +++ b/backend/src/hatchling/builders/binary.py @@ -114,7 +114,7 @@ def outputs(self) -> list[dict[str, Any]]: if self.scripts: self.__outputs = [ { - 'exe_stem': f'{script}-{{version}}', # version will be interpolated later + '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 From 5b233b00fc2322057d8bec4f02b44dd234eb8f3b Mon Sep 17 00:00:00 2001 From: "Loibl Johannes (IFAG DES PTS TI EA DE)" Date: Mon, 3 Jun 2024 22:38:43 +0200 Subject: [PATCH 5/7] refactor to use cached_property --- backend/src/hatchling/builders/binary.py | 78 ++++++++++++------------ 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/backend/src/hatchling/builders/binary.py b/backend/src/hatchling/builders/binary.py index 41ec72ef1..f23bda387 100644 --- a/backend/src/hatchling/builders/binary.py +++ b/backend/src/hatchling/builders/binary.py @@ -2,6 +2,7 @@ import os import sys +from functools import cached_property from typing import TYPE_CHECKING, Any, Callable from hatchling.builders.config import BuilderConfig @@ -20,8 +21,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.__scripts: list[str] | None = None self.__python_version: str | None = None self.__pyapp_version: str | None = None - self.__env_vars: dict[str, str] | None = None - self.__outputs: list[dict[str, Any]] | None = None @property def scripts(self) -> list[str]: @@ -83,51 +82,54 @@ def pyapp_version(self) -> str: return self.__pyapp_version - @property - def env_vars(self) -> dict[str, str]: - if self.__env_vars is None: - env_vars = self.target_config.get('env-vars') - - if env_vars is None: - self.__env_vars = {} - elif isinstance(env_vars, dict): - self.__env_vars = env_vars - else: - message = f'Field `tool.hatch.build.targets.{self.plugin_name}.env-vars` must be a table' + @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 self.__env_vars + return env_vars - @property + @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. """ - if self.__outputs is None: - outputs = self.target_config.get('outputs') - - if not outputs: # None or empty table - # Fill in the default build targets. - # First check the scripts section, if it is empty, fall-back to the default build target. - if self.scripts: - self.__outputs = [ - { - '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 - ] - else: # the default if nothing is defined - at least one empty table must be defined - self.__outputs = [{}] - elif isinstance(outputs, list): - self.__outputs = outputs - else: - message = f'Field `tool.hatch.build.targets.{self.plugin_name}.outputs` must be an array of tables' - raise TypeError(message) - - return self.__outputs + 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): From 48695df0c11c4cd25f7ace169dce4adf700364b4 Mon Sep 17 00:00:00 2001 From: "Loibl Johannes (IFAG DES PTS TI EA DE)" Date: Mon, 3 Jun 2024 22:38:55 +0200 Subject: [PATCH 6/7] Add context formatting for env vars --- backend/src/hatchling/builders/binary.py | 3 +++ tests/backend/builders/test_binary.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/hatchling/builders/binary.py b/backend/src/hatchling/builders/binary.py index f23bda387..161f9d6de 100644 --- a/backend/src/hatchling/builders/binary.py +++ b/backend/src/hatchling/builders/binary.py @@ -173,6 +173,9 @@ def build_bootstrap( **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', '') diff --git a/tests/backend/builders/test_binary.py b/tests/backend/builders/test_binary.py index 7da9bd7d5..642b52bd1 100644 --- a/tests/backend/builders/test_binary.py +++ b/tests/backend/builders/test_binary.py @@ -753,7 +753,7 @@ def test_custom_build_targets(self, hatch, temp_dir, mocker): 'env-vars': { 'PYAPP_DISTIBUTION_EMBED': 'true', 'PYAPP_PIP_EXTRA_INDEX_ARGS': '--index-url foobar', - 'CARGO_TARGET_DIR': (project_path / 'pyapp_cargo').as_posix(), + 'CARGO_TARGET_DIR': '{root}/pyapp_cargo', }, 'outputs': [ { @@ -782,7 +782,7 @@ def test_custom_build_targets(self, hatch, temp_dir, mocker): ['cargo', 'install', 'pyapp', '--force', '--root', mocker.ANY], cwd=mocker.ANY, env=ExpectedEnvVars({ - 'CARGO_TARGET_DIR': (temp_dir / 'my-app/pyapp_cargo').as_posix(), + 'CARGO_TARGET_DIR': f'{project_path}/pyapp_cargo', 'PYAPP_DISTIBUTION_EMBED': 'true', 'PYAPP_EXEC_MODULE': 'myapp', 'PYAPP_IS_GUI': 'true', From 5dcbd86a4e6e30b256eafe1fe7ba59b0240d7740 Mon Sep 17 00:00:00 2001 From: "Loibl Johannes (IFAG DES PTS TI EA DE)" Date: Mon, 3 Jun 2024 23:18:08 +0200 Subject: [PATCH 7/7] Update documentation --- docs/plugins/builder/binary.md | 73 +++++++++++++++++++++++++++++++--- mkdocs.yml | 1 + 2 files changed, 68 insertions(+), 6 deletions(-) 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 3f44aab0d..3e0f8579f 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