Add a RevOptions class (#4707)

* Add the RevOptions class, and test.

* Start using the RevOptions class.

* Add news file.

* Update for mypy.

* Fix test after rebasing.

* Address @xavfernandez's review comments.
This commit is contained in:
Chris Jerdonek 2017-10-01 15:21:11 -07:00 committed by Xavier Fernandez
parent 72f677f263
commit 3e56733df4
8 changed files with 250 additions and 80 deletions

View File

@ -1,6 +1,7 @@
"""Handles all VCS (version control) support"""
from __future__ import absolute_import
import copy
import errno
import logging
import os
@ -16,7 +17,7 @@ from pip._internal.utils.misc import (
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
if MYPY_CHECK_RUNNING:
from typing import Dict, Tuple
from typing import Dict, Optional, Tuple
from pip._internal.basecommand import Command
__all__ = ['vcs', 'get_src_requirement']
@ -25,6 +26,67 @@ __all__ = ['vcs', 'get_src_requirement']
logger = logging.getLogger(__name__)
class RevOptions(object):
"""
Encapsulates a VCS-specific revision to install, along with any VCS
install options.
Instances of this class should be treated as if immutable.
"""
def __init__(self, vcs, rev=None, extra_args=None):
"""
Args:
vcs: a VersionControl object.
rev: the name of the revision to install.
extra_args: a list of extra options.
"""
if extra_args is None:
extra_args = []
self.extra_args = extra_args
self.rev = rev
self.vcs = vcs
def __repr__(self):
return '<RevOptions {}: rev={!r}>'.format(self.vcs.name, self.rev)
@property
def arg_rev(self):
if self.rev is None:
return self.vcs.default_arg_rev
return self.rev
def to_args(self):
"""
Return the VCS-specific command arguments.
"""
args = []
rev = self.arg_rev
if rev is not None:
args += self.vcs.get_base_rev_args(rev)
args += self.extra_args
return args
def to_display(self):
if not self.rev:
return ''
return ' (to revision {})'.format(self.rev)
def make_new(self, rev):
"""
Make a copy of the current instance, but with a new rev.
Args:
rev: the name of the revision for the new object.
"""
return self.vcs.make_rev_options(rev, extra_args=self.extra_args)
class VcsSupport(object):
_registry = {} # type: Dict[str, Command]
schemes = ['ssh', 'git', 'hg', 'bzr', 'sftp', 'svn']
@ -104,11 +166,31 @@ class VersionControl(object):
dirname = ''
# List of supported schemes for this Version Control
schemes = () # type: Tuple[str, ...]
default_arg_rev = None # type: Optional[str]
def __init__(self, url=None, *args, **kwargs):
self.url = url
super(VersionControl, self).__init__(*args, **kwargs)
def get_base_rev_args(self, rev):
"""
Return the base revision arguments for a vcs command.
Args:
rev: the name of a revision to install. Cannot be None.
"""
raise NotImplementedError
def make_rev_options(self, rev=None, extra_args=None):
"""
Return a RevOptions object.
Args:
rev: the name of a revision to install.
extra_args: a list of extra options.
"""
return RevOptions(self, rev, extra_args=extra_args)
def _is_local_repository(self, repo):
"""
posix absolute paths start with os.path.sep,
@ -180,12 +262,18 @@ class VersionControl(object):
def switch(self, dest, url, rev_options):
"""
Switch the repo at ``dest`` to point to ``URL``.
Args:
rev_options: a RevOptions object.
"""
raise NotImplementedError
def update(self, dest, rev_options):
"""
Update an already-existing repo to the given ``rev_options``.
Args:
rev_options: a RevOptions object.
"""
raise NotImplementedError
@ -193,18 +281,25 @@ class VersionControl(object):
"""
Return True if the version is identical to what exists and
doesn't need to be updated.
Args:
rev_options: a RevOptions object.
"""
raise NotImplementedError
def check_destination(self, dest, url, rev_options, rev_display):
def check_destination(self, dest, url, rev_options):
"""
Prepare a location to receive a checkout/clone.
Return True if the location is ready for (and requires) a
checkout/clone, False otherwise.
Args:
rev_options: a RevOptions object.
"""
checkout = True
prompt = False
rev_display = rev_options.to_display()
if os.path.exists(dest):
checkout = False
if os.path.exists(os.path.join(dest, self.dirname)):

View File

@ -29,6 +29,9 @@ class Bazaar(VersionControl):
if getattr(urllib_parse, 'uses_fragment', None):
urllib_parse.uses_fragment.extend(['lp'])
def get_base_rev_args(self, rev):
return ['-r', rev]
def export(self, location):
"""
Export the Bazaar repository at the url to the destination location
@ -49,24 +52,22 @@ class Bazaar(VersionControl):
self.run_command(['switch', url], cwd=dest)
def update(self, dest, rev_options):
self.run_command(['pull', '-q'] + rev_options, cwd=dest)
cmd_args = ['pull', '-q'] + rev_options.to_args()
self.run_command(cmd_args, cwd=dest)
def obtain(self, dest):
url, rev = self.get_url_rev()
if rev:
rev_options = ['-r', rev]
rev_display = ' (to revision %s)' % rev
else:
rev_options = []
rev_display = ''
if self.check_destination(dest, url, rev_options, rev_display):
rev_options = self.make_rev_options(rev)
if self.check_destination(dest, url, rev_options):
rev_display = rev_options.to_display()
logger.info(
'Checking out %s%s to %s',
url,
rev_display,
display_path(dest),
)
self.run_command(['branch', '-q'] + rev_options + [url, dest])
cmd_args = ['branch', '-q'] + rev_options.to_args() + [url, dest]
self.run_command(cmd_args)
def get_url_rev(self):
# hotfix the URL scheme after removing bzr+ from bzr+ssh:// readd it

View File

@ -27,6 +27,7 @@ class Git(VersionControl):
schemes = (
'git', 'git+http', 'git+https', 'git+ssh', 'git+git', 'git+file',
)
default_arg_rev = 'origin/HEAD'
def __init__(self, url=None, *args, **kwargs):
@ -49,6 +50,9 @@ class Git(VersionControl):
super(Git, self).__init__(url, *args, **kwargs)
def get_base_rev_args(self, rev):
return [rev]
def get_git_version(self):
VERSION_PFX = 'git version '
version = self.run_command(['version'], show_stdout=False)
@ -74,20 +78,25 @@ class Git(VersionControl):
show_stdout=False, cwd=temp_dir.path
)
def check_rev_options(self, rev, dest, rev_options):
def check_rev_options(self, dest, rev_options):
"""Check the revision options before checkout to compensate that tags
and branches may need origin/ as a prefix.
Returns the SHA1 of the branch or tag if found.
Returns a new RevOptions object for the SHA1 of the branch or tag
if found.
Args:
rev_options: a RevOptions object.
"""
revisions = self.get_short_refs(dest)
rev = rev_options.arg_rev
origin_rev = 'origin/%s' % rev
if origin_rev in revisions:
# remote branch
return [revisions[origin_rev]]
return rev_options.make_new(revisions[origin_rev])
elif rev in revisions:
# a local tag or branch name
return [revisions[rev]]
return rev_options.make_new(revisions[rev])
else:
logger.warning(
"Could not find a tag or branch '%s', assuming commit or ref",
@ -101,12 +110,16 @@ class Git(VersionControl):
but current rev will always point to a sha. This means that a branch
or tag will never compare as True. So this ultimately only matches
against exact shas.
Args:
rev_options: a RevOptions object.
"""
return self.get_revision(dest).startswith(rev_options[0])
return self.get_revision(dest).startswith(rev_options.arg_rev)
def switch(self, dest, url, rev_options):
self.run_command(['config', 'remote.origin.url', url], cwd=dest)
self.run_command(['checkout', '-q'] + rev_options, cwd=dest)
cmd_args = ['checkout', '-q'] + rev_options.to_args()
self.run_command(cmd_args, cwd=dest)
self.update_submodules(dest)
@ -118,36 +131,28 @@ class Git(VersionControl):
else:
self.run_command(['fetch', '-q'], cwd=dest)
# Then reset to wanted revision (maybe even origin/master)
if rev_options:
rev_options = self.check_rev_options(
rev_options[0], dest, rev_options,
)
self.run_command(['reset', '--hard', '-q'] + rev_options, cwd=dest)
rev_options = self.check_rev_options(dest, rev_options)
cmd_args = ['reset', '--hard', '-q'] + rev_options.to_args()
self.run_command(cmd_args, cwd=dest)
#: update submodules
self.update_submodules(dest)
def obtain(self, dest):
url, rev = self.get_url_rev()
if rev:
rev_options = [rev]
rev_display = ' (to %s)' % rev
else:
rev_options = ['origin/HEAD']
rev_display = ''
if self.check_destination(dest, url, rev_options, rev_display):
rev_options = self.make_rev_options(rev)
if self.check_destination(dest, url, rev_options):
rev_display = rev_options.to_display()
logger.info(
'Cloning %s%s to %s', url, rev_display, display_path(dest),
)
self.run_command(['clone', '-q', url, dest])
if rev:
rev_options = self.check_rev_options(rev, dest, rev_options)
rev_options = self.check_rev_options(dest, rev_options)
# Only do a checkout if rev_options differs from HEAD
if not self.check_version(dest, rev_options):
self.run_command(
['fetch', '-q', url] + rev_options,
cwd=dest,
)
cmd_args = ['fetch', '-q', url] + rev_options.to_args()
self.run_command(cmd_args, cwd=dest,)
self.run_command(
['checkout', '-q', 'FETCH_HEAD'],
cwd=dest,

View File

@ -19,6 +19,9 @@ class Mercurial(VersionControl):
repo_name = 'clone'
schemes = ('hg', 'hg+http', 'hg+https', 'hg+ssh', 'hg+static-http')
def get_base_rev_args(self, rev):
return [rev]
def export(self, location):
"""Export the Hg repository at the url to the destination location"""
with TempDirectory(kind="export") as temp_dir:
@ -41,21 +44,19 @@ class Mercurial(VersionControl):
'Could not switch Mercurial repository to %s: %s', url, exc,
)
else:
self.run_command(['update', '-q'] + rev_options, cwd=dest)
cmd_args = ['update', '-q'] + rev_options.to_args()
self.run_command(cmd_args, cwd=dest)
def update(self, dest, rev_options):
self.run_command(['pull', '-q'], cwd=dest)
self.run_command(['update', '-q'] + rev_options, cwd=dest)
cmd_args = ['update', '-q'] + rev_options.to_args()
self.run_command(cmd_args, cwd=dest)
def obtain(self, dest):
url, rev = self.get_url_rev()
if rev:
rev_options = [rev]
rev_display = ' (to revision %s)' % rev
else:
rev_options = []
rev_display = ''
if self.check_destination(dest, url, rev_options, rev_display):
rev_options = self.make_rev_options(rev)
if self.check_destination(dest, url, rev_options):
rev_display = rev_options.to_display()
logger.info(
'Cloning hg %s%s to %s',
url,
@ -63,7 +64,8 @@ class Mercurial(VersionControl):
display_path(dest),
)
self.run_command(['clone', '--noupdate', '-q', url, dest])
self.run_command(['update', '-q'] + rev_options, cwd=dest)
cmd_args = ['update', '-q'] + rev_options.to_args()
self.run_command(cmd_args, cwd=dest)
def get_url(self, location):
url = self.run_command(

View File

@ -28,6 +28,9 @@ class Subversion(VersionControl):
repo_name = 'checkout'
schemes = ('svn', 'svn+ssh', 'svn+http', 'svn+https', 'svn+svn')
def get_base_rev_args(self, rev):
return ['-r', rev]
def get_info(self, location):
"""Returns (url, revision), where both are strings"""
assert not location.rstrip('/').endswith(self.dirname), \
@ -59,7 +62,7 @@ class Subversion(VersionControl):
def export(self, location):
"""Export the svn repository at the url to the destination location"""
url, rev = self.get_url_rev()
rev_options = get_rev_options(url, rev)
rev_options = get_rev_options(self, url, rev)
url = self.remove_auth_from_url(url)
logger.info('Exporting svn repository %s to %s', url, location)
with indent_log():
@ -67,32 +70,31 @@ class Subversion(VersionControl):
# Subversion doesn't like to check out over an existing
# directory --force fixes this, but was only added in svn 1.5
rmtree(location)
self.run_command(
['export'] + rev_options + [url, location],
show_stdout=False)
cmd_args = ['export'] + rev_options.to_args() + [url, location]
self.run_command(cmd_args, show_stdout=False)
def switch(self, dest, url, rev_options):
self.run_command(['switch'] + rev_options + [url, dest])
cmd_args = ['switch'] + rev_options.to_args() + [url, dest]
self.run_command(cmd_args)
def update(self, dest, rev_options):
self.run_command(['update'] + rev_options + [dest])
cmd_args = ['update'] + rev_options.to_args() + [dest]
self.run_command(cmd_args)
def obtain(self, dest):
url, rev = self.get_url_rev()
rev_options = get_rev_options(url, rev)
rev_options = get_rev_options(self, url, rev)
url = self.remove_auth_from_url(url)
if rev:
rev_display = ' (to revision %s)' % rev
else:
rev_display = ''
if self.check_destination(dest, url, rev_options, rev_display):
if self.check_destination(dest, url, rev_options):
rev_display = rev_options.to_display()
logger.info(
'Checking out %s%s to %s',
url,
rev_display,
display_path(dest),
)
self.run_command(['checkout', '-q'] + rev_options + [url, dest])
cmd_args = ['checkout', '-q'] + rev_options.to_args() + [url, dest]
self.run_command(cmd_args)
def get_location(self, dist, dependency_links):
for url in dependency_links:
@ -238,12 +240,10 @@ class Subversion(VersionControl):
return surl
def get_rev_options(url, rev):
if rev:
rev_options = ['-r', rev]
else:
rev_options = []
def get_rev_options(vcs, url, rev):
"""
Return a RevOptions object.
"""
r = urllib_parse.urlsplit(url)
if hasattr(r, 'username'):
# >= Python-2.5
@ -259,11 +259,13 @@ def get_rev_options(url, rev):
else:
username, password = None, None
extra_args = []
if username:
rev_options += ['--username', username]
extra_args += ['--username', username]
if password:
rev_options += ['--password', password]
return rev_options
extra_args += ['--password', password]
return vcs.make_rev_options(rev, extra_args=extra_args)
vcs.register(Subversion)

View File

@ -58,6 +58,11 @@ def test_get_short_refs_should_ignore_no_branch(script):
assert result['branch0.1'] == commit, result
def call_check_version(vcs, path, rev):
rev_options = vcs.make_rev_options(rev)
return vcs.check_version(path, rev_options)
@pytest.mark.network
def test_check_version(script):
version_pkg_path = _create_test_package(script)
@ -67,37 +72,40 @@ def test_check_version(script):
cwd=version_pkg_path
).stdout.strip()
git = Git()
assert git.check_version(version_pkg_path, [commit])
assert git.check_version(version_pkg_path, [commit[:7]])
assert not git.check_version(version_pkg_path, ['branch0.1'])
assert not git.check_version(version_pkg_path, ['abc123'])
assert call_check_version(git, version_pkg_path, commit)
assert call_check_version(git, version_pkg_path, commit[:7])
assert not call_check_version(git, version_pkg_path, 'branch0.1')
assert not call_check_version(git, version_pkg_path, 'abc123')
@patch('pip._internal.vcs.git.Git.get_short_refs')
def test_check_rev_options_should_handle_branch_name(get_refs_mock):
get_refs_mock.return_value = {'master': '123456', '0.1': '123456'}
get_refs_mock.return_value = {'master': '123456', '0.1': 'abc123'}
git = Git()
rev_options = git.make_rev_options('master')
result = git.check_rev_options('master', '.', [])
assert result == ['123456']
new_options = git.check_rev_options('.', rev_options)
assert new_options.rev == '123456'
@patch('pip._internal.vcs.git.Git.get_short_refs')
def test_check_rev_options_should_handle_tag_name(get_refs_mock):
get_refs_mock.return_value = {'master': '123456', '0.1': '123456'}
get_refs_mock.return_value = {'master': '123456', '0.1': 'abc123'}
git = Git()
rev_options = git.make_rev_options('0.1')
result = git.check_rev_options('0.1', '.', [])
assert result == ['123456']
new_options = git.check_rev_options('.', rev_options)
assert new_options.rev == 'abc123'
@patch('pip._internal.vcs.git.Git.get_short_refs')
def test_check_rev_options_should_handle_ambiguous_commit(get_refs_mock):
get_refs_mock.return_value = {'master': '123456', '0.1': '123456'}
git = Git()
rev_options = git.make_rev_options('0.1')
result = git.check_rev_options('0.1', '.', [])
assert result == ['123456'], result
new_options = git.check_rev_options('.', rev_options)
assert new_options.rev == '123456'
# TODO(pnasrat) fix all helpers to do right things with paths on windows.

View File

@ -2,9 +2,10 @@ import pytest
from mock import Mock
from pip._vendor.packaging.version import parse as parse_version
from pip._internal.vcs import VersionControl
from pip._internal.vcs import RevOptions, VersionControl
from pip._internal.vcs.bazaar import Bazaar
from pip._internal.vcs.git import Git
from pip._internal.vcs.mercurial import Mercurial
from pip._internal.vcs.subversion import Subversion
from tests.lib import pyversion
@ -14,6 +15,61 @@ else:
VERBOSE_FALSE = 0
def test_rev_options_repr():
rev_options = RevOptions(Git(), 'develop')
assert repr(rev_options) == "<RevOptions git: rev='develop'>"
@pytest.mark.parametrize(('vcs', 'expected1', 'expected2', 'kwargs'), [
# First check VCS-specific RevOptions behavior.
(Bazaar(), [], ['-r', '123'], {}),
(Git(), ['origin/HEAD'], ['123'], {}),
(Mercurial(), [], ['123'], {}),
(Subversion(), [], ['-r', '123'], {}),
# Test extra_args. For this, test using a single VersionControl class.
(Git(), ['origin/HEAD', 'opt1', 'opt2'], ['123', 'opt1', 'opt2'],
dict(extra_args=['opt1', 'opt2'])),
])
def test_rev_options_to_args(vcs, expected1, expected2, kwargs):
"""
Test RevOptions.to_args().
"""
assert RevOptions(vcs, **kwargs).to_args() == expected1
assert RevOptions(vcs, '123', **kwargs).to_args() == expected2
def test_rev_options_to_display():
"""
Test RevOptions.to_display().
"""
# The choice of VersionControl class doesn't matter here since
# the implementation is the same for all of them.
vcs = Git()
rev_options = RevOptions(vcs)
assert rev_options.to_display() == ''
rev_options = RevOptions(vcs, 'master')
assert rev_options.to_display() == ' (to revision master)'
def test_rev_options_make_new():
"""
Test RevOptions.make_new().
"""
# The choice of VersionControl class doesn't matter here since
# the implementation is the same for all of them.
vcs = Git()
rev_options = RevOptions(vcs, 'master', extra_args=['foo', 'bar'])
new_options = rev_options.make_new('develop')
assert new_options is not rev_options
assert new_options.extra_args == ['foo', 'bar']
assert new_options.rev == 'develop'
assert new_options.vcs is vcs
@pytest.fixture
def git():
git_url = 'http://github.com/pypa/pip-test-package'
@ -60,7 +116,8 @@ def test_git_get_src_requirements(git, dist):
('foo', False),
))
def test_git_check_version(git, ref, result):
assert git.check_version('foo', ref) is result
rev_options = git.make_rev_options(ref)
assert git.check_version('foo', rev_options) is result
def test_translate_egg_surname():