diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 1ccfd449338..36b05a2ccdd 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -203,7 +203,7 @@ def run(self, options, args): finder=finder, session=session, wheel_cache=None, - use_user_site=False, + working_scheme="global", upgrade_strategy="to-satisfy-only", force_reinstall=False, ignore_dependencies=options.ignore_dependencies, diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index e144e39ed79..6a6942603ba 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -5,6 +5,7 @@ import operator import os import shutil +import site from pip._internal import cmdoptions from pip._internal.basecommand import RequirementCommand @@ -12,13 +13,17 @@ from pip._internal.exceptions import ( CommandError, InstallationError, PreviousBuildDirError ) -from pip._internal.locations import distutils_scheme, virtualenv_no_global +from pip._internal.locations import ( + distutils_scheme, running_under_virtualenv, virtualenv_no_global +) from pip._internal.operations.prepare import RequirementPreparer from pip._internal.req import RequirementSet from pip._internal.resolve import Resolver from pip._internal.status_codes import ERROR from pip._internal.utils.filesystem import check_path_owner -from pip._internal.utils.misc import ensure_dir, get_installed_version +from pip._internal.utils.misc import ( + ensure_dir, get_installed_version, guess_correct_working_scheme +) from pip._internal.utils.temp_dir import TempDirectory from pip._internal.wheel import WheelBuilder @@ -75,14 +80,37 @@ def __init__(self, *args, **kw): '. Use --upgrade to replace existing packages in ' 'with new versions.' ) + + cmd_opts.add_option( + '--scheme', + dest='working_scheme', + default=None, + help=( + "Determines where to install the package. Valid values are " + "global, user and venv." + ) + ) + cmd_opts.add_option( '--user', dest='use_user_site', action='store_true', - help="Install to the Python user install directory for your " - "platform. Typically ~/.local/, or %APPDATA%\\Python on " - "Windows. (See the Python documentation for site.USER_BASE " - "for full details.)") + help=( + "Shorthand for --scheme user; if --scheme is passed, " + "this option is ignored." + ), + ) + + cmd_opts.add_option( + '--global', + dest='use_global_site', + action='store_true', + help=( + "Shorthand for --scheme global; if --scheme is passed, " + "this option is ignored." + ), + ) + cmd_opts.add_option( '--root', dest='root_path', @@ -192,17 +220,67 @@ def run(self, options, args): options.src_dir = os.path.abspath(options.src_dir) install_options = options.install_options or [] - if options.use_user_site: + + # When not passed a scheme, try to figure out what scheme to be used. + # Also handles --user and --global. + if options.working_scheme is None: + if options.use_user_site and options.use_global_site: + raise CommandError( + "Unable to determine which scheme to use for " + "installation (got both --global and --user). " + "Please specify scheme explicitly using --scheme." + ) + elif options.use_user_site or options.use_global_site: + if options.use_user_site: + options.working_scheme = "user" + else: + assert options.use_global_site + options.working_scheme = "global" + # If we're in an virtualenv, we want to use the venv scheme. + elif running_under_virtualenv(): + options.working_scheme = "venv" + # user scheme doesn't make sense if user site-packages is disabled + # or if a prefix is to be used. + elif not site.ENABLE_USER_SITE or options.prefix_path: + options.working_scheme = "global" + else: + # NOTE: We do backwards compatibility checks here. Eventually, + # this line should just be: + # options.working_scheme = "user" + options.working_scheme = guess_correct_working_scheme() + + # Ensure no one can ever depend on these variables. + del options.use_global_site + del options.use_user_site + + # Ensure the given scheme is a valid one. + if options.working_scheme not in ["user", "global", "venv"]: + raise CommandError( + "Got unknown scheme {!r}. " + "Should be one of 'global', 'user' or 'venv'." + .format(options.working_scheme) + ) + + # Sanity checks for working schemes + if options.working_scheme == "venv": + if not running_under_virtualenv(): + raise CommandError( + "Can not use 'venv' scheme when a virtualenv is not " + "active." + ) + options.require_venv = True + elif options.working_scheme == "user": if options.prefix_path: raise CommandError( - "Can not combine '--user' and '--prefix' as they imply " - "different installation locations" + "Can not combine '--scheme user' and '--prefix' as they " + "imply different installation locations." ) if virtualenv_no_global(): raise InstallationError( - "Can not perform a '--user' install. User site-packages " - "are not visible in this virtualenv." + "Can not perform a user scheme install. User " + "site-packages are not visible in this virtualenv." ) + install_options.append('--user') install_options.append('--prefix=') @@ -246,7 +324,7 @@ def run(self, options, args): target_dir=target_temp_dir.path, pycompile=options.compile, require_hashes=options.require_hashes, - use_user_site=options.use_user_site, + working_scheme=options.working_scheme, ) self.populate_requirement_set( @@ -266,7 +344,7 @@ def run(self, options, args): finder=finder, session=session, wheel_cache=wheel_cache, - use_user_site=options.use_user_site, + working_scheme=options.working_scheme, upgrade_strategy=upgrade_strategy, force_reinstall=options.force_reinstall, ignore_dependencies=options.ignore_dependencies, @@ -301,7 +379,7 @@ def run(self, options, args): ) possible_lib_locations = get_lib_location_guesses( - user=options.use_user_site, + user=options.working_scheme == "user", home=target_temp_dir.path, root=options.root_path, prefix=options.prefix_path, @@ -326,11 +404,11 @@ def run(self, options, args): except EnvironmentError as e: message_parts = [] - user_option_part = "Consider using the `--user` option" + user_option_part = "Consider using `--scheme user` option" permissions_part = "Check the permissions" if e.errno == errno.EPERM: - if not options.use_user_site: + if options.working_scheme != "user": message_parts.extend([ user_option_part, " or ", permissions_part.lower(), diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index e03983cd124..82e02b750e9 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -164,7 +164,7 @@ def run(self, options, args): finder=finder, session=session, wheel_cache=wheel_cache, - use_user_site=False, + working_scheme="global", upgrade_strategy="to-satisfy-only", force_reinstall=False, ignore_dependencies=options.ignore_dependencies, diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py index 43b85665bd6..4a81dfcce56 100644 --- a/src/pip/_internal/req/req_install.py +++ b/src/pip/_internal/req/req_install.py @@ -115,7 +115,7 @@ def __init__(self, req, comes_from, source_dir=None, editable=False, self.install_succeeded = None # UninstallPathSet of uninstalled distribution (for possible rollback) self.uninstalled_pathset = None - self.use_user_site = False + self.working_scheme = "global" self.target_dir = None self.options = options if options else {} self.pycompile = pycompile @@ -840,7 +840,7 @@ def get_install_args(self, global_options, record_filename, root, prefix): else: install_args += ["--no-compile"] - if running_under_virtualenv(): + if self.working_scheme == "venv": py_ver_str = 'python' + sysconfig.get_python_version() install_args += ['--install-headers', os.path.join(sys.prefix, 'include', 'site', @@ -913,7 +913,7 @@ def check_if_exists(self): existing_dist = pkg_resources.get_distribution( self.req.name ) - if self.use_user_site: + if self.working_scheme == "user": if dist_in_usersite(existing_dist): self.conflicts_with = existing_dist elif (running_under_virtualenv() and @@ -935,7 +935,7 @@ def move_wheel_files(self, wheeldir, root=None, prefix=None, warn_script_location=True): move_wheel_files( self.name, self.req, wheeldir, - user=self.use_user_site, + user=self.working_scheme == "user", home=self.target_dir, root=root, prefix=prefix, diff --git a/src/pip/_internal/req/req_set.py b/src/pip/_internal/req/req_set.py index fb07120e880..ab64992b0a8 100644 --- a/src/pip/_internal/req/req_set.py +++ b/src/pip/_internal/req/req_set.py @@ -13,8 +13,8 @@ class RequirementSet(object): def __init__(self, - require_hashes=False, target_dir=None, use_user_site=False, - pycompile=True): + require_hashes=False, target_dir=None, + working_scheme="global", pycompile=True): """Create a RequirementSet. :param wheel_cache: The pip wheel cache, for passing to @@ -29,7 +29,7 @@ def __init__(self, self.unnamed_requirements = [] self.successfully_downloaded = [] self.reqs_to_cleanup = [] - self.use_user_site = use_user_site + self.working_scheme = working_scheme self.target_dir = target_dir # set from --target option self.pycompile = pycompile # Maps from install_req -> dependencies_of_install_req @@ -81,7 +81,7 @@ def add_requirement(self, install_req, parent_req_name=None, wheel.filename ) - install_req.use_user_site = self.use_user_site + install_req.working_scheme = self.working_scheme install_req.target_dir = self.target_dir install_req.pycompile = self.pycompile install_req.is_direct = (parent_req_name is None) diff --git a/src/pip/_internal/resolve.py b/src/pip/_internal/resolve.py index 0c22ee9c92c..b38ac5fdf5e 100644 --- a/src/pip/_internal/resolve.py +++ b/src/pip/_internal/resolve.py @@ -32,7 +32,7 @@ class Resolver(object): _allowed_strategies = {"eager", "only-if-needed", "to-satisfy-only"} - def __init__(self, preparer, session, finder, wheel_cache, use_user_site, + def __init__(self, preparer, session, finder, wheel_cache, working_scheme, ignore_dependencies, ignore_installed, ignore_requires_python, force_reinstall, isolated, upgrade_strategy): super(Resolver, self).__init__() @@ -54,7 +54,7 @@ def __init__(self, preparer, session, finder, wheel_cache, use_user_site, self.ignore_dependencies = ignore_dependencies self.ignore_installed = ignore_installed self.ignore_requires_python = ignore_requires_python - self.use_user_site = use_user_site + self.working_scheme = working_scheme def resolve(self, requirement_set): """Resolve what operations need to be done @@ -120,7 +120,7 @@ def _set_req_to_reinstall(self, req): """ # Don't uninstall the conflict if doing a user install and the # conflict is not a user install. - if not self.use_user_site or dist_in_usersite(req.satisfied_by): + if self.working_scheme != "user" or dist_in_usersite(req.satisfied_by): req.conflicts_with = req.satisfied_by req.satisfied_by = None diff --git a/src/pip/_internal/utils/misc.py b/src/pip/_internal/utils/misc.py index d9ecbeb8302..5a5b40623bb 100644 --- a/src/pip/_internal/utils/misc.py +++ b/src/pip/_internal/utils/misc.py @@ -15,6 +15,7 @@ import subprocess import sys import tarfile +import tempfile import zipfile from collections import deque @@ -28,8 +29,8 @@ from pip._internal.compat import console_to_str, expanduser, stdlib_pkgs from pip._internal.exceptions import InstallationError from pip._internal.locations import ( - running_under_virtualenv, site_packages, user_site, virtualenv_no_global, - write_delete_marker_file + distutils_scheme, running_under_virtualenv, site_packages, user_site, + virtualenv_no_global, write_delete_marker_file ) if PY2: @@ -877,3 +878,38 @@ def enum(*sequential, **named): reverse = dict((value, key) for key, value in enums.items()) enums['reverse_mapping'] = reverse return type('Enum', (), enums) + + +def guess_correct_working_scheme(): + # XXX: This is purely for addressing backwards compatibility concerns. + + def _can_create_file_in(folder_path): + """Returns whether a file can be created in the given folder + """ + # If the given folder does not exist, try to create it. If there's + # going to be a successful installation, the folder probably needs to + # be created anyway. + try: + ensure_dir(folder_path) + except EnvironmentError: + return False + + # Try to create a temporary file in the folder and delete it. + try: + with tempfile.TemporaryFile(dir=folder_path): + pass + except EnvironmentError: + return False + + return True + + # This is the "smart" portion. Check if there's any potential global folder + # for which pip doesn't have the required permissions. If there's any such + # location, pip should perform a user installation instead. + for folder in distutils_scheme("").values(): + if not _can_create_file_in(folder): + return "user" + + # Assume the user wants to do a global installation. This is the most + # backwards compatible policy; preserving behaviour for `sudo pip install` + return "global" diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py index 9d137e8d1de..32c9495836b 100644 --- a/tests/functional/test_install.py +++ b/tests/functional/test_install.py @@ -708,7 +708,7 @@ def test_install_package_conflict_prefix_and_user(script, data): expect_error=True, quiet=True, ) assert ( - "Can not combine '--user' and '--prefix'" in result.stderr + "Can not combine '--scheme user' and '--prefix'" in result.stderr ) diff --git a/tests/functional/test_install_user.py b/tests/functional/test_install_user.py index cf0cb513773..c9a3d95a73d 100644 --- a/tests/functional/test_install_user.py +++ b/tests/functional/test_install_user.py @@ -66,7 +66,7 @@ def test_install_subversion_usersite_editable_with_distribute( tmpdir.join("cache"), ) ) - result.assert_installed('INITools', use_user_site=True) + result.assert_installed('INITools', in_user_site=True) def test_install_curdir_usersite(self, script, virtualenv, data): """ @@ -98,8 +98,8 @@ def test_install_user_venv_nositepkgs_fails(self, script, data): expect_error=True, ) assert ( - "Can not perform a '--user' install. User site-packages are not " - "visible in this virtualenv." in result.stderr + "Can not perform a user scheme install. User site-packages are " + "not visible in this virtualenv." in result.stderr ) @pytest.mark.network diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py index 99af72dc52c..5538d03894b 100644 --- a/tests/lib/__init__.py +++ b/tests/lib/__init__.py @@ -170,7 +170,7 @@ def __str__(self): def assert_installed(self, pkg_name, editable=True, with_files=[], without_files=[], without_egg_link=False, - use_user_site=False, sub_dir=False): + in_user_site=False, sub_dir=False): e = self.test_env if editable: @@ -182,7 +182,7 @@ def assert_installed(self, pkg_name, editable=True, with_files=[], without_egg_link = True pkg_dir = e.site_packages / pkg_name - if use_user_site: + if in_user_site: egg_link_path = e.user_site / pkg_name + '.egg-link' else: egg_link_path = e.site_packages / pkg_name + '.egg-link' @@ -217,7 +217,7 @@ def assert_installed(self, pkg_name, editable=True, with_files=[], repr(egg_link_contents)) )) - if use_user_site: + if in_user_site: pth_file = e.user_site / 'easy-install.pth' else: pth_file = e.site_packages / 'easy-install.pth' diff --git a/tests/unit/test_req.py b/tests/unit/test_req.py index 1e4646cd54a..550957621d4 100644 --- a/tests/unit/test_req.py +++ b/tests/unit/test_req.py @@ -44,7 +44,7 @@ def _basic_resolver(self, finder): return Resolver( preparer=preparer, wheel_cache=None, session=PipSession(), finder=finder, - use_user_site=False, upgrade_strategy="to-satisfy-only", + working_scheme="global", upgrade_strategy="to-satisfy-only", ignore_dependencies=False, ignore_installed=False, ignore_requires_python=False, force_reinstall=False, isolated=False,