Repository: pypa/pip

Issue:
Need an escape hatch for corrupted cache.
### Description

If the pip wheel_cache json file is corrupted, users get a confusing error and there does not seem to be a recovery mode other than finding and removing the cache.

`pip cache purge` does not resolve such a problem.

It is not completely clear how the file became corrupted in this case, but it may have been related to an instance of a disk quota being exceeded.

### Expected behavior

* The JSON decoder exception should be caught.
* Either `pip` should automatically remove / regenerate the cache data file or `pip` should advise the user on how to proceed.

In this case, the exception propagated from `pip/_internal/cache.py::WheelCache.record_download_origin()`, which called `DirectUrl.from_json()`. Letting the  JSONDecoderError propagate through `DirectUrl.from_json()` seems appropriate. It also seems reasonable to let it propagate through `WheelCache.record_download_origin()`.

I think one of the following would be an appropriate place for additional error handling.
* `pip/_internal/wheel_builder.py::build()` produces a `build_failures` list, but does not catch exceptions for conversion to "build_failures".
* `pip/_internal/commands/install.py::InstallCommand.run()` calls `build` in a `try` block, but only OSError is caught.

It also seems reasonable that `pip cache purge` could reinitialize state files more completely.

### pip version

main, as of 20 April 2023

### Python version

all

### OS

all

### How to Reproduce

`pip install` a package from a source distribution, producing a cachable "wheel", but with invalid JSON files in the pip cache.

### Output

```
Building wheels for collected packages: ...
  Stored in directory: /.../.cache/pip/wheels/e4/50/f2/fddc95515d81964ea105d53c75210d0c94388d80faf5e0b797
ERROR: Exception:
Traceback (most recent call last):
  File "/.../lib/python3.9/site-packages/pip/_internal/cli/base_command.py", line 169, in exc_logging_wrapper
    status = run_func(*args)
  File "/.../lib/python3.9/site-packages/pip/_internal/cli/req_command.py", line 248, in wrapper
    return func(self, options, args)
  File "/.../lib/python3.9/site-packages/pip/_internal/commands/install.py", line 417, in run
    _, build_failures = build(
  File "/.../lib/python3.9/site-packages/pip/_internal/wheel_builder.py", line 334, in build
    wheel_cache.record_download_origin(cache_dir, req.download_info)
  File "/.../lib/python3.9/site-packages/pip/_internal/cache.py", line 261, in record_download_origin
    origin = DirectUrl.from_json(origin_path.read_text())
  File "/.../lib/python3.9/site-packages/pip/_internal/models/direct_url.py", line 231, in from_json
    return cls.from_dict(json.loads(s))
  File "/opt/cray/pe/python/[3.9.12.1/lib/python3.9/json/__init__.py](http://3.9.12.1/lib/python3.9/json/__init__.py)", line 346, in loads
    return _default_decoder.decode(s)
  File "/opt/cray/pe/python/[3.9.12.1/lib/python3.9/json/decoder.py](http://3.9.12.1/lib/python3.9/json/decoder.py)", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "/opt/cray/pe/python/[3.9.12.1/lib/python3.9/json/decoder.py](http://3.9.12.1/lib/python3.9/json/decoder.py)", line 355, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
```

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).

Discussion:

Patch:
diff --git a/news/11985.bugfix.rst b/news/11985.bugfix.rst
new file mode 100644
index 000000000..66c8e8786
--- /dev/null
+++ b/news/11985.bugfix.rst
@@ -0,0 +1,1 @@
+Ignore invalid or unreadable ``origin.json`` files in the cache of locally built wheels.
diff --git a/src/pip/_internal/cache.py b/src/pip/_internal/cache.py
index 05f0a9acb..8d3a664c7 100644
--- a/src/pip/_internal/cache.py
+++ b/src/pip/_internal/cache.py
@@ -194,7 +194,17 @@ class CacheEntry:
         self.origin: Optional[DirectUrl] = None
         origin_direct_url_path = Path(self.link.file_path).parent / ORIGIN_JSON_NAME
         if origin_direct_url_path.exists():
