1from __future__ import annotations
2
3import functools
4import logging
5import os
6import pathlib
7import sys
8import sysconfig
9
10from pip._internal.models.scheme import SCHEME_KEYS, Scheme
11from pip._internal.utils.compat import WINDOWS
12from pip._internal.utils.deprecation import deprecated
13from pip._internal.utils.virtualenv import running_under_virtualenv
14
15from . import _sysconfig
16from .base import (
17 USER_CACHE_DIR,
18 get_major_minor_version,
19 get_src_prefix,
20 is_osx_framework,
21 site_packages,
22 user_site,
23)
24
25__all__ = [
26 "USER_CACHE_DIR",
27 "get_bin_prefix",
28 "get_bin_user",
29 "get_major_minor_version",
30 "get_platlib",
31 "get_purelib",
32 "get_scheme",
33 "get_src_prefix",
34 "site_packages",
35 "user_site",
36]
37
38
39logger = logging.getLogger(__name__)
40
41
42_PLATLIBDIR: str = getattr(sys, "platlibdir", "lib")
43
44_USE_SYSCONFIG_DEFAULT = sys.version_info >= (3, 10)
45
46
47def _should_use_sysconfig() -> bool:
48 """This function determines the value of _USE_SYSCONFIG.
49
50 By default, pip uses sysconfig on Python 3.10+.
51 But Python distributors can override this decision by setting:
52 sysconfig._PIP_USE_SYSCONFIG = True / False
53 Rationale in https://github.com/pypa/pip/issues/10647
54
55 This is a function for testability, but should be constant during any one
56 run.
57 """
58 return bool(getattr(sysconfig, "_PIP_USE_SYSCONFIG", _USE_SYSCONFIG_DEFAULT))
59
60
61_USE_SYSCONFIG = _should_use_sysconfig()
62
63if not _USE_SYSCONFIG:
64 # Import distutils lazily to avoid deprecation warnings,
65 # but import it soon enough that it is in memory and available during
66 # a pip reinstall.
67 from . import _distutils
68
69# Be noisy about incompatibilities if this platforms "should" be using
70# sysconfig, but is explicitly opting out and using distutils instead.
71if _USE_SYSCONFIG_DEFAULT and not _USE_SYSCONFIG:
72 _MISMATCH_LEVEL = logging.WARNING
73else:
74 _MISMATCH_LEVEL = logging.DEBUG
75
76
77def _looks_like_bpo_44860() -> bool:
78 """The resolution to bpo-44860 will change this incorrect platlib.
79
80 See <https://bugs.python.org/issue44860>.
81 """
82 from distutils.command.install import INSTALL_SCHEMES
83
84 try:
85 unix_user_platlib = INSTALL_SCHEMES["unix_user"]["platlib"]
86 except KeyError:
87 return False
88 return unix_user_platlib == "$usersite"
89
90
91def _looks_like_red_hat_patched_platlib_purelib(scheme: dict[str, str]) -> bool:
92 platlib = scheme["platlib"]
93 if "/$platlibdir/" in platlib:
94 platlib = platlib.replace("/$platlibdir/", f"/{_PLATLIBDIR}/")
95 if "/lib64/" not in platlib:
96 return False
97 unpatched = platlib.replace("/lib64/", "/lib/")
98 return unpatched.replace("$platbase/", "$base/") == scheme["purelib"]
99
100
101@functools.cache
102def _looks_like_red_hat_lib() -> bool:
103 """Red Hat patches platlib in unix_prefix and unix_home, but not purelib.
104
105 This is the only way I can see to tell a Red Hat-patched Python.
106 """
107 from distutils.command.install import INSTALL_SCHEMES
108
109 return all(
110 k in INSTALL_SCHEMES
111 and _looks_like_red_hat_patched_platlib_purelib(INSTALL_SCHEMES[k])
112 for k in ("unix_prefix", "unix_home")
113 )
114
115
116@functools.cache
117def _looks_like_debian_scheme() -> bool:
118 """Debian adds two additional schemes."""
119 from distutils.command.install import INSTALL_SCHEMES
120
121 return "deb_system" in INSTALL_SCHEMES and "unix_local" in INSTALL_SCHEMES
122
123
124@functools.cache
125def _looks_like_red_hat_scheme() -> bool:
126 """Red Hat patches ``sys.prefix`` and ``sys.exec_prefix``.
127
128 Red Hat's ``00251-change-user-install-location.patch`` changes the install
129 command's ``prefix`` and ``exec_prefix`` to append ``"/local"``. This is
130 (fortunately?) done quite unconditionally, so we create a default command
131 object without any configuration to detect this.
132 """
133 from distutils.command.install import install
134 from distutils.dist import Distribution
135
136 cmd = install(Distribution())
137 cmd.finalize_options()
138 return (
139 cmd.exec_prefix == f"{os.path.normpath(sys.exec_prefix)}/local"
140 and cmd.prefix == f"{os.path.normpath(sys.prefix)}/local"
141 )
142
143
144@functools.cache
145def _looks_like_slackware_scheme() -> bool:
146 """Slackware patches sysconfig but fails to patch distutils and site.
147
148 Slackware changes sysconfig's user scheme to use ``"lib64"`` for the lib
149 path, but does not do the same to the site module.
150 """
151 if user_site is None: # User-site not available.
152 return False
153 try:
154 paths = sysconfig.get_paths(scheme="posix_user", expand=False)
155 except KeyError: # User-site not available.
156 return False
157 return "/lib64/" in paths["purelib"] and "/lib64/" not in user_site
158
159
160@functools.cache
161def _looks_like_msys2_mingw_scheme() -> bool:
162 """MSYS2 patches distutils and sysconfig to use a UNIX-like scheme.
163
164 However, MSYS2 incorrectly patches sysconfig ``nt`` scheme. The fix is
165 likely going to be included in their 3.10 release, so we ignore the warning.
166 See msys2/MINGW-packages#9319.
167
168 MSYS2 MINGW's patch uses lowercase ``"lib"`` instead of the usual uppercase,
169 and is missing the final ``"site-packages"``.
170 """
171 paths = sysconfig.get_paths("nt", expand=False)
172 return all(
173 "Lib" not in p and "lib" in p and not p.endswith("site-packages")
174 for p in (paths[key] for key in ("platlib", "purelib"))
175 )
176
177
178@functools.cache
179def _warn_mismatched(old: pathlib.Path, new: pathlib.Path, *, key: str) -> None:
180 issue_url = "https://github.com/pypa/pip/issues/10151"
181 message = (
182 "Value for %s does not match. Please report this to <%s>"
183 "\ndistutils: %s"
184 "\nsysconfig: %s"
185 )
186 logger.log(_MISMATCH_LEVEL, message, key, issue_url, old, new)
187
188
189def _warn_if_mismatch(old: pathlib.Path, new: pathlib.Path, *, key: str) -> bool:
190 if old == new:
191 return False
192 _warn_mismatched(old, new, key=key)
193 return True
194
195
196@functools.cache
197def _log_context(
198 *,
199 user: bool = False,
200 home: str | None = None,
201 root: str | None = None,
202 prefix: str | None = None,
203) -> None:
204 parts = [
205 "Additional context:",
206 "user = %r",
207 "home = %r",
208 "root = %r",
209 "prefix = %r",
210 ]
211
212 logger.log(_MISMATCH_LEVEL, "\n".join(parts), user, home, root, prefix)
213
214
215def get_scheme(
216 dist_name: str,
217 user: bool = False,
218 home: str | None = None,
219 root: str | None = None,
220 isolated: bool = False,
221 prefix: str | None = None,
222) -> Scheme:
223 new = _sysconfig.get_scheme(
224 dist_name,
225 user=user,
226 home=home,
227 root=root,
228 isolated=isolated,
229 prefix=prefix,
230 )
231 if _USE_SYSCONFIG:
232 return new
233
234 old = _distutils.get_scheme(
235 dist_name,
236 user=user,
237 home=home,
238 root=root,
239 isolated=isolated,
240 prefix=prefix,
241 )
242
243 warning_contexts = []
244 for k in SCHEME_KEYS:
245 old_v = pathlib.Path(getattr(old, k))
246 new_v = pathlib.Path(getattr(new, k))
247
248 if old_v == new_v:
249 continue
250
251 # distutils incorrectly put PyPy packages under ``site-packages/python``
252 # in the ``posix_home`` scheme, but PyPy devs said they expect the
253 # directory name to be ``pypy`` instead. So we treat this as a bug fix
254 # and not warn about it. See bpo-43307 and python/cpython#24628.
255 skip_pypy_special_case = (
256 sys.implementation.name == "pypy"
257 and home is not None
258 and k in ("platlib", "purelib")
259 and old_v.parent == new_v.parent
260 and old_v.name.startswith("python")
261 and new_v.name.startswith("pypy")
262 )
263 if skip_pypy_special_case:
264 continue
265
266 # sysconfig's ``osx_framework_user`` does not include ``pythonX.Y`` in
267 # the ``include`` value, but distutils's ``headers`` does. We'll let
268 # CPython decide whether this is a bug or feature. See bpo-43948.
269 skip_osx_framework_user_special_case = (
270 user
271 and is_osx_framework()
272 and k == "headers"
273 and old_v.parent.parent == new_v.parent
274 and old_v.parent.name.startswith("python")
275 )
276 if skip_osx_framework_user_special_case:
277 continue
278
279 # On Red Hat and derived Linux distributions, distutils is patched to
280 # use "lib64" instead of "lib" for platlib.
281 if k == "platlib" and _looks_like_red_hat_lib():
282 continue
283
284 # On Python 3.9+, sysconfig's posix_user scheme sets platlib against
285 # sys.platlibdir, but distutils's unix_user incorrectly continues
286 # using the same $usersite for both platlib and purelib. This creates a
287 # mismatch when sys.platlibdir is not "lib".
288 skip_bpo_44860 = (
289 user
290 and k == "platlib"
291 and not WINDOWS
292 and _PLATLIBDIR != "lib"
293 and _looks_like_bpo_44860()
294 )
295 if skip_bpo_44860:
296 continue
297
298 # Slackware incorrectly patches posix_user to use lib64 instead of lib,
299 # but not usersite to match the location.
300 skip_slackware_user_scheme = (
301 user
302 and k in ("platlib", "purelib")
303 and not WINDOWS
304 and _looks_like_slackware_scheme()
305 )
306 if skip_slackware_user_scheme:
307 continue
308
309 # Both Debian and Red Hat patch Python to place the system site under
310 # /usr/local instead of /usr. Debian also places lib in dist-packages
311 # instead of site-packages, but the /usr/local check should cover it.
312 skip_linux_system_special_case = (
313 not (user or home or prefix or running_under_virtualenv())
314 and old_v.parts[1:3] == ("usr", "local")
315 and len(new_v.parts) > 1
316 and new_v.parts[1] == "usr"
317 and (len(new_v.parts) < 3 or new_v.parts[2] != "local")
318 and (_looks_like_red_hat_scheme() or _looks_like_debian_scheme())
319 )
320 if skip_linux_system_special_case:
321 continue
322
323 # MSYS2 MINGW's sysconfig patch does not include the "site-packages"
324 # part of the path. This is incorrect and will be fixed in MSYS.
325 skip_msys2_mingw_bug = (
326 WINDOWS and k in ("platlib", "purelib") and _looks_like_msys2_mingw_scheme()
327 )
328 if skip_msys2_mingw_bug:
329 continue
330
331 # CPython's POSIX install script invokes pip (via ensurepip) against the
332 # interpreter located in the source tree, not the install site. This
333 # triggers special logic in sysconfig that's not present in distutils.
334 # https://github.com/python/cpython/blob/8c21941ddaf/Lib/sysconfig.py#L178-L194
335 skip_cpython_build = (
336 sysconfig.is_python_build(check_home=True)
337 and not WINDOWS
338 and k in ("headers", "include", "platinclude")
339 )
340 if skip_cpython_build:
341 continue
342
343 warning_contexts.append((old_v, new_v, f"scheme.{k}"))
344
345 if not warning_contexts:
346 return old
347
348 # Check if this path mismatch is caused by distutils config files. Those
349 # files will no longer work once we switch to sysconfig, so this raises a
350 # deprecation message for them.
351 default_old = _distutils.distutils_scheme(
352 dist_name,
353 user,
354 home,
355 root,
356 isolated,
357 prefix,
358 ignore_config_files=True,
359 )
360 if any(default_old[k] != getattr(old, k) for k in SCHEME_KEYS):
361 deprecated(
362 reason=(
363 "Configuring installation scheme with distutils config files "
364 "is deprecated and will no longer work in the near future. If you "
365 "are using a Homebrew or Linuxbrew Python, please see discussion "
366 "at https://github.com/Homebrew/homebrew-core/issues/76621"
367 ),
368 replacement=None,
369 gone_in=None,
370 )
371 return old
372
373 # Post warnings about this mismatch so user can report them back.
374 for old_v, new_v, key in warning_contexts:
375 _warn_mismatched(old_v, new_v, key=key)
376 _log_context(user=user, home=home, root=root, prefix=prefix)
377
378 return old
379
380
381def get_bin_prefix() -> str:
382 new = _sysconfig.get_bin_prefix()
383 if _USE_SYSCONFIG:
384 return new
385
386 old = _distutils.get_bin_prefix()
387 if _warn_if_mismatch(pathlib.Path(old), pathlib.Path(new), key="bin_prefix"):
388 _log_context()
389 return old
390
391
392def get_bin_user() -> str:
393 return _sysconfig.get_scheme("", user=True).scripts
394
395
396def _looks_like_deb_system_dist_packages(value: str) -> bool:
397 """Check if the value is Debian's APT-controlled dist-packages.
398
399 Debian's ``distutils.sysconfig.get_python_lib()`` implementation returns the
400 default package path controlled by APT, but does not patch ``sysconfig`` to
401 do the same. This is similar to the bug worked around in ``get_scheme()``,
402 but here the default is ``deb_system`` instead of ``unix_local``. Ultimately
403 we can't do anything about this Debian bug, and this detection allows us to
404 skip the warning when needed.
405 """
406 if not _looks_like_debian_scheme():
407 return False
408 if value == "/usr/lib/python3/dist-packages":
409 return True
410 return False
411
412
413def get_purelib() -> str:
414 """Return the default pure-Python lib location."""
415 new = _sysconfig.get_purelib()
416 if _USE_SYSCONFIG:
417 return new
418
419 old = _distutils.get_purelib()
420 if _looks_like_deb_system_dist_packages(old):
421 return old
422 if _warn_if_mismatch(pathlib.Path(old), pathlib.Path(new), key="purelib"):
423 _log_context()
424 return old
425
426
427def get_platlib() -> str:
428 """Return the default platform-shared lib location."""
429 new = _sysconfig.get_platlib()
430 if _USE_SYSCONFIG:
431 return new
432
433 from . import _distutils
434
435 old = _distutils.get_platlib()
436 if _looks_like_deb_system_dist_packages(old):
437 return old
438 if _warn_if_mismatch(pathlib.Path(old), pathlib.Path(new), key="platlib"):
439 _log_context()
440 return old