Repository: pypa/pip

Issue:
Way to clear items from pip cache of specified age.
I use pip a lot and had never considered anything about caching, and find I have a 1.7gb pip cache.

It would be useful if there was a command that could clear it of items beyond a specified age.

That way I could could create a script to run every day to delete anything in pip that is older than a month (and to do the same for unrelated things like yarn etc).


Discussion:
The [pip cache](https://pip.pypa.io/en/latest/reference/pip_cache/) command which is recently introduced, can be used to list down all cached wheel files via `pip cache list`.

IIUC, this feature will use the file creation time of these wheel files, and remove cached wheel files whose creation time is x days/weeks/months etc from current time.
I am thinking about implementing this by adding an `--keep-max-age` to the `pip cache` command.
It can take time in `x` last days, and then only removes from the cache if the created/accessed date is older then the `current date - X days`

what do you think @deveshks @pradyunsg?
its not a whole lot of code, but I am not sure that allowing only days is flexible enough, and also there may be a cache item that you use a lot but was created a while ago, so maybe access date is preferable.
Actually I was planning to attempt this, and hence I commented here to get a response from the poster, but it's perfectly fine if you want to go ahead and take a stab at this.
Sorry, maybe I didn't read your post properly because I didn't know what IIUC stands for.

Yes, both proposals seem reasonable.
> Sorry, maybe I didn't read your post properly because I didn't know what IIUC stands for.

Apologies for the slang. Just as a clarification, my usage of [IIUC](https://en.wiktionary.org/wiki/IIUC) meant `If I Understand Correctly`.

Thanks, strangely this became clear as I was writing the reply, TBH I use a lot of these acronyms myself, so maybe I should be aware that people may be as time starved as me when they read my messages :)
> Actually I was planning to attempt this, and hence I commented here to get a response from the poster, but it's perfectly fine if you want to go ahead and take a stab at this.

You can actually do this if you want, I won't have much time I can devote to this :(
> You can actually do this if you want, I won't have much time I can devote to this :(

Thanks for that, I will wait for @pradyunsg 's thoughts on whether this is something we want to support before attempting it.
Sounds like a good idea. There's a related issue about adding a time-based sort order for pip cache, so it might make sense to tackle that as well! 
> Sounds like a good idea. There's a related issue about adding a time-based sort order for pip cache, so it might make sense to tackle that as well!

Is there an issue filed for `time-based sort order`? I couldn't find a similar issue in the pip tracker.

Also what would be the preferred approach of trying this out? Should I just start by taking a 
`--keep-max-age` with the number of days to being with as suggested by @NoahGorny with `pip cache purge`, 

We can delete based on either the created time, or access time, bu I am not sure which one to pick. We can  play safe though and  do both and ask the user to choose between one of them. Let me know what you think?
FWIW https://github.com/pypa/pip/pull/3146 implemented something like that :)
> FWIW #3146 implemented something like that :)

Thanks, I see that there is a `--not-accessed-since` and `--remove` flag which could be helpful. Let me look at those and see what can I come up with. 
> > FWIW #3146 implemented something like that :)
> 
> Thanks, I see that there is a `--not-accessed-since` and `--remove` flag which could be helpful. Let me look at those and see what can I come up with.

@xavfernandez , I have gone ahead and created https://github.com/pypa/pip/pull/8474 to address this issue based on your PR. I would really appreciate you taking a look at the same as well.

Resolving patch:
diff --git a/src/pip/_internal/commands/cache.py b/src/pip/_internal/commands/cache.py
--- a/src/pip/_internal/commands/cache.py
+++ b/src/pip/_internal/commands/cache.py
@@ -37,11 +37,25 @@ class CacheCommand(Command):
     usage = """
         %prog dir
         %prog info
-        %prog list [<pattern>]
+        %prog list [<pattern>] [--format=[human, abspath]]
         %prog remove <pattern>
         %prog purge
     """
 
+    def add_options(self):
+        # type: () -> None
+
+        self.cmd_opts.add_option(
+            '--format',
+            action='store',
+            dest='list_format',
+            default="human",
+            choices=('human', 'abspath'),
+            help="Select the output format among: human (default) or abspath"
+        )
+
+        self.parser.insert_option_group(0, self.cmd_opts)
+
     def run(self, options, args):
         # type: (Values, List[Any]) -> int
         handlers = {
@@ -116,7 +130,13 @@ def list_cache_items(self, options, args):
             pattern = '*'
 
         files = self._find_wheels(options, pattern)
+        if options.list_format == 'human':
+            self.format_for_human(files)
+        else:
+            self.format_for_abspath(files)
 
+    def format_for_human(self, files):
+        # type: (List[str]) -> None
         if not files:
             logger.info('Nothing cached.')
             return
@@ -129,6 +149,17 @@ def list_cache_items(self, options, args):
         logger.info('Cache contents:\n')
         logger.info('\n'.join(sorted(results)))
 
+    def format_for_abspath(self, files):
+        # type: (List[str]) -> None
+        if not files:
+            return
+
+        results = []
+        for filename in files:
+            results.append(filename)
+
+        logger.info('\n'.join(sorted(results)))
+
     def remove_cache_items(self, options, args):
         # type: (Values, List[Any]) -> None
         if len(args) > 1:


Test patch:
diff --git a/tests/functional/test_cache.py b/tests/functional/test_cache.py
--- a/tests/functional/test_cache.py
+++ b/tests/functional/test_cache.py
@@ -70,6 +70,19 @@ def list_matches_wheel(wheel_name, result):
     return any(map(lambda l: l.startswith(expected), lines))
 
 
+def list_matches_wheel_abspath(wheel_name, result):
+    """Returns True if any line in `result`, which should be the output of
+    a `pip cache list --format=abspath` call, is a valid path and belongs to
+    `wheel_name`.
+
+    E.g., If wheel_name is `foo-1.2.3` it searches for a line starting with
+          `foo-1.2.3-py3-none-any.whl`."""
+    lines = result.stdout.splitlines()
+    expected = '{}-py3-none-any.whl'.format(wheel_name)
+    return any(map(lambda l: os.path.basename(l).startswith(expected)
+               and os.path.exists(l), lines))
+
+
 @pytest.fixture
 def remove_matches_wheel(wheel_cache_dir):
     """Returns True if any line in `result`, which should be the output of
@@ -132,6 +145,18 @@ def test_cache_list(script):
     assert list_matches_wheel('zzz-7.8.9', result)
 
 
+@pytest.mark.usefixtures("populate_wheel_cache")
+def test_cache_list_abspath(script):
+    """Running `pip cache list --format=abspath` should return full
+    paths of exactly what the populate_wheel_cache fixture adds."""
+    result = script.pip('cache', 'list', '--format=abspath')
+
+    assert list_matches_wheel_abspath('yyy-1.2.3', result)
+    assert list_matches_wheel_abspath('zzz-4.5.6', result)
+    assert list_matches_wheel_abspath('zzz-4.5.7', result)
+    assert list_matches_wheel_abspath('zzz-7.8.9', result)
+
+
 @pytest.mark.usefixtures("empty_wheel_cache")
 def test_cache_list_with_empty_cache(script):
     """Running `pip cache list` with an empty cache should print
@@ -140,6 +165,14 @@ def test_cache_list_with_empty_cache(script):
     assert result.stdout == "Nothing cached.\n"
 
 
+@pytest.mark.usefixtures("empty_wheel_cache")
+def test_cache_list_with_empty_cache_abspath(script):
+    """Running `pip cache list --format=abspath` with an empty cache should not
+    print anything and exit."""
+    result = script.pip('cache', 'list', '--format=abspath')
+    assert result.stdout.strip() == ""
+
+
 def test_cache_list_too_many_args(script):
     """Passing `pip cache list` too many arguments should cause an error."""
     script.pip('cache', 'list', 'aaa', 'bbb',
@@ -158,6 +191,19 @@ def test_cache_list_name_match(script):
     assert list_matches_wheel('zzz-7.8.9', result)
 
 
+@pytest.mark.usefixtures("populate_wheel_cache")
+def test_cache_list_name_match_abspath(script):
+    """Running `pip cache list zzz --format=abspath` should list paths of
+    zzz-4.5.6, zzz-4.5.7, zzz-7.8.9, but nothing else."""
+    result = script.pip('cache', 'list', 'zzz', '--format=abspath',
+                        '--verbose')
+
+    assert not list_matches_wheel_abspath('yyy-1.2.3', result)
+    assert list_matches_wheel_abspath('zzz-4.5.6', result)
+    assert list_matches_wheel_abspath('zzz-4.5.7', result)
+    assert list_matches_wheel_abspath('zzz-7.8.9', result)
+
+
 @pytest.mark.usefixtures("populate_wheel_cache")
 def test_cache_list_name_and_version_match(script):
     """Running `pip cache list zzz-4.5.6` should list zzz-4.5.6, but
@@ -170,6 +216,19 @@ def test_cache_list_name_and_version_match(script):
     assert not list_matches_wheel('zzz-7.8.9', result)
 
 
+@pytest.mark.usefixtures("populate_wheel_cache")
+def test_cache_list_name_and_version_match_abspath(script):
+    """Running `pip cache list zzz-4.5.6 --format=abspath` should list path of
+    zzz-4.5.6, but nothing else."""
+    result = script.pip('cache', 'list', 'zzz-4.5.6', '--format=abspath',
+                        '--verbose')
+
+    assert not list_matches_wheel_abspath('yyy-1.2.3', result)
+    assert list_matches_wheel_abspath('zzz-4.5.6', result)
+    assert not list_matches_wheel_abspath('zzz-4.5.7', result)
+    assert not list_matches_wheel_abspath('zzz-7.8.9', result)
+
+
 @pytest.mark.usefixtures("populate_wheel_cache")
 def test_cache_remove_no_arguments(script):
     """Running `pip cache remove` with no arguments should cause an error."""