Repository: pypa/build
Pull request #434 (2022-01-31 13:43:50)

env._find_executable_and_scripts(): Prefer the venv installation scheme if it exists
Python distributors with custom default installation scheme can set a
scheme that can't be used to expand the paths in a venv.
This can happen if build itself is not installed in a venv.
The distributors are encouraged to set a "venv" scheme to be used for this.
See https://bugs.python.org/issue45413
and https://github.com/pypa/virtualenv/issues/2208

Since Python that ships with the macOS developer tools does not have the "venv"
scheme yet, we keep the special case below. Once it gains the "venv" scheme,
it will be preferred.

Fixes https://github.com/pypa/build/issues/433

hroncok: > @hroncok would you be able to move each part of the comment to above its relevant if/elif clause? I think that makes the code easier to read, instead of having this big comment.

Done.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9f5883c..d94c45a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -18,12 +18,12 @@ repos:
   - id: trailing-whitespace
   - id: double-quote-string-fixer
 - repo: https://github.com/asottile/pyupgrade
-  rev: v2.31.0
+  rev: v2.31.1
   hooks:
   - id: pyupgrade
     args: ["--py36-plus"]
 - repo: https://github.com/psf/black
-  rev: 21.12b0
+  rev: 22.1.0
   hooks:
   - id: black
 - repo: https://github.com/PyCQA/isort
@@ -40,3 +40,8 @@ repos:
   - id: flake8
     additional_dependencies: ["flake8-bugbear == 20.1.4"]
     language_version: python3.9
+- repo: https://github.com/codespell-project/codespell
+  rev: "v2.1.0"
+  hooks:
+  - id: codespell
+    args: ["-L", "sur"]
diff --git a/pyproject.toml b/pyproject.toml
index a193fb1..6cd86fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,12 +31,17 @@ junit_family = "xunit2"
 norecursedirs = "tests/integration/*"
 markers = [
   "isolated",
+  "pypy3323bug",
 ]
 filterwarnings = [
   "error",
+  "ignore:path is deprecated.:DeprecationWarning",
 ]
 
 [tool.mypy]
+files = "src"
+python_version = "3.6"
+warn_unused_configs = true
 strict = true
 
 [[tool.mypy.overrides]]
diff --git a/setup.cfg b/setup.cfg
index 1621a4a..16d08d0 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -63,7 +63,7 @@ test =
     setuptools>=56.0.0;python_version >= "3.10"
 typing =
     importlib-metadata>=4.6.4
-    mypy==0.910
+    mypy==0.931
     typing-extensions>=3.7.4.3
 virtualenv =
     virtualenv>=20.0.35
diff --git a/src/build/__init__.py b/src/build/__init__.py
index 41adff7..a1117df 100644
--- a/src/build/__init__.py
+++ b/src/build/__init__.py
@@ -12,6 +12,7 @@ import os
 import re
 import subprocess
 import sys
+import textwrap
 import types
 import warnings
 import zipfile
@@ -85,8 +86,8 @@ class BuildBackendException(Exception):
         self, exception: Exception, description: Optional[str] = None, exc_info: _ExcInfoType = (None, None, None)
     ) -> None:
         super().__init__()
-        self.exception: Exception = exception
-        self.exc_info: _ExcInfoType = exc_info
+        self.exception = exception
+        self.exc_info = exc_info
         self._description = description
 
     def __str__(self) -> str:
@@ -104,6 +105,27 @@ class BuildSystemTableValidationError(BuildException):
         return f'Failed to validate `build-system` in pyproject.toml: {self.args[0]}'
 
 
