Skip to content

Commit

Permalink
Merge pull request #11663 from uranusjr/pep-668
Browse files Browse the repository at this point in the history
  • Loading branch information
uranusjr authored Jan 18, 2023
2 parents a84317b + 5e5480b commit 95a58e7
Show file tree
Hide file tree
Showing 7 changed files with 390 additions and 5 deletions.
3 changes: 3 additions & 0 deletions news/11381.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Implement logic to read the ``EXTERNALLY-MANAGED`` file as specified in PEP 668.
This allows a downstream Python distributor to prevent users from using pip to
modify the externally managed environment.
15 changes: 15 additions & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from pip._internal.utils.filesystem import test_writable_dir
from pip._internal.utils.logging import getLogger
from pip._internal.utils.misc import (
check_externally_managed,
ensure_dir,
get_pip_version,
protect_pip_from_modification_on_windows,
Expand Down Expand Up @@ -284,6 +285,20 @@ def run(self, options: Values, args: List[str]) -> int:
if options.use_user_site and options.target_dir is not None:
raise CommandError("Can not combine '--user' and '--target'")

# Check whether the environment we're installing into is externally
# managed, as specified in PEP 668. Specifying --root, --target, or
# --prefix disables the check, since there's no reliable way to locate
# the EXTERNALLY-MANAGED file for those cases. An exception is also
# made specifically for "--dry-run --report" for convenience.
installing_into_current_environment = (
not (options.dry_run and options.json_report_file)
and options.root_path is None
and options.target_dir is None
and options.prefix_path is None
)
if installing_into_current_environment:
check_externally_managed()

upgrade_strategy = "to-satisfy-only"
if options.upgrade:
upgrade_strategy = options.upgrade_strategy
Expand Down
7 changes: 6 additions & 1 deletion src/pip/_internal/commands/uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
install_req_from_line,
install_req_from_parsed_requirement,
)
from pip._internal.utils.misc import protect_pip_from_modification_on_windows
from pip._internal.utils.misc import (
check_externally_managed,
protect_pip_from_modification_on_windows,
)

logger = logging.getLogger(__name__)

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

check_externally_managed()

