summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJordan Cook <JWCook@users.noreply.github.com>2022-06-11 21:50:06 -0500
committerJordan Cook <jordan.cook@pioneer.com>2022-06-11 22:10:08 -0500
commit38c43b6e6227108cc3fee1f9e5eff1b047030bf3 (patch)
tree65aa141ee72b0478024af3dab23dd9d98e7eb327
parent03d83c137d55dc2d168e7716a5b753e8a2cf64ea (diff)
parent4138b8b47b3266b136efbd868ae1ab38570a2a6f (diff)
downloadrequests-cache-38c43b6e6227108cc3fee1f9e5eff1b047030bf3.tar.gz
Merge pull request #654 from requests-cache/cleanup
Misc cleanup
-rw-r--r--.github/workflows/deploy.yml4
-rw-r--r--HISTORY.md2
-rw-r--r--requests_cache/backends/base.py2
-rw-r--r--requests_cache/backends/sqlite.py45
-rw-r--r--requests_cache/models/raw_response.py51
-rwxr-xr-xrequests_cache/models/response.py12
-rw-r--r--requests_cache/serializers/cattrs.py9
-rw-r--r--requests_cache/serializers/pipeline.py13
-rw-r--r--tests/integration/test_filesystem.py5
-rw-r--r--tests/integration/test_sqlite.py13
10 files changed, 87 insertions, 69 deletions
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 9091d72..680fba5 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -23,13 +23,13 @@ env:
STRESS_TEST_MULTIPLIER: 5
jobs:
- # Run tests for all supported requests versions
+ # Run tests for all supported requests versions and minimum supported python version
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7]
- requests-version: [2.22, 2.23, 2.24, 2.25, 2.26, latest]
+ requests-version: [2.22, 2.23, 2.24, 2.25, 2.26, 2.27, latest]
fail-fast: false
services:
nginx:
diff --git a/HISTORY.md b/HISTORY.md
index 8926679..62249f2 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -30,7 +30,7 @@
**Backends:**
* SQLite:
- * Improve performance for removing expired items
+ * Improve performance for removing expired responses with `delete()`
* Add `size()` method to get estimated size of the database (including in-memory databases)
* Add `sorted()` method with sorting and other query options
* Add `wal` parameter to enable write-ahead logging
diff --git a/requests_cache/backends/base.py b/requests_cache/backends/base.py
index e307cb4..5e00db0 100644
--- a/requests_cache/backends/base.py
+++ b/requests_cache/backends/base.py
@@ -329,7 +329,7 @@ class BaseStorage(MutableMapping[KT, VT], ABC):
# Wrap in a SerializerPipeline, if needed
if not isinstance(serializer, SerializerPipeline):
serializer = SerializerPipeline([serializer], name=str(serializer))
- serializer.decode_content = decode_content
+ serializer.set_decode_content(decode_content)
self.serializer = serializer
logger.debug(f'Initialized {type(self).__name__} with serializer: {self.serializer}')
diff --git a/requests_cache/backends/sqlite.py b/requests_cache/backends/sqlite.py
index 0fa5d99..17a34c2 100644
--- a/requests_cache/backends/sqlite.py
+++ b/requests_cache/backends/sqlite.py
@@ -17,6 +17,8 @@ from typing import Collection, Iterator, List, Tuple, Type, Union
from platformdirs import user_cache_dir
+from requests_cache.models.response import CachedResponse
+
from .._utils import chunkify, get_valid_kwargs
from . import BaseCache, BaseStorage
@@ -71,21 +73,31 @@ class SQLiteCache(BaseCache):
expired: bool = False,
**kwargs,
):
- """More efficient implementation of :py:meth:`BaseCache.delete`"""
+ """A more efficient SQLite implementation of :py:meth:`BaseCache.delete`"""
if keys:
self.responses.bulk_delete(keys)
if expired:
- self.responses.delete_expired()
+ self._delete_expired()
+
+ # For any remaining conditions, use base implementation
if kwargs:
with self.responses._lock, self.redirects._lock:
return super().delete(**kwargs)
else:
self._prune_redirects()
+
self.responses.vacuum()
self.redirects.vacuum()
+ def _delete_expired(self):
+ """A more efficient implementation deleting expired responses in SQL"""
+ with self.responses._lock, self.responses.connection(commit=True) as con:
+ con.execute(
+ f'DELETE FROM {self.responses.table_name} WHERE expires <= ?', (round(time()),)
+ )
+
def _prune_redirects(self):
- """More efficient implementation of :py:meth:`BaseCache.remove_invalid_redirects`"""
+ """A more efficient implementation of removing invalid redirects in SQL"""
with self.redirects.connection(commit=True) as conn:
t1 = self.redirects.table_name
t2 = self.responses.table_name
@@ -97,12 +109,23 @@ class SQLiteCache(BaseCache):
')'
)
+ def filter( # type: ignore
+ self, valid: bool = True, expired: bool = True, **kwargs
+ ) -> Iterator[CachedResponse]:
+ """A more efficient implementation of :py:meth:`BaseCache.filter`, in the case where we want
+ to get **only** expired responses
+ """
+ if expired and not valid and not kwargs:
+ return self.responses.sorted(expired=True)
+ else:
+ return super().filter(valid, expired, **kwargs)
+
def sorted(
self,
key: str = 'expires',
reversed: bool = False,
limit: int = None,
- exclude_expired=False,
+ expired: bool = True,
):
"""Get cached responses, with sorting and other query options.
@@ -110,9 +133,9 @@ class SQLiteCache(BaseCache):
key: Key to sort by; either 'expires', 'size', or 'key'
reversed: Sort in descending order
limit: Maximum number of responses to return
- exclude_expired: Only return unexpired responses
+ expired: Set to ``False`` to exclude expired responses
"""
- return self.responses.sorted(key, reversed, limit, exclude_expired)
+ return self.responses.sorted(key, reversed, limit, expired)
class SQLiteDict(BaseStorage):
@@ -260,12 +283,6 @@ class SQLiteDict(BaseStorage):
self.init_db()
self.vacuum()
- def delete_expired(self):
- """Delete expired items from the cache"""
- with self._lock, self.connection(commit=True) as con:
- con.execute(f"DELETE FROM {self.table_name} WHERE expires <= ?", (round(time()),))
- self.vacuum()
-
def size(self) -> int:
"""Return the size of the database, in bytes. For an in-memory database, this will be an
estimate based on page size.
@@ -283,7 +300,7 @@ class SQLiteDict(BaseStorage):
return page_count * page_size
def sorted(
- self, key: str = 'expires', reversed: bool = False, limit: int = None, exclude_expired=False
+ self, key: str = 'expires', reversed: bool = False, limit: int = None, expired: bool = True
):
"""Get cache values in sorted order; see :py:meth:`.SQLiteCache.sorted` for usage details"""
# Get sort key, direction, and limit
@@ -297,7 +314,7 @@ class SQLiteDict(BaseStorage):
# Filter out expired items, if specified
filter_expr = ''
params: Tuple = ()
- if exclude_expired:
+ if not expired:
filter_expr = 'WHERE expires is null or expires > ?'
params = (time(),)
diff --git a/requests_cache/models/raw_response.py b/requests_cache/models/raw_response.py
index f94a543..5850b2a 100644
--- a/requests_cache/models/raw_response.py
+++ b/requests_cache/models/raw_response.py
@@ -1,6 +1,6 @@
from io import BytesIO
from logging import getLogger
-from typing import Mapping
+from typing import TYPE_CHECKING
from attr import define, field, fields_dict
from requests import Response
@@ -15,50 +15,49 @@ from . import RichMixin
logger = getLogger(__name__)
+if TYPE_CHECKING:
+ from . import CachedResponse
+
+
@define(auto_attribs=False, repr=False, slots=False)
class CachedHTTPResponse(RichMixin, HTTPResponse):
- """A serializable dataclass that emulates :py:class:`~urllib3.response.HTTPResponse`.
- Supports streaming requests and generator usage.
+ """A wrapper class that emulates :py:class:`~urllib3.response.HTTPResponse`.
- The only action this doesn't support is explicitly calling :py:meth:`.read` with
- ``decode_content=False``.
+ This enables consistent behavior for streaming requests and generator usage in the following
+ cases:
+ * On an original response, after reading its content to write to the cache
+ * On a cached response
"""
decode_content: bool = field(default=None)
- # These headers are redundant and not serialized; copied in init and CachedResponse post-init
- headers: HTTPHeaderDict = None # type: ignore
+ headers: HTTPHeaderDict = field(factory=HTTPHeaderDict)
reason: str = field(default=None)
request_url: str = field(default=None)
status: int = field(default=0)
strict: int = field(default=0)
version: int = field(default=0)
- def __init__(self, *args, body: bytes = None, headers: Mapping = None, **kwargs):
+ def __init__(self, body: bytes = None, **kwargs):
"""First initialize via HTTPResponse, then via attrs"""
kwargs = {k: v for k, v in kwargs.items() if v is not None}
super().__init__(body=BytesIO(body or b''), preload_content=False, **kwargs)
-
self._body = body
- self.headers = HTTPHeaderDict(headers)
- self.__attrs_init__(*args, **kwargs) # type: ignore # False positive in mypy 0.920+?
+ self.__attrs_init__(**kwargs) # type: ignore # False positive in mypy 0.920+?
@classmethod
- def from_response(cls, original_response: Response):
+ def from_response(cls, response: Response):
"""Create a CachedHTTPResponse based on an original response"""
# Copy basic attributes
- raw = original_response.raw
- copy_attrs = list(fields_dict(cls).keys()) + ['headers']
- kwargs = {k: getattr(raw, k, None) for k in copy_attrs}
-
- # Note: _request_url is not available in urllib <=1.21
- kwargs['request_url'] = getattr(raw, '_request_url', None)
+ raw = response.raw
+ kwargs = {k: getattr(raw, k, None) for k in fields_dict(cls).keys()}
+ kwargs['request_url'] = raw._request_url
# Copy response data and restore response object to its original state
if hasattr(raw, '_fp') and not is_fp_closed(raw._fp):
body = raw.read(decode_content=False)
kwargs['body'] = body
raw._fp = BytesIO(body)
- original_response.content # This property reads, decodes, and stores response content
+ response.content # This property reads, decodes, and stores response content
# After reading, reset file pointer on original raw response
raw._fp = BytesIO(body)
@@ -67,6 +66,18 @@ class CachedHTTPResponse(RichMixin, HTTPResponse):
return cls(**kwargs) # type: ignore # False positive in mypy 0.920+?
+ @classmethod
+ def from_cached_response(cls, response: 'CachedResponse'):
+ """Create a CachedHTTPResponse based on a cached response"""
+ obj = cls(
+ headers=HTTPHeaderDict(response.headers),
+ reason=response.reason,
+ status=response.status_code,
+ request_url=response.request.url,
+ )
+ obj.reset(response._content)
+ return obj
+
def release_conn(self):
"""No-op for compatibility"""
@@ -74,7 +85,7 @@ class CachedHTTPResponse(RichMixin, HTTPResponse):
"""Simplified reader for cached content that emulates
:py:meth:`urllib3.response.HTTPResponse.read()`
"""
- if 'content-encoding' in self.headers and decode_content is False:
+ if 'Content-Encoding' in self.headers and decode_content is False:
logger.warning('read(decode_content=False) is not supported for cached responses')
data = self._fp.read(amt)
diff --git a/requests_cache/models/response.py b/requests_cache/models/response.py
index 68df763..c1f85e1 100755
--- a/requests_cache/models/response.py
+++ b/requests_cache/models/response.py
@@ -10,7 +10,6 @@ from attr import define, field
from requests import PreparedRequest, Response
from requests.cookies import RequestsCookieJar
from requests.structures import CaseInsensitiveDict
-from urllib3._collections import HTTPHeaderDict
from ..policy.expiration import ExpirationTime, get_expiration_datetime
from . import CachedHTTPResponse, CachedRequest, RichMixin
@@ -73,18 +72,15 @@ class CachedResponse(RichMixin, BaseResponse):
expires: Optional[datetime] = field(default=None)
headers: CaseInsensitiveDict = field(factory=CaseInsensitiveDict)
history: List['CachedResponse'] = field(factory=list) # type: ignore
- raw: CachedHTTPResponse = field(factory=CachedHTTPResponse, repr=False)
+ raw: CachedHTTPResponse = None # type: ignore # Not serialized; populated from CachedResponse attrs
reason: str = field(default=None)
request: CachedRequest = field(factory=CachedRequest) # type: ignore
status_code: int = field(default=0)
url: str = field(default=None)
def __attrs_post_init__(self):
- """Re-initialize raw response body after deserialization"""
- if self.raw._body is None and self._content is not None:
- self.raw.reset(self._content)
- if not self.raw.headers:
- self.raw.headers = HTTPHeaderDict(self.headers)
+ """Re-initialize raw (urllib3) response after deserialization"""
+ self.raw = self.raw or CachedHTTPResponse.from_cached_response(self)
@classmethod
def from_response(cls, response: Response, **kwargs):
@@ -101,8 +97,8 @@ class CachedResponse(RichMixin, BaseResponse):
setattr(obj, k, getattr(response, k, None))
# Store request, raw response, and next response (if it's a redirect response)
- obj.request = CachedRequest.from_request(response.request)
obj.raw = CachedHTTPResponse.from_response(response)
+ obj.request = CachedRequest.from_request(response.request)
obj._next = CachedRequest.from_request(response.next) if response.next else None
# Store response body, which will have been read & decoded by requests.Response by now
diff --git a/requests_cache/serializers/cattrs.py b/requests_cache/serializers/cattrs.py
index befc4b5..d5c3dda 100644
--- a/requests_cache/serializers/cattrs.py
+++ b/requests_cache/serializers/cattrs.py
@@ -13,13 +13,13 @@ serialization formats.
"""
from datetime import datetime, timedelta
from decimal import Decimal
+from json import JSONDecodeError
from typing import Callable, Dict, ForwardRef, MutableMapping
from cattr import GenConverter
from requests.cookies import RequestsCookieJar, cookiejar_from_dict
-from requests.exceptions import JSONDecodeError
+from requests.exceptions import RequestException
from requests.structures import CaseInsensitiveDict
-from urllib3._collections import HTTPHeaderDict
from ..models import CachedResponse, DecodedContent
from .pipeline import Stage
@@ -102,9 +102,6 @@ def init_converter(
converter.register_structure_hook(
CaseInsensitiveDict, lambda obj, cls: CaseInsensitiveDict(obj)
)
- converter.register_unstructure_hook(HTTPHeaderDict, dict)
- converter.register_structure_hook(HTTPHeaderDict, lambda obj, cls: HTTPHeaderDict(obj))
-
# Convert decoded JSON body back to string
converter.register_structure_hook(
DecodedContent, lambda obj, cls: json.dumps(obj) if isinstance(obj, dict) else obj
@@ -140,7 +137,7 @@ def _decode_content(response: CachedResponse, response_dict: Dict) -> Dict:
try:
response_dict['_decoded_content'] = response.json()
response_dict.pop('_content', None)
- except JSONDecodeError:
+ except (JSONDecodeError, RequestException):
pass
# Decode body as text
diff --git a/requests_cache/serializers/pipeline.py b/requests_cache/serializers/pipeline.py
index 08e1cac..652c8f5 100644
--- a/requests_cache/serializers/pipeline.py
+++ b/requests_cache/serializers/pipeline.py
@@ -60,20 +60,11 @@ class SerializerPipeline:
value = step(value)
return value
- # TODO: I don't love this. Could BaseStorage init be refactored to not need this getter/setter?
- @property
- def decode_content(self) -> bool:
- for stage in self.stages:
- if hasattr(stage, 'decode_content'):
- return stage.decode_content
- return False
-
- @decode_content.setter
- def decode_content(self, value: bool):
+ def set_decode_content(self, decode_content: bool):
"""Set decode_content, if the pipeline contains a CattrStage or compatible object"""
for stage in self.stages:
if hasattr(stage, 'decode_content'):
- stage.decode_content = value
+ stage.decode_content = decode_content
def __str__(self) -> str:
return f'SerializerPipeline(name={self.name}, n_stages={len(self.dump_stages)})'
diff --git a/tests/integration/test_filesystem.py b/tests/integration/test_filesystem.py
index 54c782d..dfe9414 100644
--- a/tests/integration/test_filesystem.py
+++ b/tests/integration/test_filesystem.py
@@ -82,15 +82,16 @@ class TestFileCache(BaseCacheTest):
"""Test all relevant combinations of response formats X serializers"""
if not _valid_serializer(serializer):
pytest.skip(f'Dependencies not installed for {serializer}')
+ serializer.set_decode_content(False)
super().test_all_response_formats(response_format, serializer)
@pytest.mark.parametrize('serializer', [json_serializer, yaml_serializer])
@pytest.mark.parametrize('response_format', HTTPBIN_FORMATS)
def test_all_response_formats__no_decode_content(self, response_format, serializer):
- """Test with decode_content=False for text-based serialization formats"""
+ """Test with decode_content=True for text-based serialization formats"""
if not _valid_serializer(serializer):
pytest.skip(f'Dependencies not installed for {serializer}')
- serializer.decode_content = False
+ serializer.set_decode_content(True)
self.test_all_response_formats(response_format, serializer)
@pytest.mark.parametrize('serializer_name', SERIALIZERS.keys())
diff --git a/tests/integration/test_sqlite.py b/tests/integration/test_sqlite.py
index 54b0ceb..580948b 100644
--- a/tests/integration/test_sqlite.py
+++ b/tests/integration/test_sqlite.py
@@ -211,7 +211,7 @@ class TestSQLiteDict(BaseStorageTest):
cache[f'key_{i}'] = response
# Items should only include unexpired (even numbered) items, and still be in sorted order
- items = list(cache.sorted(key='expires', exclude_expired=True))
+ items = list(cache.sorted(key='expires', expired=False))
assert len(items) == 50
prev_item = None
@@ -272,6 +272,13 @@ class TestSQLiteCache(BaseCacheTest):
session = self.init_session()
assert session.cache.db_path == session.cache.responses.db_path
+ @patch.object(SQLiteDict, 'sorted')
+ def test_filter__expired_only(self, mock_sorted):
+ """Filtering by expired only should use a more efficient SQL query"""
+ session = self.init_session()
+ session.cache.filter(valid=False, expired=True)
+ mock_sorted.assert_called_once_with(expired=True)
+
def test_sorted(self):
"""Test wrapper method for SQLiteDict.sorted(), with all arguments combined"""
session = self.init_session(clear=False)
@@ -287,9 +294,7 @@ class TestSQLiteCache(BaseCacheTest):
session.cache.responses[f'key_{i}'] = response
# Sorted items should be in ascending order by expiration time
- items = list(
- session.cache.sorted(key='expires', exclude_expired=True, reversed=True, limit=100)
- )
+ items = list(session.cache.sorted(key='expires', expired=False, reversed=True, limit=100))
assert len(items) == 100
prev_item = None