-            self.origin = DirectUrl.from_json(origin_direct_url_path.read_text())
+            try:
+                self.origin = DirectUrl.from_json(
+                    origin_direct_url_path.read_text(encoding="utf-8")
+                )
+            except Exception as e:
+                logger.warning(
+                    "Ignoring invalid cache entry origin file %s for %s (%s)",
+                    origin_direct_url_path,
+                    link.filename,
+                    e,
+                )
 
 
 class WheelCache(Cache):
@@ -257,16 +267,26 @@ class WheelCache(Cache):
     @staticmethod
     def record_download_origin(cache_dir: str, download_info: DirectUrl) -> None:
         origin_path = Path(cache_dir) / ORIGIN_JSON_NAME
-        if origin_path.is_file():
-            origin = DirectUrl.from_json(origin_path.read_text())
-            # TODO: use DirectUrl.equivalent when https://github.com/pypa/pip/pull/10564
-            # is merged.
-            if origin.url != download_info.url:
+        if origin_path.exists():
+            try:
+                origin = DirectUrl.from_json(origin_path.read_text(encoding="utf-8"))
+            except Exception as e:
                 logger.warning(
-                    "Origin URL %s in cache entry %s does not match download URL %s. "
-                    "This is likely a pip bug or a cache corruption issue.",
-                    origin.url,
-                    cache_dir,
-                    download_info.url,
+                    "Could not read origin file %s in cache entry (%s). "
+                    "Will attempt to overwrite it.",
+                    origin_path,
+                    e,
                 )
+            else:
+                # TODO: use DirectUrl.equivalent when
+                # https://github.com/pypa/pip/pull/10564 is merged.
+                if origin.url != download_info.url:
+                    logger.warning(
+                        "Origin URL %s in cache entry %s does not match download URL "
+                        "%s. This is likely a pip bug or a cache corruption issue. "
+                        "Will overwrite it with the new value.",
+                        origin.url,
+                        cache_dir,
+                        download_info.url,
+                    )
         origin_path.write_text(download_info.to_json(), encoding="utf-8")


Test patch:
diff --git a/tests/unit/test_req.py b/tests/unit/test_req.py
index c9742812b..74b9712dc 100644
--- a/tests/unit/test_req.py
+++ b/tests/unit/test_req.py
@@ -445,6 +445,25 @@ class TestRequirementSet:
             assert isinstance(req.download_info.info, ArchiveInfo)
             assert req.download_info.info.hash == hash
 
+    def test_download_info_archive_cache_with_invalid_origin(
+        self, tmp_path: Path, shared_data: TestData, caplog: pytest.LogCaptureFixture
+    ) -> None:
+        """Test an invalid origin.json is ignored."""
+        url = shared_data.packages.joinpath("simple-1.0.tar.gz").as_uri()
+        finder = make_test_finder()
+        wheel_cache = WheelCache(str(tmp_path / "cache"))
+        cache_entry_dir = wheel_cache.get_path_for_link(Link(url))
+        Path(cache_entry_dir).mkdir(parents=True)
+        Path(cache_entry_dir).joinpath("origin.json").write_text("{")  # invalid json
+        wheel.make_wheel(name="simple", version="1.0").save_to_dir(cache_entry_dir)
+        with self._basic_resolver(finder, wheel_cache=wheel_cache) as resolver:
+            ireq = get_processed_req_from_line(f"simple @ {url}")
+            reqset = resolver.resolve([ireq], True)
+            assert len(reqset.all_requirements) == 1
+            req = reqset.all_requirements[0]
+            assert req.is_wheel_from_cache
+            assert "Ignoring invalid cache entry origin file" in caplog.messages[0]
+
     def test_download_info_local_wheel(self, data: TestData) -> None:
         """Test that download_info is set for requirements from a local wheel."""
         finder = make_test_finder()