Skip to content

Commit 9be5943

Browse files
committed
fix(env): ensure whl installer uses writable path
This change fixes an issue where when system environment is used as a destination for wheel installation as a user without access to system site packages, installation fails with permission error. Functionally, this now follows the same behaviour as when a package is installed to user site. Relates-to: #8408
1 parent 8d72912 commit 9be5943

File tree

5 files changed

+120
-11
lines changed

5 files changed

+120
-11
lines changed

src/poetry/installation/wheel_installer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def install(self, wheel: Path) -> None:
9595
except _WheelFileValidationError as e:
9696
self.invalid_wheels[wheel] = e.issues
9797

98-
scheme_dict = self._env.paths.copy()
98+
scheme_dict = self._env.scheme_dict.copy()
9999
scheme_dict["headers"] = str(
100100
Path(scheme_dict["include"]) / source.distribution
101101
)

src/poetry/utils/env/base_env.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
from typing import TYPE_CHECKING
1616
from typing import Any
1717

18+
from installer.utils import SCHEME_NAMES
1819
from virtualenv.seed.wheels.embed import get_embed_wheel
1920

2021
from poetry.utils.env.exceptions import EnvCommandError
2122
from poetry.utils.env.site_packages import SitePackages
2223
from poetry.utils.helpers import get_real_windows_path
24+
from poetry.utils.helpers import is_dir_writable
2325

2426

