Commits

Ian Bicking committed db318d9 Merge

merge

Comments (0)

Files changed (12)

docs/configuration.txt

+Configuration
+=============
+
+pip allows you to set its default options by using the following facilities,
+in the order of each item's importance:
+
+1. Command line options
+
+2. `Environment variables`_
+
+3. `Config files`_
+
+   1. Command specific section, e.g. ``[install]``
+   2. Global section ``[global]``
+
+That means it will check each of those configuration sources and set the
+defaults appropriately.
+
+Examples
+--------
+
+- ``--host=foo`` overrides ``PIP_HOST=foo``
+- ``PIP_HOST=foo`` overrides a config file with ``[global] host = foo``
+- A command specific section in the config file ``[<command>] host = bar``
+  overrides the option with same name in the ``[global]`` config file section
+- Environment variables override config files
+
+Config files
+------------
+
+pip allows you to set all command line option defaults in a standard ini
+style config file.
+
+The names of the settings are derived from the long command line option, e.g.
+if you want to use a different package index (``--index-url``) and set the
+HTTP timeout (``--default-timeout``) to 60 seconds your config file would
+look like this:
+
+.. code-block:: ini
+
+    [global]
+    timeout = 60
+    index-url = http://download.zope.org/ppix
+
+Each subcommand can be configured optionally in its own section so that every
+global setting with the same name will be overridden; e.g. decreasing the
+``timeout`` to ``10`` seconds when running the `freeze`
+(`Freezing Requirements`_) command and using ``60`` seconds for all other
+commands is possible with:
+
+.. code-block:: ini
+
+    [global]
+    timeout = 60
+    
+    [freeze]
+    timeout = 10
+
+Boolean options like ``--ignore-installed`` or ``--no-dependencies`` can be
+set like this:
+
+.. code-block:: ini
+
+    [install]
+    ignore-installed = true
+    no-dependencies = yes
+
+Appending options like ``--find-links`` can be written on multiple lines:
+
+.. code-block:: ini
+
+    [global]
+    find-links =
+        http://download.example.com
+
+    [install]
+    find-links =
+        http://mirror1.example.com
+        http://mirror2.example.com
+
+Location
+********
+
+The names and locations of the configuration files vary slightly across
+platforms.
+
+On Unix and Mac OS X the configuration file is: :file:`$HOME/.pip.cfg`
+
+And on Windows, the configuration file is: :file:`%HOME%\\pip.cfg`
+
+Environment variables
+-----------------------
+
+Just like with `config files`_, each of pip's command line options
+(long version, e.g. ``--find-links``) are automatically set by looking for
+environment variables with the name format ``PIP_<UPPER_NAME>``. That means
+the name of the command line options are capitalized and have dashes (``-``)
+replaced with underscores (``_``).
+
+For example, to redefine the default timeout you can also set an
+environment variable::
+
+    export PIP_DEFAULT_TIMEOUT=60
+    pip install ipython
+
+Which is the same as passing the option to pip directly::
+
+    pip --default-timeout=60 install ipython
 <http://bitbucket.org/ianb/pip/>`_).
 
 .. toctree::
+   :maxdepth: 1
 
    news
    requirement-format
+   configuration
 
 .. comment: split here
 
 any necessary headers installed, etc.  Binary packages are hard, this is
 relatively easy.
 
-Using pip With virtualenv
+Using pip with virtualenv
 -------------------------
 
 pip is most nutritious when used with `virtualenv
 Except, if you have ``virtualenv`` installed and the path ``new-env/``
 doesn't exist, then a new virtualenv will be created.
 
-Using pip with buildout
------------------------
+pip also has two advanced features for working with virtualenvs -- both of
+which activated by defining a variable in your environment.
 
-If you are using `zc.buildout
-<http://pypi.python.org/pypi/zc.buildout>`_ you should look at
-`gp.recipe.pip <http://pypi.python.org/pypi/gp.recipe.pip>`_ as an
-option to use pip and virtualenv in your buildouts.
+To tell pip to only run if there is a virtualenv currently activated,
+and to bail if not, use::
+
+    export PIP_REQUIRE_VIRTUALENV=true
+
+To tell pip to automatically use the currently active virtualenv::
+
+    export PIP_RESPECT_VIRTUALENV=true
+
+Providing an environment with ``-E`` will be ignored.
 
 Using pip with virtualenvwrapper
 ---------------------------------
     export PIP_VIRTUALENV_BASE=$WORKON_HOME
 
 in your .bashrc under the line starting with ``export WORKON_HOME``.
+
+Using pip with buildout
+-----------------------
+
+If you are using `zc.buildout
+<http://pypi.python.org/pypi/zc.buildout>`_ you should look at
+`gp.recipe.pip <http://pypi.python.org/pypi/gp.recipe.pip>`_ as an
+option to use pip and virtualenv in your buildouts.
 * Add ``pip uninstall`` and uninstall-before upgrade (from Carl
   Meyer).
 
+* Extended configurability with config files and environment variables.
+
 * Allow packages to be upgraded, e.g., ``pip install Package==0.1``
   then ``pip install Package==0.2``.
 
