Skip to content

Commit 69cb3f8

Browse files
committed
Check EXTERNALLY-MANAGED in install and uninstall
This implements the PEP 668 logic to 'pip install' and 'pip uninstall'. Are there any other commands that may need it? This implementation disables the check is any of --prefix, --home, or --target is provided, since those can indicate the command does not actually install into the environment. Note that it is still possible the command is still modifying the environment, but we don't have a way to stop the user *that* determined to break the environment anyway (they can always just use those flags in a virtual environment). Also not sure how best this can be tested.
1 parent ba38c33 commit 69cb3f8

File tree

4 files changed

+83
-1
lines changed

4 files changed

+83
-1
lines changed

news/11381.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Implement logic to read the ``EXTERNALLY-MANAGED`` file as specified in PEP 668.
2+
This allows a downstream Python distributor to prevent users from using pip to
3+
modify the externally managed environment.

src/pip/_internal/commands/install.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from pip._internal.utils.logging import getLogger
4343
from pip._internal.utils.misc import (
4444
ensure_dir,
45+
get_externally_managed_error,
4546
get_pip_version,
4647
protect_pip_from_modification_on_windows,
4748
write_output,
@@ -284,6 +285,21 @@ def run(self, options: Values, args: List[str]) -> int:
284285
if options.use_user_site and options.target_dir is not None:
285286
raise CommandError("Can not combine '--user' and '--target'")
286287

288+
# Check whether the environment we're installing into is externally
289+
# managed, as specified in PEP 668. Specifying --root, --target, or
290+
# --prefix disables the check, since there's no reliable way to locate
291+
# the EXTERNALLY-MANAGED file for those cases.
292+
installing_into_current_environment = (
293+
not options.dry_run
294+
and options.root_path is None
295+
and options.target_dir is None
296+
and options.prefix_path is None
297+
)
298+
if installing_into_current_environment:
299+
externally_managed_error = get_externally_managed_error()
300+
if externally_managed_error is not None:
301+
raise InstallationError(externally_managed_error)
302+
287303
upgrade_strategy = "to-satisfy-only"
288304
if options.upgrade:
289305
upgrade_strategy = options.upgrade_strategy

src/pip/_internal/commands/uninstall.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
install_req_from_line,
1515
install_req_from_parsed_requirement,
1616
)
17-
from pip._internal.utils.misc import protect_pip_from_modification_on_windows
17+
from pip._internal.utils.misc import (
18+
get_externally_managed_error,
19+
protect_pip_from_modification_on_windows,
20+
)
1821

1922
logger = logging.getLogger(__name__)
2023

@@ -90,6 +93,10 @@ def run(self, options: Values, args: List[str]) -> int:
9093
f'"pip help {self.name}")'
9194
)
9295

96+
externally_managed_error = get_externally_managed_error()
97+
if externally_managed_error is not None:
98+
raise InstallationError(externally_managed_error)
99+
93100
protect_pip_from_modification_on_windows(
94101
modifying_pip="pip" in reqs_to_uninstall
95102
)

src/pip/_internal/utils/misc.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
# The following comment should be removed at some point in the future.
22
# mypy: strict-optional=False
33

4+
import configparser
45
import contextlib
56
import errno
67
import getpass
78
import hashlib
89
import io
10+
import locale
911
import logging
1012
import os
1113
import posixpath
1214
import shutil
1315
import stat
1416
import sys
17+
import sysconfig
1518
import urllib.parse
1619
from io import StringIO
1720
from itertools import filterfalse, tee, zip_longest
@@ -57,6 +60,7 @@
5760
"captured_stdout",
5861
"ensure_dir",
5962
"remove_auth_from_url",
63+
"get_externally_managed_error",
6064
"ConfiguredBuildBackendHookCaller",
6165
]
6266

@@ -581,6 +585,58 @@ def protect_pip_from_modification_on_windows(modifying_pip: bool) -> None:
581585
)
582586

583587

588+
_DEFAULT_EXTERNALLY_MANAGED_ERROR = f"""\
589+
The Python environment under {sys.prefix} is managed externally, and may not be
590+
manipulated by the user. Please use specific tooling from the distributor of
591+
the Python installation to interact with this environment instead.
592+
"""
593+
594+
595+
def _iter_externally_managed_error_keys() -> Iterator[str]:
596+
lang, _ = locale.getlocale(locale.LC_MESSAGES)
597+
if lang is not None:
598+
yield f"Error-{lang}"
599+
for sep in ("-", "_"):
600+
before, found, _ = lang.partition(sep)
601+
if not found:
602+
continue
603+
yield f"Error-{before}"
604+
yield "Error"
605+
606+
607+
def get_externally_managed_error() -> Optional[str]:
608+
"""Get an error message from the EXTERNALLY-MANAGED config file.
609+
610+
This checks whether the current environment pip is running in is externally
611+
managed. If the EXTERNALLY-MANAGED file is found, the vendor-provided error
612+
message is read and returned (if available; a default message is used
613+
otherwise), as specified in `PEP 668`_.
614+
615+
If the current environment is *not* externally managed, *None* is returned.
616+
617+
.. _`PEP 668`: https://peps.python.org/pep-0668/
618+
"""
619+
if running_under_virtualenv():
620+
return None
621+
marker = os.path.join(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED")
622+
if not os.path.isfile(marker):
623+
return None
624+
try:
625+
parser = configparser.ConfigParser(interpolation=None)
626+
parser.read(marker, encoding="utf-8")
627+
except (OSError, UnicodeDecodeError) as e:
628+
logger.warning("Ignoring %s due to error %s", marker, e)
629+
return _DEFAULT_EXTERNALLY_MANAGED_ERROR
630+
try:
631+
section = parser["externally-managed"]
632+
except KeyError:
633+
return _DEFAULT_EXTERNALLY_MANAGED_ERROR
634+
for key in _iter_externally_managed_error_keys():
635+
with contextlib.suppress(KeyError):
636+
return section[key]
637+
return _DEFAULT_EXTERNALLY_MANAGED_ERROR
638+
639+
584640
def is_console_interactive() -> bool:
585641
"""Is this console interactive?"""
586642
return sys.stdin is not None and sys.stdin.isatty()

0 commit comments

Comments
 (0)