Add versioned virtualenv

BUG=chromium:703769
TEST=None

Change-Id: I25ef7145028c17ec247441fb2ab529ef49c7d102
diff --git a/bin/create_venv b/bin/create_venv
new file mode 100755
index 0000000..fecf7a0
--- /dev/null
+++ b/bin/create_venv
@@ -0,0 +1,15 @@
+#!/bin/bash
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+#
+# Create or update a virtualenv.
+#
+# $ create_venv path/to/requirements.txt
+#
+# This creates a versioned virtualenv with the given requirements
+# installed.  The path to the created virtualenv is printed to stdout.
+set -eu
+basedir=$(readlink -f -- "$(dirname -- "${BASH_SOURCE[0]}" )")
+venvdir=$(cd "$basedir"; cd ..; pwd)
+PYTHONPATH="$venvdir" /usr/bin/python2.7 -m cros_venv.scripts.create_venv "$@"
diff --git a/bin/find_virtualenv.sh b/bin/find_virtualenv.sh
index 9cdd775..9df7872 100644
--- a/bin/find_virtualenv.sh
+++ b/bin/find_virtualenv.sh
@@ -9,23 +9,25 @@
 # sourcing script should cd into the directory containing this script
 # first.  This script defines functions for performing common tasks:
 #
-# exec_python -- Execute Python module inside of virtualenv
+# exec_python_module -- Execute Python module inside of virtualenv
+#
+# This script is a canonical template that can be copied to other
+# repositories.  The venv_repo variable should be changed to point to
+# this repository.
 set -eu
 
 realpath() {
     readlink -f -- "$1"
 }
 
-readonly virtualenv_dir=$(realpath ..)
-readonly venv_dir=$(realpath ../venv/.venv)
-readonly reqs_file=$(realpath ../venv/requirements.txt)
-
-_create_venv() {
-    "$virtualenv_dir/create_venv" "$venv_dir" "$reqs_file" >&2
-}
+# venv_repo should be changed if this script is copied to other repos.
+readonly venv_repo=$(realpath ..)
+readonly create_script=$(realpath "$venv_repo/bin/create_venv")
+readonly venv_home=$(realpath ../venv)
+readonly reqs_file=$(realpath "$venv_home/requirements.txt")
 
 exec_python_module() {
-    _create_venv
-    export PYTHONPATH='../venv'
-    exec "$venv_dir/bin/python" -m "$@"
+    venvdir=$("$create_script" "$reqs_file")
+    export PYTHONPATH=$venv_home
+    exec "$venvdir/bin/python" -m "$@"
 }
diff --git a/create_venv b/create_venv
index d146005..ede60cd 100755
--- a/create_venv
+++ b/create_venv
@@ -5,7 +5,7 @@
 #
 # Create or update a virtualenv.
 #
-# $ create_venv path/to/venv path/to/requirements.txt [path/to/custom.pth]
+# $ create_venv path/to/venv path/to/requirements.txt
 #
 # See the cros_venv Python package for details, or pass the --help
 # option.
diff --git a/cros_venv/__init__.py b/cros_venv/__init__.py
index 07a8f31..62ef90d 100644
--- a/cros_venv/__init__.py
+++ b/cros_venv/__init__.py
@@ -4,13 +4,5 @@
 
 """This package contains modules for creating and updating virtualenvs.
 
-To run:
-
-    $ python2 -m venv
-
-See __main__.py for details.
-
-To run the tests:
-
-    $ python2 -m unittest discover venv
+To run the tests, use the script bin/run_tests.
 """
diff --git a/cros_venv/__main__.py b/cros_venv/__main__.py
index a580d36..9c361a0 100644
--- a/cros_venv/__main__.py
+++ b/cros_venv/__main__.py
@@ -4,7 +4,10 @@
 
 """Create or update a virtualenv.
 
-See __init__.py for how to run this module.
+$ python -m cros_venv path/to/venv path/to/requirements.txt
+
+This creates a virtualenv at path/to/venv with the given requirements
+installed.
 """
 
 from __future__ import absolute_import
