Skip to content

Commit 95a58e7

Browse files
authored
Merge pull request pypa#11663 from uranusjr/pep-668
2 parents a84317b + 5e5480b commit 95a58e7

File tree

7 files changed

+390
-5
lines changed

7 files changed

+390
-5
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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from pip._internal.utils.filesystem import test_writable_dir
4242
from pip._internal.utils.logging import getLogger
4343
from pip._internal.utils.misc import (
44+
check_externally_managed,
4445
ensure_dir,
4546
get_pip_version,
4647
protect_pip_from_modification_on_windows,
@@ -284,6 +285,20 @@ 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. An exception is also
292+
# made specifically for "--dry-run --report" for convenience.
293+
installing_into_current_environment = (
294+
not (options.dry_run and options.json_report_file)
295+
and options.root_path is None
296+
and options.target_dir is None
297+
and options.prefix_path is None
298+
)
299+
if installing_into_current_environment:
300+
check_externally_managed()
301+
287302
upgrade_strategy = "to-satisfy-only"
288303
if options.upgrade:
289304
upgrade_strategy = options.upgrade_strategy

src/pip/_internal/commands/uninstall.py

Lines changed: 6 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+
check_externally_managed,
19+
protect_pip_from_modification_on_windows,
20+
)
1821

1922
logger = logging.getLogger(__name__)
2023

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

96+
check_externally_managed()
97+
9398
protect_pip_from_modification_on_windows(
9499
modifying_pip="pip" in reqs_to_uninstall
95100
)