protect_pip_from_modification_on_windows(
modifying_pip="pip" in reqs_to_uninstall
)
Expand Down
87 changes: 86 additions & 1 deletion src/pip/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
"""

import configparser
import contextlib
import locale
import logging
import pathlib
import re
import sys
from itertools import chain, groupby, repeat
from typing import TYPE_CHECKING, Dict, List, Optional, Union
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union

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

logger = logging.getLogger(__name__)


#
# Scaffolding
Expand Down Expand Up @@ -658,3 +665,81 @@ def __str__(self) -> str:
assert self.error is not None
message_part = f".\n{self.error}\n"
return f"Configuration file {self.reason}{message_part}"


_DEFAULT_EXTERNALLY_MANAGED_ERROR = f"""\
The Python environment under {sys.prefix} is managed externally, and may not be
manipulated by the user. Please use specific tooling from the distributor of
the Python installation to interact with this environment instead.
"""


class ExternallyManagedEnvironment(DiagnosticPipError):
"""The current environment is externally managed.
This is raised when the current environment is externally managed, as
defined by `PEP 668`_. The ``EXTERNALLY-MANAGED`` configuration is checked
and displayed when the error is bubbled up to the user.
:param error: The error message read from ``EXTERNALLY-MANAGED``.
"""

reference = "externally-managed-environment"

def __init__(self, error: Optional[str]) -> None:
if error is None:
context = Text(_DEFAULT_EXTERNALLY_MANAGED_ERROR)
else:
context = Text(error)
super().__init__(
message="This environment is externally managed",
context=context,
note_stmt=(
"If you believe this is a mistake, please contact your "
"Python installation or OS distribution provider."
),
hint_stmt=Text("See PEP 668 for the detailed specification."),
)

@staticmethod
def _iter_externally_managed_error_keys() -> Iterator[str]:
# LC_MESSAGES is in POSIX, but not the C standard. The most common
# platform that does not implement this category is Windows, where
# using other categories for console message localization is equally
# unreliable, so we fall back to the locale-less vendor message. This
# can always be re-evaluated when a vendor proposes a new alternative.
try:
category = locale.LC_MESSAGES
except AttributeError:
lang: Optional[str] = None
else:
lang, _ = locale.getlocale(category)
if lang is not None:
yield f"Error-{lang}"
for sep in ("-", "_"):
before, found, _ = lang.partition(sep)
if not found:
continue
yield f"Error-{before}"
yield "Error"

@classmethod
def from_config(
cls,
config: Union[pathlib.Path, str],
) -> "ExternallyManagedEnvironment":
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read(config, encoding="utf-8")
section = parser["externally-managed"]
for key in cls._iter_externally_managed_error_keys():
with contextlib.suppress(KeyError):
return cls(section[key])
except KeyError:
pass
except (OSError, UnicodeDecodeError, configparser.ParsingError):
from pip._internal.utils._log import VERBOSE

exc_info = logger.isEnabledFor(VERBOSE)
logger.warning("Failed to read %s", config, exc_info=exc_info)
return cls(None)
20 changes: 18 additions & 2 deletions src/pip/_internal/utils/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import shutil
import stat
import sys
import sysconfig
import urllib.parse
from io import StringIO
from itertools import filterfalse, tee, zip_longest
Expand All @@ -38,7 +39,7 @@
from pip._vendor.tenacity import retry, stop_after_delay, wait_fixed

from pip import __version__
from pip._internal.exceptions import CommandError
from pip._internal.exceptions import CommandError, ExternallyManagedEnvironment
from pip._internal.locations import get_major_minor_version
from pip._internal.utils.compat import WINDOWS
from pip._internal.utils.virtualenv import running_under_virtualenv
Expand All @@ -57,10 +58,10 @@
"captured_stdout",
"ensure_dir",
"remove_auth_from_url",
"check_externally_managed",
"ConfiguredBuildBackendHookCaller",
]


logger = logging.getLogger(__name__)

T = TypeVar("T")
Expand Down Expand Up @@ -581,6 +582,21 @@ def protect_pip_from_modification_on_windows(modifying_pip: bool) -> None:
)


def check_externally_managed() -> None:
"""Check whether the current environment is externally managed.
If the ``EXTERNALLY-MANAGED`` config file is found, the current environment
is considered externally managed, and an ExternallyManagedEnvironment is
raised.
"""
if running_under_virtualenv():
return
marker = os.path.join(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED")
if not os.path.isfile(marker):
return
raise ExternallyManagedEnvironment.from_config(marker)


def is_console_interactive() -> bool:
"""Is this console interactive?"""
return sys.stdin is not None and sys.stdin.isatty()
Expand Down
81 changes: 81 additions & 0 deletions tests/functional/test_pep668.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import json
import pathlib
import textwrap
from typing import List

import pytest

from tests.lib import PipTestEnvironment, create_basic_wheel_for_package
from tests.lib.venv import VirtualEnvironment


@pytest.fixture()
def patch_check_externally_managed(virtualenv: VirtualEnvironment) -> None:
# Since the tests are run from a virtual environment, and we can't
# guarantee access to the actual stdlib location (where EXTERNALLY-MANAGED
# needs to go into), we patch the check to always raise a simple message.
virtualenv.sitecustomize = textwrap.dedent(
"""\
from pip._internal.exceptions import ExternallyManagedEnvironment
from pip._internal.utils import misc
def check_externally_managed():
raise ExternallyManagedEnvironment("I am externally managed")
misc.check_externally_managed = check_externally_managed
"""
)


@pytest.mark.parametrize(
"arguments",
[
pytest.param(["install"], id="install"),
pytest.param(["install", "--user"], id="install-user"),
pytest.param(["install", "--dry-run"], id="install-dry-run"),
pytest.param(["uninstall", "-y"], id="uninstall"),
],
)
@pytest.mark.usefixtures("patch_check_externally_managed")
def test_fails(script: PipTestEnvironment, arguments: List[str]) -> None:
result = script.pip(*arguments, "pip", expect_error=True)
assert "I am externally managed" in result.stderr


@pytest.mark.parametrize(
"arguments",
[
pytest.param(["install", "--root"], id="install-root"),
pytest.param(["install", "--prefix"], id="install-prefix"),
pytest.param(["install", "--target"], id="install-target"),
],
)
@pytest.mark.usefixtures("patch_check_externally_managed")
def test_allows_if_out_of_environment(
script: PipTestEnvironment,
arguments: List[str],
) -> None:
wheel = create_basic_wheel_for_package(script, "foo", "1.0")
result = script.pip(*arguments, script.scratch_path, wheel.as_uri())
assert "Successfully installed foo-1.0" in result.stdout
assert "I am externally managed" not in result.stderr


@pytest.mark.usefixtures("patch_check_externally_managed")
def test_allows_install_dry_run(
script: PipTestEnvironment,
tmp_path: pathlib.Path,
) -> None:
output = tmp_path.joinpath("out.json")
wheel = create_basic_wheel_for_package(script, "foo", "1.0")
result = script.pip(
"install",
"--dry-run",
f"--report={output.as_posix()}",
wheel.as_uri(),
expect_stderr=True,
)
assert "Would install foo-1.0" in result.stdout
assert "I am externally managed" not in result.stderr
with output.open(encoding="utf8") as f:
assert isinstance(json.load(f), dict)
Loading

0 comments on commit 95a58e7

Please sign in to comment.