diff --git a/cros_venv/scripts/__init__.py b/cros_venv/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/cros_venv/scripts/__init__.py
diff --git a/cros_venv/scripts/create_venv.py b/cros_venv/scripts/create_venv.py
new file mode 100644
index 0000000..2864d1b
--- /dev/null
+++ b/cros_venv/scripts/create_venv.py
@@ -0,0 +1,46 @@
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Create or update a virtualenv.
+
+$ python -m cros_venv.scripts.create_venv path/to/requirements.txt
+
+This creates a versioned virtualenv with the given requirements
+installed.  The path to the created virtualenv is printed to stdout.
+"""
+
+from __future__ import absolute_import
+from __future__ import print_function
+from __future__ import unicode_literals
+
+import argparse
+import logging
+
+from cros_venv import venvlib
+
+
+def main():
+    """See module docstring."""
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('reqs_file')
+    parser.add_argument('--verbose', action='store_true')
+    args = parser.parse_args()
+
+    configure_logging(args.verbose)
+
+    with open(args.reqs_file, 'r') as f:
+        spec = venvlib.make_spec(f)
+    venv = venvlib.VersionedVenv(spec)
+    print(venv.ensure())
+
+
+def configure_logging(verbose):
+    if verbose:
+        logging.basicConfig(level='DEBUG')
+    else:
+        logging.getLogger().addHandler(logging.NullHandler())
+
+
+if __name__ == '__main__':
+    main()
diff --git a/cros_venv/test_venvlib.py b/cros_venv/test_venvlib.py
index eb30643..66315d1 100644
--- a/cros_venv/test_venvlib.py
+++ b/cros_venv/test_venvlib.py
@@ -6,9 +6,14 @@
 from __future__ import print_function
 from __future__ import unicode_literals
 
+from cStringIO import StringIO
 import os
 import unittest
+import warnings
 
+import mock
+
+from cros_venv import flock
 from cros_venv import testcases
 from cros_venv import venvlib
 
@@ -17,6 +22,11 @@
 
     """TestCase for Venv that uses a temp directory."""
 
+    def run(self, result=None):
+        with warnings.catch_warnings():
+            warnings.filterwarnings('ignore')
+            super(VenvTmpDirTestCase, self).run(result)
+
     def test__reqs_up_to_date(self):
         """Test _reqs_up_to_date()."""
         venv = venvlib.Venv('.', 'reqs.txt')
@@ -92,6 +102,19 @@
             iter(['foo', 'bar']),
         ))
 
+    def test__get_cache_dir(self):
+        got = venvlib._get_cache_dir()
+        self.assertTrue(got.endswith('/.cache/cros_venv'))
+
+    def test__get_cache_dir_should_be_absolute(self):
+        got = venvlib._get_cache_dir()
+        self.assertTrue(got.startswith('/'))
+
+    @mock.patch.object(venvlib, 'sys')
+    def test__get_python_version(self, sys):
+        sys.version_info = (2, 7, 12, 'final', 0)
+        self.assertEqual(venvlib._get_python_version(), '2.7.12')
+
 
 class VenvlibTmpDirTestCase(testcases.TmpdirTestCase):
 
@@ -104,3 +127,196 @@
             venvlib._makedirs_exist_ok('foo')
         except OSError:
             self.fail('OSError raised')
+
+    def test__log_check_call(self):
+        with open('tmp', 'w') as f:
+            venvlib._log_check_call(['echo', 'hi'], logfile=f)
+        with open('tmp', 'r') as f:
+            self.assertEqual(f.read(), "Running [u'echo', u'hi']\nhi\n")
+
+
+class VenvPathsTestCase(unittest.TestCase):
+
+    """Tests for _VenvPaths."""
+
+    def test_repr(self):
+        paths = venvlib._VenvPaths('/tmp')
+        self.assertEqual(repr(paths),
+                         "_VenvPaths(u'/tmp')")
+
+    def test_venvdir(self):
+        """Test for venvdir attribute."""
+        paths = venvlib._VenvPaths('/tmp')
+        self.assertEqual(paths.venvdir, '/tmp')
+
+    def test_python(self):
+        """Test for python attribute."""
+        paths = venvlib._VenvPaths('/tmp')
+        self.assertEqual(paths.python, '/tmp/bin/python')
+
+    def test_lockfile(self):
+        """Test for lockfile attribute."""
+        paths = venvlib._VenvPaths('/tmp')
+        self.assertEqual(paths.lockfile, '/tmp/change.lock')
+
+    def test_logfile(self):
+        """Test for logfile attribute."""
+        paths = venvlib._VenvPaths('/tmp')
+        self.assertEqual(paths.logfile, '/tmp/create.log')
+
+    def test_spec(self):
+        """Test for spec attribute."""
+        paths = venvlib._VenvPaths('/tmp')
+        self.assertEqual(paths.spec, '/tmp/spec.json')
+
+
+class VersionedVenvTestCase(unittest.TestCase):
+
+    """Tests for VersionedVenv."""
+
+    def test_repr(self):
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        venv = venvlib.VersionedVenv(spec)
+        with mock.patch.object(venvlib._VenvSpec, '__repr__') as venv_repr:
+            venv_repr.return_value = 'foo'
+            self.assertEqual(repr(venv),
+                             "VersionedVenv(foo)")
+
+
+class VersionedVenvTmpDirTestCase(testcases.TmpdirTestCase):
+
+    """Tests for VersionedVenv that use a temp directory."""
+
+    def setUp(self):
+        super(VersionedVenvTmpDirTestCase, self).setUp()
+        patcher = mock.patch.object(venvlib, '_get_cache_dir')
+        get_cache_dir = patcher.start()
+        self.addCleanup(patcher.stop)
+        get_cache_dir.return_value = os.getcwd()
+
+    @mock.patch.object(venvlib.VersionedVenv, '_create')
+    @mock.patch.object(venvlib.VersionedVenv, '_check')
+    def test__check_or_create_should_create_missing_dir(self, check, create):
+        """Test that _check_or_create() creates when directory is missing."""
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        venv = venvlib.VersionedVenv(spec)
+        venv._check_or_create()
+        check.assert_not_called()
+        create.assert_called_once_with()
+
+    @mock.patch.object(venvlib.VersionedVenv, '_create')
+    @mock.patch.object(venvlib.VersionedVenv, '_check')
+    def test__check_or_create_should_create_missing_spec(self, check, create):
+        """Test that _check_or_create() creates when spec file is missing."""
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        venv = venvlib.VersionedVenv(spec)
+        venvdir = 'venv-1.7.6-5442279a613e6a2c4a93c9bda10e63f4'
+        os.mkdir(venvdir)
+        venv._check_or_create()
+        check.assert_not_called()
+        create.assert_called_once_with()
+
+    @mock.patch.object(venvlib.VersionedVenv, '_create')
+    @mock.patch.object(venvlib.VersionedVenv, '_check')
+    def test__check_or_create_should_check_with_spec(self, check, create):
+        """Test that _check_or_create() creates when spec file is missing."""
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        venv = venvlib.VersionedVenv(spec)
+        venvdir = 'venv-1.7.6-5442279a613e6a2c4a93c9bda10e63f4'
+        os.mkdir(venvdir)
+        with open(venvdir + '/spec.json', 'w') as f:
+            f.write('["1.7.6", "foo==1.0.0\\n"]')
+        venv._check_or_create()
+        check.assert_called_once_with(spec)
+        create.assert_not_called()
+
+    def test__check_should_pass_with_same_spec(self):
+        """Test that _check() passes when spec matches."""
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        venv = venvlib.VersionedVenv(spec)
+        try:
+            venv._check(spec)
+        except venvlib.SpecMismatchError:
+            self.fail('Check did not pass')
+
+    def test__check_should_fail_with_different_spec(self):
+        """Test that _check() fails when spec does not match."""
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        venv = venvlib.VersionedVenv(spec)
+        with self.assertRaises(venvlib.SpecMismatchError):
+            venv._check(venvlib._VenvSpec('1.7.6', 'foo=2.0.0\n'))
+
+    def test_dump_spec_and_load_spec(self):
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        venv = venvlib.VersionedVenv(spec)
+        os.mkdir(venv._paths.venvdir)
+        venv._dump_spec()
+        got = venv._load_spec()
+        self.assertEqual(spec, got)
+
+
+class VenvSpecTestCase(unittest.TestCase):
+
+    """Tests for _VenvSpec."""
+
+    def test_eq(self):
+        self.assertEqual(
+            venvlib._VenvSpec('2.7.3', 'foo==1.0.0\n'),
+            venvlib._VenvSpec('2.7.3', 'foo==1.0.0\n'),
+        )
+
+    def test_not_eq(self):
+        self.assertNotEqual(
+            venvlib._VenvSpec('2.7.3', 'foo==1.0.0\n'),
+            venvlib._VenvSpec('2.7.4', 'foo==1.0.0\n'),
+        )
+
+    @mock.patch.object(venvlib, '_get_python_version')
+    def test_make_spec(self, get_ver):
+        f = StringIO('foo==1.0.0\n')
+        get_ver.return_value = '1.7.6'
+        self.assertEqual(
+            venvlib.make_spec(f),
+            venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        )
+
+    def test__get_reqs_hash(self):
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        self.assertEqual(venvlib._get_reqs_hash(spec),
+                         '5442279a613e6a2c4a93c9bda10e63f4')
+
+    @mock.patch.object(venvlib, '_get_cache_dir')
+    def test__get_venvdir(self, get_cache_dir):
+        get_cache_dir.return_value = '/home/arland/.cache/cros_venv'
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        got = venvlib._get_venvdir(spec)
+        self.assertEqual(
+            got,
+            '/home/arland/.cache/cros_venv'
+            '/venv-1.7.6-5442279a613e6a2c4a93c9bda10e63f4')
+
+    def test__get_venvdir_should_be_absolute(self):
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        got = venvlib._get_venvdir(spec)
+        self.assertTrue(got.startswith('/'))
+
+    def test_dump_spec_and_load_spec(self):
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        f = StringIO()
+        venvlib._dump_spec(spec, f)
+        f.seek(0)
+        got = venvlib._load_spec(f)
+        self.assertEqual(spec, got)
+
+    def test__make_reqs_file_should_cleanup(self):
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        with venvlib._make_reqs_file(spec) as f:
+            self.assertTrue(os.path.exists(f.name))
+        self.assertFalse(os.path.exists(f.name))
+
+    def test__make_reqs_file_content(self):
+        spec = venvlib._VenvSpec('1.7.6', 'foo==1.0.0\n')
+        with venvlib._make_reqs_file(spec) as f, \
+             open(f.name, 'r') as rfile:
+            self.assertEqual(rfile.read(),
+                             'setuptools==28.2.0\npip==8.1.2\nfoo==1.0.0\n')
diff --git a/cros_venv/venvlib.py b/cros_venv/venvlib.py
index 04460d6..1991bc0 100644
--- a/cros_venv/venvlib.py
+++ b/cros_venv/venvlib.py
@@ -8,11 +8,17 @@
 from __future__ import print_function
 from __future__ import unicode_literals
 
+import collections
+import functools
+import hashlib
 import itertools
+import json
 import os
 import shutil
 import subprocess
 import sys
+import tempfile
+import warnings
 
 from cros_venv import constants
 from cros_venv import flock
@@ -30,6 +36,7 @@
     """Wraps all operations on a virtualenv directory."""
 
     def __init__(self, venv_dir, reqs_file):
+        warnings.warn('Venv is deprecated; use VersionedVenv instead')
         self._venv_dir = venv_dir
         self._reqs_file = reqs_file
 
@@ -100,6 +107,219 @@
         shutil.copyfile(self._reqs_file, self._installed_reqs_file)
 
 
+class _VenvPaths(object):
+
+    """Wrapper defining paths inside a versioned virtualenv."""
+
+    def __init__(self, venvdir):
+        """Initialize instance.
+
+        venvdir is the absolute path to a virtualenv.
+        """
+        self.venvdir = venvdir
+
+    def __repr__(self):
+        return '{cls}({this.venvdir!r})'.format(
+            cls=type(self).__name__,
+            this=self,
+        )
+
+    @property
+    def python(self):
+        """Path to the virtualenv's Python binary."""
+        return os.path.join(self.venvdir, 'bin', 'python')
+
+    @property
+    def lockfile(self):
+        """Path to lock file for changing virtualenv."""
+        return os.path.join(self.venvdir, 'change.lock')
+
+    @property
+    def logfile(self):
+        """Path to log file for creating virtualenv."""
+        return os.path.join(self.venvdir, 'create.log')
+
+    @property
+    def spec(self):
+        """Path to spec file inside virtualenv directory."""
+        return os.path.join(self.venvdir, 'spec.json')
+
+
+class VersionedVenv(object):
+
+    """Versioned virtualenv, specified by a _VenvSpec.
+
+    This class provides a method for ensuring the versioned virtualenv
+    is created.
+    """
+
+    def __init__(self, spec):
+        """Initialize instance.
+
+        spec is a _VenvSpec.
+        """
+        self._spec = spec
+        self._paths = _VenvPaths(_get_venvdir(spec))
+
+    def __repr__(self):
+        return '{cls}({this._spec!r})'.format(
+            cls=type(self).__name__,
+            this=self,
+        )
+
+    def ensure(self):
+        """Ensure that the virtualenv exists."""
+        _makedirs_exist_ok(self._paths.venvdir)
+        with flock.FileLock(self._paths.lockfile):
+            self._check_or_create()
+        return self._paths.venvdir
+
+    def _check_or_create(self):
+        """Check virtualenv, creating it if it is not created."""
+        try:
+            existing_spec = self._load_spec()
+        except IOError:
+            self._create()
+        else:
+            self._check(existing_spec)
+
+    def _create(self):
+        """Create virtualenv."""
+        with open(self._paths.logfile, 'w') as logfile, \
+             _make_reqs_file(self._spec) as reqs_file:
+            _create_venv(venvdir=self._paths.venvdir,
+                         logfile=logfile)
+            _install_reqs_file(python_path=self._paths.python,
+                               reqs_path=reqs_file.name,
+                               logfile=logfile)
+        self._dump_spec()
+
+    def _check(self, spec):
+        """Check if the given spec matches our spec.
+
+        Raise SpecMismatchError if check fails.
+        """
+        if spec != self._spec:
+            raise SpecMismatchError
+
+    def _dump_spec(self):
+        """Save the _VenvSpec to the virtualenv on disk."""
+        with open(self._paths.spec, 'w') as f:
+            return _dump_spec(self._spec, f)
+
+    def _load_spec(self):
+        """Return the _VenvSpec for the virtualenv on disk."""
+        with open(self._paths.spec, 'r') as f:
+            return _load_spec(f)
+
+
+class SpecMismatchError(Exception):
+    """Versioned virtualenv specs do not match."""
+
+
+_VenvSpec = collections.namedtuple('_VenvSpec', 'py_version,reqs')
+
+
+def make_spec(f):
+    """Make _VenvSpec from a requirements file object."""
+    return _VenvSpec(_get_python_version(), f.read())
+
+
+def _get_reqs_hash(spec):
+    """Return hash string for _VenvSpec requirements.
+
+    Make sure to check for collisions.
+    """
+    hasher = hashlib.md5()
+    hasher.update(spec.reqs)
+    return hasher.hexdigest()
+
+
+def _get_venvdir(spec):
+    """Return the virtualenv directory to use for the _VenvSpec.
+
+    Returns absolute path.
+    """
+    cache_dir = _get_cache_dir()
+    return os.path.join(
+        cache_dir, 'venv-%s-%s' % (spec.py_version, _get_reqs_hash(spec)))
+
+
+def _dump_spec(spec, f):
+    """Dump _VenvSpec to a file."""
+    json.dump(spec, f)
+
+
+def _load_spec(f):
+    """Load _VenvSpec from a file."""
+    return _VenvSpec._make(json.load(f))
+
+
+def _make_reqs_file(spec):
+    """Return a temporary reqs file for the virtualenv spec.
+
+    The return value is a tempfile.NamedTemporaryFile, which cleans
+    up on close.  The filename is accessible via the name attribute.
+    """
+    f = tempfile.NamedTemporaryFile('w')
+    f.writelines(req + '\n' for req in _BASE_DEPENDENCIES)
+    f.write(spec.reqs)
+    f.flush()
+    return f
+
+
+def _create_venv(venvdir, logfile):
+    """Create a virtualenv at the given path."""
+    # TODO(ayatane): Ubuntu Precise ships with virtualenv 1.7, which
+    # requires specifying --setuptools, else distribute is used
+    # (distribute is deprecated).  virtualenv after 1.10 uses setuptools
+    # by default.  virtualenv >1.10 accepts the --setuptools option but
+    # does not document it.  Once we no longer have any hosts on
+    # virtualenv 1.7, the --setuptools option can be removed.
+    command = ['virtualenv', venvdir, '-p', _VENV_PY,
+               '--extra-search-dir', _PACKAGE_DIR, '--setuptools']
+    _log_check_call(command, logfile=logfile)
+
+
+def _install_reqs_file(python_path, reqs_path, logfile):
+    """Install reqs file using pip."""
+    command = [python_path, '-m', 'pip', 'install',
+               '--no-index', '-f', 'file://' + _PACKAGE_DIR, '-r', reqs_path]
+    _log_check_call(command, logfile=logfile)
+
+
+def _add_call_logging(call_func):
+    """Wrap a subprocess-style call with logging."""
+    @functools.wraps(call_func)
+    def wrapped_command(args, logfile, **kwargs):
+        """Logging-wrapped call.
+
+        Arguments are similar to subprocess.Popen, depending on the
+        underlying call.  There is an extra keyword-only parameter
+        logfile, which takes a file object.
+        """
+        logfile.write('Running %r\n' % (args,))
+        logfile.flush()
+        call_func(args, stdout=logfile, **kwargs)
+    return wrapped_command
+
+
+_log_check_call = _add_call_logging(subprocess.check_call)
+
+
+def _get_python_version():
+    """Return the version string for the current Python."""
+    return '.'.join(unicode(part) for part in sys.version_info[:3])
+
+
+def _get_cache_dir():
+    """Get cache dir to use for cros_venv.
+
+    Returns absolute path.
+    """
+    return os.path.expanduser('~/.cache/cros_venv')
+
+
 def _iter_equal(first, second):
     """Return whether two iterables are equal.