Merge pull request #1187 from ryanduguid/issue-744-content-change
Add response content change indicator
diff --git a/.gitignore b/.gitignore
index 931e573..ce438af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,6 @@
docs/_build/
docs/modules/
docs/requirements.txt
+
+# Project-agnostic
+.cache
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 30d853e..54b99ba 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -44,3 +44,9 @@
types_or: [css, javascript, json, yaml]
args: ['--single-quote']
additional_dependencies: [prettier@3.5.1]
+ - repo: https://github.com/alessio-locatelli/ruff-extra-rules
+ rev: v0.2.2
+ hooks:
+ - id: ruff-extra-rules
+ - id: ruff-extra-rules-ty
+ additional_dependencies: [ty]
diff --git a/HISTORY.md b/HISTORY.md
index 33ce642..e44edc2 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,5 +1,8 @@
# History
+## Unreleased
+* Fix unhandled `EOFError` when a cache value fails to deserialize
+
## 1.3.3 (2026-07-03)
* Add `CachedResponse.has_content_changed` to flag whether a refresh differs from the cache, when a reliable comparison is available.
* Fix SQLite `vacuum()` not freeing disk space
diff --git a/pyproject.toml b/pyproject.toml
index 93fa132..fe69acf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -180,6 +180,16 @@
[tool.ruff.lint.pycodestyle]
max-line-length = 120
+[tool.ruff-extra-rules]
+fix = true
+
+[tool.ruff-extra-rules.per-file-ignores]
+# Keeping the library interface (regardless of whether it is a public or
+# private function) is more important than renaming a function for a negligible
+# clarity improvement. However, this can be done in the next major release.
+"requests_cache/*" = ["validate-function-name"]
+"examples/*" = ["validate-function-name"]
+
[tool.typos]
files.extend-exclude = [
'.all-contributorsrc',
diff --git a/requests_cache/backends/base.py b/requests_cache/backends/base.py
index e980ae7..9dfaf18 100644
--- a/requests_cache/backends/base.py
+++ b/requests_cache/backends/base.py
@@ -25,7 +25,7 @@
from ..serializers import SerializerType, init_serializer
# Specific exceptions that may be raised during deserialization
-DESERIALIZE_ERRORS = (AttributeError, ImportError, PickleError, TypeError, ValueError)
+DESERIALIZE_ERRORS = (AttributeError, EOFError, ImportError, PickleError, TypeError, ValueError)
logger = getLogger(__name__)
diff --git a/requests_cache/backends/dynamodb.py b/requests_cache/backends/dynamodb.py
index ca3b429..6650b20 100644
--- a/requests_cache/backends/dynamodb.py
+++ b/requests_cache/backends/dynamodb.py
@@ -124,10 +124,10 @@
raise
def __getitem__(self, key):
- result = self._table.get_item(Key={'key': key})
- if 'Item' not in result:
+ response = self._table.get_item(Key={'key': key})
+ if 'Item' not in response:
raise KeyError
- return self.deserialize(key, result['Item']['value'])
+ return self.deserialize(key, response['Item']['value'])
def __setitem__(self, key, value):
item = {'key': key, 'value': self.serialize(value)}
diff --git a/requests_cache/backends/filesystem.py b/requests_cache/backends/filesystem.py
index e254e34..fc48b24 100644
--- a/requests_cache/backends/filesystem.py
+++ b/requests_cache/backends/filesystem.py
@@ -221,7 +221,7 @@
def __getitem__(self, key):
"""Get a value and update its access time in the LRU index"""
- value = super().__getitem__(key)
+ value = super().__getitem__(key) # pytriage: TR5
# Update access time in LRU index
try:
diff --git a/requests_cache/backends/gridfs.py b/requests_cache/backends/gridfs.py
index c34a783..a791507 100644
--- a/requests_cache/backends/gridfs.py
+++ b/requests_cache/backends/gridfs.py
@@ -85,10 +85,10 @@
def __getitem__(self, key):
try:
with self._lock:
- result = self.fs.find_one({'_id': key})
- if result is None:
+ file_handle = self.fs.find_one({'_id': key})
+ if file_handle is None:
raise KeyError
- return self.deserialize(key, result.read())
+ return self.deserialize(key, file_handle.read())
except CorruptGridFile as e:
logger.warning(e, exc_info=True)
raise KeyError from e
diff --git a/requests_cache/backends/mongodb.py b/requests_cache/backends/mongodb.py
index d708660..a8b52ba 100644
--- a/requests_cache/backends/mongodb.py
+++ b/requests_cache/backends/mongodb.py
@@ -117,10 +117,10 @@
self.collection.create_index('created_at', name='ttl_idx', expireAfterSeconds=ttl)
def __getitem__(self, key):
- result = self.collection.find_one({'_id': key})
- if result is None:
+ document = self.collection.find_one({'_id': key})
+ if document is None:
raise KeyError
- value = result['data'] if 'data' in result else result
+ value = document['data'] if 'data' in document else document
return self.deserialize(key, value)
def __setitem__(self, key, value):
@@ -133,8 +133,8 @@
self.collection.replace_one({'_id': key}, value, upsert=True)
def __delitem__(self, key):
- result = self.collection.find_one_and_delete({'_id': key}, {'_id': True})
- if result is None:
+ document = self.collection.find_one_and_delete({'_id': key}, {'_id': True})
+ if document is None:
raise KeyError
def __len__(self):
diff --git a/tests/conftest.py b/tests/conftest.py
index aeff1e1..ad56aff 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -171,14 +171,14 @@
def mount_mock_adapter(session: CachedSession) -> CachedSession:
- adapter = get_mock_adapter()
+ adapter = create_mock_adapter()
for protocol in MOCK_PROTOCOLS:
session.mount(protocol, adapter)
session.mock_adapter = adapter
return session
-def get_mock_adapter() -> Adapter:
+def create_mock_adapter() -> Adapter:
"""Get a requests-mock Adapter with some URLs mocked by default"""
adapter = Adapter()
adapter.register_uri(
@@ -273,7 +273,7 @@
return adapter
-def get_mock_response(
+def create_mock_response(
method='GET',
url='https://img.site.com/base/img.jpg',
status_code=200,
diff --git a/tests/integration/base_cache_test.py b/tests/integration/base_cache_test.py
index 588ce53..a8ac563 100644
--- a/tests/integration/base_cache_test.py
+++ b/tests/integration/base_cache_test.py
@@ -140,21 +140,21 @@
def test_cookies(self):
session = self.init_session()
- def get_json(url):
+ def parse_json(url):
return json.loads(session.get(url).text)
- response_1 = get_json(httpbin('cookies/set?test1=test2'))
+ response_1 = parse_json(httpbin('cookies/set?test1=test2'))
with session.cache_disabled():
- assert get_json(httpbin('cookies')) == response_1
+ assert parse_json(httpbin('cookies')) == response_1
# From cache
- response_2 = get_json(httpbin('cookies'))
- assert response_2 == get_json(httpbin('cookies'))
+ response_2 = parse_json(httpbin('cookies'))
+ assert response_2 == parse_json(httpbin('cookies'))
# Not from cache
with session.cache_disabled():
- response_3 = get_json(httpbin('cookies/set?test3=test4'))
- assert response_3 == get_json(httpbin('cookies'))
+ response_3 = parse_json(httpbin('cookies/set?test3=test4'))
+ assert response_3 == parse_json(httpbin('cookies'))
@pytest.mark.parametrize(
'cache_control, request_headers, expected_expiration',
@@ -267,8 +267,7 @@
def test_decode_gzip_response(self):
"""Test that gzip-compressed responses read decompressed content with decode_content=True"""
- session = self.init_session()
- response = session.get(httpbin('gzip'))
+ response = self.init_session().get(httpbin('gzip'))
assert b'gzipped' in response.content
response.raw._fp = BytesIO(response.content)
diff --git a/tests/unit/policy/test_actions.py b/tests/unit/policy/test_actions.py
index 2054b5e..ece8ef6 100644
--- a/tests/unit/policy/test_actions.py
+++ b/tests/unit/policy/test_actions.py
@@ -8,7 +8,7 @@
from requests_cache.cache_keys import create_key
from requests_cache.models import CachedRequest, CachedResponse
from requests_cache.policy import EXPIRE_IMMEDIATELY, CacheActions, CacheSettings, utcnow
-from tests.conftest import ETAG, HTTPDATE_STR, LAST_MODIFIED, MOCKED_URL, get_mock_response
+from tests.conftest import ETAG, HTTPDATE_STR, LAST_MODIFIED, MOCKED_URL, create_mock_response
IGNORED_DIRECTIVES = [
'no-transform',
@@ -576,7 +576,7 @@
def test_update_from_response(headers, expected_expiration):
"""Test with Cache-Control response headers"""
actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
- actions.update_from_response(get_mock_response(headers=headers))
+ actions.update_from_response(create_mock_response(headers=headers))
assert actions.expire_after == expected_expiration
assert actions.skip_write is (expected_expiration == EXPIRE_IMMEDIATELY)
@@ -584,13 +584,13 @@
def test_update_from_response__no_store():
actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
- actions.update_from_response(get_mock_response(headers={'Cache-Control': 'no-store'}))
+ actions.update_from_response(create_mock_response(headers={'Cache-Control': 'no-store'}))
assert actions.skip_write is True
def test_update_from_response__ignored():
actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=False))
- actions.update_from_response(get_mock_response(headers={'Cache-Control': 'max-age=5'}))
+ actions.update_from_response(create_mock_response(headers={'Cache-Control': 'max-age=5'}))
assert actions.expire_after is None
@@ -602,7 +602,7 @@
expiration
"""
actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
- response = get_mock_response(headers={**cache_headers, **validator_headers})
+ response = create_mock_response(headers={**cache_headers, **validator_headers})
actions.update_from_response(response)
assert actions.expires == mock_utcnow()
@@ -625,10 +625,10 @@
actions = CacheActions.from_request(
'key', BASIC_REQUEST, CacheSettings(cache_control=True, read_only=read_only)
)
- cached_response = get_mock_response(headers={'ETag': ETAG})
+ cached_response = create_mock_response(headers={'ETag': ETAG})
cached_response.expires = None
new_headers = {'ETag': ETAG, 'X-Custom-Header': 'new'} if headers_changed else {'ETag': ETAG}
- new_response = get_mock_response(status_code=304, headers=new_headers)
+ new_response = create_mock_response(status_code=304, headers=new_headers)
actions.update_revalidated_response(new_response, cached_response)
assert actions.skip_write is expected_skip_write
@@ -638,8 +638,10 @@
don't set both `Content-Length` and `Transfer-Encoding`
"""
actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
- cached_response = get_mock_response(headers={'ETag': ETAG, 'Transfer-Encoding': 'chunked'})
- new_response = get_mock_response(status_code=304, headers={'ETag': ETAG, 'Content-Length': 0})
+ cached_response = create_mock_response(headers={'ETag': ETAG, 'Transfer-Encoding': 'chunked'})
+ new_response = create_mock_response(
+ status_code=304, headers={'ETag': ETAG, 'Content-Length': 0}
+ )
cached_response = actions.update_revalidated_response(new_response, cached_response)
assert cached_response.headers['Transfer-Encoding'] == 'chunked'
assert 'Content-Length' not in cached_response.headers
diff --git a/tests/unit/test_cache_keys.py b/tests/unit/test_cache_keys.py
index eec2c63..13a6a27 100644
--- a/tests/unit/test_cache_keys.py
+++ b/tests/unit/test_cache_keys.py
@@ -232,8 +232,7 @@
def test_normalize_headers__single_header_value_as_bytes():
- headers = {'Accept': b'gzip'}
- norm_headers = normalize_headers(headers)
+ norm_headers = normalize_headers({'Accept': b'gzip'})
assert norm_headers == {'Accept': 'gzip'}
@@ -244,8 +243,7 @@
def test_normalize_headers__single_header_value_as_string():
- headers = {'Accept': 'gzip'}
- norm_headers = normalize_headers(headers)
+ norm_headers = normalize_headers({'Accept': 'gzip'})
assert norm_headers == {'Accept': 'gzip'}
diff --git a/tests/unit/test_normalize.py b/tests/unit/test_normalize.py
index 3b57868..18a1886 100644
--- a/tests/unit/test_normalize.py
+++ b/tests/unit/test_normalize.py
@@ -5,22 +5,22 @@
def test_ignores_nested():
original = b'{"data":{"key":"value","timestamp":"2022-08-04"}}'
# Only work for timestamp under data subtree
- result = normalize_json_body(
+ json_text = normalize_json_body(
original,
ignored_parameters=['timestamp'],
content_root_key='data',
)
- body = json.loads(result)
+ body = json.loads(json_text)
assert body == {'data': {'key': 'value', 'timestamp': 'REDACTED'}}
def test_ignores_nested_specialcase():
original = b'{"data":{"foo":"bar"},"extra":"keep"}'
- result = normalize_json_body(
+ json_text = normalize_json_body(
original,
ignored_parameters=['timestamp'],
content_root_key='data',
)
- body = json.loads(result)
+ body = json.loads(json_text)
# No timestamp under data, should be no filter
assert body == {'data': {'foo': 'bar'}, 'extra': 'keep'}
diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py
index a2dc71a..bf92794 100644
--- a/tests/unit/test_session.py
+++ b/tests/unit/test_session.py
@@ -325,10 +325,10 @@
'HTTPS://site.com?param_1=value_1¶m_2=value_2',
]
- def get_request(url):
+ def fetch_request(url):
return mock_session.prepare_request(requests.Request('GET', url))
- keys = [mock_session.cache.create_key(get_request(url)) for url in urls]
+ keys = [mock_session.cache.create_key(fetch_request(url)) for url in urls]
assert len(set(keys)) == 1
@@ -609,11 +609,12 @@
assert mock_session.get(MOCKED_URL).from_cache is True
-def test_unpickle_errors(mock_session):
+@pytest.mark.parametrize('error', [PickleError, EOFError])
+def test_unpickle_errors(mock_session, error):
"""If there is an error during deserialization, the request should be made again"""
assert mock_session.get(MOCKED_URL_JSON).from_cache is False
- with patch.object(mock_session.cache.responses.serializer, 'loads', side_effect=PickleError):
+ with patch.object(mock_session.cache.responses.serializer, 'loads', side_effect=error):
resp = mock_session.get(MOCKED_URL_JSON)
assert resp.from_cache is False
assert resp.json()['message'] == 'mock json response'