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