From a68a36fca2d9beec439ecbfe52ab1f91054dd046 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 19 Feb 2024 17:25:18 -0500 Subject: [PATCH 1/3] feat: use external pip if available Signed-off-by: Henry Schreiner --- src/build/env.py | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 3dc909f2..70fe8b1a 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -70,7 +70,7 @@ def _minimum_pip_version() -> str: return '19.1.0' -def _has_valid_pip(purelib: str) -> bool: +def _has_valid_pip(**distargs: str) -> bool: """ Given a path, see if Pip is present and return True if the version is sufficient for build, False if it is not. @@ -83,13 +83,29 @@ def _has_valid_pip(purelib: str) -> bool: else: from importlib import metadata - pip_distribution = next(iter(metadata.distributions(name='pip', path=[purelib]))) + pip_distribution = next(iter(metadata.distributions(name='pip', **distargs))) 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 the prerequisites are + not available (Python 3.7 only) or pip is missing, False if Pip is too old, + and True if it can be used. + """ + + try: + return _has_valid_pip() + except ModuleNotFoundError: # Python 3.7 only + return None + except StopIteration: + 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, *, isolate: bool = False) -> list[str]: + if _valid_global_pip(): + return [sys.executable, '-Im' if isolate else '-m', 'pip', '--python', self.python_executable] + else: + return [self.python_executable, '-Im' if isolate else '-m', '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(isolate=True), '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 = [str(path), '--no-seed', '--activators', ''] + else: + cmd = [str(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,22 @@ 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 _valid_global_pip(): + _subprocess([sys.executable, '-m', 'pip', '--python', executable, 'uninstall', 'setuptools', '-y']) + else: + _subprocess([executable, '-m', 'pip', 'uninstall', 'setuptools', '-y']) + return executable, script_dir From e83bfa650da28924ff660b2c28116be00b06dd90 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 22 Feb 2024 18:12:27 -0500 Subject: [PATCH 2/3] fix(types): make the passthrough general Signed-off-by: Henry Schreiner --- src/build/env.py | 2 +- tests/conftest.py | 5 +++++ tests/test_env.py | 3 +++ tests/test_main.py | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/build/env.py b/src/build/env.py index 70fe8b1a..cb6dbd70 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -70,7 +70,7 @@ def _minimum_pip_version() -> str: return '19.1.0' -def _has_valid_pip(**distargs: 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. 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..de63c42b 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') 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, From 04dd0d13ff4df43ee48a029fdb4faf8a4b00bdf6 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 26 Feb 2024 10:52:33 -0500 Subject: [PATCH 3/3] chore: address feedback Signed-off-by: Henry Schreiner --- src/build/env.py | 40 +++++++++++++++++++--------------------- tests/test_env.py | 3 +-- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index cb6dbd70..95ba9d15 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -73,17 +73,20 @@ def _minimum_pip_version() -> str: 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', **distargs))) + 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) @@ -93,16 +96,13 @@ def _has_valid_pip(**distargs: object) -> bool: @functools.lru_cache(maxsize=None) def _valid_global_pip() -> bool | None: """ - This checks for a valid global pip. Returns None if the prerequisites are - not available (Python 3.7 only) or pip is missing, False if Pip is too old, - and True if it can be used. + 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: # Python 3.7 only - return None - except StopIteration: + except ModuleNotFoundError: return None @@ -155,11 +155,11 @@ def python_executable(self) -> str: """The python executable of the isolated build environment.""" return self._python_executable - def _pip_args(self, *, isolate: bool = False) -> list[str]: + def _pip_args(self) -> list[str]: if _valid_global_pip(): - return [sys.executable, '-Im' if isolate else '-m', 'pip', '--python', self.python_executable] + return [sys.executable, '-Im', 'pip', '--python', self.python_executable] else: - return [self.python_executable, '-Im' if isolate else '-m', 'pip'] + return [self.python_executable, '-Im', 'pip'] def make_extra_environ(self) -> dict[str, str]: path = os.environ.get('PATH') @@ -185,7 +185,7 @@ def install(self, requirements: Collection[str]) -> None: req_file.write(os.linesep.join(requirements)) try: cmd = [ - *self._pip_args(isolate=True), + *self._pip_args(), 'install', '--use-pep517', '--no-warn-script-location', @@ -222,9 +222,9 @@ def _create_isolated_env_virtualenv(path: str) -> tuple[str, str]: import virtualenv if _valid_global_pip(): - cmd = [str(path), '--no-seed', '--activators', ''] + cmd = [path, '--no-seed', '--activators', ''] else: - cmd = [str(path), '--no-setuptools', '--no-wheel', '--activators', ''] + cmd = [path, '--no-setuptools', '--no-wheel', '--activators', ''] result = virtualenv.cli_run(cmd, setup_logging=False) executable = str(result.creator.exe) @@ -275,9 +275,7 @@ def _create_isolated_env_venv(path: str) -> tuple[str, str]: _subprocess([executable, '-m', 'pip', 'install', f'pip>={_minimum_pip_version()}']) # Avoid the setuptools from ensurepip to break the isolation - if _valid_global_pip(): - _subprocess([sys.executable, '-m', 'pip', '--python', executable, 'uninstall', 'setuptools', '-y']) - else: + if not _valid_global_pip(): _subprocess([executable, '-m', 'pip', 'uninstall', 'setuptools', '-y']) return executable, script_dir diff --git a/tests/test_env.py b/tests/test_env.py index de63c42b..feaf042a 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -140,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():