+class FailedProcessError(Exception):
+    """
+    Exception raised when an setup or prepration operation fails.
+    """
+
+    def __init__(self, exception: subprocess.CalledProcessError, description: str) -> None:
+        super().__init__()
+        self.exception = exception
+        self._description = description
+
+    def __str__(self) -> str:
+        cmd = ' '.join(self.exception.cmd)
+        description = f"{self._description}\n  Command '{cmd}' failed with return code {self.exception.returncode}"
+        for stream_name in ('stdout', 'stderr'):
+            stream = getattr(self.exception, stream_name)
+            if stream:
+                description += f'\n  {stream_name}:\n'
+                description += textwrap.indent(stream.decode(), '    ')
+        return description
+
+
 class TypoWarning(Warning):
     """
     Warning raised when a potential typo is found
@@ -481,11 +503,12 @@ class ProjectBuilder:
 
 __all__ = (
     '__version__',
+    'BuildBackendException',
+    'BuildException',
     'ConfigSettingsType',
+    'FailedProcessError',
+    'ProjectBuilder',
     'RunnerType',
-    'BuildException',
-    'BuildBackendException',
     'TypoWarning',
     'check_dependency',
-    'ProjectBuilder',
 )
diff --git a/src/build/__main__.py b/src/build/__main__.py
index d5685cd..843564b 100644
--- a/src/build/__main__.py
+++ b/src/build/__main__.py
@@ -13,11 +13,11 @@ import textwrap
 import traceback
 import warnings
 
-from typing import Dict, Iterable, Iterator, List, Optional, Sequence, TextIO, Type, Union
+from typing import Dict, Iterator, List, NoReturn, Optional, Sequence, TextIO, Type, Union
 
 import build
 
-from build import BuildBackendException, BuildException, ConfigSettingsType, PathType, ProjectBuilder
+from build import BuildBackendException, BuildException, ConfigSettingsType, FailedProcessError, PathType, ProjectBuilder
 from build.env import IsolatedEnvBuilder
 
 
@@ -71,7 +71,7 @@ def _setup_cli() -> None:
         colorama.init()  # fix colors on windows
 
 
-def _error(msg: str, code: int = 1) -> None:  # pragma: no cover
+def _error(msg: str, code: int = 1) -> NoReturn:  # pragma: no cover
     """
     Print an error message and exit. Will color the output when writing to a TTY.
 
