From a22e7b0bb19b160748bc7185693eb74573e9cfb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Fri, 12 Jun 2020 22:45:31 +0100 Subject: [PATCH 1/5] main: build in a virtual env by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As discussed in [1] python-build should now build inside a virtual environment, as recommended in PEP517. This patch implements that functionality and sets it as the default behavior. It also introduces a --no-isolation flag to disable this. [1] https://discuss.python.org/t/moving-python-build-to-pypa/4390 Signed-off-by: Filipe Laíns --- .github/workflows/build.yml | 6 ---- build/__init__.py | 31 +++++++++++++------- build/__main__.py | 38 +++++++++++++++++------- docs/source/index.rst | 10 +++++-- tests/conftest.py | 42 ++++++++++++++++++++++++++ tests/test_main.py | 43 ++++++++++++++++----------- tests/test_projectbuilder.py | 57 ++++++++++++++++++++++++++---------- 7 files changed, 165 insertions(+), 62 deletions(-) create mode 100644 tests/conftest.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6fd883d4..7ba54475 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -132,9 +132,6 @@ jobs: if: ${{ matrix.python == 2.7 }} run: pip install typing - - name: Install build backend - run: pip install setuptools wheel - - name: Build dateutil run: python -m build -x -w ../dateutil @@ -162,8 +159,5 @@ jobs: - name: Install dependencies run: pip install toml pep517 packaging importlib_metadata - - name: Install build backend - run: pip install setuptools wheel - - name: Build Solaar run: python -m build -x -w ../solaar diff --git a/build/__init__.py b/build/__init__.py index 4d064e94..485d4a38 100644 --- a/build/__init__.py +++ b/build/__init__.py @@ -24,6 +24,15 @@ ConfigSettings = Dict[str, Union[str, List[str]]] +_DEFAULT_BACKEND = { + 'build-backend': 'setuptools.build_meta:__legacy__', + 'requires': [ + 'setuptools >= 40.8.0', + 'wheel' + ] +} + + class BuildException(Exception): ''' Exception raised by ProjectBuilder @@ -92,16 +101,14 @@ def __init__(self, srcdir='.', config_settings=None): # type: (str, Optional[Co except toml.decoder.TomlDecodeError as e: raise BuildException("Failed to parse pyproject.toml: {} ".format(e)) - try: - self._build_system = self._spec['build-system'] - except KeyError: - self._build_system = { - 'build-backend': 'setuptools.build_meta:__legacy__', - 'requires': [ - 'setuptools >= 40.8.0', - 'wheel' - ] - } + self._build_system = self._spec.get('build-system', _DEFAULT_BACKEND) + + if 'build-backend' not in self._build_system: + self._build_system['build-backend'] = _DEFAULT_BACKEND['build-backend'] + self._build_system['requires'] = self._build_system.get('requires', []) + _DEFAULT_BACKEND['requires'] + + if 'requires' not in self._build_system: + raise BuildException("Missing 'build-system.requires' in pyproject.yml") self._backend = self._build_system['build-backend'] @@ -113,6 +120,10 @@ def __init__(self, srcdir='.', config_settings=None): # type: (str, Optional[Co self.hook = pep517.wrappers.Pep517HookCaller(self.srcdir, self._backend, backend_path=self._build_system.get('backend-path')) + @property + def build_dependencies(self): # type: () -> Set[str] + return set(self._build_system['requires']) + def get_dependencies(self, distribution): # type: (str) -> Set[str] ''' Returns a set of dependencies diff --git a/build/__main__.py b/build/__main__.py index b1e6594d..b5d63af9 100644 --- a/build/__main__.py +++ b/build/__main__.py @@ -7,6 +7,8 @@ from typing import List, Optional +import pep517.envbuild + from . import BuildBackendException, BuildException, ConfigSettings, ProjectBuilder @@ -27,8 +29,26 @@ def _error(msg, code=1): # type: (str, int) -> None # pragma: no cover exit(code) -def build(srcdir, outdir, distributions, config_settings=None, skip_dependencies=False): - # type: (str, str, List[str], Optional[ConfigSettings], bool) -> None +def _build_in_isolated_env(builder, outdir, distributions): # type: (ProjectBuilder, str, List[str]) -> None + with pep517.envbuild.BuildEnvironment() as env: + env.pip_install(builder.build_dependencies) + for distribution in distributions: + builder.build(distribution, outdir) + + +def _build_in_current_env(builder, outdir, distributions, skip_dependencies=False): + # type: (ProjectBuilder, str, List[str], bool) -> None + for dist in distributions: + if not skip_dependencies: + missing = builder.check_dependencies(dist) + if missing: + _error('Missing dependencies:' + ''.join(['\n\t' + dep for dep in missing])) + + builder.build(dist, outdir) + + +def build(srcdir, outdir, distributions, config_settings=None, isolation=True, skip_dependencies=False): + # type: (str, str, List[str], Optional[ConfigSettings], bool, bool) -> None ''' Runs the build process @@ -36,6 +56,7 @@ def build(srcdir, outdir, distributions, config_settings=None, skip_dependencies :param outdir: Output directory :param distributions: Distributions to build (sdist and/or wheel) :param config_settings: Configuration settings to be passed to the backend + :param isolation: Isolate the build in a separate environment :param skip_dependencies: Do not perform the dependency check ''' if not config_settings: @@ -44,13 +65,10 @@ def build(srcdir, outdir, distributions, config_settings=None, skip_dependencies try: builder = ProjectBuilder(srcdir, config_settings) - for dist in distributions: - if not skip_dependencies: - missing = builder.check_dependencies(dist) - if missing: - _error('Missing dependencies:' + ''.join(['\n\t' + dep for dep in missing])) - - builder.build(dist, outdir) + if isolation: + _build_in_isolated_env(builder, outdir, distributions) + else: + _build_in_current_env(builder, outdir, distributions, skip_dependencies) except BuildException as e: _error(str(e)) except BuildBackendException as e: @@ -136,7 +154,7 @@ def main(cli_args, prog=None): # type: (List[str], Optional[str]) -> None if not distributions: distributions = ['sdist', 'wheel'] - build(args.srcdir, args.outdir, distributions, config_settings, args.skip_dependencies) + build(args.srcdir, args.outdir, distributions, config_settings, not args.no_isolation, args.skip_dependencies) if __name__ == '__main__': # pragma: no cover diff --git a/docs/source/index.rst b/docs/source/index.rst index f05f9923..da391872 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -23,6 +23,10 @@ can use it: usage: python-build [-h] ... +By default python-build will build the package in a isolated environment, but +this behavior can be disabled with ``--no-isolation``. + + Mission Statement ================= @@ -67,9 +71,9 @@ with :pep:`517` support. ``python -m pep517.build`` -------------------------- -``python -m pep517.build`` will invoke pip_ to install missing dependencies, -``python-build`` is *stricly* a build tool, it does not do any sort of -dependency management. If the dependencies are not met, the build will fail. +``python-build`` implements a CLI tailored to end users. ``python -m +pep517.build`` *"implements essentially the simplest possible frontend tool, +to exercise and illustrate how the core functionality can be used"*. Custom Behaviors diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..a7deacdf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: MIT + +import pytest + + +DUMMY_PYPROJECT = ''' +[build-system] +requires = ['flit_core'] +build-backend = 'flit_core.buildapi' +'''.strip() + + +DUMMY_PYPROJECT_NO_BACKEND = ''' +[build-system] +requires = [] +'''.strip() + + +DUMMY_PYPROJECT_NO_REQUIRES = ''' +[build-system] +build-backend = 'something' +'''.strip() + + +@pytest.fixture +def empty_file_mock(mocker): + return mocker.mock_open(read_data='') + + +@pytest.fixture +def pyproject_mock(mocker): + return mocker.mock_open(read_data=DUMMY_PYPROJECT) + + +@pytest.fixture +def pyproject_no_backend_mock(mocker): + return mocker.mock_open(read_data=DUMMY_PYPROJECT_NO_BACKEND) + + +@pytest.fixture +def pyproject_no_requires_mock(mocker): + return mocker.mock_open(read_data=DUMMY_PYPROJECT_NO_REQUIRES) diff --git a/tests/test_main.py b/tests/test_main.py index 09fa0b81..e61a16f5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,8 +5,10 @@ import os import sys +import pep517 import pytest +import build import build.__main__ @@ -23,31 +25,32 @@ @pytest.mark.parametrize( ('cli_args', 'build_args'), [ - ([], [cwd, out, ['sdist', 'wheel'], {}, False]), - (['-s'], [cwd, out, ['sdist'], {}, False]), - (['-w'], [cwd, out, ['wheel'], {}, False]), - (['source'], ['source', out, ['sdist', 'wheel'], {}, False]), - (['-o', 'out'], [cwd, 'out', ['sdist', 'wheel'], {}, False]), - (['-x'], [cwd, out, ['sdist', 'wheel'], {}, True]), + ([], [cwd, out, ['sdist', 'wheel'], {}, True, False]), + (['-n'], [cwd, out, ['sdist', 'wheel'], {}, False, False]), + (['-s'], [cwd, out, ['sdist'], {}, True, False]), + (['-w'], [cwd, out, ['wheel'], {}, True, False]), + (['source'], ['source', out, ['sdist', 'wheel'], {}, True, False]), + (['-o', 'out'], [cwd, 'out', ['sdist', 'wheel'], {}, True, False]), + (['-x'], [cwd, out, ['sdist', 'wheel'], {}, True, True]), ( ['-C--flag1', '-C--flag2'], [cwd, out, ['sdist', 'wheel'], { '--flag1': '', '--flag2': '', - }, False] + }, True, False] ), ( ['-C--flag=value'], [cwd, out, ['sdist', 'wheel'], { '--flag': 'value', - }, False] + }, True, False] ), ( ['-C--flag1=value', '-C--flag2=other_value', '-C--flag2=extra_value'], [cwd, out, ['sdist', 'wheel'], { '--flag1': 'value', '--flag2': ['other_value', 'extra_value'], - }, False] + }, True, False] ), ] ) @@ -72,7 +75,6 @@ def test_prog(): build.__main__.main(['--help'], prog='something') -@pytest.mark.skipif(sys.version_info[:2] == (3, 5), reason='bug in mock') def test_build(mocker): open_mock = mocker.mock_open(read_data='') mocker.patch('{}.open'.format(build_open_owner), open_mock) @@ -80,26 +82,33 @@ def test_build(mocker): mocker.patch('build.ProjectBuilder.check_dependencies') mocker.patch('build.ProjectBuilder.build') mocker.patch('build.__main__._error') + mocker.patch('pep517.envbuild.BuildEnvironment.pip_install') build.ProjectBuilder.check_dependencies.side_effect = [[], ['something'], [], []] - # check_dependencies = [] + # isolation=True build.__main__.build('.', '.', ['sdist']) - build.ProjectBuilder.build.assert_called() + build.ProjectBuilder.build.assert_called_with('sdist', '.') - # check_dependencies = ['something] - build.__main__.build('.', '.', ['sdist']) - build.__main__._error.assert_called() + # check_dependencies = [] + build.__main__.build('.', '.', ['sdist'], isolation=False) + build.ProjectBuilder.build.assert_called_with('sdist', '.') + pep517.envbuild.BuildEnvironment.pip_install.assert_called_with(set(build._DEFAULT_BACKEND['requires'])) + + # check_dependencies = ['something'] + build.__main__.build('.', '.', ['sdist'], isolation=False) + build.ProjectBuilder.build.assert_called_with('sdist', '.') + build.__main__._error.assert_called_with('Missing dependencies:\n\tsomething') build.ProjectBuilder.build.side_effect = [build.BuildException, build.BuildBackendException] build.__main__._error.reset_mock() # BuildException build.__main__.build('.', '.', ['sdist']) - build.__main__._error.assert_called() + build.__main__._error.assert_called_with('') build.__main__._error.reset_mock() # BuildBackendException build.__main__.build('.', '.', ['sdist']) - build.__main__._error.assert_called() + build.__main__._error.assert_called_with('') diff --git a/tests/test_projectbuilder.py b/tests/test_projectbuilder.py index 2da279bf..f0d273d0 100644 --- a/tests/test_projectbuilder.py +++ b/tests/test_projectbuilder.py @@ -82,14 +82,13 @@ def test_check_version(requirement_string, extra, expected): assert build.check_version(requirement_string) == expected -def test_init(mocker): - open_mock = mocker.mock_open(read_data=DUMMY_PYPROJECT) +def test_init(mocker, pyproject_mock): modules = { 'flit_core.buildapi': None, 'setuptools.build_meta:__legacy__': None, } mocker.patch('importlib.import_module', modules.get) - mocker.patch('{}.open'.format(build_open_owener), open_mock) + mocker.patch('{}.open'.format(build_open_owener), pyproject_mock) mocker.patch('pep517.wrappers.Pep517HookCaller') # data = '' @@ -98,12 +97,12 @@ def test_init(mocker): pep517.wrappers.Pep517HookCaller.reset_mock() # FileNotFoundError - open_mock.side_effect = FileNotFoundError + pyproject_mock.side_effect = FileNotFoundError build.ProjectBuilder('.') pep517.wrappers.Pep517HookCaller.assert_called_with('.', 'setuptools.build_meta:__legacy__', backend_path=None) # PermissionError - open_mock.side_effect = PermissionError + pyproject_mock.side_effect = PermissionError with pytest.raises(build.BuildException): build.ProjectBuilder('.') @@ -113,15 +112,14 @@ def test_init(mocker): build.ProjectBuilder('.') -def test_check_dependencies(mocker): - open_mock = mocker.mock_open(read_data=DUMMY_PYPROJECT) +def test_check_dependencies(mocker, pyproject_mock): mocker.patch('importlib.import_module') - mocker.patch('{}.open'.format(build_open_owener), open_mock) + mocker.patch('{}.open'.format(build_open_owener), pyproject_mock) mocker.patch('pep517.wrappers.Pep517HookCaller.get_requires_for_build_sdist') mocker.patch('pep517.wrappers.Pep517HookCaller.get_requires_for_build_wheel') mocker.patch('build.check_version') - builder = build.ProjectBuilder('.') + builder = build.ProjectBuilder() side_effects = [ [], @@ -148,26 +146,53 @@ def test_check_dependencies(mocker): not builder.check_dependencies('wheel') -@pytest.mark.skipif(sys.version_info[:2] == (3, 5), reason='bug in mock') -def test_build(mocker): - open_mock = mocker.mock_open(read_data=DUMMY_PYPROJECT) +def test_build(mocker, pyproject_mock): mocker.patch('importlib.import_module') - mocker.patch('{}.open'.format(build_open_owener), open_mock) + mocker.patch('{}.open'.format(build_open_owener), pyproject_mock) mocker.patch('pep517.wrappers.Pep517HookCaller') - builder = build.ProjectBuilder('.') + builder = build.ProjectBuilder() builder.hook.build_sdist.side_effect = [None, Exception] builder.hook.build_wheel.side_effect = [None, Exception] builder.build('sdist', '.') - builder.hook.build_sdist.assert_called() + builder.hook.build_sdist.assert_called_with('.', {}) builder.build('wheel', '.') - builder.hook.build_wheel.assert_called() + builder.hook.build_wheel.assert_called_with('.', {}) with pytest.raises(build.BuildBackendException): builder.build('sdist', '.') with pytest.raises(build.BuildBackendException): builder.build('wheel', '.') + + +def test_default_backend(mocker, empty_file_mock): + mocker.patch('importlib.import_module') + mocker.patch('{}.open'.format(build_open_owener), empty_file_mock) + mocker.patch('pep517.wrappers.Pep517HookCaller') + + builder = build.ProjectBuilder() + + assert builder._build_system == build._DEFAULT_BACKEND + + +def test_missing_backend(mocker, pyproject_no_backend_mock): + mocker.patch('importlib.import_module') + mocker.patch('{}.open'.format(build_open_owener), pyproject_no_backend_mock) + mocker.patch('pep517.wrappers.Pep517HookCaller') + + builder = build.ProjectBuilder() + + assert builder._build_system == build._DEFAULT_BACKEND + + +def test_missing_requires(mocker, pyproject_no_requires_mock): + mocker.patch('importlib.import_module') + mocker.patch('{}.open'.format(build_open_owener), pyproject_no_requires_mock) + mocker.patch('pep517.wrappers.Pep517HookCaller') + + with pytest.raises(build.BuildException): + build.ProjectBuilder() From 283b490fcad597ea7558159bcea04dcb82659a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 13 Jun 2020 15:41:57 +0100 Subject: [PATCH 2/5] ci: build python-build and pip without isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- .github/workflows/build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ba54475..303670ff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,8 +33,8 @@ jobs: - name: Install build backend run: pip install setuptools wheel - - name: Build python-build - run: python -m build -x + - name: Build python-build (without isolation) + run: python -m build -x -n pip: @@ -67,8 +67,8 @@ jobs: - name: Install build backend run: pip install setuptools wheel - - name: Build pip - run: python -m build -x -w ../pip + - name: Build pip (without isolation) + run: python -m build -x -n -w ../pip # python-install: @@ -132,7 +132,7 @@ jobs: if: ${{ matrix.python == 2.7 }} run: pip install typing - - name: Build dateutil + - name: Build dateutil (with isolation) run: python -m build -x -w ../dateutil @@ -159,5 +159,5 @@ jobs: - name: Install dependencies run: pip install toml pep517 packaging importlib_metadata - - name: Build Solaar + - name: Build Solaar (with isolation) run: python -m build -x -w ../solaar From da91bdce6dfdcc4f83d43bc3a0f1491ea207e936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 13 Jun 2020 21:08:26 +0100 Subject: [PATCH 3/5] tests: fix typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- tests/test_projectbuilder.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_projectbuilder.py b/tests/test_projectbuilder.py index f0d273d0..307ecbb1 100644 --- a/tests/test_projectbuilder.py +++ b/tests/test_projectbuilder.py @@ -20,9 +20,9 @@ email_message_from_string = importlib_metadata._compat.email_message_from_string if sys.version_info >= (3,): # pragma: no cover - build_open_owener = 'builtins' + build_open_owner = 'builtins' else: # pragma: no cover - build_open_owener = 'build' + build_open_owner = 'build' FileNotFoundError = IOError PermissionError = OSError @@ -88,7 +88,7 @@ def test_init(mocker, pyproject_mock): 'setuptools.build_meta:__legacy__': None, } mocker.patch('importlib.import_module', modules.get) - mocker.patch('{}.open'.format(build_open_owener), pyproject_mock) + mocker.patch('{}.open'.format(build_open_owner), pyproject_mock) mocker.patch('pep517.wrappers.Pep517HookCaller') # data = '' @@ -107,14 +107,14 @@ def test_init(mocker, pyproject_mock): build.ProjectBuilder('.') open_mock = mocker.mock_open(read_data=DUMMY_PYPROJECT_BAD) - mocker.patch('{}.open'.format(build_open_owener), open_mock) + mocker.patch('{}.open'.format(build_open_owner), open_mock) with pytest.raises(build.BuildException): build.ProjectBuilder('.') def test_check_dependencies(mocker, pyproject_mock): mocker.patch('importlib.import_module') - mocker.patch('{}.open'.format(build_open_owener), pyproject_mock) + mocker.patch('{}.open'.format(build_open_owner), pyproject_mock) mocker.patch('pep517.wrappers.Pep517HookCaller.get_requires_for_build_sdist') mocker.patch('pep517.wrappers.Pep517HookCaller.get_requires_for_build_wheel') mocker.patch('build.check_version') @@ -148,7 +148,7 @@ def test_check_dependencies(mocker, pyproject_mock): def test_build(mocker, pyproject_mock): mocker.patch('importlib.import_module') - mocker.patch('{}.open'.format(build_open_owener), pyproject_mock) + mocker.patch('{}.open'.format(build_open_owner), pyproject_mock) mocker.patch('pep517.wrappers.Pep517HookCaller') builder = build.ProjectBuilder() @@ -171,7 +171,7 @@ def test_build(mocker, pyproject_mock): def test_default_backend(mocker, empty_file_mock): mocker.patch('importlib.import_module') - mocker.patch('{}.open'.format(build_open_owener), empty_file_mock) + mocker.patch('{}.open'.format(build_open_owner), empty_file_mock) mocker.patch('pep517.wrappers.Pep517HookCaller') builder = build.ProjectBuilder() @@ -181,7 +181,7 @@ def test_default_backend(mocker, empty_file_mock): def test_missing_backend(mocker, pyproject_no_backend_mock): mocker.patch('importlib.import_module') - mocker.patch('{}.open'.format(build_open_owener), pyproject_no_backend_mock) + mocker.patch('{}.open'.format(build_open_owner), pyproject_no_backend_mock) mocker.patch('pep517.wrappers.Pep517HookCaller') builder = build.ProjectBuilder() @@ -191,7 +191,7 @@ def test_missing_backend(mocker, pyproject_no_backend_mock): def test_missing_requires(mocker, pyproject_no_requires_mock): mocker.patch('importlib.import_module') - mocker.patch('{}.open'.format(build_open_owener), pyproject_no_requires_mock) + mocker.patch('{}.open'.format(build_open_owner), pyproject_no_requires_mock) mocker.patch('pep517.wrappers.Pep517HookCaller') with pytest.raises(build.BuildException): From 62cc3c2d0fef2231c0783992628a02cc4110bde9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sun, 21 Jun 2020 22:09:52 +0100 Subject: [PATCH 4/5] env: introduce IsolatedEnvironment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- build/__main__.py | 7 ++-- build/env.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_env.py | 30 +++++++++++++++ tests/test_main.py | 6 +-- 4 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 build/env.py create mode 100644 tests/test_env.py diff --git a/build/__main__.py b/build/__main__.py index b5d63af9..b6778dbd 100644 --- a/build/__main__.py +++ b/build/__main__.py @@ -7,9 +7,8 @@ from typing import List, Optional -import pep517.envbuild - from . import BuildBackendException, BuildException, ConfigSettings, ProjectBuilder +from .env import IsolatedEnvironment __all__ = ['build', 'main', 'main_parser'] @@ -30,8 +29,8 @@ def _error(msg, code=1): # type: (str, int) -> None # pragma: no cover def _build_in_isolated_env(builder, outdir, distributions): # type: (ProjectBuilder, str, List[str]) -> None - with pep517.envbuild.BuildEnvironment() as env: - env.pip_install(builder.build_dependencies) + with IsolatedEnvironment() as env: + env.install(builder.build_dependencies) for distribution in distributions: builder.build(distribution, outdir) diff --git a/build/env.py b/build/env.py new file mode 100644 index 00000000..9ff63a16 --- /dev/null +++ b/build/env.py @@ -0,0 +1,93 @@ +import os +import shutil +import subprocess +import sys +import sysconfig +import tempfile +import types + + +if False: # TYPE_CHECKING # pragma: no cover + from typing import Dict, Optional, Iterable, Type + + +class IsolatedEnvironment(object): + ''' + Isolated build environment context manager + + Non-standard paths injected directly to sys.path still be passed to the environment. + ''' + + def __init__(self): # type: () -> None + self._env = {} # type: Dict[str, Optional[str]] + + def _replace_env(self, key, new): # type: (str, Optional[str]) -> None + if not new: # pragma: no cover + return + + self._env[key] = os.environ.get(key, None) + os.environ[key] = new + + def _restore_env(self): # type: () -> None + for key, val in self._env.items(): + if val is None: + os.environ.pop(key, None) + else: + os.environ[key] = val + + def _get_env_path(self, path): # type: (str) -> Optional[str] + return sysconfig.get_path(path, vars=self._env_vars) + + def __enter__(self): # type: () -> IsolatedEnvironment + self._path = tempfile.mkdtemp(prefix='build-env-') + self._env_vars = { + 'base': self._path, + 'platbase': self._path, + } + + sys_path = sys.path[1:] + + remove_paths = os.environ.get('PYTHONPATH', '').split(os.pathsep) + + for path in ('purelib', 'platlib'): + our_path = sysconfig.get_path(path) + if our_path: + remove_paths.append(our_path) + + for scheme in sysconfig.get_scheme_names(): + our_path = sysconfig.get_path(path, scheme) + if our_path: + remove_paths.append(our_path) + + env_path = self._get_env_path(path) + if env_path: + sys_path.append(env_path) + + for path in remove_paths: + if path in sys_path: + sys_path.remove(path) + + self._replace_env('PATH', self._get_env_path('scripts')) + self._replace_env('PYTHONPATH', os.pathsep.join(sys_path)) + self._replace_env('PYTHONHOME', self._path) + + return self + + def __exit__(self, typ, value, traceback): + # type: (Optional[Type[BaseException]], Optional[BaseException], Optional[types.TracebackType]) -> None + if self._path and os.path.isdir(self._path): + shutil.rmtree(self._path) + + self._restore_env() + + def install(self, requirements): # type: (Iterable[str]) -> None + ''' + Installs the specified requirements on the environment + ''' + if not requirements: + return + + subprocess.check_call([sys.executable, '-m', 'ensurepip'], cwd=self._path) + + cmd = [sys.executable, '-m', 'pip', 'install', '--ignore-installed', '--prefix', self._path] + list(requirements) + subprocess.check_call(cmd) diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 00000000..2f09c3dc --- /dev/null +++ b/tests/test_env.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: MIT + +import os +import subprocess +import sys +import sysconfig + +import build.env + + +def test_isolated_environment(mocker): + with build.env.IsolatedEnvironment() as env: + if os.name != 'nt': + assert os.environ['PATH'] == os.path.join(env._path, 'bin') + assert os.environ['PYTHONHOME'] == env._path + + for path in ('purelib', 'platlib'): + assert sysconfig.get_path(path) not in os.environ['PYTHONPATH'].split(os.pathsep) + + mocker.patch('subprocess.check_call') + + env.install([]) + subprocess.check_call.assert_not_called() + + env.install(['some', 'requirements']) + if sys.version_info[:2] != (3, 5): + subprocess.check_call.assert_called() + assert subprocess.check_call.call_args[0][0] == [ + sys.executable, '-m', 'pip', 'install', '--ignore-installed', '--prefix', env._path, 'some', 'requirements' + ] diff --git a/tests/test_main.py b/tests/test_main.py index e61a16f5..5f5d5dbd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,7 +5,6 @@ import os import sys -import pep517 import pytest import build @@ -82,9 +81,10 @@ def test_build(mocker): mocker.patch('build.ProjectBuilder.check_dependencies') mocker.patch('build.ProjectBuilder.build') mocker.patch('build.__main__._error') - mocker.patch('pep517.envbuild.BuildEnvironment.pip_install') + mocker.patch('build.env.IsolatedEnvironment.install') build.ProjectBuilder.check_dependencies.side_effect = [[], ['something'], [], []] + build.env.IsolatedEnvironment._path = mocker.Mock() # isolation=True build.__main__.build('.', '.', ['sdist']) @@ -93,7 +93,7 @@ def test_build(mocker): # check_dependencies = [] build.__main__.build('.', '.', ['sdist'], isolation=False) build.ProjectBuilder.build.assert_called_with('sdist', '.') - pep517.envbuild.BuildEnvironment.pip_install.assert_called_with(set(build._DEFAULT_BACKEND['requires'])) + build.env.IsolatedEnvironment.install.assert_called_with(set(build._DEFAULT_BACKEND['requires'])) # check_dependencies = ['something'] build.__main__.build('.', '.', ['sdist'], isolation=False) From e5efbcca2df30b7de5e14477cdbd4fb83f1d932c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sun, 21 Jun 2020 22:38:22 +0100 Subject: [PATCH 5/5] ci: fix building dateutil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 303670ff..07d0b37a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -134,6 +134,8 @@ jobs: - name: Build dateutil (with isolation) run: python -m build -x -w ../dateutil + env: + SETUPTOOLS_SCM_PRETEND_VERSION: dummy Solaar: