Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1423 - Better PyApp integration #1547

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
193 changes: 131 additions & 62 deletions backend/src/hatchling/builders/binary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Copy link

@mmorys mmorys Aug 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have tested the proposed changes and this does not fix the issue defined in #1436. The project is not actually installed within the PyApp binary, resulting in a No matching distribution error.

According to the PyApp Docs there are 3 ways to define the project path. Here, PYAPP_PROJECT_NAME and PYAPP_PROJECT_VERSION are defined, meaning you are using the Identifier configuration which means "the package will be installed from a package index like PyPI". This is not what we want, as the package is local and not necessarily on PyPi.

Rather than defining these two variables here, the builder should utilize the Embedding config method:

  • Build an sdist or wheel in a temporary directory
  • Set PYAPP_PROJECT_PATH to point to this file
    • Name and version are automatically derived from the sdist/wheel metadata.

See my comment on the related issue for how I got around this issue by building and pointing to an sdist.

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

Expand All @@ -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)
73 changes: 67 additions & 6 deletions docs/plugins/builder/binary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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 `"<scriptname>-{version}"`.

You cannot define `outputs` and `scripts` at the same time.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ theme:
features:
- content.action.edit
- content.code.copy
- content.code.annotate
- content.tabs.link
- content.tooltips
- navigation.expand
Expand Down
Loading