+* Allow installing/upgrading to Package==dev (fix "Source version does not
+  match target version" errors).
+
+* Extended integration with virtualenv by providing an option to
+  automatically use an active virtualenv and an option to warn if no active
+  virtualenv is found.
+
+* Fixed a bug with pip install --download and editable packages, where
+  directories were being set with 0000 permissions, now defaults to 755.
+
+* Fixed uninstallation of easy_installed console_scripts.
+
+* Fixed uninstallation on Mac OS X Framework layout installs
+
+* Fixed bug preventing uninstall of editables with source outside venv.
+
+* Creates download cache directory if not existing.
+
 0.5.1
 -----
 
 import shutil
 import fnmatch
 import operator
-import stat
+import copy
 try:
     from hashlib import md5
 except ImportError:
 import time
 import logging
 import ConfigParser
+from distutils.util import strtobool
+from distutils import sysconfig
 
 class InstallationError(Exception):
     """General exception during installation"""
 class BadCommand(Exception):
     """Raised when virtualenv or a command is not found"""
 
-if getattr(sys, 'real_prefix', None):
-    ## FIXME: is build/ a good name?
-    base_prefix = os.path.join(sys.prefix, 'build')
-    base_src_prefix = os.path.join(sys.prefix, 'src')
-else:
-    ## FIXME: this isn't a very good default
-    base_prefix = os.path.join(os.getcwd(), 'build')
-    base_src_prefix = os.path.join(os.getcwd(), 'src')
-
-# FIXME doesn't account for venv linked to global site-packages
-if sys.platform == 'win32':
-    lib_py = os.path.join(sys.prefix, 'Lib')
-    bin_py = os.path.join(sys.prefix, 'Scripts')
-    # buildout uses 'bin' on Windows too?
-    if not os.path.exists(bin_py):
-        bin_py = os.path.join(sys.prefix, 'bin')
-else:
-    lib_py = os.path.join(sys.prefix, 'lib', 'python%s' % sys.version[:3])
-    bin_py = os.path.join(sys.prefix, 'bin')
-    
-pypi_url = "http://pypi.python.org/simple"
-
-default_timeout = 15
-
-## FIXME: this shouldn't be a module setting
-default_vcs = None
-if os.environ.get('PIP_DEFAULT_VCS'):
-    default_vcs = os.environ['PIP_DEFAULT_VCS']
-
-virtualenv_base = os.environ.get('PIP_VIRTUALENV_BASE')
-
-try:
-    pip_dist = pkg_resources.get_distribution('pip')
-    version = '%s from %s (python %s)' % (
-        pip_dist, pip_dist.location, sys.version[:3])
-except pkg_resources.DistributionNotFound:
-    # when running pip.py without installing
-    version=None
-
 try:
     any
 except NameError:
                 return True
         return False
 
+if getattr(sys, 'real_prefix', None):
+    ## FIXME: is build/ a good name?
+    build_prefix = os.path.join(sys.prefix, 'build')
+    src_prefix = os.path.join(sys.prefix, 'src')
+else:
+    ## FIXME: this isn't a very good default
+    build_prefix = os.path.join(os.getcwd(), 'build')
+    src_prefix = os.path.join(os.getcwd(), 'src')
+
+# FIXME doesn't account for venv linked to global site-packages
+
+site_packages = sysconfig.get_python_lib()
+if sys.platform == 'win32':
+    bin_py = os.path.join(sys.prefix, 'Scripts')
+    # buildout uses 'bin' on Windows too?
+    if not os.path.exists(bin_py):
+        bin_py = os.path.join(sys.prefix, 'bin')
+    config_filename = 'pip.cfg'
+else:
+    bin_py = os.path.join(sys.prefix, 'bin')
+    config_filename = '.pip.cfg'
+    # Forcing to use /usr/local/bin for standard Mac OS X framework installs
+    if sys.platform[:6] == 'darwin' and sys.prefix[:16] == '/System/Library/':
+        bin_py = '/usr/local/bin'
+
+class UpdatingDefaultsHelpFormatter(optparse.IndentedHelpFormatter):
+
+    def expand_default(self, option):
+        if self.parser is not None:
+            self.parser.update_defaults(self.parser.defaults)
+        return optparse.IndentedHelpFormatter.expand_default(self, option)
+
+
+class ConfigOptionParser(optparse.OptionParser):
+    """Custom option parser which updates its defaults by by checking the
+    configuration files and environmental variables"""
+
+    def __init__(self, *args, **kwargs):
+        self.config = ConfigParser.RawConfigParser()
+        self.name = kwargs.pop('name')
+        self.files = self.get_config_files()
+        self.config.read(self.files)
+        assert self.name
+        optparse.OptionParser.__init__(self, *args, **kwargs)
+
+    def get_config_files(self):
+        config_file = os.environ.get('PIP_CONFIG_FILE', False)
+        if config_file and os.path.exists(config_file):
+            return [config_file]
+        # FIXME: add ~/.python/pip.cfg or whatever Python core decides here
+        return [os.path.join(os.path.expanduser('~'), config_filename)]
+
+    def update_defaults(self, defaults):
+        """Updates the given defaults with values from the config files and
+        the environ. Does a little special handling for certain types of
+        options (lists)."""
+        # Then go and look for the other sources of configuration:
+        config = {}
+        # 1. config files
+        for section in ('global', self.name):
+            config.update(dict(self.get_config_section(section)))
+        # 2. environmental variables
+        config.update(dict(self.get_environ_vars()))
+        # Then set the options with those values
+        for key, val in config.iteritems():
+            key = key.replace('_', '-')
+            if not key.startswith('--'):
+                key = '--%s' % key # only prefer long opts
+            option = self.get_option(key)
+            if option is not None:
+                # ignore empty values
+                if not val:
+                    continue
+                # handle multiline configs
+                if option.action == 'append':
+                    val = val.split()
+                else:
+                    option.nargs = 1
+                if option.action in ('store_true', 'store_false', 'count'):
+                    val = strtobool(val)
+                try:
+                    val = option.convert_value(key, val)
+                except optparse.OptionValueError, e:
+                    print ("An error occured during configuration: %s" % e)
+                    sys.exit(3)
+                defaults[option.dest] = val
+        return defaults
+
+    def get_config_section(self, name):
+        """Get a section of a configuration"""
+        if self.config.has_section(name):
+            return self.config.items(name)
+        return []
+
+    def get_environ_vars(self, prefix='PIP_'):
+        """Returns a generator with all environmental vars with prefix PIP_"""
+        for key, val in os.environ.iteritems():
+            if key.startswith(prefix):
+                yield (key.replace(prefix, '').lower(), val)
+
+    def get_default_values(self):
+        """Overridding to make updating the defaults after instantiation of
+        the option parser possible, update_defaults() does the dirty work."""
+        if not self.process_default_values:
+            # Old, pre-Optik 1.5 behaviour.
+            return optparse.Values(self.defaults)
+
+        defaults = self.update_defaults(self.defaults.copy()) # ours
+        for option in self._get_all_options():
+            default = defaults.get(option.dest)
+            if isinstance(default, basestring):
+                opt_str = option.get_opt_string()
+                defaults[option.dest] = option.check_value(opt_str, default)
+        return optparse.Values(defaults)
+
+try:
+    pip_dist = pkg_resources.get_distribution('pip')
+    version = '%s from %s (python %s)' % (
+        pip_dist, pip_dist.location, sys.version[:3])
+except pkg_resources.DistributionNotFound:
+    # when running pip.py without installing
+    version=None
+
 def rmtree_errorhandler(func, path, exc_info):
     """On Windows, the files in .svn are read-only, so when rmtree() tries to
     remove them, an exception is thrown.  We catch that here, remove the
             return self.get_backend(vc_type)
         return None
 
-
 vcs = VcsSupport()
 
-parser = optparse.OptionParser(
+parser = ConfigOptionParser(
     usage='%prog COMMAND [OPTIONS]',
     version=version,
-    add_help_option=False)
+    add_help_option=False,
+    formatter=UpdatingDefaultsHelpFormatter(),
+    name='global')
 
 parser.add_option(
     '-h', '--help',
     'created. Ignored if --environment is not used or '
     'the virtualenv already exists.')
 parser.add_option(
+    # Defines a default root directory for virtualenvs, relative
+    # virtualenvs names/paths are considered relative to it.
+    '--virtualenv-base',
+    dest='venv_base',
+    type='str',
+    default='',
+    help=optparse.SUPPRESS_HELP)
+parser.add_option(
+    # Run only if inside a virtualenv, bail if not.
+    '--require-virtualenv', '--require-venv',
+    dest='require_venv',
+    action='store_true',
+    default=False,
+    help=optparse.SUPPRESS_HELP)
+parser.add_option(
+    # Use automatically an activated virtualenv instead of installing
+    # globally. -E will be ignored if used.
+    '--respect-virtualenv', '--respect-venv',
+    dest='respect_venv',
+    action='store_true',
+    default=False,
+    help=optparse.SUPPRESS_HELP)
+
+parser.add_option(
     '-v', '--verbose',
     dest='verbose',
     action='count',
     metavar='FILENAME',
     help='Log file where a complete (maximum verbosity) record will be kept')
 parser.add_option(
+    # Writes the log levels explicitely to the log'
+    '--log-explicit-levels',
+    dest='log_explicit_levels',
+    action='store_true',
+    default=False,
+    help=optparse.SUPPRESS_HELP)
+parser.add_option(
+    # The default log file
+    '--local-log', '--log-file',
+    dest='log_file',
+    metavar='FILENAME',
+    default='./pip-log.txt',
+    help=optparse.SUPPRESS_HELP)
+
+parser.add_option(
     '--proxy',
     dest='proxy',
     type='str',
     help="Specify a proxy in the form user:passwd@proxy.server:port. "
     "Note that the user:password@ is optional and required only if you "
     "are behind an authenticated proxy.  If you provide "
-    "user@proxy.server:port then you will be prompted for a password."
-    )
+    "user@proxy.server:port then you will be prompted for a password.")
 parser.add_option(
-    '--timeout',
+    '--timeout', '--default-timeout',
     metavar='SECONDS',
     dest='timeout',
     type='float',
-    default=default_timeout,
-    help='Set the socket timeout (default %s seconds)' % default_timeout)
+    default=15,
+    help='Set the socket timeout (default %default seconds)')
+parser.add_option(
+    # The default version control system for editables, e.g. 'svn'
+    '--default-vcs',
+    dest='default_vcs',
+    type='str',
+    default='',
+    help=optparse.SUPPRESS_HELP)
+parser.add_option(
+    # A regex to be used to skip requirements
+    '--skip-requirements-regex',
+    dest='skip_requirements_regex',
+    type='str',
+    default='',
+    help=optparse.SUPPRESS_HELP)
 
 parser.disable_interspersed_args()
 
-
 _commands = {}
 
 class Command(object):
     usage = None
     def __init__(self):
         assert self.name
-        self.parser = optparse.OptionParser(
+        self.parser = ConfigOptionParser(
             usage=self.usage,
             prog='%s %s' % (sys.argv[0], self.name),
-            version=parser.version)
+            version=parser.version,
+            formatter=UpdatingDefaultsHelpFormatter(),
+            name=self.name)
         for option in parser.option_list:
             if not option.dest or option.dest == 'help':
                 # -h, --version, etc
         _commands[self.name] = self
 
     def merge_options(self, initial_options, options):
-        for attr in ['log', 'venv', 'proxy']:
+        # Make sure we have all global options carried over
+        for attr in ['log', 'venv', 'proxy', 'venv_base', 'require_venv',
+                     'respect_venv', 'log_explicit_levels', 'log_file',
+                     'timeout', 'default_vcs', 'skip_requirements_regex']:
             setattr(options, attr, getattr(initial_options, attr) or getattr(options, attr))
         options.quiet += initial_options.quiet
         options.verbose += initial_options.verbose
         options, args = self.parser.parse_args(args)
         self.merge_options(initial_options, options)
 
+        if options.require_venv and not options.venv:
+            # If a venv is required check if it can really be found
+            if not os.environ.get('VIRTUAL_ENV'):
+                print 'Could not find an activated virtualenv (required).'
+                sys.exit(3)
+            # Automatically install in currently activated venv if required
+            options.respect_venv = True
+
         if args and args[-1] == '___VENV_RESTART___':
             ## FIXME: We don't do anything this this value yet:
             venv_location = args[-2]
             args = args[:-2]
             options.venv = None
+        else:
+            # If given the option to respect the activated environment
+            # check if no venv is given as a command line parameter
+            if options.respect_venv and os.environ.get('VIRTUAL_ENV'):
+                if options.venv and os.path.exists(options.venv):
+                    # Make sure command line venv and environmental are the same
+                    if (os.path.realpath(os.path.expanduser(options.venv)) !=
+                            os.path.realpath(os.environ.get('VIRTUAL_ENV'))):
+                        print ("Given virtualenv (%s) doesn't match "
+                               "currently activated virtualenv (%s)."
+                               % (options.venv, os.environ.get('VIRTUAL_ENV')))
+                        sys.exit(3)
+                else:
+                    options.venv = os.environ.get('VIRTUAL_ENV')
+                    print 'Using already activated environment %s' % options.venv
         level = 1 # Notify
         level += options.verbose
         level -= options.quiet
         complete_log = []
         logger = Logger([(level, sys.stdout),
                          (Logger.DEBUG, complete_log.append)])
-        if os.environ.get('PIP_LOG_EXPLICIT_LEVELS'):
+        if options.log_explicit_levels:
             logger.explicit_levels = True
         if options.venv:
             if options.verbose > 0:
             site_packages=False
             if options.site_packages:
                 site_packages=True
-            restart_in_venv(options.venv, site_packages, complete_args)
+            restart_in_venv(options.venv, options.venv_base, site_packages,
+                            complete_args)
             # restart_in_venv should actually never return, but for clarity...
             return
         ## FIXME: not sure if this sure come before or after venv restart
         if log_fp is not None:
             log_fp.close()
         if exit:
-            log_fn = os.environ.get('PIP_LOG_FILE', './pip-log.txt')
+            log_fn = options.log_file
             text = '\n'.join(complete_log)
             logger.fatal('Storing complete log in %s' % log_fn)
             log_fp = open_logfile_append(log_fn)
             metavar='URL',
             help='URL to look for packages at')
         self.parser.add_option(
-            '-i', '--index-url',
+            '-i', '--index-url', '--pypi-url',
             dest='index_url',
             metavar='URL',
-            default=pypi_url,
-            help='base URL of Python Package Index')
+            default='http://pypi.python.org/simple',
+            help='Base URL of Python Package Index (default %default)')
         self.parser.add_option(
             '--extra-index-url',
             dest='extra_index_urls',
             metavar='URL',
             action='append',
             default=[],
-            help='extra URLs of package indexes to use in addition to --index-url')
+            help='Extra URLs of package indexes to use in addition to --index-url')
         self.parser.add_option(
             '--no-index',
             dest='no_index',
             dest='build_dir',
             metavar='DIR',
             default=None,
-            help='Unpack packages into DIR (default %s) and build from there' % base_prefix)
+            help='Unpack packages into DIR (default %s) and build from there' % build_prefix)
         self.parser.add_option(
             '-d', '--download', '--download-dir', '--download-directory',
             dest='download_dir',
             default=None,
             help='Download packages into DIR instead of installing them')
         self.parser.add_option(
+            '--download-cache',
+            dest='download_cache',
+            metavar='DIR',
+            default=None,
+            help='Cache downloaded packages in DIR')
+        self.parser.add_option(
             '--src', '--source', '--source-dir', '--source-directory',
             dest='src_dir',
             metavar='DIR',
             default=None,
-            help='Check out --editable packages into DIR (default %s)' % base_src_prefix)
+            help='Check out --editable packages into DIR (default %s)' % src_prefix)
 
         self.parser.add_option(
             '-U', '--upgrade',
             help="Extra arguments to be supplied to the setup.py install "
             "command (use like --install-option=\"--install-scripts=/usr/local/bin\").  "
             "Use multiple --install-option options to pass multiple options to setup.py install.  "
-            "If you are using an option with a directory path, be sure to use absolute path."
-            )
+            "If you are using an option with a directory path, be sure to use absolute path.")
 
     def run(self, options, args):
         if not options.build_dir:
-            options.build_dir = base_prefix
+            options.build_dir = build_prefix
         if not options.src_dir:
-            options.src_dir = base_src_prefix
+            options.src_dir = src_prefix
         if options.download_dir:
             options.no_install = True
             options.ignore_installed = True
             build_dir=options.build_dir,
             src_dir=options.src_dir,
             download_dir=options.download_dir,
+            download_cache=options.download_cache,
             upgrade=options.upgrade,
             ignore_installed=options.ignore_installed,
             ignore_dependencies=options.ignore_dependencies)
                 InstallRequirement.from_line(name, None))
         for name in options.editables:
             requirement_set.add_requirement(
-                InstallRequirement.from_editable(name))
+                InstallRequirement.from_editable(name, default_vcs=options.default_vcs))
         for filename in options.requirements:
-            for req in parse_requirements(filename, finder=finder):
+            for req in parse_requirements(filename, finder=finder, options=options):
                 requirement_set.add_requirement(req)
         requirement_set.install_files(finder, force_root_egg_info=self.bundle)
         if not options.no_install and not self.bundle:
             requirement_set.add_requirement(
                 InstallRequirement.from_line(name))
         for filename in options.requirements:
-            for req in parse_requirements(filename):
+            for req in parse_requirements(filename, options=options):
                 requirement_set.add_requirement(req)
         requirement_set.uninstall(auto_confirm=options.yes)
 
         if not args:
             raise InstallationError('You must give a bundle filename')
         if not options.build_dir:
-            options.build_dir = backup_dir(base_prefix, '-bundle')
+            options.build_dir = backup_dir(build_prefix, '-bundle')
         if not options.src_dir:
-            options.src_dir = backup_dir(base_src_prefix, '-bundle')
+            options.src_dir = backup_dir(src_prefix, '-bundle')
         # We have to get everything when creating a bundle:
         options.ignore_installed = True
         logger.notify('Putting temporary build files in %s and source/develop files in %s'
         ## FIXME: Obviously this should be settable:
         find_tags = False
         skip_match = None
-        if os.environ.get('PIP_SKIP_REQUIREMENTS_REGEX'):
-            skip_match = re.compile(os.environ['PIP_SKIP_REQUIREMENTS_REGEX'])
+
+        skip_regex = options.skip_requirements_regex
+        if skip_regex:
+            skip_match = re.compile(skip_regex)
 
         logger.move_stdout_to_stderr()
         dependency_links = []
                         line = line[2:].strip()
                     else:
                         line = line[len('--editable'):].strip().lstrip('=')
-                    line_req = InstallRequirement.from_editable(line)
+                    line_req = InstallRequirement.from_editable(line, default_vcs=options.default_vcs)
                 elif (line.startswith('-r') or line.startswith('--requirement')
                       or line.startswith('-Z') or line.startswith('--always-unzip')):
                     logger.debug('Skipping line %r' % line.strip())
     traceback.print_exception(*exc_info, **dict(file=out))
     return out.getvalue()
 
-def restart_in_venv(venv, site_packages, args):
+def restart_in_venv(venv, base, site_packages, args):
     """
     Restart this script using the interpreter in the given virtual environment
     """
-    if virtualenv_base\
-            and not os.path.isabs(venv)\
-            and not venv.startswith('~'):
-        base = os.path.expanduser(virtualenv_base)
+    if base and not os.path.isabs(venv) and not venv.startswith('~'):
+        base = os.path.expanduser(base)
         # ensure we have an abs basepath at this point:
         #    a relative one makes no sense (or does it?)
         if os.path.isabs(base):
                     file_locations.append(filename_to_url2(fn))
             else:
                 url_locations.append(url)
-        
+
         locations = [Link(url) for url in url_locations]
         logger.debug('URLs to search for versions for %s:' % req)
         for location in locations:
         self.uninstalled = None
 
     @classmethod
-    def from_editable(cls, editable_req, comes_from=None):
-        name, url = parse_editable(editable_req)
+    def from_editable(cls, editable_req, comes_from=None, default_vcs=None):
+        name, url = parse_editable(editable_req, default_vcs)
         if url.startswith('file:'):
             source_dir = url_to_filename(url)
         else:
 
     def assert_source_matches_version(self):
         assert self.source_dir
-        if self.comes_from == 'command line':
+        if self.comes_from is None:
             # We don't check the versions of things explicitly installed.
             # This makes, e.g., "pip Package==dev" possible
             return
         thus uninstallation within a virtual environment can only
         modify that virtual environment, even if the virtualenv is
         linked to global site-packages.
-        
+
         """
         if not self.check_if_exists():
             raise UninstallationError("Cannot uninstall requirement %s, not installed" % (self.name,))
         dist = self.satisfied_by or self.conflicts_with
         paths_to_remove = UninstallPathSet(dist, sys.prefix)
-        if not paths_to_remove.can_uninstall():
-            return
 
         pip_egg_info_path = os.path.join(dist.location,
                                          dist.egg_name()) + '.egg-info'
         easy_install_egg = dist.egg_name() + '.egg'
         # This won't find a globally-installed develop egg if
-        # we're in a virtualenv (lib_py is based on sys.prefix).
+        # we're in a virtualenv.
         # (There doesn't seem to be any metadata in the
         # Distribution object for a develop egg that points back
         # to its .egg-link and easy-install.pth files).  That's
         # OK, because we restrict ourselves to making changes
         # within sys.prefix anyway.
-        develop_egg_link = os.path.join(lib_py, 'site-packages',
+        develop_egg_link = os.path.join(site_packages,
                                         dist.project_name) + '.egg-link'
         if os.path.exists(pip_egg_info_path):
             # package installed by pip
             easy_install_pth = os.path.join(os.path.dirname(develop_egg_link),
                                             'easy-install.pth')
             paths_to_remove.add_pth(easy_install_pth, dist.location)
-
-        # get scripts from metadata FIXME there seems to be no way to
-        # get info about installed scripts from a
-        # develop-install. python setup.py develop --record in
-        # install_editable seemingly ought to work, but does not
+            # fix location (so we can uninstall links to sources outside venv)
+            paths_to_remove.location = develop_egg_link
+
+        # find distutils scripts= scripts
         if dist.has_metadata('scripts') and dist.metadata_isdir('scripts'):
             for script in dist.metadata_listdir('scripts'):
                 paths_to_remove.add(os.path.join(bin_py, script))
                 if sys.platform == 'win32':
                     paths_to_remove.add(os.path.join(bin_py, script) + '.bat')
 
+        # find console_scripts
+        if dist.has_metadata('entry_points.txt'):
+            config = ConfigParser.SafeConfigParser()
+            config.readfp(FakeFile(dist.get_metadata_lines('entry_points.txt')))
+            for name, value in config.items('console_scripts'):
+                paths_to_remove.add(os.path.join(bin_py, name))
+                if sys.platform == 'win32':
+                    paths_to_remove.add(os.path.join(bin_py, name) + '.exe')
+                    paths_to_remove.add(os.path.join(bin_py, name) + '-script.py')
+
         paths_to_remove.remove(auto_confirm)
         self.uninstalled = paths_to_remove
 
                 for dirname in dirnames:
                     dirname = os.path.join(dirpath, dirname)
                     name = self._clean_zip_name(dirname, dir)
-                    zip.writestr(self.name + '/' + name + '/', '')
+                    zipdir = zipfile.ZipInfo(self.name + '/' + name + '/')
+                    zipdir.external_attr = 0755 << 16L
+                    zip.writestr(zipdir, '')
                 for filename in filenames:
                     if filename == 'pip-delete-this-directory.txt':
                         continue
         for dest_dir in self._bundle_build_dirs:
             package = os.path.basename(dest_dir)
             yield InstallRequirement(
-                package, self, 
+                package, self,
                 source_dir=dest_dir)
 
     def move_bundle_files(self, dest_build_dir, dest_src_dir):
 
 class RequirementSet(object):
 
-    def __init__(self, build_dir, src_dir, download_dir, upgrade=False, ignore_installed=False, ignore_dependencies=False):
+    def __init__(self, build_dir, src_dir, download_dir, download_cache=None,
+                 upgrade=False, ignore_installed=False,
+                 ignore_dependencies=False):
         self.build_dir = build_dir
         self.src_dir = src_dir
         self.download_dir = download_dir
+        self.download_cache = download_cache
         self.upgrade = upgrade
         self.ignore_installed = ignore_installed
         self.requirements = {}
         md5_hash = link.md5_hash
         target_url = link.url.split('#', 1)[0]
         target_file = None
-        if os.environ.get('PIP_DOWNLOAD_CACHE'):
-            target_file = os.path.join(os.environ['PIP_DOWNLOAD_CACHE'],
+        if self.download_cache:
+            if not os.path.isdir(self.download_cache):
+                logger.indent -= 2
+                logger.notify('Creating supposed download cache at %s' % self.download_cache)
+                logger.indent += 2
+                os.makedirs(self.download_cache)
+            target_file = os.path.join(self.download_cache,
                                        urllib.quote(target_url, ''))
         if (target_file and os.path.exists(target_file)
             and os.path.exists(target_file+'.content-type')):
         Compare two repo URLs for identity, ignoring incidental differences.
         """
         return (self.normalize_url(url1) == self.normalize_url(url2))
-    
+
     def parse_vcs_bundle_file(self, content):
         """
         Takes the contents of the bundled text file that explains how to revert
         Update an already-existing repo to the given ``rev_options``.
         """
         raise NotImplementedError
-    
+
     def check_destination(self, dest, url, rev_options, rev_display):
         """
         Prepare a location to receive a checkout/clone.
                 shutil.move(dest, dest_dir)
                 checkout = True
         return checkout
-    
+
     def unpack(self, location):
         raise NotImplementedError
 
 
     def get_revision(self, location):
         return self.get_info(location)[1]
-    
+
     def parse_vcs_bundle_file(self, content):
         for line in content.splitlines():
             if not line.strip() or line.strip().startswith('#'):
     def switch(self, dest, url, rev_options):
         call_subprocess(
             ['svn', 'switch'] + rev_options + [url, dest])
-            
+
     def update(self, dest, rev_options):
         call_subprocess(
             ['svn', 'update'] + rev_options + [dest])
             if origin_rev in inverse_revisions:
                 rev = inverse_revisions[origin_rev]
             else:
-                raise InstallationError("Could not find a tag or branch '%s' in repository %s"
-                                        % (rev, display_path(dest)))
+                logger.warn("Could not find a tag or branch '%s', assuming commit." % rev)
         return [rev]
 
     def switch(self, dest, url, rev_options):
         url, rev = self.get_url_rev()
         if rev:
             rev_options = [rev]
-            rev_display = ' (to revision %s)' % rev
+            rev_display = ' (to %s)' % rev
         else:
             rev_options = ['origin/master']
             rev_display = ''
                                        branch_revs[current_rev].replace('origin/', ''))
         else:
             full_egg_name = '%s-dev' % dist.egg_name()
-            
+
         return '%s@%s#egg=%s' % (repo, current_rev, full_egg_name)
 
     def get_url_rev(self):
         call_subprocess(['hg', 'pull', '-q'], cwd=dest)
         call_subprocess(
             ['hg', 'update', '-q'] + rev_options, cwd=dest)
-        
+
     def obtain(self, dest):
         url, rev = self.get_url_rev()
         if rev:
     def update(self, dest, rev_options):
         call_subprocess(
             [self.cmd, 'pull', '-q'] + rev_options, cwd=dest)
-            
+
     def obtain(self, dest):
         url, rev = self.get_url_rev()
         if rev:
     f.close()
     return url, content
 
-def parse_requirements(filename, finder=None, comes_from=None):
+def parse_requirements(filename, finder=None, comes_from=None, options=None):
     skip_match = None
-    if os.environ.get('PIP_SKIP_REQUIREMENTS_REGEX'):
-        skip_match = re.compile(os.environ['PIP_SKIP_REQUIREMENTS_REGEX'])
+    skip_regex = options.skip_requirements_regex
+    if skip_regex:
+        skip_match = re.compile(skip_regex)
     filename, content = get_file_content(filename, comes_from=comes_from)
     for line_number, line in enumerate(content.splitlines()):
         line_number += 1
                 req_url = urlparse.urljoin(filename, url)
             elif not _scheme_re.search(req_url):
                 req_url = os.path.join(os.path.dirname(filename), req_url)
-            for item in parse_requirements(req_url, finder, comes_from=filename):
+            for item in parse_requirements(req_url, finder, comes_from=filename, options=options):
                 yield item
         elif line.startswith('-Z') or line.startswith('--always-unzip'):
             # No longer used, but previously these were used in
                 else:
                     line = line[len('--editable'):].strip()
                 req = InstallRequirement.from_editable(
-                    line, comes_from)
+                    line, comes_from=comes_from, default_vcs=options.default_vcs)
             else:
                 req = InstallRequirement.from_line(line, comes_from)
             yield req
         path = '.' + path[len(os.getcwd()):]
     return path
 
-def parse_editable(editable_req):
+def parse_editable(editable_req, default_vcs=None):
     """Parses svn+http://blahblah@rev#egg=Foobar into a requirement
     (Foobar) and a URL"""
     url = editable_req
     else:
         return name
 
+def is_framework_layout(path):
+    """Return True if the current platform is the default Python of Mac OS X
+    which installs scripts in /usr/local/bin"""
+    return (sys.platform[:6] == 'darwin' and
+            (path[:9] == '/Library/' or path[:16] == '/System/Library/'))
+
 def strip_prefix(path, prefix):
     """ If ``path`` begins with ``prefix``, return ``path`` with
     ``prefix`` stripped off.  Otherwise return None."""
-    if path.startswith(prefix):
-        return path.replace(prefix + os.path.sep, '')
-    return None
+    prefixes = [prefix]
+    # Yep, we are special casing the framework layout of MacPython here
+    if is_framework_layout(sys.prefix):
+        for location in ('/Library', '/usr/local'):
+            if path.startswith(location):
+                prefixes.append(location)
+    for prefix in prefixes:
+        if path.startswith(prefix):
+            return prefix, path.replace(prefix + os.path.sep, '')
+    return None, None
 
 class UninstallPathSet(object):
     """A set of file paths to be removed in the uninstallation of a
         self.pth = {}
         self.prefix = os.path.normcase(os.path.realpath(restrict_to_prefix))
         self.dist = dist
+        self.location = dist.location
         self.save_dir = None
         self._moved_paths = []
 
-    def can_uninstall(self):
-        if not strip_prefix(self.dist.location, self.prefix):
+    def _can_uninstall(self):
+        prefix, stripped = strip_prefix(self.location, self.prefix)
+        if not stripped:
             logger.notify("Not uninstalling %s at %s, outside environment %s"
                           % (self.dist.project_name, self.dist.location,
                              self.prefix))
             return False
         return True
-        
+
     def add(self, path):
+        path = os.path.abspath(path)
         if not os.path.exists(path):
             return
-        stripped = strip_prefix(os.path.normcase(path), self.prefix)
+        prefix, stripped = strip_prefix(os.path.normcase(path), self.prefix)
         if stripped:
-            self.paths.add(stripped)
+            self.paths.add((prefix, stripped))
         else:
-            self._refuse.add(path)
+            self._refuse.add((prefix, path))
 
     def add_pth(self, pth_file, entry):
-        stripped = strip_prefix(os.path.normcase(pth_file), self.prefix)
+        prefix, stripped = strip_prefix(os.path.normcase(pth_file), self.prefix)
         if stripped:
             entry = os.path.normcase(entry)
             if stripped not in self.pth:
-                self.pth[stripped] = UninstallPthEntries(os.path.join(self.prefix, stripped))
+                self.pth[stripped] = UninstallPthEntries(os.path.join(prefix, stripped))
             self.pth[stripped].add(os.path.normcase(entry))
         else:
-            self._refuse.add(pth_file)
-        
+            self._refuse.add((prefix, pth_file))
+
     def compact(self, paths):
         """Compact a path set to contain the minimal number of paths
         necessary to contain all paths in the set. If /a/path/ and
         /a/path/to/a/file.txt are both in the set, leave only the
         shorter path."""
         short_paths = set()
-        for path in sorted(paths, lambda x, y: cmp(len(x), len(y))):
+        def sort_set(x, y):
+            prefix_x, path_x = x
+            prefix_y, path_y = y
+            return cmp(len(path_x), len(path_y))
+        for prefix, path in sorted(paths, sort_set):
             if not any([(path.startswith(shortpath) and
                          path[len(shortpath.rstrip(os.path.sep))] == os.path.sep)
-                        for shortpath in short_paths]):
-                short_paths.add(path)
+                        for shortprefix, shortpath in short_paths]):
+                short_paths.add((prefix, path))
         return short_paths
 
     def remove(self, auto_confirm=False):
         """Remove paths in ``self.paths`` with confirmation (unless
         ``auto_confirm`` is True)."""
+        if not self._can_uninstall():
+            return
         logger.notify('Uninstalling %s:' % self.dist.project_name)
         logger.indent += 2
         paths = sorted(self.compact(self.paths))
             if auto_confirm:
                 response = 'y'
             else:
-                for path in paths:
-                    logger.notify(path)
+                for prefix, path in paths:
+                    logger.notify(os.path.join(prefix, path))
                 response = ask('Proceed (y/n)? ', ('y', 'n'))
             if self._refuse:
-                logger.notify('Not removing or modifying (outside of sys.prefix):')
-                for path in self.compact(self._refuse):
-                    logger.notify(path)
+                logger.notify('Not removing or modifying (outside of prefix):')
+                for prefix, path in self.compact(self._refuse):
+                    logger.notify(os.path.join(prefix, path))
             if response == 'y':
                 self.save_dir = tempfile.mkdtemp('-uninstall', 'pip-')
-                for path in paths:
-                    full_path = os.path.join(self.prefix, path)
+                for prefix, path in paths:
+                    full_path = os.path.join(prefix, path)
                     new_path = os.path.join(self.save_dir, path)
                     new_dir = os.path.dirname(new_path)
                     logger.info('Removing file or directory %s' % full_path)
-                    self._moved_paths.append(path)
+                    self._moved_paths.append((prefix, path))
                     os.renames(full_path, new_path)
                 for pth in self.pth.values():
                     pth.remove()
                 logger.notify('Successfully uninstalled %s' % self.dist.project_name)
-                
+
         finally:
             logger.indent -= 2
 
             logger.error("Can't roll back %s; was not uninstalled" % self.dist.project_name)
             return False
         logger.notify('Rolling back uninstall of %s' % self.dist.project_name)
-        for path in self._moved_paths:
+        for prefix, path in self._moved_paths:
             tmp_path = os.path.join(self.save_dir, path)
-            real_path = os.path.join(self.prefix, path)
+            real_path = os.path.join(prefix, path)
             logger.info('Replacing %s' % real_path)
             os.renames(tmp_path, real_path)
         for pth in self.pth:
             shutil.rmtree(self.save_dir)
             self.save_dir = None
             self._moved_paths = []
-        
+
 
 class UninstallPthEntries(object):
     def __init__(self, pth_file):
         fh.writelines(self._saved_lines)
         fh.close()
         return True
-        
+
+class FakeFile(object):
+    """Wrap a list of lines in an object with readline() to make
+    ConfigParser happy."""
+    def __init__(self, lines):
+        self._gen = (l for l in lines)
+
+    def readline(self):
+        try:
+            return self._gen.next()
+        except StopIteration:
+            return ''
+    
 def splitext(path):
     """Like os.path.splitext, but take off .tar too"""
     base, ext = posixpath.splitext(path)

tests/packages/README.txt

+This package exists for testing uninstall-rollback. 
+
+Version 0.2broken has a setup.py crafted to fail on install (and only on
+install). If any earlier step would fail (i.e. egg-info-generation), the
+already-installed version would never be uninstalled, so uninstall-rollback
+would not come into play.

tests/packages/broken-0.1.tar.gz

Binary file added.

tests/packages/broken-0.2broken.tar.gz

Binary file added.

tests/test_basic.txt

     >>> assert 'src/initools' in result.files_created
     >>> assert 'src/initools/.svn' in result.files_created
 
+
+Using package==dev::
+
+    >>> reset_env()
+    >>> result = run_pip('install', 'INITools==dev', expect_error=True)
+    >>> assert (lib_py + 'site-packages/initools') in result.files_created, str(result.stdout)
+
 Cloning from Git::
 
     >>> reset_env()

tests/test_config.txt

+Basic setup::
+
+    >>> from __main__ import here, reset_env, run_pip, clear_environ, write_file
+    >>> import os
+
+Test if ConfigOptionParser reads env vars (e.g. not using PyPI here)
+
+    >>> environ = clear_environ(os.environ.copy())
+    >>> environ['PIP_NO_INDEX'] = '1'
+    >>> reset_env(environ)
+    >>> result = run_pip('install', '-vvv', 'INITools', expect_error=True)
+    >>> assert "Ignoring indexes:" in result.stdout
+    >>> assert "DistributionNotFound: No distributions at all found for INITools" in result.stdout
+
+Test if command line options override environmental variables
+
+    >>> environ = clear_environ(os.environ.copy())
+    >>> environ['PIP_INDEX_URL'] = 'http://pypi.appspot.com/'
+    >>> reset_env(environ)
+    >>> result = run_pip('install', '-vvv', 'INITools', expect_error=True)
+    >>> assert "Getting page http://pypi.appspot.com/INITools" in result.stdout
+    >>> reset_env(environ)
+    >>> result = run_pip('install', '-vvv', '--index-url', 'http://download.zope.org/ppix', 'INITools', expect_error=True)
+    >>> assert "http://pypi.appspot.com/INITools" not in result.stdout
+    >>> assert "Getting page http://download.zope.org/ppix" in result.stdout
+
+Test command line flags that append to defaults set by environmental variables
+
+    >>> environ = clear_environ(os.environ.copy())
+    >>> environ['PIP_FIND_LINKS'] = 'http://pypi.pinaxproject.com'
+    >>> reset_env(environ)
+    >>> result = run_pip('install', '-vvv', 'INITools', expect_error=True)
+    >>> assert "Analyzing links from page http://pypi.pinaxproject.com" in result.stdout
+    >>> reset_env(environ)
+    >>> result = run_pip('install', '-vvv', '--find-links', 'http://example.com', 'INITools', expect_error=True)
+    >>> assert "Analyzing links from page http://pypi.pinaxproject.com" in result.stdout
+    >>> assert "Analyzing links from page http://example.com" in result.stdout
+
+Test config files (global, overriding a global config with a local, overriding all with a command line flag)
+
+    >>> import tempfile
+    >>> f, config_file = tempfile.mkstemp('-pip.cfg', 'test-')
+    >>> environ = clear_environ(os.environ.copy())
+    >>> environ['PIP_CONFIG_FILE'] = config_file # set this to make pip load it
+    >>> reset_env(environ)
+    >>> write_file(config_file, '''\
+    ... [global]
+    ... index-url = http://download.zope.org/ppix
+    ... ''')
+    >>> result = run_pip('install', '-vvv', 'INITools', expect_error=True)
+    >>> assert "Getting page http://download.zope.org/ppix/INITools" in result.stdout
+    >>> reset_env(environ)
+    >>> write_file(config_file, '''\
+    ... [global]
+    ... index-url = http://download.zope.org/ppix
+    ... [install]
+    ... index-url = http://pypi.appspot.com/
+    ... ''')
+    >>> result = run_pip('install', '-vvv', 'INITools', expect_error=True)
+    >>> assert "Getting page http://pypi.appspot.com/INITools" in result.stdout
+    >>> result = run_pip('install', '-vvv', '--index-url', 'http://pypi.python.org/simple', 'INITools', expect_error=True)
+    >>> assert "Getting page http://download.zope.org/ppix/INITools" not in result.stdout
+    >>> assert "Getting page http://pypi.appspot.com/INITools" not in result.stdout
+    >>> assert "Getting page http://pypi.python.org/simple/INITools" in result.stdout

tests/test_pip.py

                 return True
         return False
 
-def reset_env():
+def clear_environ(environ):
+    return dict(((k, v) for k, v in environ.iteritems()
+                if not k.lower().startswith('pip_')))
+
+def reset_env(environ=None):
     global env
-    environ = os.environ.copy()
+    if not environ:
+        environ = os.environ.copy()
+        environ = clear_environ(environ)
+        environ['PIP_DOWNLOAD_CACHE'] = download_cache
     environ['PIP_NO_INPUT'] = '1'
-    environ['PIP_DOWNLOAD_CACHE'] = download_cache
     env = TestFileEnvironment(base_path, ignore_hidden=False, environ=environ)
     env.run(sys.executable, '-m', 'virtualenv', '--no-site-packages', env.base_path)
-    # To avoid the 0.9c8 svn 1.5 incompatibility:
-    env.run('%s/bin/easy_install' % env.base_path, 'http://peak.telecommunity.com/snapshots/setuptools-0.7a1dev-r66388.tar.gz')
+    # make sure we have current setuptools to avoid svn incompatibilities
+    env.run('%s/bin/easy_install' % env.base_path, 'setuptools==0.6c11')
     env.run('mkdir', 'src')
 
 def run_pip(*args, **kw):
     options, args = parser.parse_args()
     reset_env()
     if not args:
-        args = ['test_basic.txt', 'test_requirements.txt', 'test_freeze.txt', 'test_proxy.txt', 'test_uninstall.txt', 'test_upgrade.txt']
+        args = ['test_basic.txt', 'test_requirements.txt', 'test_freeze.txt', 'test_proxy.txt', 'test_uninstall.txt', 'test_upgrade.txt', 'test_config.txt']
     optionflags = doctest.ELLIPSIS
     if options.first:
         optionflags |= doctest.REPORT_ONLY_FIRST_FAILURE

tests/test_uninstall.txt

 
     >>> from __main__ import here, reset_env, run_pip, pyversion, lib_py, get_env, diff_states, write_file
     >>> from os.path import join
+    >>> from tempfile import mkdtemp
     >>> easy_install_pth = join(lib_py, 'site-packages', 'easy-install.pth')
 
 Simple install and uninstall::
 
     >>> reset_env()
     >>> env = get_env()
-    >>> result = env.run(join(env.base_path, 'bin', 'easy_install'), 'Markdown')
-    >>> assert('Markdown' in result.files_updated[easy_install_pth].bytes), result.files_after[easy-install_pth].bytes
-    >>> result2 = run_pip('uninstall', 'markdown', '-y', expect_error=True)
+    >>> result = env.run(join(env.base_path, 'bin', 'easy_install'), 'PyLogo')
+    >>> assert('PyLogo' in result.files_updated[easy_install_pth].bytes), result.files_after[easy-install_pth].bytes
+    >>> result2 = run_pip('uninstall', 'pylogo', '-y', expect_error=True)
     >>> diff_states(result.files_before, result2.files_after, ignore=['build']).values()
     [{}, {}, {}]
 
-Uninstall a package with more files (script entry points, empty directories)::
+Uninstall a package with more files (script entry points, extra directories)::
 
     >>> reset_env()
     >>> result = run_pip('install', 'virtualenv', expect_error=True)
     >>> diff_states(result.files_before, result2.files_after, ignore=['build']).values()
     [{}, {}, {}]
 
+Same, but easy_installed::
+
+    >>> reset_env()
+    >>> result = env.run(join(env.base_path, 'bin', 'easy_install'), 'virtualenv')
+    >>> assert ('bin/virtualenv') in result.files_created, sorted(result.files_created.keys())
+    >>> result2 = run_pip('uninstall', 'virtualenv', '-y', expect_error=True)
+    >>> diff_states(result.files_before, result2.files_after, ignore=['build']).values()
+    [{}, {}, {}]
+
 Uninstall an editable installation from svn::
 
     >>> reset_env()
     >>> diff_states(result.files_before, result2.files_after, ignore=['src/initools', 'build']).values()
     [{}, {}, {}]
 
+Editable install from existing source outside the venv::
+
+    >>> reset_env()
+    >>> tmpdir = mkdtemp()
+    >>> result = env.run('hg', 'clone', 'http://bitbucket.org/ianb/virtualenv/', tmpdir)
+    >>> result2 = run_pip('install', '-e', tmpdir)
+    >>> assert (join(lib_py, 'site-packages', 'virtualenv.egg-link') in result2.files_created), result2.files_created.keys()
+    >>> result3 = run_pip('uninstall', '-y', 'virtualenv', expect_error=True)
+    >>> diff_states(result.files_before, result3.files_after, ignore=['build']).values()
+    [{}, {}, {}]
+
 Uninstall from a requirements file::
 
     >>> reset_env()
     >>> write_file('test-req.txt', '''\
     ... -e svn+http://svn.colorstudy.com/INITools/trunk#egg=initools-dev
     ... # and something else to test out:
-    ... simplejson<=1.7.4
+    ... PyLogo<0.4
     ... ''')
     >>> result = run_pip('install', '-r', 'test-req.txt')
     >>> result2 = run_pip('uninstall', '-r', 'test-req.txt', '-y')

tests/test_upgrade.txt

     >>> result2 = run_pip('install', 'INITools', expect_error=True)
     >>> assert not result2.files_created, 'pip install INITools upgraded when it should not have'
 
-And it does upgrade if requested
+It does upgrade to specific version requested::
+
+    >>> reset_env()
+    >>> result = run_pip('install', 'INITools==0.1', expect_error=True)
+    >>> result2 = run_pip('install', 'INITools==0.2', expect_error=True)
+    >>> assert result2.files_created, 'pip install with specific version did not upgrade'
+
+And it does upgrade if requested::
 
     >>> reset_env()
     >>> result = run_pip('install', 'INITools==0.1', expect_error=True)
 Automatic uninstall-before-upgrade::
 
     >>> reset_env()
-    >>> result = run_pip('install', 'simplejson==1.7.4', expect_error=True)
-    >>> assert join(lib_py + 'site-packages', 'simplejson') in result.files_created, sorted(result.files_created.keys())
-    >>> result2 = run_pip('install', 'simplejson==2.0.9', expect_error=True)
-    >>> assert result2.files_created, 'upgrade to simplejson 2.0.9 failed'
-    >>> result3 = run_pip('uninstall', 'simplejson', '-y', expect_error=True)
+    >>> result = run_pip('install', 'INITools==0.2', expect_error=True)
+    >>> assert join(lib_py + 'site-packages', 'initools') in result.files_created, sorted(result.files_created.keys())
+    >>> result2 = run_pip('install', 'INITools==0.3', expect_error=True)
+    >>> assert result2.files_created, 'upgrade to INITools 0.3 failed'
+    >>> result3 = run_pip('uninstall', 'initools', '-y', expect_error=True)
     >>> diff_states(result.files_before, result3.files_after, ignore=['build']).values()
     [{}, {}, {}]
 
     >>> diff_states(result.files_before, result3.files_after, ignore=['build', 'test-req.txt']).values()
     [{}, {}, {}]
 
-# FIXME Testing rollback of auto-uninstall requires a
-# publicly-available package that has a broken setup.py such that
-# "python setup.py egg-info" works, but "python setup.py install"
-# fails. Wouldn't be too hard to create such a package, but at the
-# moment I'm not sure where to host it, and don't feel like adding yet
-# another external point of failure to these tests. So test it
-# manually! Real-world need for this feature should be minimal, as the
-# common failure modes (package can't be downloaded, setup.py really
-# broken) will cause failure before the existing distribution is ever
-# uninstalled.
+Test uninstall-rollback (using test package with a setup.py crafted to
+fail on install)::
+
+    >>> reset_env()
+    >>> env = get_env()
+    >>> find_links = 'file://' + join(here, 'packages')
+    >>> result = run_pip('install', '-f', find_links, '--no-index', 'broken==0.1')
+    >>> assert (join(lib_py, 'site-packages', 'broken.py') in result.files_created), result.files_created.keys()
+    >>> result2 = run_pip('install', '-f', find_links, '--no-index', 'broken==0.2broken', expect_error=True)
+    >>> assert result2.returncode == 1
+    >>> env.run(join(env.base_path, 'bin', 'python'), '-c', "import broken; print broken.VERSION").stdout
+    '0.1\n'
+    >>> diff_states(result.files_after, result2.files_after, ignore=['build', 'pip-log.txt']).values()
+    [{}, {}, {}]