diff --git a/src/build/env.py b/src/build/env.py index 3dc909f2..95ba9d15 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -70,26 +70,42 @@ def _minimum_pip_version() -> str: return '19.1.0' -def _has_valid_pip(purelib: str) -> bool: +def _has_valid_pip(**distargs: object) -> bool: """ Given a path, see if Pip is present and return True if the version is - sufficient for build, False if it is not. + sufficient for build, False if it is not. ModuleNotFoundError is thrown if + pip is not present. """ import packaging.version - if sys.version_info < (3, 8): - import importlib_metadata as metadata - else: - from importlib import metadata + from ._importlib import metadata + + name = 'pip' - pip_distribution = next(iter(metadata.distributions(name='pip', path=[purelib]))) + try: + pip_distribution = next(iter(metadata.distributions(name=name, **distargs))) + except StopIteration: + raise ModuleNotFoundError(name) from None current_pip_version = packaging.version.Version(pip_distribution.version) return current_pip_version >= packaging.version.Version(_minimum_pip_version()) +@functools.lru_cache(maxsize=None) +def _valid_global_pip() -> bool | None: + """ + This checks for a valid global pip. Returns None if pip is missing, False + if Pip is too old, and True if it can be used. + """ + + try: + return _has_valid_pip() + except ModuleNotFoundError: + return None + + def _subprocess(cmd: list[str]) -> None: """Invoke subprocess and output stdout and stderr if it fails.""" try: @@ -139,6 +155,12 @@ def python_executable(self) -> str: """The python executable of the isolated build environment.""" return self._python_executable + def _pip_args(self) -> list[str]: + if _valid_global_pip(): + return [sys.executable, '-Im', 'pip', '--python', self.python_executable] + else: + return [self.python_executable, '-Im', 'pip'] + def make_extra_environ(self) -> dict[str, str]: path = os.environ.get('PATH') return {'PATH': os.pathsep.join([self._scripts_dir, path]) if path is not None else self._scripts_dir} @@ -163,9 +185,7 @@ def install(self, requirements: Collection[str]) -> None: req_file.write(os.linesep.join(requirements)) try: cmd = [ - self.python_executable, - '-Im', - 'pip', + *self._pip_args(), 'install', '--use-pep517', '--no-warn-script-location', @@ -201,7 +221,11 @@ def _create_isolated_env_virtualenv(path: str) -> tuple[str, str]: """ import virtualenv - cmd = [str(path), '--no-setuptools', '--no-wheel', '--activators', ''] + if _valid_global_pip(): + cmd = [path, '--no-seed', '--activators', ''] + else: + cmd = [path, '--no-setuptools', '--no-wheel', '--activators', ''] + result = virtualenv.cli_run(cmd, setup_logging=False) executable = str(result.creator.exe) script_dir = str(result.creator.script_dir) @@ -240,18 +264,20 @@ def _create_isolated_env_venv(path: str) -> tuple[str, str]: with warnings.catch_warnings(): if sys.version_info[:3] == (3, 11, 0): warnings.filterwarnings('ignore', 'check_home argument is deprecated and ignored.', DeprecationWarning) - venv.EnvBuilder(with_pip=True, symlinks=symlinks).create(path) + venv.EnvBuilder(with_pip=not _valid_global_pip(), symlinks=symlinks).create(path) except subprocess.CalledProcessError as exc: raise 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 - if not _has_valid_pip(purelib): + if not _valid_global_pip() and not _has_valid_pip(path=[purelib]): _subprocess([executable, '-m', 'pip', 'install', f'pip>={_minimum_pip_version()}']) # Avoid the setuptools from ensurepip to break the isolation - _subprocess([executable, '-m', 'pip', 'uninstall', 'setuptools', '-y']) + if not _valid_global_pip(): + _subprocess([executable, '-m', 'pip', 'uninstall', 'setuptools', '-y']) + return executable, script_dir diff --git a/tests/conftest.py b/tests/conftest.py index ecc25383..8f989d68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,6 +65,11 @@ def is_integration(item): return os.path.basename(item.location[0]) == 'test_integration.py' +@pytest.fixture() +def local_pip(monkeypatch): + monkeypatch.setattr(build.env, '_valid_global_pip', lambda: None) + + @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 diff --git a/tests/test_env.py b/tests/test_env.py index 41d1f478..feaf042a 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -26,6 +26,7 @@ def test_isolation(): @pytest.mark.isolated +@pytest.mark.usefixtures('local_pip') def test_isolated_environment_install(mocker): with build.env.DefaultIsolatedEnv() as env: mocker.patch('build.env._subprocess') @@ -117,6 +118,7 @@ def test_isolated_env_log(mocker, caplog, package_test_flit): @pytest.mark.isolated +@pytest.mark.usefixtures('local_pip') def test_default_pip_is_never_too_old(): with build.env.DefaultIsolatedEnv() as env: version = subprocess.check_output( @@ -130,6 +132,7 @@ def test_default_pip_is_never_too_old(): @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']) +@pytest.mark.usefixtures('local_pip') def test_pip_needs_upgrade_mac_os_11(mocker, pip_version, arch): SimpleNamespace = collections.namedtuple('SimpleNamespace', 'version') @@ -137,8 +140,7 @@ def test_pip_needs_upgrade_mac_os_11(mocker, pip_version, arch): 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),)) + mocker.patch('build._importlib.metadata.distributions', return_value=(SimpleNamespace(version=pip_version),)) min_version = Version('20.3' if arch == 'x86_64' else '21.0.1') with build.env.DefaultIsolatedEnv(): diff --git a/tests/test_main.py b/tests/test_main.py index 8edc3094..2ca18e47 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -342,6 +342,7 @@ def main_reload_styles(): ], ids=['no-color', 'color'], ) +@pytest.mark.usefixtures('local_pip') def test_output_env_subprocess_error( mocker, monkeypatch,