@@ -146,23 +146,24 @@ def _build(
 def _handle_build_error() -> Iterator[None]:
     try:
         yield
-    except BuildException as e:
+    except (BuildException, FailedProcessError) as e:
         _error(str(e))
     except BuildBackendException as e:
         if isinstance(e.exception, subprocess.CalledProcessError):
             print()
+            _error(str(e))
+
+        if e.exc_info:
+            tb_lines = traceback.format_exception(
+                e.exc_info[0],
+                e.exc_info[1],
+                e.exc_info[2],
+                limit=-1,
+            )
+            tb = ''.join(tb_lines)
         else:
-            if e.exc_info:
-                tb_lines = traceback.format_exception(
-                    e.exc_info[0],
-                    e.exc_info[1],
-                    e.exc_info[2],
-                    limit=-1,
-                )
-                tb = ''.join(tb_lines)
-            else:
-                tb = traceback.format_exc(-1)
-            print('\n{dim}{}{reset}\n'.format(tb.strip('\n'), **_STYLES))
+            tb = traceback.format_exc(-1)
+        print('\n{dim}{}{reset}\n'.format(tb.strip('\n'), **_STYLES))
         _error(str(e))
 
 
@@ -250,9 +251,6 @@ def main_parser() -> argparse.ArgumentParser:
     """
     Construct the main parser.
     """
-    # mypy does not recognize module.__path__
-    # https://github.com/python/mypy/issues/1422
-    paths: Iterable[str] = build.__path__  # type: ignore
     parser = argparse.ArgumentParser(
         description=textwrap.indent(
             textwrap.dedent(
@@ -285,7 +283,7 @@ def main_parser() -> argparse.ArgumentParser:
         '--version',
         '-V',
         action='version',
-        version=f"build {build.__version__} ({','.join(paths)})",
+        version=f"build {build.__version__} ({','.join(build.__path__)})",
     )
     parser.add_argument(
         '--sdist',
diff --git a/src/build/env.py b/src/build/env.py
index 80c42d8..c558f70 100644
--- a/src/build/env.py
+++ b/src/build/env.py
@@ -74,7 +74,7 @@ def _should_use_virtualenv() -> bool:
 def _subprocess(cmd: List[str]) -> None:
     """Invoke subprocess and output stdout and stderr if it fails."""
     try:
-        subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+        subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
     except subprocess.CalledProcessError as e:
         print(e.output.decode(), end='', file=sys.stderr)
         raise e
@@ -219,7 +219,7 @@ class _IsolatedEnvVenvPip(IsolatedEnv):
 
 def _create_isolated_env_virtualenv(path: str) -> Tuple[str, str]:
     """
-    On Python 2 we use the virtualenv package to provision a virtual environment.
+    We optionally can use the virtualenv package to provision a virtual environment.
 
     :param path: The path where to create the isolated build environment
     :return: The Python executable and script folder
@@ -235,7 +235,7 @@ def _create_isolated_env_virtualenv(path: str) -> Tuple[str, str]:
 def _fs_supports_symlink() -> bool:
     """Return True if symlinks are supported"""
     # Using definition used by venv.main()
-    if os.name != 'nt':
+    if not sys.platform.startswith('win'):
         return True
 
     # Windows may support symlinks (setting in Windows 10)
@@ -260,7 +260,12 @@ def _create_isolated_env_venv(path: str) -> Tuple[str, str]:
 
     import packaging.version
 
-    venv.EnvBuilder(with_pip=True, symlinks=_fs_supports_symlink()).create(path)
+    symlinks = _fs_supports_symlink()
+    try:
+        venv.EnvBuilder(with_pip=True, symlinks=symlinks).create(path)
+    except subprocess.CalledProcessError as exc:
+        raise build.FailedProcessError(exc, 'Failed to create venv. Maybe try installing virtualenv.') from None
+
     executable, script_dir, purelib = _find_executable_and_scripts(path)
 
     # Get the version of pip in the environment
@@ -293,18 +298,25 @@ def _find_executable_and_scripts(path: str) -> Tuple[str, str, str]:
     """
     config_vars = sysconfig.get_config_vars().copy()  # globally cached, copy before altering it
     config_vars['base'] = path
-    # The Python that ships with the macOS developer tools varies the
-    # default scheme depending on whether the ``sys.prefix`` is part of a framework.
-    # The framework "osx_framework_library" scheme
-    # can't be used to expand the paths in a venv, which
-    # can happen if build itself is not installed in a venv.
-    # If the Apple-custom "osx_framework_library" scheme is available
-    # we enforce "posix_prefix", the venv scheme, for isolated envs.
-    if 'osx_framework_library' in sysconfig.get_scheme_names():
+    scheme_names = sysconfig.get_scheme_names()
+    if 'venv' in scheme_names:
+        # Python distributors with custom default installation scheme can set a
+        # scheme that can't be used to expand the paths in a venv.
+        # This can happen if build itself is not installed in a venv.
+        # The distributors are encouraged to set a "venv" scheme to be used for this.
+        # See https://bugs.python.org/issue45413
+        # and https://github.com/pypa/virtualenv/issues/2208
+        paths = sysconfig.get_paths(scheme='venv', vars=config_vars)
+    elif 'osx_framework_library' in scheme_names:
+        # The Python that ships with the macOS developer tools varies the
+        # default scheme depending on whether the ``sys.prefix`` is part of a framework.
+        # But it does not (yet) set the "venv" scheme.
+        # If the Apple-custom "osx_framework_library" scheme is available but "venv"
+        # is not, we use "posix_prefix" instead which is venv-compatible there.
         paths = sysconfig.get_paths(scheme='posix_prefix', vars=config_vars)
     else:
         paths = sysconfig.get_paths(vars=config_vars)
-    executable = os.path.join(paths['scripts'], 'python.exe' if os.name == 'nt' else 'python')
+    executable = os.path.join(paths['scripts'], 'python.exe' if sys.platform.startswith('win') else 'python')
     if not os.path.exists(executable):
         raise RuntimeError(f'Virtual environment creation failed, executable {executable} missing')
 
diff --git a/tox.ini b/tox.ini
index f5a11d2..13819c6 100644
--- a/tox.ini
+++ b/tox.ini
@@ -23,13 +23,14 @@ passenv =
 setenv =
     COVERAGE_FILE = {toxworkdir}/.coverage.{envname}
     TEST_STATUS_DIR = {envtmpdir}
+    PYPY3323BUG = 1
     path: TEST_MODE = path
     sdist: TEST_MODE = sdist
     wheel: TEST_MODE = wheel
 extras =
     test
 commands =
-    pytest -rsx --cov --cov-config pyproject.toml \
+    pytest -ra --cov --cov-config pyproject.toml \
       --cov-report=html:{envdir}/htmlcov --cov-context=test \
       --cov-report=xml:{toxworkdir}/coverage.{envname}.xml \
       tests {posargs:-n auto}
@@ -51,14 +52,14 @@ commands =
 description = run type check on code base
 extras = typing
 commands =
-    mypy --show-error-codes --python-version 3.6 src/build
+    mypy --show-error-codes src/build
 
 [testenv:{py311, py310, py39, py38, py37, py36, pypy3}-min]
 description = check minimum versions required of all dependencies
 skip_install = true
 commands =
     pip install .[test] -c tests/constraints.txt
-    pytest -rsx tests {posargs:-n auto}
+    pytest -ra tests {posargs:-n auto}
 
 [testenv:docs]
 description = build documentations


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 20a1558..015a7b7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -66,7 +66,7 @@ def pytest_addoption(parser):
     parser.addoption('--only-integration', action='store_true', help='only run the integration tests')
 
 
-PYPY3_WIN_VENV_BAD = platform.python_implementation() == 'PyPy' and os.name == 'nt'
+PYPY3_WIN_VENV_BAD = platform.python_implementation() == 'PyPy' 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'
 
 
@@ -81,6 +81,8 @@ def pytest_collection_modifyitems(config, items):
         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', [])
diff --git a/tests/test_env.py b/tests/test_env.py
index 831d725..d1d8f97 100644
--- a/tests/test_env.py
+++ b/tests/test_env.py
@@ -1,7 +1,7 @@
 # SPDX-License-Identifier: MIT
 import collections
+import inspect
 import logging
-import os
 import platform
 import shutil
 import subprocess
@@ -97,12 +97,14 @@ def test_isolated_env_has_install_still_abstract():
         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()
-    builder.log('something')
+    frameinfo = inspect.getframeinfo(inspect.currentframe())
+    builder.log('something')  # line number 106
     with builder as env:
         env.install(['something'])
 
@@ -112,7 +114,7 @@ def test_isolated_env_log(mocker, caplog, package_test_flit):
         ('INFO', 'Installing packages in isolated environment... (something)'),
     ]
     if sys.version_info >= (3, 8):  # stacklevel
-        assert [(record.lineno) for record in caplog.records] == [105, 107, 198]
+        assert [(record.lineno) for record in caplog.records] == [frameinfo.lineno + 1, 107, 198]
 
 
 @pytest.mark.isolated
@@ -150,8 +152,8 @@ def test_pip_needs_upgrade_mac_os_11(mocker, pip_version, arch):
 
 
 @pytest.mark.isolated
-@pytest.mark.skipif(IS_PYPY3 and os.name == 'nt', reason='Isolated tests not supported on PyPy3 + Windows')
-@pytest.mark.parametrize('has_symlink', [True, False] if os.name == 'nt' else [True])
+@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')
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 0db1b00..bc2f4ff 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -16,7 +16,7 @@ import pytest
 import build.__main__
 
 
-IS_WINDOWS = os.name == 'nt'
+IS_WINDOWS = sys.platform.startswith('win')
 IS_PYPY3 = platform.python_implementation() == 'PyPy'
 
 
@@ -102,7 +102,7 @@ def test_build(monkeypatch, project, args, call, 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 os.name == 'nt' else ''}"
+        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
diff --git a/tests/test_main.py b/tests/test_main.py
index d34f1bc..dabfad9 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -5,7 +5,9 @@ import importlib
 import io
 import os
 import re
+import subprocess
 import sys
+import venv
 
 import pytest
 
@@ -184,6 +186,7 @@ def test_build_raises_build_backend_exception(mocker, package_test_flit):
         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'])
 
@@ -193,6 +196,7 @@ def test_build_package(tmp_dir, package_test_setuptools):
     ]
 
 
+@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'])
 
@@ -202,6 +206,7 @@ def test_build_package_via_sdist(tmp_dir, package_test_setuptools):
     ]
 
 
+@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'])
@@ -212,6 +217,7 @@ def test_build_package_via_sdist_invalid_distribution(tmp_dir, package_test_setu
         build.__main__.build_package_via_sdist(package_test_setuptools, tmp_dir, ['sdist'])
 
 
+@pytest.mark.pypy3323bug
 @pytest.mark.parametrize(
     ('args', 'output'),
     [
@@ -300,6 +306,7 @@ def main_reload_styles():
         importlib.reload(build.__main__)
 
 
+@pytest.mark.pypy3323bug
 @pytest.mark.parametrize(
     ('color', 'stdout_error', 'stdout_body'),
     [
@@ -394,3 +401,31 @@ def test_colors_conflict(monkeypatch, main_reload_styles):
             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', '')
+
+    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_projectbuilder.py b/tests/test_projectbuilder.py
index 98687f8..a05b6ca 100644
--- a/tests/test_projectbuilder.py
+++ b/tests/test_projectbuilder.py
@@ -173,7 +173,7 @@ def test_init(mocker, package_test_flit, package_legacy, test_no_permission, pac
     )
 
     # PermissionError
-    if os.name != 'nt':  # can't correctly set the permissions required for this
+    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)
 
diff --git a/tests/test_util.py b/tests/test_util.py
index eeb94aa..9f090b5 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -5,6 +5,7 @@ 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)
@@ -13,6 +14,7 @@ def test_wheel_metadata(package_test_setuptools, isolated):
     assert metadata['version'] == '1.0.0'
 
 
+@pytest.mark.pypy3323bug
 def test_wheel_metadata_isolation(package_test_flit):
     try:
         import flit_core  # noqa: F401
@@ -33,6 +35,7 @@ def test_wheel_metadata_isolation(package_test_flit):
         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)