Issue #2563: Read cached wheels from ~/.cache/pip

This won't put wheels into that directory, but will read them if they
are there. --no-cache-dir will disable reading such wheels.
This commit is contained in:
Robert Collins 2015-03-31 10:44:02 +13:00
parent 786ff8c607
commit 4926409340
13 changed files with 116 additions and 20 deletions

View File

@ -388,6 +388,23 @@ Windows
:file:`<CSIDL_LOCAL_APPDATA>\\pip\\Cache` :file:`<CSIDL_LOCAL_APPDATA>\\pip\\Cache`
Wheel cache
***********
Pip will read from the subdirectory ``wheels`` within the pip cache dir and use
any packages found there. This is disabled via the same ``no-cache-dir`` option
that disables the HTTP cache. The internal structure of that cache is not part
of the Pip API. As of 7.0 pip uses a subdirectory per sdist that wheels were
built from, and wheels within that subdirectory.
Pip attempts to choose the best wheels from those built in preference to
building a new wheel. Note that this means when a package has both optional
C extensions and builds `py` tagged wheels when the C extension can't be built
that pip will not attempt to build a better wheel for Python's that would have
supported it, once any generic wheel is built. To correct this, make sure that
the wheel's are built with Python specific tags - e.g. pp on Pypy.
Hash Verification Hash Verification
+++++++++++++++++ +++++++++++++++++

View File

@ -289,6 +289,7 @@ class RequirementCommand(Command):
requirement_set.add_requirement( requirement_set.add_requirement(
InstallRequirement.from_line( InstallRequirement.from_line(
name, None, isolated=options.isolated_mode, name, None, isolated=options.isolated_mode,
cache_root=options.cache_dir
) )
) )
@ -298,6 +299,7 @@ class RequirementCommand(Command):
name, name,
default_vcs=options.default_vcs, default_vcs=options.default_vcs,
isolated=options.isolated_mode, isolated=options.isolated_mode,
cache_root=options.cache_dir
) )
) )

View File

@ -60,7 +60,8 @@ class FreezeCommand(Command):
local_only=options.local, local_only=options.local,
user_only=options.user, user_only=options.user,
skip_regex=options.skip_requirements_regex, skip_regex=options.skip_requirements_regex,
isolated=options.isolated_mode) isolated=options.isolated_mode,
cache_root=options.cache_dir)
for line in freeze(**freeze_kwargs): for line in freeze(**freeze_kwargs):
sys.stdout.write(line + '\n') sys.stdout.write(line + '\n')

View File