src/pip/_internal/exceptions.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@
66
"""
77

88
import configparser
9+
import contextlib
10+
import locale
11+
import logging
12+
import pathlib
913
import re
14+
import sys
1015
from itertools import chain, groupby, repeat
11-
from typing import TYPE_CHECKING, Dict, List, Optional, Union
16+
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union
1217

1318
from pip._vendor.requests.models import Request, Response
1419
from pip._vendor.rich.console import Console, ConsoleOptions, RenderResult
@@ -22,6 +27,8 @@
2227
from pip._internal.metadata import BaseDistribution
2328
from pip._internal.req.req_install import InstallRequirement
2429

30+
logger = logging.getLogger(__name__)
31+
2532

2633
#
2734
# Scaffolding
@@ -658,3 +665,81 @@ def __str__(self) -> str:
658665
assert self.error is not None
659666
message_part = f".\n{self.error}\n"
660667
return f"Configuration file {self.reason}{message_part}"
668+
669+
670+
_DEFAULT_EXTERNALLY_MANAGED_ERROR = f"""\
671+
The Python environment under {sys.prefix} is managed externally, and may not be
672+
manipulated by the user. Please use specific tooling from the distributor of
673+
the Python installation to interact with this environment instead.
674+
"""
675+
676+
677+
class ExternallyManagedEnvironment(DiagnosticPipError):
678+
"""The current environment is externally managed.
679+
680+
This is raised when the current environment is externally managed, as
681+
defined by `PEP 668`_. The ``EXTERNALLY-MANAGED`` configuration is checked
682+
and displayed when the error is bubbled up to the user.
683+
684+
:param error: The error message read from ``EXTERNALLY-MANAGED``.
685+
"""
686+
687+
reference = "externally-managed-environment"
688+
689+
def __init__(self, error: Optional[str]) -> None:
690+
if error is None:
691+
context = Text(_DEFAULT_EXTERNALLY_MANAGED_ERROR)
692+
else:
693+
context = Text(error)
694+
super().__init__(
695+
message="This environment is externally managed",
696+
context=context,
697+
note_stmt=(
698+
"If you believe this is a mistake, please contact your "
699+
"Python installation or OS distribution provider."
700+
),
701+
hint_stmt=Text("See PEP 668 for the detailed specification."),
702+
)
703+
704+
@staticmethod
705+
def _iter_externally_managed_error_keys() -> Iterator[str]:
706+
# LC_MESSAGES is in POSIX, but not the C standard. The most common
707+
# platform that does not implement this category is Windows, where
708+
# using other categories for console message localization is equally
709+
# unreliable, so we fall back to the locale-less vendor message. This
710+
# can always be re-evaluated when a vendor proposes a new alternative.
711+
try:
712+
category = locale.LC_MESSAGES
713+
except AttributeError:
714+
lang: Optional[str] = None
715+
else:
716+
lang, _ = locale.getlocale(category)
717+
if lang is not None:
718+
yield f"Error-{lang}"
719+
for sep in ("-", "_"):
720+
before, found, _ = lang.partition(sep)
721+
if not found:
722+
continue
723+
yield f"Error-{before}"
724+
yield "Error"
725+
726+
@classmethod
727+
def from_config(
728+
cls,
729+
config: Union[pathlib.Path, str],
730+
) -> "ExternallyManagedEnvironment":
731+
parser = configparser.ConfigParser(interpolation=None)
732+
try:
733+
parser.read(config, encoding="utf-8")
734+
section = parser["externally-managed"]
735+
for key in cls._iter_externally_managed_error_keys():
736+
with contextlib.suppress(KeyError):
737+
return cls(section[key])
738+
except KeyError:
739+
pass
740+
except (OSError, UnicodeDecodeError, configparser.ParsingError):
741+
from pip._internal.utils._log import VERBOSE
742+
743+
exc_info = logger.isEnabledFor(VERBOSE)
744+
logger.warning("Failed to read %s", config, exc_info=exc_info)
745+
return cls(None)

src/pip/_internal/utils/misc.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import shutil
1313
import stat
1414
import sys
15+
import sysconfig
1516
import urllib.parse
1617
from io import StringIO
1718
from itertools import filterfalse, tee, zip_longest
@@ -38,7 +39,7 @@
3839
from pip._vendor.tenacity import retry, stop_after_delay, wait_fixed
3940

4041
from pip import __version__
41-
from pip._internal.exceptions import CommandError
42+
from pip._internal.exceptions import CommandError, ExternallyManagedEnvironment
4243
from pip._internal.locations import get_major_minor_version
4344
from pip._internal.utils.compat import WINDOWS
4445
from pip._internal.utils.virtualenv import running_under_virtualenv
@@ -57,10 +58,10 @@
5758
"captured_stdout",
5859
"ensure_dir",
5960
"remove_auth_from_url",
61+
"check_externally_managed",
6062
"ConfiguredBuildBackendHookCaller",
6163
]
6264

63-
6465
logger = logging.getLogger(__name__)
6566

6667
T = TypeVar("T")
@@ -581,6 +582,21 @@ def protect_pip_from_modification_on_windows(modifying_pip: bool) -> None:
581582
)
582583

583584

585+
def check_externally_managed() -> None:
586+
"""Check whether the current environment is externally managed.
587+
588+
If the ``EXTERNALLY-MANAGED`` config file is found, the current environment
589+
is considered externally managed, and an ExternallyManagedEnvironment is
590+
raised.
591+
"""
592+
if running_under_virtualenv():
593+
return
594+
marker = os.path.join(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED")
595+
if not os.path.isfile(marker):
596+
return
597+
raise ExternallyManagedEnvironment.from_config(marker)
598+
599+
584600
def is_console_interactive() -> bool:
585601
"""Is this console interactive?"""
586602
return sys.stdin is not None and sys.stdin.isatty()

tests/functional/test_pep668.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import json
2+
import pathlib
3+
import textwrap
4+
from typing import List
5+
6+
import pytest
7+
8+
from tests.lib import PipTestEnvironment, create_basic_wheel_for_package
9+
from tests.lib.venv import VirtualEnvironment
10+
11+
12+
@pytest.fixture()
13+
def patch_check_externally_managed(virtualenv: VirtualEnvironment) -> None:
14+
# Since the tests are run from a virtual environment, and we can't
15+
# guarantee access to the actual stdlib location (where EXTERNALLY-MANAGED
16+
# needs to go into), we patch the check to always raise a simple message.
17+
virtualenv.sitecustomize = textwrap.dedent(
18+
"""\
19+
from pip._internal.exceptions import ExternallyManagedEnvironment
20+
from pip._internal.utils import misc
21+
22+
def check_externally_managed():
23+
raise ExternallyManagedEnvironment("I am externally managed")
24+
25+
misc.check_externally_managed = check_externally_managed
26+
"""
27+
)
28+
29+
30+
@pytest.mark.parametrize(
31+
"arguments",
32+
[
33+
pytest.param(["install"], id="install"),
34+
pytest.param(["install", "--user"], id="install-user"),
35+
pytest.param(["install", "--dry-run"], id="install-dry-run"),
36+
pytest.param(["uninstall", "-y"], id="uninstall"),
37+
],
38+
)
39+
@pytest.mark.usefixtures("patch_check_externally_managed")
40+
def test_fails(script: PipTestEnvironment, arguments: List[str]) -> None:
41+
result = script.pip(*arguments, "pip", expect_error=True)
42+
assert "I am externally managed" in result.stderr
43+
44+
45+
@pytest.mark.parametrize(
46+
"arguments",
47+
[
48+
pytest.param(["install", "--root"], id="install-root"),
49+
pytest.param(["install", "--prefix"], id="install-prefix"),
50+
pytest.param(["install", "--target"], id="install-target"),
51+
],
52+
)
53+
@pytest.mark.usefixtures("patch_check_externally_managed")
54+
def test_allows_if_out_of_environment(
55+
script: PipTestEnvironment,
56+
arguments: List[str],
57+
) -> None:
58+
wheel = create_basic_wheel_for_package(script, "foo", "1.0")
59+
result = script.pip(*arguments, script.scratch_path, wheel.as_uri())
60+
assert "Successfully installed foo-1.0" in result.stdout
61+
assert "I am externally managed" not in result.stderr
62+
63+
64+
@pytest.mark.usefixtures("patch_check_externally_managed")
65+
def test_allows_install_dry_run(
66+
script: PipTestEnvironment,
67+
tmp_path: pathlib.Path,
68+
) -> None:
69+
output = tmp_path.joinpath("out.json")
70+
wheel = create_basic_wheel_for_package(script, "foo", "1.0")
71+
result = script.pip(
72+
"install",
73+
"--dry-run",
74+
f"--report={output.as_posix()}",
75+
wheel.as_uri(),
76+
expect_stderr=True,
77+
)
78+
assert "Would install foo-1.0" in result.stdout
79+
assert "I am externally managed" not in result.stderr
80+
with output.open(encoding="utf8") as f:
81+
assert isinstance(json.load(f), dict)

0 commit comments

Comments
 (0)