2527
if TYPE_CHECKING:
@@ -245,6 +247,60 @@ def set_paths(
245247

246248
# clear cached properties using the env paths
247249
self.__dict__.pop("fallbacks", None)
250+
self.__dict__.pop("scheme_dict", None)
251+
252+
@cached_property
253+
def scheme_dict(self) -> dict[str, str]:
254+
"""
255+
This property exists to allow cases where system environment paths are not writable and
256+
user site is enabled. This enables us to ensure packages (wheels) are correctly installed
257+
into directories where the current user can write to.
258+
259+
If all candidates in `self.paths` is writable, no modification is made. If at least one path is not writable
260+
and all generated writable candidates are indeed writable, these are used instead. If any candidate is not
261+
writable, the original paths are returned.
262+
263+
Alternative writable candidates are generated by replacing discovered prefix, with "userbase"
264+
if available. The original prefix is computed as the common path prefix of "scripts" and "purelib".
265+
For example, given `{ "purelib": "/usr/local/lib/python3.13/site-packages", "scripts": "/usr/local/bin",
266+
"userbase": "/home/user/.local" }`; the candidate "purelib" path would be
267+
`/home/user/.local/lib/python3.13/site-packages`.
268+
"""
269+
paths = self.paths.copy()
270+
271+
if (
272+
not self.is_venv()
273+
and paths.get("userbase")
274+
and ("scripts" in paths and "purelib" in paths)
275+
):
276+
overrides: dict[str, str] = {}
277+
278+
try:
279+
base_path = os.path.commonpath([paths["scripts"], paths["purelib"]])
280+
except ValueError:
281+
return paths
282+
283+
scheme_names = [key for key in SCHEME_NAMES if key in self.paths]
284+
285+
for key in scheme_names:
286+
if not is_dir_writable(path=Path(paths[key]), create=True):
287+
# there is at least one path that is not writable
288+
break
289+
else:
290+
# all paths are writable, return early
291+
return paths
292+
293+
for key in scheme_names:
294+
candidate = paths[key].replace(base_path, paths["userbase"])
295+
if not is_dir_writable(path=Path(candidate), create=True):
296+
# at least one candidate is not writable, we cannot do much here
297+
return paths
298+
299+
overrides[key] = candidate
300+
301+
paths.update(overrides)
302+
303+
return paths
248304

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

tests/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import keyring
1616
import pytest
1717

18+
from installer.utils import SCHEME_NAMES
1819
from jaraco.classes import properties
1920
from keyring.backend import KeyringBackend
2021
from keyring.backends.fail import Keyring as FailKeyring
@@ -33,6 +34,7 @@
3334
from poetry.repositories.installed_repository import InstalledRepository
3435
from poetry.utils.cache import ArtifactCache
3536
from poetry.utils.env import EnvManager
37+
from poetry.utils.env import MockEnv
3638
from poetry.utils.env import SystemEnv
3739
from poetry.utils.env import VirtualEnv
3840
from poetry.utils.password_manager import PoetryKeyring
@@ -639,3 +641,25 @@ def handle(self) -> int:
639641
@pytest.fixture(autouse=True)
640642
def default_keyring(with_null_keyring: None) -> None:
641643
pass
644+
645+
646+
@pytest.fixture
647+
def system_env(tmp_path_factory: TempPathFactory, mocker: MockerFixture) -> SystemEnv:
648+
base_path = tmp_path_factory.mktemp("system_env")
649+
env = MockEnv(path=base_path, sys_path=[str(base_path / "purelib")])
650+
assert env.path.is_dir()
651+
652+
userbase = env.path / "userbase"
653+
userbase.mkdir(exist_ok=False)
654+
env.paths["userbase"] = str(userbase)
655+
656+
paths = {str(scheme): str(env.path / scheme) for scheme in SCHEME_NAMES}
657+
env.paths.update(paths)
658+
659+
for path in paths.values():
660+
Path(path).mkdir(exist_ok=False)
661+
662+
mocker.patch.object(EnvManager, "get_system_env", return_value=env)
663+
664+
env.set_paths()
665+
return env

tests/plugins/test_plugin_manager.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@
2929
from poetry.repositories import Repository
3030
from poetry.repositories import RepositoryPool
3131
from poetry.repositories.installed_repository import InstalledRepository
32-
from poetry.utils.env import Env
33-
from poetry.utils.env import EnvManager
34-
from poetry.utils.env import MockEnv
3532
from tests.helpers import mock_metadata_entry_points
3633

3734

@@ -40,6 +37,7 @@
4037
from pytest_mock import MockerFixture
4138

4239
from poetry.console.commands.command import Command
40+
from poetry.utils.env import Env
4341
from tests.conftest import Config
4442
from tests.types import FixtureDirGetter
4543

@@ -84,13 +82,6 @@ def pool(repo: Repository) -> RepositoryPool:
8482
return pool
8583

8684

87-
@pytest.fixture
88-
def system_env(tmp_path: Path, mocker: MockerFixture) -> Env:
89-
env = MockEnv(path=tmp_path, sys_path=[str(tmp_path / "purelib")])
90-
mocker.patch.object(EnvManager, "get_system_env", return_value=env)
91-
return env
92-
93-
9485
@pytest.fixture
9586
def poetry(fixture_dir: FixtureDirGetter, config: Config) -> Poetry:
9687
project_path = fixture_dir("simple_project")

tests/utils/env/test_env.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
import pytest
1313

14+
from deepdiff.diff import DeepDiff
15+
from installer.utils import SCHEME_NAMES
16+
1417
from poetry.factory import Factory
1518
from poetry.repositories.installed_repository import InstalledRepository
1619
from poetry.utils._compat import WINDOWS
@@ -23,6 +26,7 @@
2326
from poetry.utils.env import VirtualEnv
2427
from poetry.utils.env import build_environment
2528
from poetry.utils.env import ephemeral_environment
29+
from poetry.utils.helpers import is_dir_writable
2630

2731

2832
if TYPE_CHECKING:
@@ -510,3 +514,37 @@ def test_command_from_bin_preserves_relative_path(manager: EnvManager) -> None:
510514
env = manager.get()
511515
command = env.get_command_from_bin("./foo.py")
512516
assert command == ["./foo.py"]
517+
518+
519+
@pytest.fixture
520+
def system_env_read_only(system_env: SystemEnv, mocker: MockerFixture) -> SystemEnv:
521+
original_is_dir_writable = is_dir_writable
522+
523+
read_only_paths = {system_env.paths[key] for key in SCHEME_NAMES}
524+
525+
def mock_is_dir_writable(path: Path, create: bool = False) -> bool:
526+
if str(path) in read_only_paths:
527+
return False
528+
return original_is_dir_writable(path, create)
529+
530+
mocker.patch("poetry.utils.env.base_env.is_dir_writable", new=mock_is_dir_writable)
531+
532+
return system_env
533+
534+
535+
def test_env_scheme_dict_returns_original_when_writable(system_env: SystemEnv) -> None:
536+
assert not DeepDiff(system_env.scheme_dict, system_env.paths, ignore_order=True)
537+
538+
539+
def test_env_scheme_dict_returns_modified_when_read_only(
540+
system_env_read_only: SystemEnv,
541+
) -> None:
542+
scheme_dict = system_env_read_only.scheme_dict
543+
assert DeepDiff(scheme_dict, system_env_read_only.paths, ignore_order=True)
544+
545+
paths = system_env_read_only.paths
546+
assert all(
547+
Path(scheme_dict[scheme]).exists()
548+
and scheme_dict[scheme].startswith(paths["userbase"])
549+
for scheme in SCHEME_NAMES
550+
)

0 commit comments

Comments
 (0)