@ -239,6 +239,7 @@ class InstallCommand(RequirementCommand):
delete=build_delete) as build_dir: delete=build_delete) as build_dir:
requirement_set = RequirementSet( requirement_set = RequirementSet(
build_dir=build_dir, build_dir=build_dir,
cache_root=options.cache_dir,
src_dir=options.src_dir, src_dir=options.src_dir,
download_dir=options.download_dir, download_dir=options.download_dir,
upgrade=options.upgrade, upgrade=options.upgrade,

View File

@ -131,6 +131,7 @@ class ListCommand(Command):
for dist in installed_packages: for dist in installed_packages:
req = InstallRequirement.from_line( req = InstallRequirement.from_line(
dist.key, None, isolated=options.isolated_mode, dist.key, None, isolated=options.isolated_mode,
cache_root=options.cache_dir,
) )
typ = 'unknown' typ = 'unknown'
try: try:

View File

@ -45,6 +45,7 @@ class UninstallCommand(Command):
requirement_set = RequirementSet( requirement_set = RequirementSet(
build_dir=None, build_dir=None,
cache_root=options.cache_dir,
src_dir=None, src_dir=None,
download_dir=None, download_dir=None,
isolated=options.isolated_mode, isolated=options.isolated_mode,
@ -54,13 +55,15 @@ class UninstallCommand(Command):
requirement_set.add_requirement( requirement_set.add_requirement(
InstallRequirement.from_line( InstallRequirement.from_line(
name, isolated=options.isolated_mode, name, isolated=options.isolated_mode,
cache_root=options.cache_dir,
) )
) )
for filename in options.requirements: for filename in options.requirements:
for req in parse_requirements( for req in parse_requirements(
filename, filename,
options=options, options=options,
session=session): session=session,
cache_root=options.cache_dir):
requirement_set.add_requirement(req) requirement_set.add_requirement(req)
if not requirement_set.has_requirements: if not requirement_set.has_requirements:
raise InstallationError( raise InstallationError(

View File

@ -159,6 +159,7 @@ class WheelCommand(RequirementCommand):
delete=build_delete) as build_dir: delete=build_delete) as build_dir:
requirement_set = RequirementSet( requirement_set = RequirementSet(
build_dir=build_dir, build_dir=build_dir,
cache_root=options.cache_dir,
src_dir=options.src_dir, src_dir=options.src_dir,
download_dir=None, download_dir=None,
ignore_dependencies=options.ignore_dependencies, ignore_dependencies=options.ignore_dependencies,

View File

@ -21,7 +21,8 @@ def freeze(
find_links=None, local_only=None, user_only=None, skip_regex=None, find_links=None, local_only=None, user_only=None, skip_regex=None,
find_tags=False, find_tags=False,
default_vcs=None, default_vcs=None,
isolated=False): isolated=False,
cache_root=None):
find_links = find_links or [] find_links = find_links or []
skip_match = None skip_match = None
@ -75,11 +76,13 @@ def freeze(
line, line,
default_vcs=default_vcs, default_vcs=default_vcs,
isolated=isolated, isolated=isolated,
cache_root=cache_root,
) )
else: else:
line_req = InstallRequirement.from_line( line_req = InstallRequirement.from_line(
line, line,
isolated=isolated, isolated=isolated,
cache_root=cache_root,
) )
if not line_req.name: if not line_req.name:

View File

@ -77,7 +77,7 @@ IGNORE = 5
def parse_requirements(filename, finder=None, comes_from=None, options=None, def parse_requirements(filename, finder=None, comes_from=None, options=None,
session=None): session=None, cache_root=None):
""" """
Parse a requirements file and yield InstallRequirement instances. Parse a requirements file and yield InstallRequirement instances.
@ -87,7 +87,6 @@ def parse_requirements(filename, finder=None, comes_from=None, options=None,
:param options: Global options. :param options: Global options.
:param session: Instance of pip.download.PipSession. :param session: Instance of pip.download.PipSession.
""" """
if session is None: if session is None:
raise TypeError( raise TypeError(
"parse_requirements() missing 1 required keyword argument: " "parse_requirements() missing 1 required keyword argument: "
@ -99,7 +98,7 @@ def parse_requirements(filename, finder=None, comes_from=None, options=None,
) )
parser = parse_content( parser = parse_content(
filename, content, finder, comes_from, options, session filename, content, finder, comes_from, options, session, cache_root
) )
for item in parser: for item in parser:
@ -107,7 +106,7 @@ def parse_requirements(filename, finder=None, comes_from=None, options=None,
def parse_content(filename, content, finder=None, comes_from=None, def parse_content(filename, content, finder=None, comes_from=None,
options=None, session=None): options=None, session=None, cache_root=None):
# Split, sanitize and join lines with continuations. # Split, sanitize and join lines with continuations.
content = content.splitlines() content = content.splitlines()
@ -129,8 +128,8 @@ def parse_content(filename, content, finder=None, comes_from=None,
comes_from = '-r %s (line %s)' % (filename, line_number) comes_from = '-r %s (line %s)' % (filename, line_number)
isolated = options.isolated_mode if options else False isolated = options.isolated_mode if options else False
yield InstallRequirement.from_line( yield InstallRequirement.from_line(
req, comes_from, isolated=isolated, options=opts req, comes_from, isolated=isolated, options=opts,
) cache_root=cache_root)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
elif linetype == REQUIREMENT_EDITABLE: elif linetype == REQUIREMENT_EDITABLE:
@ -139,8 +138,8 @@ def parse_content(filename, content, finder=None, comes_from=None,
default_vcs = options.default_vcs if options else None default_vcs = options.default_vcs if options else None
yield InstallRequirement.from_editable( yield InstallRequirement.from_editable(
value, comes_from=comes_from, value, comes_from=comes_from,
default_vcs=default_vcs, isolated=isolated default_vcs=default_vcs, isolated=isolated,
) cache_root=cache_root)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
elif linetype == REQUIREMENT_FILE: elif linetype == REQUIREMENT_FILE:
@ -152,8 +151,8 @@ def parse_content(filename, content, finder=None, comes_from=None,
req_url = os.path.join(os.path.dirname(filename), value) req_url = os.path.join(os.path.dirname(filename), value)
# TODO: Why not use `comes_from='-r {} (line {})'` here as well? # TODO: Why not use `comes_from='-r {} (line {})'` here as well?
parser = parse_requirements( parser = parse_requirements(
req_url, finder, comes_from, options, session req_url, finder, comes_from, options, session,
) cache_root=cache_root)
for req in parser: for req in parser:
yield req yield req

View File

@ -73,7 +73,8 @@ class InstallRequirement(object):
def __init__(self, req, comes_from, source_dir=None, editable=False, def __init__(self, req, comes_from, source_dir=None, editable=False,
link=None, as_egg=False, update=True, editable_options=None, link=None, as_egg=False, update=True, editable_options=None,
pycompile=True, markers=None, isolated=False, options=None): pycompile=True, markers=None, isolated=False, options=None,
cache_root=None):
self.extras = () self.extras = ()
if isinstance(req, six.string_types): if isinstance(req, six.string_types):
req = pkg_resources.Requirement.parse(req) req = pkg_resources.Requirement.parse(req)
@ -88,6 +89,7 @@ class InstallRequirement(object):
editable_options = {} editable_options = {}
self.editable_options = editable_options self.editable_options = editable_options
self._cache_root = cache_root
self.link = link self.link = link
self.as_egg = as_egg self.as_egg = as_egg
self.markers = markers self.markers = markers
@ -118,7 +120,7 @@ class InstallRequirement(object):
@classmethod @classmethod
def from_editable(cls, editable_req, comes_from=None, default_vcs=None, def from_editable(cls, editable_req, comes_from=None, default_vcs=None,
isolated=False, options=None): isolated=False, options=None, cache_root=None):
from pip.index import Link from pip.index import Link
name, url, extras_override, editable_options = parse_editable( name, url, extras_override, editable_options = parse_editable(
@ -133,7 +135,8 @@ class InstallRequirement(object):
link=Link(url), link=Link(url),
editable_options=editable_options, editable_options=editable_options,
isolated=isolated, isolated=isolated,
options=options if options else {}) options=options if options else {},
cache_root=cache_root)
if extras_override is not None: if extras_override is not None:
res.extras = extras_override res.extras = extras_override
@ -141,7 +144,9 @@ class InstallRequirement(object):
return res return res
@classmethod @classmethod
def from_line(cls, name, comes_from=None, isolated=False, options=None): def from_line(
cls, name, comes_from=None, isolated=False, options=None,
cache_root=None):
"""Creates an InstallRequirement from a name, which might be a """Creates an InstallRequirement from a name, which might be a
requirement, directory containing 'setup.py', filename, or URL. requirement, directory containing 'setup.py', filename, or URL.
""" """
@ -208,7 +213,7 @@ class InstallRequirement(object):
options = options if options else {} options = options if options else {}
return cls(req, comes_from, link=link, markers=markers, return cls(req, comes_from, link=link, markers=markers,
isolated=isolated, options=options) isolated=isolated, options=options, cache_root=cache_root)
def __str__(self): def __str__(self):
if self.req: if self.req:
@ -241,6 +246,16 @@ class InstallRequirement(object):
if self.link is None: if self.link is None:
self.link = finder.find_requirement(self, upgrade) self.link = finder.find_requirement(self, upgrade)
@property
def link(self):
return self._link
@link.setter
def link(self, link):
# Lookup a cached wheel, if possible.
link = pip.wheel.cached_wheel(self._cache_root, link)
self._link = link
@property @property
def specifier(self): def specifier(self):
return self.req.specifier return self.req.specifier

View File

@ -139,7 +139,8 @@ class RequirementSet(object):
ignore_installed=False, as_egg=False, target_dir=None, ignore_installed=False, as_egg=False, target_dir=None,
ignore_dependencies=False, force_reinstall=False, ignore_dependencies=False, force_reinstall=False,
use_user_site=False, session=None, pycompile=True, use_user_site=False, session=None, pycompile=True,
isolated=False, wheel_download_dir=None): isolated=False, wheel_download_dir=None,
cache_root=None):
"""Create a RequirementSet. """Create a RequirementSet.
:param wheel_download_dir: Where still-packed .whl files should be :param wheel_download_dir: Where still-packed .whl files should be
@ -149,6 +150,8 @@ class RequirementSet(object):
:param download_dir: Where still packed archives should be written to. :param download_dir: Where still packed archives should be written to.
If None they are not saved, and are deleted immediately after If None they are not saved, and are deleted immediately after
unpacking. unpacking.
:param cache_root: The root of the pip cache, for passing to
InstallRequirement.
""" """
if session is None: if session is None:
raise TypeError( raise TypeError(
@ -181,6 +184,7 @@ class RequirementSet(object):
if wheel_download_dir: if wheel_download_dir:
wheel_download_dir = normalize_path(wheel_download_dir) wheel_download_dir = normalize_path(wheel_download_dir)
self.wheel_download_dir = wheel_download_dir self.wheel_download_dir = wheel_download_dir
self._cache_root = cache_root
# Maps from install_req -> dependencies_of_install_req # Maps from install_req -> dependencies_of_install_req
self._dependencies = defaultdict(list) self._dependencies = defaultdict(list)
@ -512,6 +516,7 @@ class RequirementSet(object):
str(subreq), str(subreq),
req_to_install, req_to_install,
isolated=self.isolated, isolated=self.isolated,
cache_root=self._cache_root,
) )
more_reqs.extend(self.add_requirement( more_reqs.extend(self.add_requirement(
sub_install_req, req_to_install.name)) sub_install_req, req_to_install.name))

View File

@ -5,6 +5,7 @@ from __future__ import absolute_import
import compileall import compileall
import csv import csv
import errno
import functools import functools
import hashlib import hashlib
import logging import logging
@ -20,6 +21,8 @@ from email.parser import Parser
from pip._vendor.six import StringIO from pip._vendor.six import StringIO
import pip
from pip.download import path_to_url
from pip.exceptions import InvalidWheelFilename, UnsupportedWheel from pip.exceptions import InvalidWheelFilename, UnsupportedWheel
from pip.locations import distutils_scheme from pip.locations import distutils_scheme
from pip import pep425tags from pip import pep425tags
@ -39,6 +42,51 @@ VERSION_COMPATIBLE = (1, 0)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _cache_for_filename(cache_dir, sdistfilename):
"""Return a directory to store cached wheels in for sdistfilename.
Because there are M wheels for any one sdist, we provide a directory
to cache them in, and then consult that directory when looking up
cache hits.
:param cache_dir: The cache_dir being used by pip.
:param sdistfilename: The filename of the sdist for which this will cache
wheels.
"""
return os.path.join(cache_dir, 'wheels', sdistfilename)
def cached_wheel(cache_dir, link):
if not cache_dir:
return link
if not link:
return link
if link.is_wheel:
return link
root = _cache_for_filename(cache_dir, link.filename)
try:
wheel_names = os.listdir(root)
except OSError as e:
if e.errno == errno.ENOENT:
return link
raise
candidates = []
for wheel_name in wheel_names:
try:
wheel = Wheel(wheel_name)
except InvalidWheelFilename:
continue
if not wheel.supported():
# Built for a different python/arch/etc
continue
candidates.append((wheel.support_index_min(), wheel_name))
if not candidates:
return link
candidates.sort()
path = os.path.join(root, candidates[0][1])
return pip.index.Link(path_to_url(path), trusted=True)
def rehash(path, algo='sha256', blocksize=1 << 20): def rehash(path, algo='sha256', blocksize=1 << 20):
"""Return (hash, length) for path using hashlib.new(algo)""" """Return (hash, length) for path using hashlib.new(algo)"""
h = hashlib.new(algo) h = hashlib.new(algo)

View File

@ -182,7 +182,7 @@ class TestParseContent(object):
import pip.req.req_file import pip.req.req_file
def stub_parse_requirements(req_url, finder, comes_from, options, def stub_parse_requirements(req_url, finder, comes_from, options,
session): session, cache_root):
return [req] return [req]
parse_requirements_stub = stub(call=stub_parse_requirements) parse_requirements_stub = stub(call=stub_parse_requirements)
monkeypatch.setattr(pip.req.req_file, 'parse_requirements', monkeypatch.setattr(pip.req.req_file, 'parse_requirements',