Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(env): ensure whl installer uses writable path #9014

Merged
merged 3 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/poetry/installation/wheel_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def install(self, wheel: Path) -> None:
except _WheelFileValidationError as e:
self.invalid_wheels[wheel] = e.issues

scheme_dict = self._env.paths.copy()
scheme_dict = self._env.scheme_dict.copy()
scheme_dict["headers"] = str(
Path(scheme_dict["include"]) / source.distribution
)
Expand Down
3 changes: 1 addition & 2 deletions src/poetry/plugins/plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,7 @@ def _install(
project.add_dependency(dependency)

# force new package to be installed in the project cache instead of Poetry's env
poetry_env.paths["platlib"] = str(self._path)
poetry_env.paths["purelib"] = str(self._path)
poetry_env.set_paths(purelib=self._path, platlib=self._path)

self._ensure_cache_directory()

Expand Down
92 changes: 92 additions & 0 deletions src/poetry/utils/env/base_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
from typing import TYPE_CHECKING
from typing import Any

from installer.utils import SCHEME_NAMES
from virtualenv.seed.wheels.embed import get_embed_wheel

from poetry.utils.env.exceptions import EnvCommandError
from poetry.utils.env.site_packages import SitePackages
from poetry.utils.helpers import get_real_windows_path
from poetry.utils.helpers import is_dir_writable


if TYPE_CHECKING:
Expand Down Expand Up @@ -217,6 +219,89 @@ def fallbacks(self) -> list[Path]:
paths += [self.usersite] if self.usersite else []
return paths

def set_paths(
self,
purelib: str | Path | None = None,
platlib: str | Path | None = None,
userbase: str | Path | None = None,
usersite: str | Path | None = None,
) -> None:
"""
A cached property aware way to set environment paths during runtime.

In some cases, like in `PluginManager._install()` method, paths are modified during execution. Direct
modification of `self.paths` is not safe as caches relying on are not invalidated. This helper method
ensures that we clear the relevant caches why paths are modified.
"""
if purelib:
self.paths["purelib"] = str(purelib)

if platlib:
self.paths["platlib"] = str(platlib)

if userbase:
self.paths["userbase"] = str(userbase)

if usersite:
self.paths["usersite"] = str(usersite)

# clear cached properties using the env paths
self.__dict__.pop("fallbacks", None)
self.__dict__.pop("scheme_dict", None)

@cached_property
def scheme_dict(self) -> dict[str, str]:
"""
This property exists to allow cases where system environment paths are not writable and
user site is enabled. This enables us to ensure packages (wheels) are correctly installed
into directories where the current user can write to.

If all candidates in `self.paths` is writable, no modification is made. If at least one path is not writable
and all generated writable candidates are indeed writable, these are used instead. If any candidate is not
writable, the original paths are returned.

Alternative writable candidates are generated by replacing discovered prefix, with "userbase"
if available. The original prefix is computed as the common path prefix of "scripts" and "purelib".
For example, given `{ "purelib": "/usr/local/lib/python3.13/site-packages", "scripts": "/usr/local/bin",
"userbase": "/home/user/.local" }`; the candidate "purelib" path would be
`/home/user/.local/lib/python3.13/site-packages`.
"""
paths = self.paths.copy()

if (
not self.is_venv()
and paths.get("userbase")
and ("scripts" in paths and "purelib" in paths)
):
overrides: dict[str, str] = {}

try:
base_path = os.path.commonpath([paths["scripts"], paths["purelib"]])
except ValueError:
return paths

scheme_names = [key for key in SCHEME_NAMES if key in self.paths]

for key in scheme_names:
if not is_dir_writable(path=Path(paths[key]), create=True):
# there is at least one path that is not writable
break
else:
# all paths are writable, return early
return paths

for key in scheme_names:
candidate = paths[key].replace(base_path, paths["userbase"])
if not is_dir_writable(path=Path(candidate), create=True):
# at least one candidate is not writable, we cannot do much here
return paths

overrides[key] = candidate

paths.update(overrides)

return paths

def _get_lib_dirs(self) -> list[Path]:
return [self.purelib, self.platlib, *self.fallbacks]

Expand Down Expand Up @@ -309,6 +394,13 @@ def run_pip(self, *args: str, **kwargs: Any) -> str:
return self._run(cmd, **kwargs)

def run_python_script(self, content: str, **kwargs: Any) -> str:
# Options Used:
# -I : Run Python in isolated mode. (#6627)
# -W ignore : Suppress warnings.
#
# TODO: Consider replacing (-I) with (-EP) once support for managing Python <3.11 environments dropped.
# This is useful to prevent user site being disabled over zealously.

return self.run(
self._executable,
"-I",
Expand Down
3 changes: 2 additions & 1 deletion src/poetry/utils/env/script_strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ def _version_nodot(version):

if site.check_enableusersite():
paths["usersite"] = site.getusersitepackages()
paths["userbase"] = site.getuserbase()

paths["userbase"] = site.getuserbase()

print(json.dumps(paths))
"""
24 changes: 24 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import keyring
import pytest

from installer.utils import SCHEME_NAMES
from jaraco.classes import properties
from keyring.backend import KeyringBackend
from keyring.backends.fail import Keyring as FailKeyring
Expand All @@ -39,6 +40,7 @@
from poetry.repositories.installed_repository import InstalledRepository
from poetry.utils.cache import ArtifactCache
from poetry.utils.env import EnvManager
from poetry.utils.env import MockEnv
from poetry.utils.env import SystemEnv
from poetry.utils.env import VirtualEnv
from poetry.utils.password_manager import PoetryKeyring
Expand Down Expand Up @@ -722,3 +724,25 @@ def handle(self) -> int:
@pytest.fixture(autouse=True)
def default_keyring(with_null_keyring: None) -> None:
pass


@pytest.fixture
def system_env(tmp_path_factory: TempPathFactory, mocker: MockerFixture) -> SystemEnv:
base_path = tmp_path_factory.mktemp("system_env")
env = MockEnv(path=base_path, sys_path=[str(base_path / "purelib")])
assert env.path.is_dir()

userbase = env.path / "userbase"
userbase.mkdir(exist_ok=False)
env.paths["userbase"] = str(userbase)

paths = {str(scheme): str(env.path / scheme) for scheme in SCHEME_NAMES}
env.paths.update(paths)

for path in paths.values():
Path(path).mkdir(exist_ok=False)

mocker.patch.object(EnvManager, "get_system_env", return_value=env)

env.set_paths()
return env
11 changes: 1 addition & 10 deletions tests/plugins/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@
from poetry.repositories import Repository
from poetry.repositories import RepositoryPool
from poetry.repositories.installed_repository import InstalledRepository
from poetry.utils.env import Env
from poetry.utils.env import EnvManager
from poetry.utils.env import MockEnv
from tests.helpers import mock_metadata_entry_points


Expand All @@ -40,6 +37,7 @@
from pytest_mock import MockerFixture

from poetry.console.commands.command import Command
from poetry.utils.env import Env
from tests.conftest import Config
from tests.types import FixtureDirGetter

Expand Down Expand Up @@ -84,13 +82,6 @@ def pool(repo: Repository) -> RepositoryPool:
return pool


@pytest.fixture
def system_env(tmp_path: Path, mocker: MockerFixture) -> Env:
env = MockEnv(path=tmp_path, sys_path=[str(tmp_path / "purelib")])
mocker.patch.object(EnvManager, "get_system_env", return_value=env)
return env


@pytest.fixture
def poetry(fixture_dir: FixtureDirGetter, config: Config) -> Poetry:
project_path = fixture_dir("simple_project")
Expand Down
38 changes: 38 additions & 0 deletions tests/utils/env/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

import pytest

from deepdiff.diff import DeepDiff
from installer.utils import SCHEME_NAMES

from poetry.factory import Factory
from poetry.repositories.installed_repository import InstalledRepository
from poetry.utils._compat import WINDOWS
Expand All @@ -23,6 +26,7 @@
from poetry.utils.env import VirtualEnv
from poetry.utils.env import build_environment
from poetry.utils.env import ephemeral_environment
from poetry.utils.helpers import is_dir_writable


if TYPE_CHECKING:
Expand Down Expand Up @@ -510,3 +514,37 @@ def test_command_from_bin_preserves_relative_path(manager: EnvManager) -> None:
env = manager.get()
command = env.get_command_from_bin("./foo.py")
assert command == ["./foo.py"]


@pytest.fixture
def system_env_read_only(system_env: SystemEnv, mocker: MockerFixture) -> SystemEnv:
original_is_dir_writable = is_dir_writable

read_only_paths = {system_env.paths[key] for key in SCHEME_NAMES}

def mock_is_dir_writable(path: Path, create: bool = False) -> bool:
if str(path) in read_only_paths:
return False
return original_is_dir_writable(path, create)

mocker.patch("poetry.utils.env.base_env.is_dir_writable", new=mock_is_dir_writable)

return system_env


def test_env_scheme_dict_returns_original_when_writable(system_env: SystemEnv) -> None:
assert not DeepDiff(system_env.scheme_dict, system_env.paths, ignore_order=True)


def test_env_scheme_dict_returns_modified_when_read_only(
system_env_read_only: SystemEnv,
) -> None:
scheme_dict = system_env_read_only.scheme_dict
assert DeepDiff(scheme_dict, system_env_read_only.paths, ignore_order=True)

paths = system_env_read_only.paths
assert all(
Path(scheme_dict[scheme]).exists()
and scheme_dict[scheme].startswith(paths["userbase"])
for scheme in SCHEME_NAMES
)