diff options
Diffstat (limited to 'tests')
39 files changed, 1953 insertions, 0 deletions
diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d65b64e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: MIT + +import os +import os.path +import shutil +import stat +import sys +import sysconfig +import tempfile + +import pytest + +import build.env + + +def pytest_addoption(parser): + os.environ['PYTHONWARNINGS'] = 'ignore:DEPRECATION::pip._internal.cli.base_command' # for when not run within tox + os.environ['PIP_DISABLE_PIP_VERSION_CHECK'] = '1' # do not pollute stderr with upgrade advisory + parser.addoption('--run-integration', action='store_true', help='run the integration tests') + parser.addoption('--only-integration', action='store_true', help='only run the integration tests') + + +PYPY3_WIN_VENV_BAD = ( + sys.implementation.name == 'pypy' and sys.implementation.version < (7, 3, 9) and sys.platform.startswith('win') +) +PYPY3_WIN_M = 'https://foss.heptapod.net/pypy/pypy/-/issues/3323 and https://foss.heptapod.net/pypy/pypy/-/issues/3321' + + +def pytest_collection_modifyitems(config, items): + skip_int = pytest.mark.skip(reason='integration tests not run (no --run-integration flag)') + skip_other = pytest.mark.skip(reason='only integration tests are run (got --only-integration flag)') + + if config.getoption('--run-integration') and config.getoption('--only-integration'): # pragma: no cover + raise pytest.UsageError("--run-integration and --only-integration can't be used together, choose one") + + if len(items) == 1: # do not require flags if called directly + return + for item in items: + is_integration_file = is_integration(item) + if PYPY3_WIN_VENV_BAD and item.get_closest_marker('pypy3323bug') and os.environ.get('PYPY3323BUG', None): + item.add_marker(pytest.mark.xfail(reason=PYPY3_WIN_M, strict=False)) + if PYPY3_WIN_VENV_BAD and item.get_closest_marker('isolated'): + if not (is_integration_file and item.originalname == 'test_build') or ( + hasattr(item, 'callspec') and '--no-isolation' not in item.callspec.params.get('args', []) + ): + item.add_marker(pytest.mark.xfail(reason=PYPY3_WIN_M, strict=True)) + if is_integration_file: # pragma: no cover + if not config.getoption('--run-integration') and not config.getoption('--only-integration'): + item.add_marker(skip_int) + elif config.getoption('--only-integration'): # pragma: no cover + item.add_marker(skip_other) + # run integration tests after unit tests + items.sort(key=lambda i: 1 if is_integration(i) else 0) + + +def is_integration(item): + return os.path.basename(item.location[0]) == 'test_integration.py' + + +@pytest.fixture(scope='session', autouse=True) +def ensure_syconfig_vars_created(): + # the config vars are globally cached and may use get_path, make sure they are created + sysconfig.get_config_vars() + + +@pytest.fixture +def packages_path(): + return os.path.realpath(os.path.join(__file__, '..', 'packages')) + + +def generate_package_path_fixture(package_name): + @pytest.fixture + def fixture(packages_path): + return os.path.join(packages_path, package_name) + + return fixture + + +# Generate path fixtures dynamically. +package_names = os.listdir(os.path.join(os.path.dirname(__file__), 'packages')) +for package_name in package_names: + normalized_name = package_name.replace('-', '_') + fixture_name = f'package_{normalized_name}' + globals()[fixture_name] = generate_package_path_fixture(package_name) + + +@pytest.fixture +def test_no_permission(packages_path): + path = os.path.join(packages_path, 'test-no-permission') + file = os.path.join(path, 'pyproject.toml') + orig_stat = os.stat(file).st_mode + + os.chmod(file, ~stat.S_IRWXU) + + yield os.path.join(packages_path, 'test-no-permission') + + os.chmod(file, orig_stat) + + +@pytest.fixture +def tmp_dir(): + path = tempfile.mkdtemp(prefix='python-build-test-') + + yield path + + shutil.rmtree(path) + + +@pytest.fixture(autouse=True) +def force_venv(mocker): + mocker.patch.object(build.env, '_should_use_virtualenv', lambda: False) diff --git a/tests/constraints.txt b/tests/constraints.txt new file mode 100644 index 0000000..b073bd9 --- /dev/null +++ b/tests/constraints.txt @@ -0,0 +1,9 @@ +importlib-metadata==0.22 +packaging==19.0 +pep517==0.9.1 +setuptools==42.0.0; python_version < "3.10" +setuptools==56.0.0; python_version >= "3.10" +toml==0.10.0 +tomli==1.0.0 +virtualenv==20.0.35 +wheel==0.36.0 diff --git a/tests/packages/inline/build.py b/tests/packages/inline/build.py new file mode 100644 index 0000000..a8e0dcb --- /dev/null +++ b/tests/packages/inline/build.py @@ -0,0 +1,63 @@ +import os +import sys + +from textwrap import dedent +from zipfile import ZipFile + + +name = 'demo_pkg_inline' +pkg_name = name.replace('_', '-') + +version = '1.0.0' +dist_info = f'{name}-{version}.dist-info' + +metadata = f'{dist_info}/METADATA' +wheel = f'{dist_info}/WHEEL' +entry_points = f'{dist_info}/entry_points.txt' +record = f'{dist_info}/RECORD' +init = f'{name}/__init__.py' +content = { + init: f"def do():\n print('greetings from {name}')", + metadata: f""" + Metadata-Version: 2.1 + Name: {pkg_name} + Version: {version} + Summary: Summary of package + Home-page: Does not exists + Author: someone + Author-email: a@o.com + License: MIT + Platform: ANY + + Desc + """, + wheel: f""" + Wheel-Version: 1.0 + Generator: {name}-{version} + Root-Is-Purelib: true + Tag: py3-none-any + """, + f'{dist_info}/top_level.txt': name, + entry_points: '\n[console_scripts]\ndemo-pkg-inline = demo_pkg_inline:do', + record: f""" + {name}/__init__.py,, + {dist_info}/METADATA,, + {dist_info}/WHEEL,, + {dist_info}/top_level.txt,, + {dist_info}/RECORD,, + """, +} + + +def build_wheel(wheel_directory, metadata_directory=None, config_settings=None): + base_name = f'{name}-{version}-py{sys.version_info.major}-none-any.whl' + path = os.path.join(wheel_directory, base_name) + with ZipFile(str(path), 'w') as zip_file_handler: + for arc_name, data in content.items(): + zip_file_handler.writestr(arc_name, dedent(data).strip()) + print(f'created wheel {path}') + return base_name + + +def get_requires_for_build_wheel(config_settings): + return [] diff --git a/tests/packages/inline/pyproject.toml b/tests/packages/inline/pyproject.toml new file mode 100644 index 0000000..dc9ecbb --- /dev/null +++ b/tests/packages/inline/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires = [] +build-backend = "build" +backend-path = ["."] diff --git a/tests/packages/legacy/legacy/__init__.py b/tests/packages/legacy/legacy/__init__.py new file mode 100644 index 0000000..d5a030b --- /dev/null +++ b/tests/packages/legacy/legacy/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: MIT + +""" +legacy - Example legacy package +""" +__version__ = '1.0.0' diff --git a/tests/packages/legacy/setup.py b/tests/packages/legacy/setup.py new file mode 100644 index 0000000..9feee16 --- /dev/null +++ b/tests/packages/legacy/setup.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python + +from setuptools import setup + + +setup( + name='legacy', + version='1.0.0', + author='Filipe Laíns', + author_email='lains@archlinux.org', + classifiers=[ + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + ], + packages=['legacy'], +) diff --git a/tests/packages/test-bad-backend/pyproject.toml b/tests/packages/test-bad-backend/pyproject.toml new file mode 100644 index 0000000..c199a33 --- /dev/null +++ b/tests/packages/test-bad-backend/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = [] +build-backend = "nonsense_package" diff --git a/tests/packages/test-bad-syntax/pyproject.toml b/tests/packages/test-bad-syntax/pyproject.toml new file mode 100644 index 0000000..cb84496 --- /dev/null +++ b/tests/packages/test-bad-syntax/pyproject.toml @@ -0,0 +1,2 @@ +[build-system] +requires = ['bad' 'syntax'] diff --git a/tests/packages/test-bad-wheel/backend_bad_wheel.py b/tests/packages/test-bad-wheel/backend_bad_wheel.py new file mode 100644 index 0000000..039cae0 --- /dev/null +++ b/tests/packages/test-bad-wheel/backend_bad_wheel.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: MIT + +from setuptools.build_meta import build_sdist # noqa: F401 + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + return 'not a wheel' diff --git a/tests/packages/test-bad-wheel/pyproject.toml b/tests/packages/test-bad-wheel/pyproject.toml new file mode 100644 index 0000000..77de5df --- /dev/null +++ b/tests/packages/test-bad-wheel/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +build-backend = 'backend_bad_wheel' +backend-path = ['.'] +requires = ['setuptools >= 42.0.0'] diff --git a/tests/packages/test-bad-wheel/setup.cfg b/tests/packages/test-bad-wheel/setup.cfg new file mode 100644 index 0000000..1a4be2f --- /dev/null +++ b/tests/packages/test-bad-wheel/setup.cfg @@ -0,0 +1,3 @@ +[metadata] +name = test_bad_wheel +version = 0.0.1 diff --git a/tests/packages/test-cant-build-via-sdist/backend_bad_sdist.py b/tests/packages/test-cant-build-via-sdist/backend_bad_sdist.py new file mode 100644 index 0000000..2afdb3d --- /dev/null +++ b/tests/packages/test-cant-build-via-sdist/backend_bad_sdist.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MIT + +import os.path +import tarfile +import zipfile + + +def build_sdist(sdist_directory, config_settings=None): + name = 'test_cant_build_via_sdist-1.0.0' + file = f'{name}.tar.gz' + with tarfile.open(os.path.join(sdist_directory, file), 'w') as t: + t.add('pyproject.toml', f'{name}/pyproject.toml') + t.add('backend_bad_sdist.py', f'{name}/backend_bad_sdist.py') + return file + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + if not os.path.isfile('some-file-that-is-needed-for-build.txt'): + raise FileNotFoundError('some-file-that-is-needed-for-build.txt is missing!') + # pragma: no cover + file = 'test_cant_build_via_sdist-1.0.0-py2.py3-none-any.whl' + zipfile.ZipFile(os.path.join(wheel_directory, file), 'w').close() + return file diff --git a/tests/packages/test-cant-build-via-sdist/pyproject.toml b/tests/packages/test-cant-build-via-sdist/pyproject.toml new file mode 100644 index 0000000..e74afad --- /dev/null +++ b/tests/packages/test-cant-build-via-sdist/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +build-backend = 'backend_bad_sdist' +backend-path = ['.'] +requires = [] diff --git a/tests/packages/test-cant-build-via-sdist/some-file-that-is-needed-for-build.txt b/tests/packages/test-cant-build-via-sdist/some-file-that-is-needed-for-build.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/packages/test-cant-build-via-sdist/some-file-that-is-needed-for-build.txt diff --git a/tests/packages/test-flit/pyproject.toml b/tests/packages/test-flit/pyproject.toml new file mode 100644 index 0000000..9758b57 --- /dev/null +++ b/tests/packages/test-flit/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ['flit_core >=2,<3'] +build-backend = 'flit_core.buildapi' + +[tool.flit.metadata] +module = 'test_flit' +author = 'Filipe Laíns' +author-email = 'lains@archlinux.org' +classifiers = [ + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', +] diff --git a/tests/packages/test-flit/test_flit/__init__.py b/tests/packages/test-flit/test_flit/__init__.py new file mode 100644 index 0000000..264c2e3 --- /dev/null +++ b/tests/packages/test-flit/test_flit/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: MIT + +""" +test_flit - Example flit package +""" +__version__ = '1.0.0' diff --git a/tests/packages/test-invalid-requirements/pyproject.toml b/tests/packages/test-invalid-requirements/pyproject.toml new file mode 100644 index 0000000..11974a0 --- /dev/null +++ b/tests/packages/test-invalid-requirements/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ['setuptools >= 42.0.0', 'wheel >= 0.36.0', 'this is invalid'] +build-backend = 'setuptools.build_meta' diff --git a/tests/packages/test-invalid-requirements/setup.cfg b/tests/packages/test-invalid-requirements/setup.cfg new file mode 100644 index 0000000..aa22d23 --- /dev/null +++ b/tests/packages/test-invalid-requirements/setup.cfg @@ -0,0 +1,3 @@ +[metadata] +name = test_invalid_requirements +version = 1.0.0 diff --git a/tests/packages/test-metadata/backend.py b/tests/packages/test-metadata/backend.py new file mode 100644 index 0000000..727dab9 --- /dev/null +++ b/tests/packages/test-metadata/backend.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: MIT + +import pathlib +import textwrap + + +def get_requires_for_build_wheel(config_settings=None): + return ['tomli'] + + +def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): + import tomli + + with open('pyproject.toml', 'rb') as f: + metadata = tomli.load(f) + + distinfo = pathlib.Path( + metadata_directory, + '{}-{}.dist-info'.format( + metadata['project']['name'].replace('-', '-'), + metadata['project']['version'], + ), + ) + distinfo.mkdir(parents=True, exist_ok=True) + distinfo.joinpath('METADATA').write_text( + textwrap.dedent( + f''' + Metadata-Version: 2.2 + Name: {metadata['project']['name']} + Version: {metadata['project']['version']} + Summary: {metadata['project']['description']} + ''' + ).strip() + ) + return distinfo.name + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + raise NotImplementedError + + +def build_sdist(sdist_directory, config_settings=None): + raise NotImplementedError diff --git a/tests/packages/test-metadata/pyproject.toml b/tests/packages/test-metadata/pyproject.toml new file mode 100644 index 0000000..3900263 --- /dev/null +++ b/tests/packages/test-metadata/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = [] +build-backend = 'backend' +backend-path = ['.'] + +[project] +name = 'test-metadata' +version = '1.0.0' +description = 'hello!' + +[tool.black] +line-length = 127 +skip-string-normalization = true +target-version = ['py39', 'py38', 'py37', 'py36'] diff --git a/tests/packages/test-no-backend/pyproject.toml b/tests/packages/test-no-backend/pyproject.toml new file mode 100644 index 0000000..024e9e6 --- /dev/null +++ b/tests/packages/test-no-backend/pyproject.toml @@ -0,0 +1,2 @@ +[build-system] +requires = [] diff --git a/tests/packages/test-no-permission/pyproject.toml b/tests/packages/test-no-permission/pyproject.toml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/packages/test-no-permission/pyproject.toml diff --git a/tests/packages/test-no-prepare/backend_no_prepare.py b/tests/packages/test-no-prepare/backend_no_prepare.py new file mode 100644 index 0000000..206de1a --- /dev/null +++ b/tests/packages/test-no-prepare/backend_no_prepare.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from setuptools.build_meta import build_sdist, build_wheel # noqa: F401 diff --git a/tests/packages/test-no-prepare/pyproject.toml b/tests/packages/test-no-prepare/pyproject.toml new file mode 100644 index 0000000..c6ca5f8 --- /dev/null +++ b/tests/packages/test-no-prepare/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +build-backend = 'backend_no_prepare' +backend-path = ['.'] +requires = ['setuptools >= 42.0.0', 'wheel >= 0.36.0'] diff --git a/tests/packages/test-no-prepare/setup.cfg b/tests/packages/test-no-prepare/setup.cfg new file mode 100644 index 0000000..c9e0a96 --- /dev/null +++ b/tests/packages/test-no-prepare/setup.cfg @@ -0,0 +1,3 @@ +[metadata] +name = test_no_prepare +version = 1.0.0 diff --git a/tests/packages/test-no-project/empty.txt b/tests/packages/test-no-project/empty.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/packages/test-no-project/empty.txt diff --git a/tests/packages/test-no-requires/pyproject.toml b/tests/packages/test-no-requires/pyproject.toml new file mode 100644 index 0000000..4f25948 --- /dev/null +++ b/tests/packages/test-no-requires/pyproject.toml @@ -0,0 +1,2 @@ +[build-system] +build-backend = 'something' diff --git a/tests/packages/test-optional-hooks/hookless_backend.py b/tests/packages/test-optional-hooks/hookless_backend.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/packages/test-optional-hooks/hookless_backend.py diff --git a/tests/packages/test-optional-hooks/pyproject.toml b/tests/packages/test-optional-hooks/pyproject.toml new file mode 100644 index 0000000..2796891 --- /dev/null +++ b/tests/packages/test-optional-hooks/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires = [] +build-backend = 'hookless_backend' +backend-path = ['.'] diff --git a/tests/packages/test-setuptools/pyproject.toml b/tests/packages/test-setuptools/pyproject.toml new file mode 100644 index 0000000..b00a27a --- /dev/null +++ b/tests/packages/test-setuptools/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ['setuptools >= 42.0.0', 'wheel >= 0.36.0'] +build-backend = 'setuptools.build_meta' diff --git a/tests/packages/test-setuptools/setup.cfg b/tests/packages/test-setuptools/setup.cfg new file mode 100644 index 0000000..bf198b6 --- /dev/null +++ b/tests/packages/test-setuptools/setup.cfg @@ -0,0 +1,6 @@ +[metadata] +name = test_setuptools +version = 1.0.0 + +[bdist_wheel] +universal = 1 diff --git a/tests/packages/test-typo/pyproject.toml b/tests/packages/test-typo/pyproject.toml new file mode 100644 index 0000000..02d1af2 --- /dev/null +++ b/tests/packages/test-typo/pyproject.toml @@ -0,0 +1,3 @@ +[build_sytem] +requires = ['setuptools >= 40.8.0', 'wheel'] +build-backend = 'setuptools.build_meta' diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 0000000..f6f381a --- /dev/null +++ b/tests/test_env.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: MIT +import collections +import inspect +import logging +import platform +import subprocess +import sys +import sysconfig + +import pytest + +from packaging.version import Version + +import build.env + + +IS_PYPY3 = platform.python_implementation() == 'PyPy' + + +@pytest.mark.isolated +def test_isolation(): + subprocess.check_call([sys.executable, '-c', 'import build.env']) + with build.env.IsolatedEnvBuilder() as env: + with pytest.raises(subprocess.CalledProcessError): + debug = 'import sys; import os; print(os.linesep.join(sys.path));' + subprocess.check_call([env.executable, '-c', f'{debug} import build.env']) + + +@pytest.mark.isolated +def test_isolated_environment_install(mocker): + with build.env.IsolatedEnvBuilder() as env: + mocker.patch('build.env._subprocess') + + env.install([]) + build.env._subprocess.assert_not_called() + + env.install(['some', 'requirements']) + build.env._subprocess.assert_called() + args = build.env._subprocess.call_args[0][0][:-1] + assert args == [ + env.executable, + '-Im', + 'pip', + 'install', + '--use-pep517', + '--no-warn-script-location', + '-r', + ] + + +@pytest.mark.skipif(IS_PYPY3, reason='PyPy3 uses get path to create and provision venv') +@pytest.mark.skipif(sys.platform != 'darwin', reason='workaround for Apple Python') +def test_can_get_venv_paths_with_conflicting_default_scheme(mocker): + get_scheme_names = mocker.patch('sysconfig.get_scheme_names', return_value=('osx_framework_library',)) + with build.env.IsolatedEnvBuilder(): + pass + assert get_scheme_names.call_count == 1 + + +@pytest.mark.skipif('posix_local' not in sysconfig.get_scheme_names(), reason='workaround for Debian/Ubuntu Python') +def test_can_get_venv_paths_with_posix_local_default_scheme(mocker): + get_paths = mocker.spy(sysconfig, 'get_paths') + # We should never call this, but we patch it to ensure failure if we do + get_default_scheme = mocker.patch('sysconfig.get_default_scheme', return_value='posix_local') + with build.env.IsolatedEnvBuilder(): + pass + get_paths.assert_called_once_with(scheme='posix_prefix', vars=mocker.ANY) + assert get_default_scheme.call_count == 0 + + +def test_executable_missing_post_creation(mocker): + venv_create = mocker.patch('venv.EnvBuilder.create') + with pytest.raises(RuntimeError, match='Virtual environment creation failed, executable .* missing'): + with build.env.IsolatedEnvBuilder(): + pass + assert venv_create.call_count == 1 + + +def test_isolated_env_abstract(): + with pytest.raises(TypeError): + build.env.IsolatedEnv() + + +def test_isolated_env_has_executable_still_abstract(): + class Env(build.env.IsolatedEnv): + @property + def executable(self): + raise NotImplementedError + + with pytest.raises(TypeError): + Env() + + +def test_isolated_env_has_install_still_abstract(): + class Env(build.env.IsolatedEnv): + def install(self, requirements): + raise NotImplementedError + + with pytest.raises(TypeError): + Env() + + +@pytest.mark.pypy3323bug +def test_isolated_env_log(mocker, caplog, package_test_flit): + mocker.patch('build.env._subprocess') + caplog.set_level(logging.DEBUG) + + builder = build.env.IsolatedEnvBuilder() + frameinfo = inspect.getframeinfo(inspect.currentframe()) + builder.log('something') # line number 106 + with builder as env: + env.install(['something']) + + assert [(record.levelname, record.message) for record in caplog.records] == [ + ('INFO', 'something'), + ('INFO', 'Creating venv isolated environment...'), + ('INFO', 'Installing packages in isolated environment... (something)'), + ] + if sys.version_info >= (3, 8): # stacklevel + assert [(record.lineno) for record in caplog.records] == [ + frameinfo.lineno + 1, + frameinfo.lineno - 6, + frameinfo.lineno + 85, + ] + + +@pytest.mark.isolated +def test_default_pip_is_never_too_old(): + with build.env.IsolatedEnvBuilder() as env: + version = subprocess.check_output( + [env.executable, '-c', 'import pip; print(pip.__version__)'], universal_newlines=True + ).strip() + assert Version(version) >= Version('19.1') + + +@pytest.mark.isolated +@pytest.mark.parametrize('pip_version', ['20.2.0', '20.3.0', '21.0.0', '21.0.1']) +@pytest.mark.parametrize('arch', ['x86_64', 'arm64']) +def test_pip_needs_upgrade_mac_os_11(mocker, pip_version, arch): + SimpleNamespace = collections.namedtuple('SimpleNamespace', 'version') + + _subprocess = mocker.patch('build.env._subprocess') + mocker.patch('platform.system', return_value='Darwin') + mocker.patch('platform.machine', return_value=arch) + mocker.patch('platform.mac_ver', return_value=('11.0', ('', '', ''), '')) + metadata_name = 'importlib_metadata' if sys.version_info < (3, 8) else 'importlib.metadata' + mocker.patch(metadata_name + '.distributions', return_value=(SimpleNamespace(version=pip_version),)) + + min_version = Version('20.3' if arch == 'x86_64' else '21.0.1') + with build.env.IsolatedEnvBuilder(): + if Version(pip_version) < min_version: + print(_subprocess.call_args_list) + upgrade_call, uninstall_call = _subprocess.call_args_list + answer = 'pip>=20.3.0' if arch == 'x86_64' else 'pip>=21.0.1' + assert upgrade_call[0][0][1:] == ['-m', 'pip', 'install', answer] + assert uninstall_call[0][0][1:] == ['-m', 'pip', 'uninstall', 'setuptools', '-y'] + else: + (uninstall_call,) = _subprocess.call_args_list + assert uninstall_call[0][0][1:] == ['-m', 'pip', 'uninstall', 'setuptools', '-y'] + + +@pytest.mark.isolated +@pytest.mark.skipif(IS_PYPY3 and sys.platform.startswith('win'), reason='Isolated tests not supported on PyPy3 + Windows') +@pytest.mark.parametrize('has_symlink', [True, False] if sys.platform.startswith('win') else [True]) +def test_venv_symlink(mocker, has_symlink): + if has_symlink: + mocker.patch('os.symlink') + mocker.patch('os.unlink') + else: + mocker.patch('os.symlink', side_effect=OSError()) + + # Cache must be cleared to rerun + build.env._fs_supports_symlink.cache_clear() + supports_symlink = build.env._fs_supports_symlink() + build.env._fs_supports_symlink.cache_clear() + + assert supports_symlink is has_symlink diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..bc2f4ff --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: MIT + +import os +import os.path +import platform +import re +import shutil +import subprocess +import sys +import tarfile +import urllib.request + +import filelock +import pytest + +import build.__main__ + + +IS_WINDOWS = sys.platform.startswith('win') +IS_PYPY3 = platform.python_implementation() == 'PyPy' + + +INTEGRATION_SOURCES = { + 'dateutil': ('dateutil/dateutil', '2.8.1'), + 'pip': ('pypa/pip', '20.2.1'), + 'Solaar': ('pwr-Solaar/Solaar', '1.0.3'), + 'flit': ('takluyver/flit', '2.3.0'), +} + +_SDIST = re.compile('.*.tar.gz') +_WHEEL = re.compile('.*.whl') +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def get_project(name, tmp_path): + dest = tmp_path / name + if name == 'build': + # our own project is available in-source, just ignore development files + + def _ignore_folder(base, filenames): + ignore = [n for n in filenames if n in excl or any(n.endswith(i) for i in ('_cache', '.egg-info', '.pyc'))] + if os.path.basename == ROOT and 'build' in filenames: # ignore build only at root (our module is build too) + ignore.append('build') + return ignore + + excl = '.tox', 'dist', '.git', '__pycache__', '.integration-sources', '.github', 'tests', 'docs' + shutil.copytree(ROOT, str(dest), ignore=_ignore_folder) + return dest + + # for other projects download from github and cache it + tar_store = os.path.join(ROOT, '.integration-sources') + try: + os.makedirs(tar_store) + except OSError: # python 2 has no exist_ok, and checking with exists is not parallel safe + pass # just ignore, if the creation failed we will have another failure soon that will notify the user + + github_org_repo, version = INTEGRATION_SOURCES[name] + tar_filename = f'{name}-{version}.tar.gz' + tarball = os.path.join(tar_store, tar_filename) + with filelock.FileLock(os.path.join(tar_store, f'{tar_filename}.lock')): + if not os.path.exists(tarball): + url = f'https://github.com/{github_org_repo}/archive/{version}.tar.gz' + with urllib.request.urlopen(url) as request, open(tarball, 'wb') as file_handler: + shutil.copyfileobj(request, file_handler) + with tarfile.open(tarball, 'r:gz') as tar_handler: + tar_handler.extractall(str(dest)) + return dest / f'{name}-{version}' + + +@pytest.mark.parametrize( + 'call', + [ + None, # via code + [sys.executable, '-m', 'build'], # module + ['pyproject-build'], # entrypoint + ], + ids=['code', 'module', 'entrypoint'], +) +@pytest.mark.parametrize( + 'args', + [[], ['-x', '--no-isolation']], + ids=['isolated', 'no_isolation'], +) +@pytest.mark.parametrize( + 'project', + [ + 'build', + 'pip', + 'dateutil', + 'Solaar', + 'flit', + ], +) +@pytest.mark.isolated +def test_build(monkeypatch, project, args, call, tmp_path): + if project == 'flit' and '--no-isolation' in args: + pytest.xfail("can't build flit without isolation due to missing dependencies") + if project == 'Solaar' and IS_WINDOWS and IS_PYPY3: + pytest.xfail('Solaar fails building wheels via sdists on Windows on PyPy 3') + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('SETUPTOOLS_SCM_PRETEND_VERSION', '0+dummy') # for the projects that use setuptools_scm + + if call and call[0] == 'pyproject-build': + exe_name = f"pyproject-build{'.exe' if sys.platform.startswith('win') else ''}" + exe = os.path.join(os.path.dirname(sys.executable), exe_name) + if os.path.exists(exe): + call[0] = exe + else: + pytest.skip('Running via PYTHONPATH, so the pyproject-build entrypoint is not available') + path = get_project(project, tmp_path) + pkgs = tmp_path / 'pkgs' + args = [str(path), '-o', str(pkgs)] + args + + if call is None: + build.__main__.main(args) + else: + subprocess.check_call(call + args) + + pkg_names = os.listdir(str(pkgs)) + assert list(filter(_SDIST.match, pkg_names)) + assert list(filter(_WHEEL.match, pkg_names)) + + +def test_isolation(tmp_dir, package_test_flit, mocker): + try: + import flit_core # noqa: F401 + except ModuleNotFoundError: + pass + else: + pytest.xfail('flit_core is available -- we want it missing!') # pragma: no cover + + mocker.patch('build.__main__._error') + + build.__main__.main([package_test_flit, '-o', tmp_dir, '--no-isolation']) + build.__main__._error.assert_called_with("Backend 'flit_core.buildapi' is not available.") diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..e1fbe0c --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,437 @@ +# SPDX-License-Identifier: MIT + +import contextlib +import importlib +import io +import os +import re +import subprocess +import sys +import venv + +import pytest + +import build +import build.__main__ + + +build_open_owner = 'builtins' + +cwd = os.getcwd() +out = os.path.join(cwd, 'dist') + + +@pytest.mark.parametrize( + ('cli_args', 'build_args', 'hook'), + [ + ( + [], + [cwd, out, ['wheel'], {}, True, False], + 'build_package_via_sdist', + ), + ( + ['-n'], + [cwd, out, ['wheel'], {}, False, False], + 'build_package_via_sdist', + ), + ( + ['-s'], + [cwd, out, ['sdist'], {}, True, False], + 'build_package', + ), + ( + ['-w'], + [cwd, out, ['wheel'], {}, True, False], + 'build_package', + ), + ( + ['-s', '-w'], + [cwd, out, ['sdist', 'wheel'], {}, True, False], + 'build_package', + ), + ( + ['source'], + ['source', os.path.join('source', 'dist'), ['wheel'], {}, True, False], + 'build_package_via_sdist', + ), + ( + ['-o', 'out'], + [cwd, 'out', ['wheel'], {}, True, False], + 'build_package_via_sdist', + ), + ( + ['source', '-o', 'out'], + ['source', 'out', ['wheel'], {}, True, False], + 'build_package_via_sdist', + ), + ( + ['-x'], + [cwd, out, ['wheel'], {}, True, True], + 'build_package_via_sdist', + ), + ( + ['-C--flag1', '-C--flag2'], + [cwd, out, ['wheel'], {'--flag1': '', '--flag2': ''}, True, False], + 'build_package_via_sdist', + ), + ( + ['-C--flag=value'], + [cwd, out, ['wheel'], {'--flag': 'value'}, True, False], + 'build_package_via_sdist', + ), + ( + ['-C--flag1=value', '-C--flag2=other_value', '-C--flag2=extra_value'], + [cwd, out, ['wheel'], {'--flag1': 'value', '--flag2': ['other_value', 'extra_value']}, True, False], + 'build_package_via_sdist', + ), + ], +) +def test_parse_args(mocker, cli_args, build_args, hook): + mocker.patch('build.__main__.build_package', return_value=['something']) + mocker.patch('build.__main__.build_package_via_sdist', return_value=['something']) + + build.__main__.main(cli_args) + + if hook == 'build_package': + build.__main__.build_package.assert_called_with(*build_args) + elif hook == 'build_package_via_sdist': + build.__main__.build_package_via_sdist.assert_called_with(*build_args) + else: + raise ValueError(f'Unknown hook {hook}') # pragma: no cover + + +def test_prog(): + out = io.StringIO() + + with pytest.raises(SystemExit): + with contextlib.redirect_stdout(out): + build.__main__.main(['--help'], prog='something') + + assert out.getvalue().startswith('usage: something [-h]') + + +def test_version(capsys): + with pytest.raises(SystemExit): + build.__main__.main(['--version']) + out, err = capsys.readouterr() + assert out.startswith(f'build {build.__version__}') + + +@pytest.mark.isolated +def test_build_isolated(mocker, package_test_flit): + build_cmd = mocker.patch('build.ProjectBuilder.build', return_value='something') + required_cmd = mocker.patch( + 'build.ProjectBuilder.get_requires_for_build', + side_effect=[ + ['dep1', 'dep2'], + ], + ) + mocker.patch('build.__main__._error') + install = mocker.patch('build.env._IsolatedEnvVenvPip.install') + + build.__main__.build_package(package_test_flit, '.', ['sdist']) + + install.assert_any_call({'flit_core >=2,<3'}) + + required_cmd.assert_called_with('sdist') + install.assert_any_call(['dep1', 'dep2']) + + build_cmd.assert_called_with('sdist', '.', {}) + + +def test_build_no_isolation_check_deps_empty(mocker, package_test_flit): + # check_dependencies = [] + build_cmd = mocker.patch('build.ProjectBuilder.build', return_value='something') + mocker.patch('build.ProjectBuilder.check_dependencies', return_value=[]) + + build.__main__.build_package(package_test_flit, '.', ['sdist'], isolation=False) + + build_cmd.assert_called_with('sdist', '.', {}) + + +@pytest.mark.parametrize( + ['missing_deps', 'output'], + [ + ([('foo',)], '\n\tfoo'), + ([('foo',), ('bar', 'baz', 'qux')], '\n\tfoo\n\tbar\n\tbaz -> qux'), + ], +) +def test_build_no_isolation_with_check_deps(mocker, package_test_flit, missing_deps, output): + error = mocker.patch('build.__main__._error') + build_cmd = mocker.patch('build.ProjectBuilder.build', return_value='something') + mocker.patch('build.ProjectBuilder.check_dependencies', return_value=missing_deps) + + build.__main__.build_package(package_test_flit, '.', ['sdist'], isolation=False) + + build_cmd.assert_called_with('sdist', '.', {}) + error.assert_called_with('Missing dependencies:' + output) + + +@pytest.mark.isolated +def test_build_raises_build_exception(mocker, package_test_flit): + mocker.patch('build.ProjectBuilder.get_requires_for_build', side_effect=build.BuildException) + mocker.patch('build.env._IsolatedEnvVenvPip.install') + + with pytest.raises(build.BuildException): + build.__main__.build_package(package_test_flit, '.', ['sdist']) + + +@pytest.mark.isolated +def test_build_raises_build_backend_exception(mocker, package_test_flit): + mocker.patch('build.ProjectBuilder.get_requires_for_build', side_effect=build.BuildBackendException(Exception('a'))) + mocker.patch('build.env._IsolatedEnvVenvPip.install') + + msg = f"Backend operation failed: Exception('a'{',' if sys.version_info < (3, 7) else ''})" + with pytest.raises(build.BuildBackendException, match=re.escape(msg)): + build.__main__.build_package(package_test_flit, '.', ['sdist']) + + +@pytest.mark.pypy3323bug +def test_build_package(tmp_dir, package_test_setuptools): + build.__main__.build_package(package_test_setuptools, tmp_dir, ['sdist', 'wheel']) + + assert sorted(os.listdir(tmp_dir)) == [ + 'test_setuptools-1.0.0-py2.py3-none-any.whl', + 'test_setuptools-1.0.0.tar.gz', + ] + + +@pytest.mark.pypy3323bug +def test_build_package_via_sdist(tmp_dir, package_test_setuptools): + build.__main__.build_package_via_sdist(package_test_setuptools, tmp_dir, ['wheel']) + + assert sorted(os.listdir(tmp_dir)) == [ + 'test_setuptools-1.0.0-py2.py3-none-any.whl', + 'test_setuptools-1.0.0.tar.gz', + ] + + +@pytest.mark.pypy3323bug +def test_build_package_via_sdist_cant_build(tmp_dir, package_test_cant_build_via_sdist): + with pytest.raises(build.BuildBackendException): + build.__main__.build_package_via_sdist(package_test_cant_build_via_sdist, tmp_dir, ['wheel']) + + +def test_build_package_via_sdist_invalid_distribution(tmp_dir, package_test_setuptools): + with pytest.raises(ValueError, match='Only binary distributions are allowed but sdist was specified'): + build.__main__.build_package_via_sdist(package_test_setuptools, tmp_dir, ['sdist']) + + +@pytest.mark.pypy3323bug +@pytest.mark.parametrize( + ('args', 'output'), + [ + ( + [], + [ + '* Creating venv isolated environment...', + '* Installing packages in isolated environment... (setuptools >= 42.0.0, wheel >= 0.36.0)', + '* Getting build dependencies for sdist...', + '* Building sdist...', + '* Building wheel from sdist', + '* Creating venv isolated environment...', + '* Installing packages in isolated environment... (setuptools >= 42.0.0, wheel >= 0.36.0)', + '* Getting build dependencies for wheel...', + '* Installing packages in isolated environment... (wheel)', + '* Building wheel...', + 'Successfully built test_setuptools-1.0.0.tar.gz and test_setuptools-1.0.0-py2.py3-none-any.whl', + ], + ), + ( + ['--no-isolation'], + [ + '* Getting build dependencies for sdist...', + '* Building sdist...', + '* Building wheel from sdist', + '* Getting build dependencies for wheel...', + '* Building wheel...', + 'Successfully built test_setuptools-1.0.0.tar.gz and test_setuptools-1.0.0-py2.py3-none-any.whl', + ], + ), + ( + ['--wheel'], + [ + '* Creating venv isolated environment...', + '* Installing packages in isolated environment... (setuptools >= 42.0.0, wheel >= 0.36.0)', + '* Getting build dependencies for wheel...', + '* Installing packages in isolated environment... (wheel)', + '* Building wheel...', + 'Successfully built test_setuptools-1.0.0-py2.py3-none-any.whl', + ], + ), + ( + ['--wheel', '--no-isolation'], + [ + '* Getting build dependencies for wheel...', + '* Building wheel...', + 'Successfully built test_setuptools-1.0.0-py2.py3-none-any.whl', + ], + ), + ( + ['--sdist', '--no-isolation'], + [ + '* Getting build dependencies for sdist...', + '* Building sdist...', + 'Successfully built test_setuptools-1.0.0.tar.gz', + ], + ), + ( + ['--sdist', '--wheel', '--no-isolation'], + [ + '* Getting build dependencies for sdist...', + '* Building sdist...', + '* Getting build dependencies for wheel...', + '* Building wheel...', + 'Successfully built test_setuptools-1.0.0.tar.gz and test_setuptools-1.0.0-py2.py3-none-any.whl', + ], + ), + ], + ids=[ + 'via-sdist-isolation', + 'via-sdist-no-isolation', + 'wheel-direct-isolation', + 'wheel-direct-no-isolation', + 'sdist-direct-no-isolation', + 'sdist-and-wheel-direct-no-isolation', + ], +) +@pytest.mark.flaky(reruns=5) +def test_output(package_test_setuptools, tmp_dir, capsys, args, output): + build.__main__.main([package_test_setuptools, '-o', tmp_dir] + args) + stdout, stderr = capsys.readouterr() + assert stdout.splitlines() == output + + +@pytest.fixture() +def main_reload_styles(): + try: + yield + finally: + importlib.reload(build.__main__) + + +@pytest.mark.pypy3323bug +@pytest.mark.parametrize( + ('color', 'stdout_error', 'stdout_body'), + [ + ( + False, + 'ERROR ', + [ + '* Creating venv isolated environment...', + '* Installing packages in isolated environment... (setuptools >= 42.0.0, this is invalid, wheel >= 0.36.0)', + '', + 'Traceback (most recent call last):', + ], + ), + ( + True, + '\33[91mERROR\33[0m ', + [ + '\33[1m* Creating venv isolated environment...\33[0m', + '\33[1m* Installing packages in isolated environment... ' + '(setuptools >= 42.0.0, this is invalid, wheel >= 0.36.0)\33[0m', + '', + '\33[2mTraceback (most recent call last):', + ], + ), + ], + ids=['no-color', 'color'], +) +def test_output_env_subprocess_error( + mocker, + monkeypatch, + main_reload_styles, + package_test_invalid_requirements, + tmp_dir, + capsys, + color, + stdout_body, + stdout_error, +): + try: + # do not inject hook to have clear output on capsys + mocker.patch('colorama.init') + except ModuleNotFoundError: # colorama might not be available + pass + + monkeypatch.delenv('NO_COLOR', raising=False) + monkeypatch.setenv('FORCE_COLOR' if color else 'NO_COLOR', '') + + importlib.reload(build.__main__) # reload module to set _STYLES + + with pytest.raises(SystemExit): + build.__main__.main([package_test_invalid_requirements, '-o', tmp_dir]) + stdout, stderr = capsys.readouterr() + stdout, stderr = stdout.splitlines(), stderr.splitlines() + + assert stdout[:4] == stdout_body + assert stdout[-1].startswith(stdout_error) + + assert len(stderr) == 1 + assert stderr[0].startswith('ERROR: Invalid requirement: ') + + +@pytest.mark.parametrize( + ('tty', 'env', 'colors'), + [ + (False, {}, build.__main__._NO_COLORS), + (True, {}, build.__main__._COLORS), + (False, {'NO_COLOR': ''}, build.__main__._NO_COLORS), + (True, {'NO_COLOR': ''}, build.__main__._NO_COLORS), + (False, {'FORCE_COLOR': ''}, build.__main__._COLORS), + (True, {'FORCE_COLOR': ''}, build.__main__._COLORS), + ], +) +def test_colors(mocker, monkeypatch, main_reload_styles, tty, env, colors): + mocker.patch('sys.stdout.isatty', return_value=tty) + for key, value in env.items(): + monkeypatch.setenv(key, value) + + importlib.reload(build.__main__) # reload module to set _STYLES + + assert build.__main__._STYLES == colors + + +def test_colors_conflict(monkeypatch, main_reload_styles): + with monkeypatch.context() as m: + m.setenv('NO_COLOR', '') + m.setenv('FORCE_COLOR', '') + + with pytest.warns( + UserWarning, + match='Both NO_COLOR and FORCE_COLOR environment variables are set, disabling color', + ): + importlib.reload(build.__main__) + + assert build.__main__._STYLES == build.__main__._NO_COLORS + + +def raise_called_process_err(*args, **kwargs): + raise subprocess.CalledProcessError(1, ['test', 'args'], b'stdoutput', b'stderror') + + +def test_venv_fail(monkeypatch, package_test_flit, tmp_dir, capsys): + monkeypatch.setattr(venv.EnvBuilder, 'create', raise_called_process_err) + monkeypatch.setenv('NO_COLOR', '') + + importlib.reload(build.__main__) # reload module to set _STYLES + + with pytest.raises(SystemExit): + build.__main__.main([package_test_flit, '-o', tmp_dir]) + + stdout, stderr = capsys.readouterr() + + assert ( + stdout + == '''\ +* Creating venv isolated environment... +ERROR Failed to create venv. Maybe try installing virtualenv. + Command 'test args' failed with return code 1 + stdout: + stdoutput + stderr: + stderror +''' + ) + assert stderr == '' diff --git a/tests/test_module.py b/tests/test_module.py new file mode 100644 index 0000000..1d3d1cc --- /dev/null +++ b/tests/test_module.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MIT + +import sys + +import pytest + +import build + + +def test_version(): + assert build.__version__ + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason='Python 3.7+ required for dir support') +def test_dir(): + assert set(dir(build)) == set(build.__all__) diff --git a/tests/test_projectbuilder.py b/tests/test_projectbuilder.py new file mode 100644 index 0000000..57ff9f9 --- /dev/null +++ b/tests/test_projectbuilder.py @@ -0,0 +1,672 @@ +# SPDX-License-Identifier: MIT + + +import copy +import importlib +import logging +import os +import sys +import textwrap + +import pep517.wrappers +import pytest + +import build + + +if sys.version_info >= (3, 8): # pragma: no cover + from importlib import metadata as importlib_metadata +else: # pragma: no cover + import importlib_metadata + +import pathlib + + +build_open_owner = 'builtins' + + +DEFAULT_BACKEND = { + 'build-backend': 'setuptools.build_meta:__legacy__', + 'requires': ['setuptools >= 40.8.0', 'wheel'], +} + + +class MockDistribution(importlib_metadata.Distribution): + def locate_file(self, path): # pragma: no cover + return '' + + @classmethod + def from_name(cls, name): + if name == 'extras_dep': + return ExtraMockDistribution() + elif name == 'requireless_dep': + return RequirelessMockDistribution() + elif name == 'recursive_dep': + return RecursiveMockDistribution() + elif name == 'prerelease_dep': + return PrereleaseMockDistribution() + elif name == 'circular_dep': + return CircularMockDistribution() + elif name == 'nested_circular_dep': + return NestedCircularMockDistribution() + raise importlib_metadata.PackageNotFoundError + + +class ExtraMockDistribution(MockDistribution): + def read_text(self, filename): + if filename == 'METADATA': + return textwrap.dedent( + """ + Metadata-Version: 2.2 + Name: extras_dep + Version: 1.0.0 + Provides-Extra: extra_without_associated_deps + Provides-Extra: extra_with_unmet_deps + Requires-Dist: unmet_dep; extra == 'extra_with_unmet_deps' + Provides-Extra: extra_with_met_deps + Requires-Dist: extras_dep; extra == 'extra_with_met_deps' + Provides-Extra: recursive_extra_with_unmet_deps + Requires-Dist: recursive_dep; extra == 'recursive_extra_with_unmet_deps' + """ + ).strip() + + +class RequirelessMockDistribution(MockDistribution): + def read_text(self, filename): + if filename == 'METADATA': + return textwrap.dedent( + """ + Metadata-Version: 2.2 + Name: requireless_dep + Version: 1.0.0 + """ + ).strip() + + +class RecursiveMockDistribution(MockDistribution): + def read_text(self, filename): + if filename == 'METADATA': + return textwrap.dedent( + """ + Metadata-Version: 2.2 + Name: recursive_dep + Version: 1.0.0 + Requires-Dist: recursive_unmet_dep + """ + ).strip() + + +class PrereleaseMockDistribution(MockDistribution): + def read_text(self, filename): + if filename == 'METADATA': + return textwrap.dedent( + """ + Metadata-Version: 2.2 + Name: prerelease_dep + Version: 1.0.1a0 + """ + ).strip() + + +class CircularMockDistribution(MockDistribution): + def read_text(self, filename): + if filename == 'METADATA': + return textwrap.dedent( + """ + Metadata-Version: 2.2 + Name: circular_dep + Version: 1.0.0 + Requires-Dist: nested_circular_dep + """ + ).strip() + + +class NestedCircularMockDistribution(MockDistribution): + def read_text(self, filename): + if filename == 'METADATA': + return textwrap.dedent( + """ + Metadata-Version: 2.2 + Name: nested_circular_dep + Version: 1.0.0 + Requires-Dist: circular_dep + """ + ).strip() + + +@pytest.mark.parametrize( + ('requirement_string', 'expected'), + [ + ('extras_dep', None), + ('missing_dep', ('missing_dep',)), + ('requireless_dep', None), + ('extras_dep[undefined_extra]', None), + # would the wheel builder filter this out? + ('extras_dep[extra_without_associated_deps]', None), + ( + 'extras_dep[extra_with_unmet_deps]', + ('extras_dep[extra_with_unmet_deps]', 'unmet_dep; extra == "extra_with_unmet_deps"'), + ), + ( + 'extras_dep[recursive_extra_with_unmet_deps]', + ( + 'extras_dep[recursive_extra_with_unmet_deps]', + 'recursive_dep; extra == "recursive_extra_with_unmet_deps"', + 'recursive_unmet_dep', + ), + ), + ('extras_dep[extra_with_met_deps]', None), + ('missing_dep; python_version>"10"', None), + ('missing_dep; python_version<="1"', None), + ('missing_dep; python_version>="1"', ('missing_dep; python_version >= "1"',)), + ('extras_dep == 1.0.0', None), + ('extras_dep == 2.0.0', ('extras_dep==2.0.0',)), + ('extras_dep[extra_without_associated_deps] == 1.0.0', None), + ('extras_dep[extra_without_associated_deps] == 2.0.0', ('extras_dep[extra_without_associated_deps]==2.0.0',)), + ('prerelease_dep >= 1.0.0', None), + ('circular_dep', None), + ], +) +def test_check_dependency(monkeypatch, requirement_string, expected): + monkeypatch.setattr(importlib_metadata, 'Distribution', MockDistribution) + assert next(build.check_dependency(requirement_string), None) == expected + + +def test_bad_project(package_test_no_project): + # Passing a nonexistent project directory + with pytest.raises(build.BuildException): + build.ProjectBuilder(os.path.join(package_test_no_project, 'does-not-exist')) + # Passing a file as a project directory + with pytest.raises(build.BuildException): + build.ProjectBuilder(os.path.join(package_test_no_project, 'empty.txt')) + # Passing a project directory with no pyproject.toml or setup.py + with pytest.raises(build.BuildException): + build.ProjectBuilder(package_test_no_project) + + +def test_init(mocker, package_test_flit, package_legacy, test_no_permission, package_test_bad_syntax): + mocker.patch('pep517.wrappers.Pep517HookCaller') + + # correct flit pyproject.toml + builder = build.ProjectBuilder(package_test_flit) + pep517.wrappers.Pep517HookCaller.assert_called_with( + package_test_flit, 'flit_core.buildapi', backend_path=None, python_executable=sys.executable, runner=builder._runner + ) + pep517.wrappers.Pep517HookCaller.reset_mock() + + # custom python + builder = build.ProjectBuilder(package_test_flit, python_executable='some-python') + pep517.wrappers.Pep517HookCaller.assert_called_with( + package_test_flit, 'flit_core.buildapi', backend_path=None, python_executable='some-python', runner=builder._runner + ) + pep517.wrappers.Pep517HookCaller.reset_mock() + + # FileNotFoundError + builder = build.ProjectBuilder(package_legacy) + pep517.wrappers.Pep517HookCaller.assert_called_with( + package_legacy, + 'setuptools.build_meta:__legacy__', + backend_path=None, + python_executable=sys.executable, + runner=builder._runner, + ) + + # PermissionError + if not sys.platform.startswith('win'): # can't correctly set the permissions required for this + with pytest.raises(build.BuildException): + build.ProjectBuilder(test_no_permission) + + # TomlDecodeError + with pytest.raises(build.BuildException): + build.ProjectBuilder(package_test_bad_syntax) + + +@pytest.mark.parametrize('value', [b'something', 'something_else']) +def test_python_executable(package_test_flit, value): + builder = build.ProjectBuilder(package_test_flit) + + builder.python_executable = value + assert builder.python_executable == value + assert builder._hook.python_executable == value + + +@pytest.mark.parametrize('distribution', ['wheel', 'sdist']) +def test_get_requires_for_build_missing_backend(packages_path, distribution): + bad_backend_path = os.path.join(packages_path, 'test-bad-backend') + builder = build.ProjectBuilder(bad_backend_path) + + with pytest.raises(build.BuildBackendException): + builder.get_requires_for_build(distribution) + + +@pytest.mark.parametrize('distribution', ['wheel', 'sdist']) +def test_get_requires_for_build_missing_optional_hooks(package_test_optional_hooks, distribution): + builder = build.ProjectBuilder(package_test_optional_hooks) + + assert builder.get_requires_for_build(distribution) == set() + + +@pytest.mark.parametrize('distribution', ['wheel', 'sdist']) +def test_build_missing_backend(packages_path, distribution, tmpdir): + bad_backend_path = os.path.join(packages_path, 'test-bad-backend') + builder = build.ProjectBuilder(bad_backend_path) + + with pytest.raises(build.BuildBackendException): + builder.build(distribution, str(tmpdir)) + + +def test_check_dependencies(mocker, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller.get_requires_for_build_sdist') + mocker.patch('pep517.wrappers.Pep517HookCaller.get_requires_for_build_wheel') + + builder = build.ProjectBuilder(package_test_flit) + + side_effects = [ + [], + ['something'], + pep517.wrappers.BackendUnavailable, + ] + + builder._hook.get_requires_for_build_sdist.side_effect = copy.copy(side_effects) + builder._hook.get_requires_for_build_wheel.side_effect = copy.copy(side_effects) + + # requires = [] + assert builder.check_dependencies('sdist') == {('flit_core<3,>=2',)} + assert builder.check_dependencies('wheel') == {('flit_core<3,>=2',)} + + # requires = ['something'] + assert builder.check_dependencies('sdist') == {('flit_core<3,>=2',), ('something',)} + assert builder.check_dependencies('wheel') == {('flit_core<3,>=2',), ('something',)} + + # BackendUnavailable + with pytest.raises(build.BuildBackendException): + builder.check_dependencies('sdist') + with pytest.raises(build.BuildBackendException): + not builder.check_dependencies('wheel') + + +def test_working_directory(tmp_dir): + assert os.path.realpath(os.curdir) != os.path.realpath(tmp_dir) + with build._working_directory(tmp_dir): + assert os.path.realpath(os.curdir) == os.path.realpath(tmp_dir) + + +def test_working_directory_exc_is_not_transformed(mocker, package_test_flit, tmp_dir): + mocker.patch('build._working_directory', side_effect=OSError) + + builder = build.ProjectBuilder(package_test_flit) + with pytest.raises(OSError): + builder._call_backend('build_sdist', tmp_dir) + + +def test_build(mocker, package_test_flit, tmp_dir): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + mocker.patch('build._working_directory', autospec=True) + + builder = build.ProjectBuilder(package_test_flit) + + builder._hook.build_sdist.side_effect = ['dist.tar.gz', Exception] + builder._hook.build_wheel.side_effect = ['dist.whl', Exception] + + assert builder.build('sdist', tmp_dir) == os.path.join(tmp_dir, 'dist.tar.gz') + builder._hook.build_sdist.assert_called_with(tmp_dir, None) + build._working_directory.assert_called_with(package_test_flit) + + assert builder.build('wheel', tmp_dir) == os.path.join(tmp_dir, 'dist.whl') + builder._hook.build_wheel.assert_called_with(tmp_dir, None) + build._working_directory.assert_called_with(package_test_flit) + + with pytest.raises(build.BuildBackendException): + build._working_directory.assert_called_with(package_test_flit) + builder.build('sdist', tmp_dir) + + with pytest.raises(build.BuildBackendException): + build._working_directory.assert_called_with(package_test_flit) + builder.build('wheel', tmp_dir) + + +def test_default_backend(mocker, package_legacy): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(package_legacy) + + assert builder._build_system == DEFAULT_BACKEND + + +def test_missing_backend(mocker, package_test_no_backend): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(package_test_no_backend) + + assert builder._build_system == {'requires': [], 'build-backend': DEFAULT_BACKEND['build-backend']} + + +def test_missing_requires(mocker, package_test_no_requires): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + with pytest.raises(build.BuildException): + build.ProjectBuilder(package_test_no_requires) + + +def test_build_system_typo(mocker, package_test_typo): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + with pytest.warns(build.TypoWarning): + build.ProjectBuilder(package_test_typo) + + +def test_missing_outdir(mocker, tmp_dir, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(package_test_flit) + builder._hook.build_sdist.return_value = 'dist.tar.gz' + out = os.path.join(tmp_dir, 'out') + + builder.build('sdist', out) + + assert os.path.isdir(out) + + +def test_relative_outdir(mocker, tmp_dir, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(package_test_flit) + builder._hook.build_sdist.return_value = 'dist.tar.gz' + + builder.build('sdist', '.') + + builder._hook.build_sdist.assert_called_with(os.path.abspath('.'), None) + + +def test_build_not_dir_outdir(mocker, tmp_dir, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(package_test_flit) + builder._hook.build_sdist.return_value = 'dist.tar.gz' + out = os.path.join(tmp_dir, 'out') + + open(out, 'a').close() # create empty file + + with pytest.raises(build.BuildException): + builder.build('sdist', out) + + +@pytest.fixture(scope='session') +def demo_pkg_inline(tmp_path_factory): + # builds a wheel without any dependencies and with a console script demo-pkg-inline + tmp_path = tmp_path_factory.mktemp('demo-pkg-inline') + builder = build.ProjectBuilder(srcdir=os.path.join(os.path.dirname(__file__), 'packages', 'inline')) + out = tmp_path / 'dist' + builder.build('wheel', str(out)) + return next(out.iterdir()) + + +@pytest.mark.isolated +def test_build_with_dep_on_console_script(tmp_path, demo_pkg_inline, capfd, mocker): + """ + All command-line scripts provided by the build-required packages must be present in the build environment's PATH. + """ + # we first install demo pkg inline as build dependency (as this provides a console script we can check) + # to validate backend invocations contain the correct path we use an inline backend that will fail, but first + # provides the PATH information (and validates shutil.which is able to discover the executable - as PEP states) + toml = textwrap.dedent( + ''' + [build-system] + requires = ["demo_pkg_inline"] + build-backend = "build" + backend-path = ["."] + + [project] + description = "Factory ⸻ A code generator 🏭" + authors = [{name = "Łukasz Langa"}] + ''' + ) + code = textwrap.dedent( + ''' + import os + import shutil + import sys + print("BB " + os.environ["PATH"]) + exe_at = shutil.which("demo-pkg-inline") + print("BB " + exe_at) + ''' + ) + (tmp_path / 'pyproject.toml').write_text(toml, encoding='UTF-8') + (tmp_path / 'build.py').write_text(code) + + deps = {str(demo_pkg_inline)} # we patch the requires demo_pkg_inline to refer to the wheel -> we don't need index + mocker.patch('build.ProjectBuilder.build_system_requires', new_callable=mocker.PropertyMock, return_value=deps) + from build.__main__ import main + + with pytest.raises(SystemExit): + main(['--wheel', '--outdir', str(tmp_path / 'dist'), str(tmp_path)]) + + out, err = capfd.readouterr() + lines = [line[3:] for line in out.splitlines() if line.startswith('BB ')] # filter for our markers + 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, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + mocker.patch('build._working_directory', autospec=True) + + builder = build.ProjectBuilder(package_test_flit) + 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(package_test_flit) + + +def test_prepare_no_hook(mocker, tmp_dir, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(package_test_flit) + 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, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(package_test_flit) + 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, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(package_test_flit) + + 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) + + +def test_no_outdir_single(mocker, tmp_dir, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller.prepare_metadata_for_build_wheel', return_value='') + + builder = build.ProjectBuilder(package_test_flit) + + out = os.path.join(tmp_dir, 'out') + builder.prepare('wheel', out) + + assert os.path.isdir(out) + + +def test_no_outdir_multiple(mocker, tmp_dir, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller.prepare_metadata_for_build_wheel', return_value='') + + builder = build.ProjectBuilder(package_test_flit) + + out = os.path.join(tmp_dir, 'does', 'not', 'exist') + builder.prepare('wheel', out) + + assert os.path.isdir(out) + + +def test_runner_user_specified(tmp_dir, package_test_flit): + def dummy_runner(cmd, cwd=None, env=None): + raise RuntimeError('Runner was called') + + builder = build.ProjectBuilder(package_test_flit, runner=dummy_runner) + with pytest.raises(build.BuildBackendException, match='Runner was called'): + builder.build('wheel', tmp_dir) + + +def test_metadata_path_no_prepare(tmp_dir, package_test_no_prepare): + builder = build.ProjectBuilder(package_test_no_prepare) + + metadata = importlib_metadata.PathDistribution( + pathlib.Path(builder.metadata_path(tmp_dir)), + ).metadata + + assert metadata['name'] == 'test-no-prepare' + assert metadata['Version'] == '1.0.0' + + +def test_metadata_path_with_prepare(tmp_dir, package_test_setuptools): + builder = build.ProjectBuilder(package_test_setuptools) + + metadata = importlib_metadata.PathDistribution( + pathlib.Path(builder.metadata_path(tmp_dir)), + ).metadata + + assert metadata['name'] == 'test-setuptools' + assert metadata['Version'] == '1.0.0' + + +def test_metadata_path_legacy(tmp_dir, package_legacy): + builder = build.ProjectBuilder(package_legacy) + + metadata = importlib_metadata.PathDistribution( + pathlib.Path(builder.metadata_path(tmp_dir)), + ).metadata + + assert metadata['name'] == 'legacy' + assert metadata['Version'] == '1.0.0' + + +def test_metadata_invalid_wheel(tmp_dir, package_test_bad_wheel): + builder = build.ProjectBuilder(package_test_bad_wheel) + + with pytest.raises(ValueError, match='Invalid wheel'): + builder.metadata_path(tmp_dir) + + +@pytest.fixture +def mock_tomli_not_available(mocker): + loads = mocker.patch('tomli.loads') + mocker.patch.dict(sys.modules, {'tomli': None}) + importlib.reload(build) + try: + yield + finally: + loads.assert_not_called() + mocker.stopall() + importlib.reload(build) + + +@pytest.mark.skipif(sys.version_info >= (3, 11), reason='No need to test old toml support on 3.11+') +def test_toml_instead_of_tomli(mocker, mock_tomli_not_available, tmp_dir, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + + builder = build.ProjectBuilder(package_test_flit) + builder._hook.build_sdist.return_value = 'dist.tar.gz' + + builder.build('sdist', '.') + + builder._hook.build_sdist.assert_called_with(os.path.abspath('.'), None) + + +def test_log(mocker, caplog, package_test_flit): + mocker.patch('pep517.wrappers.Pep517HookCaller', autospec=True) + mocker.patch('build.ProjectBuilder._call_backend', return_value='some_path') + caplog.set_level(logging.DEBUG) + + builder = build.ProjectBuilder(package_test_flit) + builder.get_requires_for_build('sdist') + builder.get_requires_for_build('wheel') + builder.prepare('wheel', '.') + builder.build('sdist', '.') + builder.build('wheel', '.') + builder.log('something') + + assert [(record.levelname, record.message) for record in caplog.records] == [ + ('INFO', 'Getting build dependencies for sdist...'), + ('INFO', 'Getting build dependencies for wheel...'), + ('INFO', 'Getting metadata for wheel...'), + ('INFO', 'Building sdist...'), + ('INFO', 'Building wheel...'), + ('INFO', 'something'), + ] + if sys.version_info >= (3, 8): # stacklevel + assert caplog.records[-1].lineno == 602 + + +@pytest.mark.parametrize( + ('pyproject_toml', 'parse_output'), + [ + ( + {'build-system': {'requires': ['foo']}}, + {'requires': ['foo'], 'build-backend': 'setuptools.build_meta:__legacy__'}, + ), + ( + {'build-system': {'requires': ['foo'], 'build-backend': 'bar'}}, + {'requires': ['foo'], 'build-backend': 'bar'}, + ), + ( + {'build-system': {'requires': ['foo'], 'build-backend': 'bar', 'backend-path': ['baz']}}, + {'requires': ['foo'], 'build-backend': 'bar', 'backend-path': ['baz']}, + ), + ], +) +def test_parse_valid_build_system_table_type(pyproject_toml, parse_output): + assert build._parse_build_system_table(pyproject_toml) == parse_output + + +@pytest.mark.parametrize( + ('pyproject_toml', 'error_message'), + [ + ( + {'build-system': {}}, + '`requires` is a required property', + ), + ( + {'build-system': {'requires': 'not an array'}}, + '`requires` must be an array of strings', + ), + ( + {'build-system': {'requires': [1]}}, + '`requires` must be an array of strings', + ), + ( + {'build-system': {'requires': ['foo'], 'build-backend': ['not a string']}}, + '`build-backend` must be a string', + ), + ( + {'build-system': {'requires': ['foo'], 'backend-path': 'not an array'}}, + '`backend-path` must be an array of strings', + ), + ( + {'build-system': {'requires': ['foo'], 'backend-path': [1]}}, + '`backend-path` must be an array of strings', + ), + ( + {'build-system': {'requires': ['foo'], 'unknown-prop': False}}, + 'Unknown properties: unknown-prop', + ), + ], +) +def test_parse_invalid_build_system_table_type(pyproject_toml, error_message): + with pytest.raises(build.BuildSystemTableValidationError, match=error_message): + build._parse_build_system_table(pyproject_toml) diff --git a/tests/test_self_packaging.py b/tests/test_self_packaging.py new file mode 100644 index 0000000..abe09f8 --- /dev/null +++ b/tests/test_self_packaging.py @@ -0,0 +1,103 @@ +# These tests check the sdist, path, and wheel of build to ensure that all are valid. + +import subprocess +import sys +import tarfile +import zipfile + +from pathlib import Path + +import pytest + + +DIR = Path(__file__).parent.resolve() +MAIN_DIR = DIR.parent + +sdist_files = { + 'LICENSE', + 'PKG-INFO', + 'README.md', + 'pyproject.toml', + 'setup.cfg', + 'setup.py', + 'src', + 'src/build', + 'src/build.egg-info', + 'src/build.egg-info/PKG-INFO', + 'src/build.egg-info/SOURCES.txt', + 'src/build.egg-info/dependency_links.txt', + 'src/build.egg-info/entry_points.txt', + 'src/build.egg-info/requires.txt', + 'src/build.egg-info/top_level.txt', + 'src/build/__init__.py', + 'src/build/__main__.py', + 'src/build/env.py', + 'src/build/py.typed', + 'src/build/util.py', +} + +wheel_files = { + 'build/__init__.py', + 'build/__main__.py', + 'build/env.py', + 'build/py.typed', + 'build/util.py', + 'dist-info/LICENSE', + 'dist-info/METADATA', + 'dist-info/RECORD', + 'dist-info/WHEEL', + 'dist-info/entry_points.txt', + 'dist-info/top_level.txt', +} + + +def test_build_sdist(monkeypatch, tmpdir): + + monkeypatch.chdir(MAIN_DIR) + + subprocess.run( + [ + sys.executable, + '-m', + 'build', + '--sdist', + '--outdir', + str(tmpdir), + ], + check=True, + ).stdout + + (sdist,) = tmpdir.visit('*.tar.gz') + + with tarfile.open(str(sdist), 'r:gz') as tar: + simpler = {n.split('/', 1)[-1] for n in tar.getnames()[1:]} + + assert simpler == sdist_files + + +@pytest.mark.parametrize('args', ((), ('--wheel',)), ids=('from_sdist', 'direct')) +def test_build_wheel(monkeypatch, tmpdir, args): + + monkeypatch.chdir(MAIN_DIR) + + subprocess.run( + [ + sys.executable, + '-m', + 'build', + *args, + '--outdir', + str(tmpdir), + ], + check=True, + ) + + (wheel,) = tmpdir.visit('*.whl') + + with zipfile.ZipFile(str(wheel)) as z: + names = z.namelist() + + trimmed = {n for n in names if 'dist-info' not in n} + trimmed |= {f"dist-info/{n.split('/', 1)[-1]}" for n in names if 'dist-info' in n} + + assert trimmed == wheel_files diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..9f090b5 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: MIT + +import pytest + +import build.util + + +@pytest.mark.pypy3323bug +@pytest.mark.parametrize('isolated', [False, True]) +def test_wheel_metadata(package_test_setuptools, isolated): + metadata = build.util.project_wheel_metadata(package_test_setuptools, isolated) + + assert metadata['name'] == 'test-setuptools' + assert metadata['version'] == '1.0.0' + + +@pytest.mark.pypy3323bug +def test_wheel_metadata_isolation(package_test_flit): + try: + import flit_core # noqa: F401 + except ModuleNotFoundError: + pass + else: + pytest.xfail('flit_core is available -- we want it missing!') # pragma: no cover + + metadata = build.util.project_wheel_metadata(package_test_flit) + + assert metadata['name'] == 'test_flit' + assert metadata['version'] == '1.0.0' + + with pytest.raises( + build.BuildBackendException, + match="Backend 'flit_core.buildapi' is not available.", + ): + build.util.project_wheel_metadata(package_test_flit, isolated=False) + + +@pytest.mark.pypy3323bug +def test_with_get_requires(package_test_metadata): + metadata = build.util.project_wheel_metadata(package_test_metadata) + + assert metadata['name'] == 'test-metadata' + assert str(metadata['version']) == '1.0.0' + assert metadata['summary'] == 'hello!' |