pypa-hatch/tests/backend/builders/test_binary.py

730 lines
25 KiB
Python

from __future__ import annotations
import os
import subprocess
import sys
from typing import Any
import pytest
from hatch.utils.fs import Path
from hatch.utils.structures import EnvVars
from hatchling.builders.app import AppBuilder
from hatchling.builders.binary import BinaryBuilder
from hatchling.builders.plugin.interface import BuilderInterface
pytestmark = [pytest.mark.requires_cargo, pytest.mark.requires_internet]
class ExpectedEnvVars:
def __init__(self, env_vars: dict):
self.env_vars = env_vars
def __eq__(self, other):
return all(not (key not in other or other[key] != value) for key, value in self.env_vars.items())
def __hash__(self): # no cov
return hash(self.env_vars)
def cargo_install(*args: Any, **_kwargs: Any) -> subprocess.CompletedProcess:
executable_name = 'pyapp.exe' if sys.platform == 'win32' else 'pyapp'
install_command: list[str] = args[0]
repo_path = os.environ.get('PYAPP_REPO', '')
if repo_path:
temp_dir = install_command[install_command.index('--target-dir') + 1]
build_target = os.environ.get('CARGO_BUILD_TARGET', '')
if build_target:
executable = Path(temp_dir, build_target, 'release', executable_name)
else:
executable = Path(temp_dir, 'release', executable_name)
executable.parent.ensure_dir_exists()
executable.touch()
else:
temp_dir = install_command[install_command.index('--root') + 1]
executable = Path(temp_dir, 'bin', executable_name)
executable.parent.ensure_dir_exists()
executable.touch()
return subprocess.CompletedProcess(install_command, returncode=0, stdout=None, stderr=None)
def test_class():
assert issubclass(BinaryBuilder, BuilderInterface)
def test_class_legacy():
assert issubclass(AppBuilder, BinaryBuilder)
def test_default_versions(isolation):
builder = BinaryBuilder(str(isolation))
assert builder.get_default_versions() == ['bootstrap']
class TestScripts:
def test_unset(self, isolation):
config = {'project': {'name': 'My.App', 'version': '0.1.0'}}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.scripts == builder.config.scripts == []
def test_default(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
'scripts': {'b': 'foo.bar.baz:cli', 'a': 'baz.bar.foo:cli'},
}
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.scripts == ['a', 'b']
def test_specific(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
'scripts': {'b': 'foo.bar.baz:cli', 'a': 'baz.bar.foo:cli'},
},
'tool': {'hatch': {'build': {'targets': {'binary': {'scripts': ['a', 'a']}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.scripts == ['a']
def test_not_array(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
'scripts': {'b': 'foo.bar.baz:cli', 'a': 'baz.bar.foo:cli'},
},
'tool': {'hatch': {'build': {'targets': {'binary': {'scripts': 9000}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match='Field `tool.hatch.build.targets.binary.scripts` must be an array'):
_ = builder.config.scripts
def test_script_not_string(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
'scripts': {'b': 'foo.bar.baz:cli', 'a': 'baz.bar.foo:cli'},
},
'tool': {'hatch': {'build': {'targets': {'binary': {'scripts': [9000]}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match='Script #1 of field `tool.hatch.build.targets.binary.scripts` must be a string'
):
_ = builder.config.scripts
def test_unknown_script(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
'scripts': {'b': 'foo.bar.baz:cli', 'a': 'baz.bar.foo:cli'},
},
'tool': {'hatch': {'build': {'targets': {'binary': {'scripts': ['c']}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(ValueError, match='Unknown script in field `tool.hatch.build.targets.binary.scripts`: c'):
_ = builder.config.scripts
class TestPythonVersion:
def test_default_no_source(self, isolation):
config = {'project': {'name': 'My.App', 'version': '0.1.0'}}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.python_version == builder.config.python_version == builder.config.SUPPORTED_VERSIONS[0]
def test_default_explicit_source(self, isolation):
config = {'project': {'name': 'My.App', 'version': '0.1.0'}}
builder = BinaryBuilder(str(isolation), config=config)
with EnvVars({'PYAPP_DISTRIBUTION_SOURCE': 'url'}):
assert builder.config.python_version == builder.config.python_version == ''
def test_set(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
},
'tool': {'hatch': {'build': {'targets': {'binary': {'python-version': '4.0'}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.python_version == '4.0'
def test_not_string(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
},
'tool': {'hatch': {'build': {'targets': {'binary': {'python-version': 9000}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match='Field `tool.hatch.build.targets.binary.python-version` must be a string'):
_ = builder.config.python_version
def test_compatibility(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
'requires-python': '<3.11',
},
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.python_version == '3.10'
def test_incompatible(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
'requires-python': '>9000',
},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match='Field `project.requires-python` is incompatible with the known distributions'
):
_ = builder.config.python_version
class TestPyAppVersion:
def test_default(self, isolation):
config = {'project': {'name': 'My.App', 'version': '0.1.0'}}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.pyapp_version == builder.config.pyapp_version == ''
def test_set(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
},
'tool': {'hatch': {'build': {'targets': {'binary': {'pyapp-version': '9000'}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.pyapp_version == '9000'
def test_not_string(self, isolation):
config = {
'project': {
'name': 'My.App',
'version': '0.1.0',
},
'tool': {'hatch': {'build': {'targets': {'binary': {'pyapp-version': 9000}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match='Field `tool.hatch.build.targets.binary.pyapp-version` must be a string'):
_ = builder.config.pyapp_version
class TestBuildBootstrap:
def test_default(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']}}},
},
},
}
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({'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0'}),
)
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.exe' if sys.platform == 'win32' else 'my-app-0.1.0')).is_file()
def test_default_build_target(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']}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / 'dist'
with project_path.as_cwd({'CARGO_BUILD_TARGET': 'target'}):
artifacts = list(builder.build())
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'}),
)
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-target.exe' if sys.platform == 'win32' else 'my-app-0.1.0-target')
).is_file()
def test_scripts(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', 'scripts': {'foo': 'bar.baz:cli'}},
'tool': {
'hatch': {
'build': {'targets': {'binary': {'versions': ['bootstrap']}}},
},
},
}
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({
'PYAPP_PROJECT_NAME': 'my-app',
'PYAPP_PROJECT_VERSION': '0.1.0',
'PYAPP_EXEC_SPEC': 'bar.baz:cli',
}),
)
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' / ('foo-0.1.0.exe' if sys.platform == 'win32' else 'foo-0.1.0')).is_file()
def test_scripts_build_target(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', 'scripts': {'foo': 'bar.baz:cli'}},
'tool': {
'hatch': {
'build': {'targets': {'binary': {'versions': ['bootstrap']}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / 'dist'
with project_path.as_cwd({'CARGO_BUILD_TARGET': 'target'}):
artifacts = list(builder.build())
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',
'PYAPP_EXEC_SPEC': 'bar.baz:cli',
}),
)
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' / ('foo-0.1.0-target.exe' if sys.platform == 'win32' else 'foo-0.1.0-target')
).is_file()
def test_custom_cargo(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']}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / 'dist'
with project_path.as_cwd({'CARGO': 'cross'}):
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
['cross', 'install', 'pyapp', '--force', '--root', mocker.ANY],
cwd=mocker.ANY,
env=ExpectedEnvVars({'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0'}),
)
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.exe' if sys.platform == 'win32' else 'my-app-0.1.0')).is_file()
def test_no_cargo(self, hatch, temp_dir, mocker):
mocker.patch('shutil.which', return_value=None)
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']}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
with pytest.raises(OSError, match='Executable `cargo` could not be found on PATH'), project_path.as_cwd():
next(builder.build())
def test_python_version(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'], 'python-version': '4.0'}}},
},
},
}
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({
'PYAPP_PROJECT_NAME': 'my-app',
'PYAPP_PROJECT_VERSION': '0.1.0',
'PYAPP_PYTHON_VERSION': '4.0',
}),
)
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.exe' if sys.platform == 'win32' else 'my-app-0.1.0')).is_file()
def test_pyapp_version(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'], 'pyapp-version': '9000'}}},
},
},
}
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, '--version', '9000'],
cwd=mocker.ANY,
env=ExpectedEnvVars({'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0'}),
)
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.exe' if sys.platform == 'win32' else 'my-app-0.1.0')).is_file()
def test_verbosity(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']}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / 'dist'
with project_path.as_cwd({'HATCH_QUIET': '1'}):
artifacts = list(builder.build())
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'}),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
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.exe' if sys.platform == 'win32' else 'my-app-0.1.0')).is_file()
def test_local_build_with_build_target(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']}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / 'dist'
with project_path.as_cwd({'PYAPP_REPO': 'test-path', 'CARGO_BUILD_TARGET': 'target'}):
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
['cargo', 'build', '--release', '--target-dir', mocker.ANY],
cwd='test-path',
env=ExpectedEnvVars({'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0'}),
)
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-target.exe' if sys.platform == 'win32' else 'my-app-0.1.0-target')
).is_file()
def test_local_build_no_build_target(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']}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / 'dist'
with project_path.as_cwd({'PYAPP_REPO': 'test-path'}):
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
['cargo', 'build', '--release', '--target-dir', mocker.ANY],
cwd='test-path',
env=ExpectedEnvVars({'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0'}),
)
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.exe' if sys.platform == 'win32' else 'my-app-0.1.0')).is_file()
def test_legacy(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': {'app': {'versions': ['bootstrap']}}},
},
},
}
builder = AppBuilder(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({'PYAPP_PROJECT_NAME': 'my-app', 'PYAPP_PROJECT_VERSION': '0.1.0'}),
)
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 / 'app' / ('my-app-0.1.0.exe' if sys.platform == 'win32' else 'my-app-0.1.0')).is_file()