diff --git a/src/build/__init__.py b/src/build/__init__.py index 284df229..0c0cc26b 100644 --- a/src/build/__init__.py +++ b/src/build/__init__.py @@ -12,7 +12,7 @@ import warnings from collections import OrderedDict -from typing import AbstractSet, Dict, Iterator, Mapping, Optional, Sequence, Set, Text, Tuple, Union +from typing import AbstractSet, Any, Callable, Dict, Iterator, Mapping, Optional, Sequence, Set, Text, Tuple, Union import pep517.wrappers import toml @@ -44,6 +44,13 @@ class BuildBackendException(Exception): Exception raised when the backend fails """ + def __init__(self, exception): # type: (Exception) -> None + super(BuildBackendException, self).__init__() + self.exception = exception # type: Exception + + def __str__(self): # type: () -> str + return 'Backend operation failed: {!r}'.format(self.exception) + class TypoWarning(Warning): """ @@ -230,8 +237,8 @@ def get_dependencies(self, distribution, config_settings=None): # type: (str, O return set(get_requires(config_settings)) except pep517.wrappers.BackendUnavailable: raise BuildException("Backend '{}' is not available.".format(self._backend)) - except Exception as e: # noqa: E722 - raise BuildBackendException('Backend operation failed: {}'.format(e)) + except Exception as e: + raise BuildBackendException(e) def check_dependencies(self, distribution, config_settings=None): # type: (str, Optional[ConfigSettings]) -> Set[Tuple[str, ...]] @@ -247,16 +254,42 @@ def check_dependencies(self, distribution, config_settings=None): dependencies = self.get_dependencies(distribution, config_settings).union(self.build_dependencies) return {u for d in dependencies for u in check_dependency(d)} - def build(self, distribution, outdir, config_settings=None): # type: (str, str, Optional[ConfigSettings]) -> str + def prepare(self, distribution, output_directory, config_settings=None): + # type: (str, str, Optional[ConfigSettings]) -> Optional[str] + """ + Prepare metadata for a distribution. + + :param distribution: Distribution to build (must be ``wheel``) + :param output_directory: Directory to put the prepared metadata in + :param config_settings: Config settings for the build backend + :returns: The full path to the prepared metadata directory + """ + prepare = getattr(self._hook, 'prepare_metadata_for_build_{}'.format(distribution)) + try: + return self._call_backend(prepare, output_directory, config_settings, _allow_fallback=False) + except BuildBackendException as exception: + if isinstance(exception.exception, pep517.wrappers.HookMissing): + return None + raise + + def build(self, distribution, output_directory, config_settings=None, metadata_directory=None): + # type: (str, str, Optional[ConfigSettings], Optional[str]) -> str """ Build a distribution. :param distribution: Distribution to build (``sdist`` or ``wheel``) - :param outdir: Output directory + :param output_directory: Directory to put the built distribution in :param config_settings: Config settings for the build backend + :param metadata_directory: If provided, should be the return value of a + previous ``prepare`` call on the same ``distribution`` kind :returns: The full path to the built distribution """ build = getattr(self._hook, 'build_{}'.format(distribution)) + kwargs = {} if metadata_directory is None else {'metadata_directory': metadata_directory} + return self._call_backend(build, output_directory, config_settings, **kwargs) + + def _call_backend(self, callback, outdir, config_settings=None, **kwargs): + # type: (Callable[...,str], str, Optional[ConfigSettings], Any) -> str outdir = os.path.abspath(outdir) if os.path.exists(outdir): @@ -267,12 +300,12 @@ def build(self, distribution, outdir, config_settings=None): # type: (str, str, try: with _working_directory(self.srcdir): - basename = build(outdir, config_settings) # type: str + basename = callback(outdir, config_settings, **kwargs) # type: str return os.path.join(outdir, basename) except pep517.wrappers.BackendUnavailable: raise BuildException("Backend '{}' is not available.".format(self._backend)) - except Exception as e: # noqa: E722 - raise BuildBackendException('Backend operation failed: {!r}'.format(e)) + except Exception as exception: + raise BuildBackendException(exception) __all__ = ( diff --git a/tests/test_env.py b/tests/test_env.py index b9f6742b..60144168 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -64,19 +64,15 @@ def test_fail_to_get_script_path(mocker): @pytest.mark.skipif(sys.version_info[0] == 2, reason='venv module used on Python 3 only') @pytest.mark.skipif(IS_PYPY3, reason='PyPy3 uses get path to create and provision venv') def test_fail_to_get_purepath(mocker): + mocker.patch.object(build.env, 'virtualenv', None) sysconfig_get_path = sysconfig.get_path - - def mock_sysconfig_get_path(path, *args, **kwargs): - if path == 'purelib': - return '' - else: - return sysconfig_get_path(path, *args, **kwargs) - - mocker.patch('sysconfig.get_path', side_effect=mock_sysconfig_get_path) + mocker.patch( + 'sysconfig.get_path', + side_effect=lambda path, *args, **kwargs: '' if path == 'purelib' else sysconfig_get_path(path, *args, **kwargs), + ) with pytest.raises(RuntimeError, match="Couldn't get environment purelib folder"): - env = build.env.IsolatedEnvBuilder() - with env: + with build.env.IsolatedEnvBuilder(): pass diff --git a/tests/test_main.py b/tests/test_main.py index 840ec30d..6d928f24 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -163,9 +163,9 @@ def test_build_raises_build_exception(mocker, test_flit_path): @pytest.mark.isolated def test_build_raises_build_backend_exception(mocker, test_flit_path): error = mocker.patch('build.__main__._error') - mocker.patch('build.ProjectBuilder.get_dependencies', side_effect=build.BuildBackendException) + mocker.patch('build.ProjectBuilder.get_dependencies', side_effect=build.BuildBackendException(Exception('a'))) mocker.patch('build.env._IsolatedEnvVenvPip.install') build.__main__.build_package(test_flit_path, '.', ['sdist']) - - error.assert_called_with('') + msg = "Backend operation failed: Exception('a'{})".format(',' if sys.version_info < (3, 7) else '') + error.assert_called_with(msg) diff --git a/tests/test_projectbuilder.py b/tests/test_projectbuilder.py index 50dc08bf..7f5b3032 100644 --- a/tests/test_projectbuilder.py +++ b/tests/test_projectbuilder.py @@ -321,7 +321,7 @@ def test_relative_outdir(mocker, tmp_dir, test_flit_path): builder._hook.build_sdist.assert_called_with(os.path.abspath('.'), None) -def test_not_dir_outdir(mocker, tmp_dir, test_flit_path): +def test_build_not_dir_outdir(mocker, tmp_dir, test_flit_path): mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) builder = build.ProjectBuilder(test_flit_path) @@ -384,3 +384,47 @@ def test_build_with_dep_on_console_script(tmp_path, demo_pkg_inline, capfd, mock path_vars = lines[0].split(os.pathsep) which_detected = lines[1] assert which_detected.startswith(path_vars[0]), out + + +def test_prepare(mocker, tmp_dir, test_flit_path): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + mocker.patch('build._working_directory', autospec=True) + + builder = build.ProjectBuilder(test_flit_path) + builder._hook.prepare_metadata_for_build_wheel.return_value = 'dist-1.0.dist-info' + + assert builder.prepare('wheel', tmp_dir) == os.path.join(tmp_dir, 'dist-1.0.dist-info') + builder._hook.prepare_metadata_for_build_wheel.assert_called_with(tmp_dir, None, _allow_fallback=False) + build._working_directory.assert_called_with(test_flit_path) + + +def test_prepare_no_hook(mocker, tmp_dir, test_flit_path): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(test_flit_path) + failure = pep517.wrappers.HookMissing('prepare_metadata_for_build_wheel') + builder._hook.prepare_metadata_for_build_wheel.side_effect = failure + + assert builder.prepare('wheel', tmp_dir) is None + + +def test_prepare_error(mocker, tmp_dir, test_flit_path): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(test_flit_path) + builder._hook.prepare_metadata_for_build_wheel.side_effect = Exception + + with pytest.raises(build.BuildBackendException, match='Backend operation failed: Exception'): + builder.prepare('wheel', tmp_dir) + + +def test_prepare_not_dir_outdir(mocker, tmp_dir, test_flit_path): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(test_flit_path) + + out = os.path.join(tmp_dir, 'out') + with open(out, 'w') as f: + f.write('Not a directory') + with pytest.raises(build.BuildException, match='Build path .* exists and is not a directory'): + builder.prepare('wheel', out)