Skip to content

Commit c729a84

Browse files
authored
Merge pull request #7002 from takluyver/install-user-fallback
Default to --user install in certain conditions
2 parents ccfd119 + 42b1ef3 commit c729a84

File tree

6 files changed

+168
-14
lines changed

6 files changed

+168
-14
lines changed

news/1668.feature

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Default to doing a user install (as if ``--user`` was passed) when the main
2+
site-packages directory is not writeable and user site-packages are enabled.

src/pip/_internal/commands/install.py

+75-11
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import operator
1313
import os
1414
import shutil
15+
import site
1516
from optparse import SUPPRESS_HELP
1617

1718
from pip._vendor import pkg_resources
@@ -31,7 +32,7 @@
3132
from pip._internal.operations.check import check_install_conflicts
3233
from pip._internal.req import RequirementSet, install_given_reqs
3334
from pip._internal.req.req_tracker import RequirementTracker
34-
from pip._internal.utils.filesystem import check_path_owner
35+
from pip._internal.utils.filesystem import check_path_owner, test_writable_dir
3536
from pip._internal.utils.misc import (
3637
ensure_dir,
3738
get_installed_version,
@@ -292,17 +293,16 @@ def run(self, options, args):
292293

293294
options.src_dir = os.path.abspath(options.src_dir)
294295
install_options = options.install_options or []
296+
297+
options.use_user_site = decide_user_install(
298+
options.use_user_site,
299+
prefix_path=options.prefix_path,
300+
target_dir=options.target_dir,
301+
root_path=options.root_path,
302+
isolated_mode=options.isolated_mode,
303+
)
304+
295305
if options.use_user_site:
296-
if options.prefix_path:
297-
raise CommandError(
298-
"Can not combine '--user' and '--prefix' as they imply "
299-
"different installation locations"
300-
)
301-
if virtualenv_no_global():
302-
raise InstallationError(
303-
"Can not perform a '--user' install. User site-packages "
304-
"are not visible in this virtualenv."
305-
)
306306
install_options.append('--user')
307307
install_options.append('--prefix=')
308308

@@ -594,6 +594,70 @@ def get_lib_location_guesses(*args, **kwargs):
594594
return [scheme['purelib'], scheme['platlib']]
595595

596596

597+
def site_packages_writable(**kwargs):
598+
return all(
599+
test_writable_dir(d) for d in set(get_lib_location_guesses(**kwargs))
600+
)
601+
602+
603+
def decide_user_install(
604+
use_user_site, # type: Optional[bool]
605+
prefix_path=None, # type: Optional[str]
606+
target_dir=None, # type: Optional[str]
607+
root_path=None, # type: Optional[str]
608+
isolated_mode=False, # type: bool
609+
):
610+
# type: (...) -> bool
611+
"""Determine whether to do a user install based on the input options.
612+
613+
If use_user_site is False, no additional checks are done.
614+
If use_user_site is True, it is checked for compatibility with other
615+
options.
616+
If use_user_site is None, the default behaviour depends on the environment,
617+
which is provided by the other arguments.
618+
"""
619+
if use_user_site is False:
620+
logger.debug("Non-user install by explicit request")
621+
return False
622+
623+
if use_user_site is True:
624+
if prefix_path:
625+
raise CommandError(
626+
"Can not combine '--user' and '--prefix' as they imply "
627+
"different installation locations"
628+
)
629+
if virtualenv_no_global():
630+
raise InstallationError(
631+
"Can not perform a '--user' install. User site-packages "
632+
"are not visible in this virtualenv."
633+
)
634+
logger.debug("User install by explicit request")
635+
return True
636+
637+
# If we are here, user installs have not been explicitly requested/avoided
638+
assert use_user_site is None
639+
640+
# user install incompatible with --prefix/--target
641+
if prefix_path or target_dir:
642+
logger.debug("Non-user install due to --prefix or --target option")
643+
return False
644+
645+
# If user installs are not enabled, choose a non-user install
646+
if not site.ENABLE_USER_SITE:
647+
logger.debug("Non-user install because user site-packages disabled")
648+
return False
649+
650+
# If we have permission for a non-user install, do that,
651+
# otherwise do a user install.
652+
if site_packages_writable(root=root_path, isolated=isolated_mode):
653+
logger.debug("Non-user install because site-packages writeable")
654+
return False
655+
656+
logger.info("Defaulting to user installation because normal site-packages "
657+
"is not writeable")
658+
return True
659+
660+
597661
def create_env_error_message(error, show_traceback, using_user_site):
598662
"""Format an error message for an EnvironmentError
599663

src/pip/_internal/utils/filesystem.py

+54
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import errno
12
import os
23
import os.path
4+
import random
35
import shutil
46
import stat
57
from contextlib import contextmanager
@@ -113,3 +115,55 @@ def replace(src, dest):
113115

114116
else:
115117
replace = _replace_retry(os.replace)
118+
119+
120+
# test_writable_dir and _test_writable_dir_win are copied from Flit,
121+
# with the author's agreement to also place them under pip's license.
122+
def test_writable_dir(path):
123+
# type: (str) -> bool
124+
"""Check if a directory is writable.
125+
126+
Uses os.access() on POSIX, tries creating files on Windows.
127+
"""
128+
# If the directory doesn't exist, find the closest parent that does.
129+
while not os.path.isdir(path):
130+
parent = os.path.dirname(path)
131+
if parent == path:
132+
break # Should never get here, but infinite loops are bad
133+
path = parent
134+
135+
if os.name == 'posix':
136+
return os.access(path, os.W_OK)
137+
138+
return _test_writable_dir_win(path)
139+
140+
141+
def _test_writable_dir_win(path):
142+
# type: (str) -> bool
143+
# os.access doesn't work on Windows: http://bugs.python.org/issue2528
144+
# and we can't use tempfile: http://bugs.python.org/issue22107
145+
basename = 'accesstest_deleteme_fishfingers_custard_'
146+
alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
147+
for i in range(10):
148+
name = basename + ''.join(random.choice(alphabet) for _ in range(6))
149+
file = os.path.join(path, name)
150+
try:
151+
fd = os.open(file, os.O_RDWR | os.O_CREAT | os.O_EXCL)
152+
except OSError as e:
153+
if e.errno == errno.EEXIST:
154+
continue
155+
if e.errno == errno.EPERM:
156+
# This could be because there's a directory with the same name.
157+
# But it's highly unlikely there's a directory called that,
158+
# so we'll assume it's because the parent dir is not writable.
159+
return False
160+
raise
161+
else:
162+
os.close(fd)
163+
os.unlink(file)
164+
return True
165+
166+
# This should never be reached
167+
raise EnvironmentError(
168+
'Unexpected condition testing for writable directory'
169+
)

tests/functional/test_install.py

-1
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,6 @@ def test_basic_editable_install(script):
256256
in result.stderr
257257
)
258258
assert not result.files_created
259-
assert not result.files_updated
260259

261260

262261
@need_svn

tests/functional/test_install_upgrade.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ def test_upgrade_to_same_version_from_url(script):
245245
'https://files.pythonhosted.org/packages/source/I/INITools/INITools-'
246246
'0.3.tar.gz',
247247
)
248-
assert not result2.files_updated, 'INITools 0.3 reinstalled same version'
248+
assert script.site_packages / 'initools' not in result2.files_updated, (
249+
'INITools 0.3 reinstalled same version'
250+
)
249251
result3 = script.pip('uninstall', 'initools', '-y')
250252
assert_all_changes(result, result3, [script.venv / 'build', 'cache'])
251253

tests/unit/test_command_install.py

+34-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import pytest
12
from mock import Mock, call, patch
23

3-
from pip._internal.commands.install import build_wheels
4+
from pip._internal.commands.install import build_wheels, decide_user_install
45

56

67
class TestWheelCache:
@@ -61,3 +62,35 @@ def test_build_wheels__wheel_not_installed(self, is_wheel_installed):
6162
]
6263

6364
assert build_failures == ['a']
65+
66+
67+
class TestDecideUserInstall:
68+
@patch('site.ENABLE_USER_SITE', True)
69+
@patch('pip._internal.commands.install.site_packages_writable')
70+
def test_prefix_and_target(self, sp_writable):
71+
sp_writable.return_value = False
72+
73+
assert decide_user_install(
74+
use_user_site=None, prefix_path='foo'
75+
) is False
76+
77+
assert decide_user_install(
78+
use_user_site=None, target_dir='bar'
79+
) is False
80+
81+
@pytest.mark.parametrize(
82+
"enable_user_site,site_packages_writable,result", [
83+
(True, True, False),
84+
(True, False, True),
85+
(False, True, False),
86+
(False, False, False),
87+
])
88+
def test_most_cases(
89+
self, enable_user_site, site_packages_writable, result, monkeypatch,
90+
):
91+
monkeypatch.setattr('site.ENABLE_USER_SITE', enable_user_site)
92+
monkeypatch.setattr(
93+
'pip._internal.commands.install.site_packages_writable',
94+
lambda **kw: site_packages_writable
95+
)
96+
assert decide_user_install(use_user_site=None) is result

0 commit comments

Comments
 (0)