Skip to content

Add 'pip cache' command #6391

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 40 commits into from
Apr 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
04c0b0e
Add 'pip cache' command.
duckinator Apr 8, 2019
b0e7b66
[commands/cache] Refactor + fix linting failures.
duckinator Oct 8, 2019
b9b29b8
[commands/cache] fix 'pip cache info'; don't hide python/abi/platform…
duckinator Oct 8, 2019
c59ced6
[commands/cache] More refactoring of cache command
duckinator Oct 8, 2019
8ae71ad
[commands/cache] Add docs for 'pip cache' command.
duckinator Oct 8, 2019
d57dcd9
[commands/cache] Add news file for cache command.
duckinator Oct 8, 2019
50604be
[commands/cache] Raise errors if wrong number of args.
duckinator Oct 9, 2019
9563dfb
[commands/cache] Refactor get_cache_info().
duckinator Oct 10, 2019
6fb1ee7
[commands/cache] fix linting error.
duckinator Oct 10, 2019
c838a67
[commands/cache] Change pattern suffix from -*.whl to *.whl.
duckinator Oct 10, 2019
61dd0bc
[commands/cache] Use location of wheel cache dir specifically.
duckinator Oct 10, 2019
94a6593
[commands/cache] Add HTML docs for `pip cache`.
duckinator Oct 10, 2019
61a0adc
[commands/cache] Add missing type annotation.
duckinator Oct 10, 2019
554133a
[commands/cache] Add file size information.
duckinator Jan 6, 2020
2d97830
[commands/cache] Minor clean-up.
duckinator Jan 13, 2020
6fa8498
[commands/cache] Avoid use of "(s)" suffix.
duckinator Jan 14, 2020
d74895a
[commands/cache] Normalize path in test.
duckinator Feb 23, 2020
10d1376
[commands/cache] Be explicit about `pip cache` only working on the wh…
duckinator Feb 23, 2020
d9dc76e
[commands/cache] Correct argument name in documentation for `pip cach…
duckinator Mar 4, 2020
f22f69e
[utils/filesystem] Convert `size` to float, for consistent behavior b…
duckinator Mar 4, 2020
03d5ec1
[utils/filesystem] Reformat comment to keep lines <79 characters long.
duckinator Mar 4, 2020
735375f
[commands/cache] Reformat documentation.
duckinator Mar 5, 2020
8cd8c91
[commands/cache] Reformat (more) documentation.
duckinator Mar 5, 2020
63ba6cc
[command/cache, utils/filesystem] Use existing format_size; remove _f…
duckinator Mar 5, 2020
ed9f885
[commands/cache] Reformat output of `pip cache info`
duckinator Mar 5, 2020
f8b67c8
[commands/cache] Fix test_cache_info test.
duckinator Mar 5, 2020
8b518b2
[commands/cache] Make filenames more realistic in tests.
duckinator Mar 5, 2020
d57407a
[commands/cache] Make _find_wheels(), and this `pip cache {list,remov…
duckinator Mar 5, 2020
e1fde1f
[commands/cache] Remove unnecessary re-definition of __init__.
duckinator Mar 5, 2020
e804aa5
[commands/cache] Have `pip cache info` raise an exception if it gets …
duckinator Mar 5, 2020
6e425d8
[tests/functional/cache] Refactor to be less redundant.
duckinator Mar 6, 2020
274b295
[tests/functional/cache] Make fixtures feel less magical.
duckinator Mar 6, 2020
c6b5a52
[tests/functional/test_cache] Always call normcase on cache dir; fix …
duckinator Mar 7, 2020
32ce3ba
[tests/functional/cache] Rewrite all of the pip cache {list,remove} t…
duckinator Mar 10, 2020
ba7c3ac
[tests/functional/test_cache] Add test `pip cache list` with an empty…
duckinator Mar 10, 2020
a20b28d
[tests/functional/test_cache] Split apart tests for `pip cache purge`.
duckinator Mar 10, 2020
8858237
[tests/functional/test_cache] Refactor list_matches_wheel() and remov…
duckinator Mar 10, 2020
b7239f5
[tests/functional/test_cache] Remove unused import.
duckinator Mar 10, 2020
0c4eafa
[tests/functional/test_cache] Fix test on Python 2.7.
duckinator Mar 10, 2020
b988417
[tests/functional/test_cache] Use os.path.join() instead of hard-codi…
duckinator Mar 13, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/html/reference/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Reference Guide
pip_list
pip_show
pip_search
pip_cache
pip_check
pip_config
pip_wheel
Expand Down
22 changes: 22 additions & 0 deletions docs/html/reference/pip_cache.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@

.. _`pip cache`:

pip cache
---------

.. contents::

Usage
*****

.. pip-command-usage:: cache

Description
***********

.. pip-command-description:: cache

Options
*******

.. pip-command-options:: cache
20 changes: 20 additions & 0 deletions docs/man/commands/cache.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
:orphan:

=========
pip-cache
=========

Description
***********

.. pip-command-description:: cache

Usage
*****

.. pip-command-usage:: cache

Options
*******

.. pip-command-options:: cache
1 change: 1 addition & 0 deletions news/6391.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``pip cache`` command for inspecting/managing pip's wheel cache.
4 changes: 4 additions & 0 deletions src/pip/_internal/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
'pip._internal.commands.search', 'SearchCommand',
'Search PyPI for packages.',
)),
('cache', CommandInfo(
'pip._internal.commands.cache', 'CacheCommand',
"Inspect and manage pip's wheel cache.",
)),
('wheel', CommandInfo(
'pip._internal.commands.wheel', 'WheelCommand',
'Build wheels from your requirements.',
Expand Down
165 changes: 165 additions & 0 deletions src/pip/_internal/commands/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
from __future__ import absolute_import

import logging
import os
import textwrap

import pip._internal.utils.filesystem as filesystem
from pip._internal.cli.base_command import Command
from pip._internal.cli.status_codes import ERROR, SUCCESS
from pip._internal.exceptions import CommandError, PipError
from pip._internal.utils.typing import MYPY_CHECK_RUNNING

if MYPY_CHECK_RUNNING:
from optparse import Values
from typing import Any, List


logger = logging.getLogger(__name__)


class CacheCommand(Command):
"""
Inspect and manage pip's wheel cache.

Subcommands:

info: Show information about the cache.
list: List filenames of packages stored in the cache.
remove: Remove one or more package from the cache.
purge: Remove all items from the cache.

<pattern> can be a glob expression or a package name.
"""

usage = """
%prog info
%prog list [<pattern>]
%prog remove <pattern>
%prog purge
"""

def run(self, options, args):
# type: (Values, List[Any]) -> int
handlers = {
"info": self.get_cache_info,
"list": self.list_cache_items,
"remove": self.remove_cache_items,
"purge": self.purge_cache,
}

# Determine action
if not args or args[0] not in handlers:
logger.error("Need an action ({}) to perform.".format(
", ".join(sorted(handlers)))
)
return ERROR

action = args[0]

# Error handling happens here, not in the action-handlers.
try:
handlers[action](options, args[1:])
except PipError as e:
logger.error(e.args[0])
return ERROR

return SUCCESS

def get_cache_info(self, options, args):
# type: (Values, List[Any]) -> None
if args:
raise CommandError('Too many arguments')

num_packages = len(self._find_wheels(options, '*'))

cache_location = self._wheels_cache_dir(options)
cache_size = filesystem.format_directory_size(cache_location)

message = textwrap.dedent("""
Location: {location}
Size: {size}
Number of wheels: {package_count}
""").format(
location=cache_location,
package_count=num_packages,
size=cache_size,
).strip()

logger.info(message)

def list_cache_items(self, options, args):
# type: (Values, List[Any]) -> None
if len(args) > 1:
raise CommandError('Too many arguments')

if args:
pattern = args[0]
else:
pattern = '*'

files = self._find_wheels(options, pattern)

if not files:
logger.info('Nothing cached.')
return

results = []
for filename in files:
wheel = os.path.basename(filename)
size = filesystem.format_file_size(filename)
results.append(' - {} ({})'.format(wheel, size))
logger.info('Cache contents:\n')
logger.info('\n'.join(sorted(results)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that I thought about sorting on (wheel, size) and then composing this list and the only situation where they'd result in a different output would be if there's 2 wheels with the same name and diff sizes -- which is very unlikely.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe... we'd want to make it possible to get the information as a "sort by size"? But that can definitely be a follow up PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re sorting on (wheel, size): Yeah, it seems unlikely enough to not be worthwhile. We can always change that in the future if it turns out we're both wrong. 😛

Re sort by size: Agree on all counts — seems useful, but I see no issue waiting until a follow-up PR for that. 🙂


def remove_cache_items(self, options, args):
# type: (Values, List[Any]) -> None
if len(args) > 1:
raise CommandError('Too many arguments')

if not args:
raise CommandError('Please provide a pattern')

files = self._find_wheels(options, args[0])
if not files:
raise CommandError('No matching packages')

for filename in files:
os.unlink(filename)
logger.debug('Removed %s', filename)
logger.info('Files removed: %s', len(files))

def purge_cache(self, options, args):
# type: (Values, List[Any]) -> None
if args:
raise CommandError('Too many arguments')

return self.remove_cache_items(options, ['*'])

def _wheels_cache_dir(self, options):
# type: (Values) -> str
return os.path.join(options.cache_dir, 'wheels')

def _find_wheels(self, options, pattern):
# type: (Values, str) -> List[str]
wheel_dir = self._wheels_cache_dir(options)

# The wheel filename format, as specified in PEP 427, is:
# {distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl
#
# Additionally, non-alphanumeric values in the distribution are
# normalized to underscores (_), meaning hyphens can never occur
# before `-{version}`.
#
# Given that information:
# - If the pattern we're given contains a hyphen (-), the user is
# providing at least the version. Thus, we can just append `*.whl`
# to match the rest of it.
# - If the pattern we're given doesn't contain a hyphen (-), the
# user is only providing the name. Thus, we append `-*.whl` to
# match the hyphen before the version, followed by anything else.
#
# PEP 427: https://www.python.org/dev/peps/pep-0427/
pattern = pattern + ("*.whl" if "-" in pattern else "-*.whl")

return filesystem.find_files(wheel_dir, pattern)
43 changes: 42 additions & 1 deletion src/pip/_internal/utils/filesystem.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import errno
import fnmatch
import os
import os.path
import random
Expand All @@ -14,10 +15,11 @@
from pip._vendor.six import PY2

from pip._internal.utils.compat import get_path_uid
from pip._internal.utils.misc import format_size
from pip._internal.utils.typing import MYPY_CHECK_RUNNING, cast

if MYPY_CHECK_RUNNING:
from typing import Any, BinaryIO, Iterator
from typing import Any, BinaryIO, Iterator, List, Union

class NamedTemporaryFileResult(BinaryIO):
@property
Expand Down Expand Up @@ -176,3 +178,42 @@ def _test_writable_dir_win(path):
raise EnvironmentError(
'Unexpected condition testing for writable directory'
)


def find_files(path, pattern):
# type: (str, str) -> List[str]
"""Returns a list of absolute paths of files beneath path, recursively,
with filenames which match the UNIX-style shell glob pattern."""
result = [] # type: List[str]
for root, dirs, files in os.walk(path):
matches = fnmatch.filter(files, pattern)
result.extend(os.path.join(root, f) for f in matches)
return result


def file_size(path):
# type: (str) -> Union[int, float]
# If it's a symlink, return 0.
if os.path.islink(path):
return 0
return os.path.getsize(path)


def format_file_size(path):
# type: (str) -> str
return format_size(file_size(path))


def directory_size(path):
# type: (str) -> Union[int, float]
size = 0.0
for root, _dirs, files in os.walk(path):
for filename in files:
file_path = os.path.join(root, filename)
size += file_size(file_path)
return size


def format_directory_size(path):
# type: (str) -> str
return format_size(directory_size(path))
Loading