Merge pull request #568 from JWCook/refresh
Revise behavior for refresh, force_refresh, and DO_NOT_CACHE
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 232736a..0d60297 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.1.0
+ rev: v4.2.0
hooks:
- id: check-toml
- id: check-yaml
diff --git a/HISTORY.md b/HISTORY.md
index d2ad014..a9aea84 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,15 +1,17 @@
# History
-## 0.10.0 (Unreleased)
-[See all issues and PRs for 0.10](https://github.com/reclosedev/requests-cache/milestone/5?closed=1)
+## Unreleased
+[See all unreleased issues and PRs](https://github.com/reclosedev/requests-cache/milestone/5?closed=1)
**Expiration & Headers:**
* Add support for `Cache-Control: only-if-cached`
* Revalidate for `Cache-Control: no-cache` request or response header
* Revalidate for `Cache-Control: max-age=0, must-revalidate` response headers
* Add `only_if_cached` option to `CachedSession.request()` and `send()` to return only cached results without sending real requests
-* Add `refresh` option to `CachedSession.request()` and `send()` to make (and cache) an new request regardless of existing cache contents
-* Add `revalidate` option to `CachedSession.request()` and `send()` to send conditional request (if possible) before using a cached response
+* Add `refresh` option to `CachedSession.request()` and `send()` to revalidate with the server before using a cached response
+* Add `force_refresh` option to `CachedSession.request()` and `send()` to awlays make and cache a new request regardless of existing cache contents
+* Make behavior for `expire_after=0` consistent with `Cache-Control: max-age=0`: if the response has a validator, save it to the cache but revalidate on use.
+* The constant `requests_cache.DO_NOT_CACHE` may be used to completely disable caching for a request
**Backends:**
* Add `wal` parameter for SQLite backend to enable write-ahead logging
@@ -24,6 +26,7 @@
* `is_expired`
* Populate `cache_key` and `expires` for new (non-cached) responses, if it was written to the cache
* Add return type hints for all `CachedSession` request methods (`get()`, `post()`, etc.)
+* Always skip both cache read and write for requests excluded by `allowable_methods` (previously only skipped write)
**Dependencies:**
* Replace `appdirs` with `platformdirs`
diff --git a/docs/user_guide/expiration.md b/docs/user_guide/expiration.md
index 24b9c8a..f6a90aa 100644
--- a/docs/user_guide/expiration.md
+++ b/docs/user_guide/expiration.md
@@ -23,38 +23,56 @@
5. Per-session expiration (`expire_after` argument for {py:class}`.CacheBackend`)
## Expiration Values
-`expire_after` can be any of the following:
-- `-1` (to never expire)
-- `0` (to "expire immediately," e.g. bypass the cache)
+`expire_after` can be any of the following time values:
- A positive number (in seconds)
- A {py:class}`~datetime.timedelta`
- A {py:class}`~datetime.datetime`
+Or one of the following special values:
+- `DO_NOT_CACHE`: Skip both reading from and writing to the cache
+- `EXPIRE_IMMEDIATELY`: Consider the response already expired, but potentially usable
+- `NEVER_EXPIRE`: Store responses indefinitely
+
+```{note}
+A value of 0 or `EXPIRE_IMMEDIATELY` will behave the same as
+[`Cache-Control: max-age=0`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#response_directives).
+Depending on other settings and headers, an expired response may either be cached and require
+revalidation for each use, or not be cached at all. See {ref}`conditional-requests` for more details.
+```
+
Examples:
```python
->>> # To specify a unit of time other than seconds, use a timedelta
>>> from datetime import timedelta
+>>> from requests_cache import DO_NOT_CACHE, NEVER_EXPIRE, EXPIRE_IMMEDIATELY, CachedSession
+
+>>> # Specify a simple expiration value in seconds
+>>> session = CachedSession(expire_after=60)
+
+>>> # To specify a unit of time other than seconds, use a timedelta
>>> session = CachedSession(expire_after=timedelta(days=30))
->>> # Update an existing session to disable expiration (i.e., store indefinitely)
->>> session.settings.expire_after = -1
+>>> # Or expire on a specific date and time
+>>> session = CachedSession(expire_after=datetime(2023, 1, 1, 0, 0))
+
+>>> # Update an existing session to store new responses indefinitely
+>>> session.settings.expire_after = NEVER_EXPIRE
>>> # Disable caching by default, unless enabled by other settings
->>> session = CachedSession(expire_after=0)
+>>> session = CachedSession(expire_after=DO_NOT_CACHE)
+
+>>> # Override for a single request: cache the response if it can be revalidated
+>>> session.request(expire_after=EXPIRE_IMMEDIATELY)
```
(url-patterns)=
## Expiration With URL Patterns
-You can use `urls_expire_after` to set different expiration values based on URL glob patterns.
-This allows you to customize caching based on what you know about the resources you're requesting
-or how you intend to use them. For example, you might request one resource that gets updated
-frequently, another that changes infrequently, and another that never changes. Example:
+You can use `urls_expire_after` to set different expiration values based on URL glob patterns:
```python
>>> urls_expire_after = {
... '*.site_1.com': 30,
... 'site_2.com/resource_1': 60 * 2,
... 'site_2.com/resource_2': 60 * 60 * 24,
-... 'site_2.com/static': -1,
+... 'site_2.com/static': NEVER_EXPIRE,
... }
>>> session = CachedSession(urls_expire_after=urls_expire_after)
```
@@ -66,6 +84,7 @@
is equivalent to `http*://site.com/resource/**`
- If there is more than one match, the first match will be used in the order they are defined
- If no patterns match a request, `CachedSession.settings.expire_after` will be used as a default
+- See {ref}`selective-caching` for an example of using `urls_expire_after` as an allowlist
(request-errors)=
## Expiration and Error Handling
@@ -133,18 +152,21 @@
### Manual Refresh
If you want to manually refresh a response before it expires, you can use the `refresh` argument.
-This will always send a new request, and ignore and overwrite any previously cached response. The
-response will be saved with a new expiration time, according to the normal expiration rules described above.
-```python
->>> response = session.get('http://httpbin.org/get')
->>> response = session.get('http://httpbin.org/get', refresh=True)
->>> assert response.from_cache is False
-```
-A related argument is `revalidate`, which is basically a "soft refresh." It will send a quick
-{ref}`conditional request <conditional-requests>` to the server, and use the cached response if the
-remote content has not changed. If the cached response does not contain validation headers, this
-option will have no effect.
+* This is equivalent to **F5** in most browsers.
+* The response will be saved with a new expiration time, according to the normal expiration rules
+described above.
+* If possible, this will {ref}`revalidate <conditional-requests>` with the server to potentially
+ avoid re-downloading an unchanged response.
+* To force a refresh (e.g., skip revalidation and always send a new request), use the
+ `force_refresh` argument. This is equivalent to **Ctrl-F5** in most browsers.
+
+Example:
+```python
+>>> response_1 = session.get('http://httpbin.org/get')
+>>> response_2 = session.get('http://httpbin.org/get', refresh=True)
+>>> assert response_2.from_cache is False
+```
### Cache-Only Requests
If you want to only use cached responses without making any real requests, you can use the
diff --git a/docs/user_guide/filtering.md b/docs/user_guide/filtering.md
index 68e65f0..e2afa70 100644
--- a/docs/user_guide/filtering.md
+++ b/docs/user_guide/filtering.md
@@ -11,7 +11,7 @@
```
(http-methods)=
-## Cached HTTP Methods
+## Filter by HTTP Methods
To cache additional HTTP methods, specify them with `allowable_methods`:
```python
>>> session = CachedSession(allowable_methods=('GET', 'POST'))
@@ -22,7 +22,7 @@
requests that may exceed the max size of a `GET` request. You may also want to cache `POST` requests
to ensure you don't send the exact same data multiple times.
-## Cached Status Codes
+## Filter by Status Codes
To cache additional status codes, specify them with `allowable_codes`
```python
>>> session = CachedSession(allowable_codes=(200, 418))
@@ -30,15 +30,14 @@
```
(selective-caching)=
-## Cached URLs
+## Filter by URLs
You can use {ref}`URL patterns <url-patterns>` to define an allowlist for selective caching, by
-using a expiration value of `0` (or `requests_cache.DO_NOT_CACHE`, to be more explicit) for
-non-matching request URLs:
+using a expiration value of `requests_cache.DO_NOT_CACHE` for non-matching request URLs:
```python
->>> from requests_cache import DO_NOT_CACHE, CachedSession
+>>> from requests_cache import DO_NOT_CACHE, NEVER_EXPIRE, CachedSession
>>> urls_expire_after = {
... '*.site_1.com': 30,
-... 'site_2.com/static': -1,
+... 'site_2.com/static': NEVER_EXPIRE,
... '*': DO_NOT_CACHE,
... }
>>> session = CachedSession(urls_expire_after=urls_expire_after)
@@ -51,6 +50,7 @@
>>> session = CachedSession(urls_expire_after=urls_expire_after, expire_after=0)
```
+(custom-filtering)=
## Custom Cache Filtering
If you need more advanced behavior for choosing what to cache, you can provide a custom filtering
function via the `filter_fn` param. This can by any function that takes a
diff --git a/examples/url_patterns.py b/examples/url_patterns.py
index 0fddb86..c948fef 100755
--- a/examples/url_patterns.py
+++ b/examples/url_patterns.py
@@ -5,13 +5,13 @@
"""
from datetime import timedelta
-from requests_cache import CachedSession
+from requests_cache import DO_NOT_CACHE, NEVER_EXPIRE, CachedSession
default_expire_after = 60 * 60 # By default, cached responses expire in an hour
urls_expire_after = {
'httpbin.org/image': timedelta(days=7), # Requests for this base URL will expire in a week
- '*.fillmurray.com': -1, # Requests matching this pattern will never expire
- '*.placeholder.com/*': 0, # Requests matching this pattern will not be cached
+ '*.fillmurray.com': NEVER_EXPIRE, # Requests matching this pattern will never expire
+ '*.placeholder.com/*': DO_NOT_CACHE, # Requests matching this pattern will not be cached
}
urls = [
'https://httpbin.org/get', # Will expire in an hour
diff --git a/requests_cache/__init__.py b/requests_cache/__init__.py
index 81da8e9..30ffb55 100644
--- a/requests_cache/__init__.py
+++ b/requests_cache/__init__.py
@@ -1,8 +1,6 @@
# flake8: noqa: E402,F401
from logging import getLogger
-logger = getLogger('requests_cache')
-
# Version is defined in pyproject.toml.
# It's copied here to make it easier for client code to check the installed version.
__version__ = '0.10.0'
@@ -11,6 +9,7 @@
from .backends import *
from .cache_control import *
from .cache_keys import *
+ from .expiration import *
from .models import *
from .patcher import *
from .serializers import *
@@ -18,4 +17,4 @@
from .settings import *
# Log and ignore ImportErrors, if imported outside a virtualenv (e.g., just to check __version__)
except ImportError as e:
- logger.warning(e, exc_info=True)
+ getLogger('requests_cache').warning(e, exc_info=True)
diff --git a/requests_cache/_utils.py b/requests_cache/_utils.py
index edbf4c6..a57f4ea 100644
--- a/requests_cache/_utils.py
+++ b/requests_cache/_utils.py
@@ -1,8 +1,9 @@
"""Minor internal utility functions that don't really belong anywhere else"""
from inspect import signature
from logging import getLogger
-from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional
+from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple
+KwargDict = Dict[str, Any]
logger = getLogger('requests_cache')
@@ -53,11 +54,23 @@
return Placeholder
-def get_valid_kwargs(func: Callable, kwargs: Dict, extras: Iterable[str] = None) -> Dict:
- """Get the subset of non-None ``kwargs`` that are valid params for ``func``"""
+def get_valid_kwargs(func: Callable, kwargs: Dict, extras: Iterable[str] = None) -> KwargDict:
+ """Get the subset of non-None ``kwargs`` that are valid arguments for ``func``"""
+ kwargs, _ = split_kwargs(func, kwargs, extras)
+ return {k: v for k, v in kwargs.items() if v is not None}
+
+
+def split_kwargs(
+ func: Callable, kwargs: Dict, extras: Iterable[str] = None
+) -> Tuple[KwargDict, KwargDict]:
+ """Split ``kwargs`` into two dicts: those that are valid arguments for ``func``, and those that
+ are not
+ """
params = list(signature(func).parameters)
params.extend(extras or [])
- return {k: v for k, v in kwargs.items() if k in params and v is not None}
+ valid_kwargs = {k: v for k, v in kwargs.items() if k in params}
+ invalid_kwargs = {k: v for k, v in kwargs.items() if k not in params}
+ return valid_kwargs, invalid_kwargs
def try_int(value: Any) -> Optional[int]:
diff --git a/requests_cache/backends/base.py b/requests_cache/backends/base.py
index f85c175..472ba9f 100644
--- a/requests_cache/backends/base.py
+++ b/requests_cache/backends/base.py
@@ -4,12 +4,14 @@
:classes-only:
:nosignatures:
"""
-import pickle
+from __future__ import annotations
+
from abc import ABC
from collections import UserDict
from collections.abc import MutableMapping
from datetime import datetime
from logging import getLogger
+from pickle import PickleError
from typing import Iterable, Iterator, Optional, Tuple, Union
from requests import PreparedRequest, Response
@@ -21,7 +23,7 @@
from ..settings import DEFAULT_CACHE_NAME, CacheSettings
# Specific exceptions that may be raised during deserialization
-DESERIALIZE_ERRORS = (AttributeError, ImportError, TypeError, ValueError, pickle.PickleError)
+DESERIALIZE_ERRORS = (AttributeError, ImportError, PickleError, TypeError, ValueError)
ResponseOrKey = Union[CachedResponse, str]
logger = getLogger(__name__)
diff --git a/requests_cache/cache_control.py b/requests_cache/cache_control.py
index ed4e9ce..a13da45 100644
--- a/requests_cache/cache_control.py
+++ b/requests_cache/cache_control.py
@@ -8,8 +8,6 @@
:functions-only:
:nosignatures:
"""
-from __future__ import annotations
-
from datetime import datetime
from logging import getLogger
from typing import Dict, MutableMapping, Optional, Tuple, Union
@@ -21,23 +19,24 @@
from ._utils import coalesce, try_int
from .expiration import (
DO_NOT_CACHE,
+ EXPIRE_IMMEDIATELY,
NEVER_EXPIRE,
ExpirationTime,
get_expiration_datetime,
+ get_expiration_seconds,
get_url_expiration,
)
from .models import CachedResponse
-from .settings import CacheSettings, RequestSettings
+from .settings import CacheSettings
__all__ = ['CacheActions']
-# Temporary header only used to support the 'refresh' option in CachedSession.request()
-REFRESH_TEMP_HEADER = 'requests-cache-refresh'
-
-CacheDirective = Union[None, int, bool]
+CacheDirective = Union[None, bool, int, str]
+HeaderDict = MutableMapping[str, str]
logger = getLogger(__name__)
+# TODO: Add custom __rich_repr__ to exclude default values to make logs cleaner (w/ RichHandler)
@define
class CacheActions:
"""Translates cache settings and headers into specific actions to take for a given cache item.
@@ -70,54 +69,57 @@
skip_write: bool = field(default=False)
# Inputs/internal attributes
- _settings: RequestSettings = field(default=None, repr=False, init=False)
+ _settings: CacheSettings = field(default=None, repr=False, init=False)
_validation_headers: Dict[str, str] = field(factory=dict, repr=False, init=False)
+ # TODO: It would be nice to not need these temp variables
+ _only_if_cached: bool = field(default=False)
+ _refresh: bool = field(default=False)
@classmethod
- def from_request(
- cls,
- cache_key: str,
- request: PreparedRequest,
- settings: CacheSettings,
- **kwargs,
- ):
- """Initialize from request info and cache settings"""
- request.headers = request.headers or CaseInsensitiveDict()
+ def from_request(cls, cache_key: str, request: PreparedRequest, settings: CacheSettings = None):
+ """Initialize from request info and cache settings.
+
+ Note on refreshing: `must-revalidate` isn't a standard request header, but is used here to
+ indicate a user-requested refresh. Typically that's only used in response headers, and
+ `max-age=0` would be used by a client to request a refresh. However, this would conflict
+ with the `expire_after` option provided in :py:meth:`.CachedSession.request`.
+ """
directives = get_cache_directives(request.headers)
logger.debug(f'Cache directives from request headers: {directives}')
- # Merge session settings and request settings
- settings = RequestSettings(settings, skip_invalid=True, **kwargs)
- settings.only_if_cached = settings.only_if_cached or 'only-if-cached' in directives
- settings.refresh = settings.refresh or bool(request.headers.pop(REFRESH_TEMP_HEADER, False))
- settings.revalidate = settings.revalidate or 'no-cache' in directives
+ # Merge relevant headers with session + request settings
+ settings = settings or CacheSettings()
+ only_if_cached = settings.only_if_cached or 'only-if-cached' in directives
+ expire_immediately = directives.get('max-age') == EXPIRE_IMMEDIATELY
+ refresh = expire_immediately or 'must-revalidate' in directives
+ force_refresh = 'no-cache' in directives
# Check expiration values in order of precedence
expire_after = coalesce(
directives.get('max-age'),
- settings.request_expire_after,
get_url_expiration(request.url, settings.urls_expire_after),
settings.expire_after,
)
- # Check conditions for reading from the cache
- skip_read = any(
- [
- settings.refresh,
- settings.disabled,
- 'no-store' in directives,
- try_int(expire_after) == DO_NOT_CACHE,
- ]
- )
+ # Check and log conditions for reading from the cache
+ read_criteria = {
+ 'disabled cache': settings.disabled,
+ 'disabled method': str(request.method) not in settings.allowable_methods,
+ 'disabled by headers': 'no-store' in directives,
+ 'disabled by refresh': force_refresh,
+ 'disabled by expiration': expire_after == DO_NOT_CACHE,
+ }
+ _log_cache_criteria('read', read_criteria)
actions = cls(
cache_key=cache_key,
expire_after=expire_after,
- skip_read=skip_read,
+ only_if_cached=only_if_cached,
+ refresh=refresh,
+ skip_read=any(read_criteria.values()),
skip_write='no-store' in directives,
)
actions._settings = settings
- actions._validation_headers = {}
return actions
@property
@@ -134,35 +136,38 @@
# Determine if we need to send a new request or respond with an error
is_expired = getattr(cached_response, 'is_expired', False)
invalid_response = cached_response is None or is_expired
- if invalid_response and self._settings.only_if_cached and not self._settings.stale_if_error:
+ if invalid_response and self._only_if_cached and not self._settings.stale_if_error:
self.error_504 = True
elif cached_response is None:
self.send_request = True
- elif is_expired and not (self._settings.only_if_cached and self._settings.stale_if_error):
+ elif is_expired and not (self._only_if_cached and self._settings.stale_if_error):
self.resend_request = True
if cached_response is not None:
self._update_validation_headers(cached_response)
+ logger.debug(f'Post-read cache actions: {self}')
def _update_validation_headers(self, response: CachedResponse):
- # Revalidation may be triggered by either stale response or request/cached response headers
+ """If needed, get validation headers based on a cached response. Revalidation may be
+ triggered by a stale response, request headers, or cached response headers.
+ """
directives = get_cache_directives(response.headers)
- revalidate = _has_validator(response.headers) and any(
- [
- response.is_expired,
- self._settings.revalidate,
- 'no-cache' in directives,
- 'must-revalidate' in directives and directives.get('max-age') == 0,
- ]
+ revalidate = _has_validator(response.headers) and (
+ response.is_expired
+ or self._refresh
+ or 'no-cache' in directives
+ or 'must-revalidate' in directives
+ and directives.get('max-age') == 0
)
# Add the appropriate validation headers, if needed
if revalidate:
- self.send_request = True
if response.headers.get('ETag'):
self._validation_headers['If-None-Match'] = response.headers['ETag']
if response.headers.get('Last-Modified'):
self._validation_headers['If-Modified-Since'] = response.headers['Last-Modified']
+ self.send_request = True
+ self.resend_request = False
def update_from_response(self, response: Response):
"""Update expiration + actions based on headers and other details from a new response.
@@ -173,24 +178,25 @@
self._update_from_response_headers(response)
# If "expired" but there's a validator, save it to the cache and revalidate on use
- expire_immediately = try_int(self.expire_after) == DO_NOT_CACHE
+ do_not_cache = self.expire_after == DO_NOT_CACHE
+ expire_immediately = self.expire_after == EXPIRE_IMMEDIATELY
has_validator = _has_validator(response.headers)
- self.skip_write = self.skip_write or (expire_immediately and not has_validator)
# Apply filter callback, if any
callback = self._settings.filter_fn
filtered_out = callback is not None and not callback(response)
- # Apply and log remaining checks needed to determine if the response should be cached
- cache_criteria = {
+ # Check and log conditions for writing to the cache
+ write_criteria = {
'disabled cache': self._settings.disabled,
'disabled method': str(response.request.method) not in self._settings.allowable_methods,
'disabled status': response.status_code not in self._settings.allowable_codes,
'disabled by filter': filtered_out,
- 'disabled by headers or expiration params': self.skip_write,
+ 'disabled by headers': self.skip_write,
+ 'disabled by expiration': do_not_cache or (expire_immediately and not has_validator),
}
- logger.debug(f'Pre-cache checks for response from {response.url}: {cache_criteria}')
- self.skip_write = any(cache_criteria.values())
+ self.skip_write = any(write_criteria.values())
+ _log_cache_criteria('write', write_criteria)
def _update_from_response_headers(self, response: Response):
"""Check response headers for expiration and other cache directives"""
@@ -217,7 +223,8 @@
) -> CachedResponse:
"""After revalidation, update the cached response's headers and reset its expiration"""
logger.debug(
- f'Response for URL {response.request.url} has not been modified; updating and using cached response'
+ f'Response for URL {response.request.url} has not been modified; '
+ 'updating and using cached response'
)
cached_response.expires = self.expires
cached_response.headers.update(response.headers)
@@ -225,25 +232,23 @@
return cached_response
-def append_directive(
- headers: Optional[MutableMapping[str, str]], directive: str
-) -> MutableMapping[str, str]:
+def append_directive(headers: HeaderDict, directive: str) -> HeaderDict:
"""Append a Cache-Control directive to existing headers (if any)"""
- headers = CaseInsensitiveDict(headers)
directives = headers['Cache-Control'].split(',') if headers.get('Cache-Control') else []
directives.append(directive)
headers['Cache-Control'] = ','.join(directives)
return headers
-def get_cache_directives(headers: MutableMapping) -> Dict[str, CacheDirective]:
- """Get all Cache-Control directives as a dict. Handle duplicate headers and comma-separated
- lists. Key-only directives are returned as ``{key: True}``.
+def get_cache_directives(headers: HeaderDict) -> Dict[str, CacheDirective]:
+ """Get all Cache-Control directives as a dict. Handles duplicate headers (with
+ CaseInsensitiveDict) and comma-separated lists.
+ Key-only directives are returned as ``{key: True}``.
"""
if not headers:
return {}
- kv_directives = {}
+ kv_directives: Dict[str, CacheDirective] = {}
if headers.get('Cache-Control'):
cache_directives = headers['Cache-Control'].split(',')
kv_directives = dict([_split_kv_directive(value) for value in cache_directives])
@@ -265,5 +270,30 @@
return header_value, True
-def _has_validator(headers: MutableMapping) -> bool:
+def set_request_headers(
+ headers: Optional[HeaderDict], expire_after, only_if_cached, refresh, force_refresh
+):
+ """Translate keyword arguments into equivalent request headers, to be handled in CacheActions"""
+ headers = CaseInsensitiveDict(headers)
+ if expire_after is not None:
+ headers = append_directive(headers, f'max-age={get_expiration_seconds(expire_after)}')
+ if only_if_cached:
+ headers = append_directive(headers, 'only-if-cached')
+ if refresh:
+ headers = append_directive(headers, 'must-revalidate')
+ if force_refresh:
+ headers = append_directive(headers, 'no-cache')
+ return headers
+
+
+def _has_validator(headers: HeaderDict) -> bool:
return bool(headers.get('ETag') or headers.get('Last-Modified'))
+
+
+def _log_cache_criteria(operation: str, criteria: Dict):
+ """Log details on any failed checks for cache read or write"""
+ if any(criteria.values()):
+ status = ', '.join([k for k, v in criteria.items() if v])
+ else:
+ status = 'Passed'
+ logger.debug(f'Pre-{operation} cache checks: {status}')
diff --git a/requests_cache/expiration.py b/requests_cache/expiration.py
index e70cae4..eca86be 100644
--- a/requests_cache/expiration.py
+++ b/requests_cache/expiration.py
@@ -8,10 +8,11 @@
from ._utils import try_int
-__all__ = ['DO_NOT_CACHE', 'NEVER_EXPIRE', 'get_expiration_datetime']
+__all__ = ['DO_NOT_CACHE', 'EXPIRE_IMMEDIATELY', 'NEVER_EXPIRE', 'get_expiration_datetime']
-# May be set by either headers or expire_after param to disable caching or disable expiration
-DO_NOT_CACHE = 0
+# Special expiration values that may be set by either headers or keyword args
+DO_NOT_CACHE = 0x0D0E0200020704 # Per RFC 4824
+EXPIRE_IMMEDIATELY = 0
NEVER_EXPIRE = -1
ExpirationTime = Union[None, int, float, str, datetime, timedelta]
@@ -22,11 +23,11 @@
def get_expiration_datetime(expire_after: ExpirationTime) -> Optional[datetime]:
"""Convert an expiration value in any supported format to an absolute datetime"""
- # Never expire
- if expire_after is None or expire_after == NEVER_EXPIRE:
+ # Never expire (or do not cache, in which case expiration won't be used)
+ if expire_after is None or expire_after in [NEVER_EXPIRE, DO_NOT_CACHE]:
return None
# Expire immediately
- elif try_int(expire_after) == DO_NOT_CACHE:
+ elif try_int(expire_after) == EXPIRE_IMMEDIATELY:
return datetime.utcnow()
# Already a datetime or datetime str
if isinstance(expire_after, str):
@@ -42,6 +43,8 @@
def get_expiration_seconds(expire_after: ExpirationTime) -> int:
"""Convert an expiration value in any supported format to an expiration time in seconds"""
+ if expire_after == DO_NOT_CACHE:
+ return DO_NOT_CACHE
expires = get_expiration_datetime(expire_after)
return ceil((expires - datetime.utcnow()).total_seconds()) if expires else NEVER_EXPIRE
diff --git a/requests_cache/session.py b/requests_cache/session.py
index 6abd360..3a1b1a3 100644
--- a/requests_cache/session.py
+++ b/requests_cache/session.py
@@ -13,7 +13,7 @@
.. autoclass:: requests_cache.session.CacheMixin
"""
-from contextlib import contextmanager
+from contextlib import contextmanager, nullcontext
from logging import getLogger
from threading import RLock
from typing import TYPE_CHECKING, Dict, Iterable, MutableMapping, Optional, Union
@@ -25,8 +25,8 @@
from ._utils import get_valid_kwargs
from .backends import BackendSpecifier, init_backend
-from .cache_control import REFRESH_TEMP_HEADER, CacheActions, append_directive
-from .expiration import ExpirationTime, get_expiration_seconds
+from .cache_control import CacheActions, set_request_headers
+from .expiration import ExpirationTime
from .models import AnyResponse, CachedResponse, OriginalResponse
from .serializers import SerializerPipeline
from .settings import (
@@ -70,7 +70,7 @@
**kwargs,
):
self.cache = init_backend(cache_name, backend, serializer=serializer, **kwargs)
- self.settings = CacheSettings(
+ self.settings = CacheSettings.from_kwargs(
expire_after=expire_after,
urls_expire_after=urls_expire_after,
cache_control=cache_control,
@@ -81,17 +81,16 @@
filter_fn=filter_fn,
key_fn=key_fn,
stale_if_error=stale_if_error,
- skip_invalid=True,
**kwargs,
)
self._lock = RLock()
- # If the mixin superclass is custom Session, pass along any valid kwargs
+ # If the mixin superclass is a custom Session, pass along any valid kwargs
super().__init__(**get_valid_kwargs(super().__init__, kwargs)) # type: ignore
@property
def settings(self) -> CacheSettings:
- """Settings that affect cache behavior, and can be changed at any time"""
+ """Settings that affect cache behavior"""
return self.cache._settings
@settings.setter
@@ -141,7 +140,7 @@
expire_after: ExpirationTime = None,
only_if_cached: bool = False,
refresh: bool = False,
- revalidate: bool = False,
+ force_refresh: bool = False,
**kwargs,
) -> AnyResponse:
"""This method prepares and sends a request while automatically performing any necessary
@@ -152,49 +151,50 @@
See :py:meth:`requests.Session.request` for base parameters. Additional parameters:
Args:
- expire_after: Expiration time to set only for this request; see details below.
- Overrides ``CachedSession.expire_after``. Accepts all the same values as
- ``CachedSession.expire_after``. Use ``-1`` to disable expiration.
+ expire_after: Expiration time to set only for this request. See :ref:`expiration` for
+ details.
only_if_cached: Only return results from the cache. If not cached, return a 504 response
instead of sending a new request.
- refresh: Always make a new request, and overwrite any previously cached response
- revalidate: Revalidate with the server before using a cached response (e.g., a "soft refresh")
+ refresh: Revalidate with the server before using a cached response, and refresh if needed
+ (e.g., a "soft refresh," like F5 in a browser)
+ force_refresh: Always make a new request, and overwrite any previously cached response
+ (e.g., a "hard refresh", like Ctrl-F5 in a browser))
Returns:
Either a new or cached response
"""
- # Set extra options as headers to be handled in send(), since we can't pass args directly
- headers = headers or {}
- if expire_after is not None:
- headers = append_directive(headers, f'max-age={get_expiration_seconds(expire_after)}')
- if only_if_cached:
- headers = append_directive(headers, 'only-if-cached')
- if revalidate:
- headers = append_directive(headers, 'no-cache')
- if refresh:
- headers[REFRESH_TEMP_HEADER] = 'true'
- kwargs['headers'] = headers
+ headers = set_request_headers(headers, expire_after, only_if_cached, refresh, force_refresh)
+ with patch_form_boundary() if kwargs.get('files') else nullcontext():
+ return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore
- with patch_form_boundary(**kwargs):
- return super().request(method, url, *args, **kwargs) # type: ignore
-
- def send(self, request: PreparedRequest, **kwargs) -> AnyResponse:
+ def send(
+ self,
+ request: PreparedRequest,
+ expire_after: ExpirationTime = None,
+ only_if_cached: bool = False,
+ refresh: bool = False,
+ force_refresh: bool = False,
+ **kwargs,
+ ) -> AnyResponse:
"""Send a prepared request, with caching. See :py:meth:`requests.Session.send` for base
parameters, and see :py:meth:`.request` for extra parameters.
**Order of operations:** For reference, a request will pass through the following methods:
- 1. :py:func:`requests.get`/:py:meth:`requests.Session.get` or other method-specific functions (optional)
+ 1. :py:func:`requests.get`, :py:meth:`CachedSession.get`, etc. (optional)
2. :py:meth:`.CachedSession.request`
3. :py:meth:`requests.Session.request`
4. :py:meth:`.CachedSession.send`
5. :py:meth:`.BaseCache.get_response`
- 6. :py:meth:`requests.Session.send` (if not previously cached)
- 7. :py:meth:`.BaseCache.save_response` (if not previously cached)
+ 6. :py:meth:`requests.Session.send` (if not using a cached response)
+ 7. :py:meth:`.BaseCache.save_response` (if not using a cached response)
"""
# Determine which actions to take based on settings and request info
+ request.headers = set_request_headers(
+ request.headers, expire_after, only_if_cached, refresh, force_refresh
+ )
actions = CacheActions.from_request(
- self.cache.create_key(request, **kwargs), request, self.settings, **kwargs
+ self.cache.create_key(request, **kwargs), request, self.settings
)
# Attempt to fetch a cached response
@@ -328,7 +328,7 @@
``['sqlite', 'filesystem', 'mongodb', 'gridfs', 'redis', 'dynamodb', 'memory']``
serializer: Serializer name or instance; name may be one of
``['pickle', 'json', 'yaml', 'bson']``.
- expire_after: Time after which cached items will expire
+ expire_after: Time after which cached items will expire. See :ref:`expiration` for details.
urls_expire_after: Expiration times to apply for different URL patterns
cache_control: Use Cache-Control and other response headers to set expiration
allowable_codes: Only cache responses with one of these status codes
@@ -338,8 +338,9 @@
ignored_parameters: List of request parameters to not match against, and exclude from the cache
stale_if_error: Return stale cache data if a new request raises an exception
filter_fn: Response filtering function that indicates whether or not a given response should
- be cached.
- key_fn: Request matching function for generating custom cache keys
+ be cached. See :ref:`custom-filtering` for details.
+ key_fn: Request matching function for generating custom cache keys. See
+ :ref:`custom-matching` for details.
"""
@@ -354,15 +355,12 @@
@contextmanager
-def patch_form_boundary(**request_kwargs):
+def patch_form_boundary():
"""If the ``files`` param is present, patch the form boundary used to separate multipart
uploads. ``requests`` does not provide a way to pass a custom boundary to urllib3, so this just
monkey-patches it instead.
"""
- if request_kwargs.get('files'):
- original_boundary = filepost.choose_boundary
- filepost.choose_boundary = lambda: '##requests-cache-form-boundary##'
- yield
- filepost.choose_boundary = original_boundary
- else:
- yield
+ original_boundary = filepost.choose_boundary
+ filepost.choose_boundary = lambda: '##requests-cache-form-boundary##'
+ yield
+ filepost.choose_boundary = original_boundary
diff --git a/requests_cache/settings.py b/requests_cache/settings.py
index e9c6006..5a7cc6f 100644
--- a/requests_cache/settings.py
+++ b/requests_cache/settings.py
@@ -1,6 +1,6 @@
from typing import Callable, Dict, Iterable, Union
-from attr import asdict, define, field
+from attr import define, field
from requests import Response
from ._utils import get_valid_kwargs
@@ -16,15 +16,12 @@
KeyCallback = Callable[..., str]
-@define(init=False)
+@define
class CacheSettings:
"""Class used internally to store settings that affect caching behavior. This allows settings
to be used across multiple modules, but exposed to the user in a single property
(:py:attr:`.CachedSession.settings`). These values can safely be modified after initialization. See
:py:class:`.CachedSession` and :ref:`user-guide` for usage details.
-
- Args:
- skip_invalid: Ignore invalid settings for easier initialization from mixed ``**kwargs``
"""
allowable_codes: Iterable[int] = field(default=DEFAULT_STATUS_CODES)
@@ -40,34 +37,21 @@
stale_if_error: bool = field(default=False)
urls_expire_after: Dict[str, ExpirationTime] = field(factory=dict)
- def __init__(self, **kwargs):
- kwargs = self._rename_kwargs(kwargs)
- if kwargs.pop('skip_invalid', False) is True:
- kwargs = get_valid_kwargs(self.__attrs_init__, kwargs)
- self.__attrs_init__(**kwargs)
+ @classmethod
+ def from_kwargs(cls, **kwargs):
+ """Constructor with some additional steps:
+
+ * Handle some deprecated argument names
+ * Ignore invalid settings, for easier initialization from mixed ``**kwargs``
+ """
+ kwargs = cls._rename_kwargs(kwargs)
+ kwargs = get_valid_kwargs(cls.__init__, kwargs)
+ return cls(**kwargs)
@staticmethod
def _rename_kwargs(kwargs):
- """Handle some deprecated argument names"""
if 'old_data_on_error' in kwargs:
kwargs['stale_if_error'] = kwargs.pop('old_data_on_error')
if 'include_get_headers' in kwargs:
kwargs['match_headers'] = kwargs.pop('include_get_headers')
return kwargs
-
-
-@define(init=False)
-class RequestSettings(CacheSettings):
- """Cache settings that may be set for an individual request"""
-
- # Additional settings that may be set for an individual request; not used at session level
- refresh: bool = field(default=False)
- revalidate: bool = field(default=False)
- request_expire_after: ExpirationTime = field(default=None)
-
- def __init__(self, session_settings: CacheSettings = None, **kwargs):
- """Start with session-level cache settings and append/override with request-level settings"""
- session_kwargs = asdict(session_settings) if session_settings else {}
- # request-level expiration needs to be stored separately
- kwargs['request_expire_after'] = kwargs.pop('expire_after', None)
- super().__init__(**{**session_kwargs, **kwargs})
diff --git a/tests/conftest.py b/tests/conftest.py
index a21b3fc..7de9b6d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -20,6 +20,7 @@
import requests
from requests_mock import ANY as ANY_METHOD
from requests_mock import Adapter
+from rich.logging import RichHandler
from timeout_decorator import timeout
from requests_cache import ALL_METHODS, CachedSession, install_cache, uninstall_cache
@@ -62,6 +63,7 @@
MOCKED_URL_REDIRECT = 'http+mock://requests-cache.com/redirect'
MOCKED_URL_REDIRECT_TARGET = 'http+mock://requests-cache.com/redirect_target'
MOCKED_URL_404 = 'http+mock://requests-cache.com/nonexistent'
+MOCKED_URL_500 = 'http+mock://requests-cache.com/answer?q=this-statement-is-false'
MOCK_PROTOCOLS = ['mock://', 'http+mock://', 'https+mock://']
PROJECT_DIR = abspath(dirname(dirname(__file__)))
@@ -77,7 +79,12 @@
# Configure logging to show log output when tests fail (or with pytest -s)
-basicConfig(level='INFO')
+basicConfig(
+ level='INFO',
+ format='%(message)s',
+ datefmt='[%m-%d %H:%M:%S]',
+ handlers=[RichHandler(rich_tracebacks=True, markup=True)],
+)
# getLogger('requests_cache').setLevel('DEBUG')
logger = getLogger(__name__)
@@ -206,11 +213,8 @@
text='mock redirected response',
status_code=200,
)
- adapter.register_uri(
- ANY_METHOD,
- MOCKED_URL_404,
- status_code=404,
- )
+ adapter.register_uri(ANY_METHOD, MOCKED_URL_404, status_code=404)
+ adapter.register_uri(ANY_METHOD, MOCKED_URL_500, status_code=500)
return adapter
diff --git a/tests/unit/test_cache_control.py b/tests/unit/test_cache_control.py
index 8847bc2..5bfa2ad 100644
--- a/tests/unit/test_cache_control.py
+++ b/tests/unit/test_cache_control.py
@@ -2,12 +2,12 @@
from unittest.mock import MagicMock, patch
import pytest
-from requests import PreparedRequest
+from requests import PreparedRequest, Request
-from requests_cache.cache_control import DO_NOT_CACHE, CacheActions
+from requests_cache.cache_control import EXPIRE_IMMEDIATELY, CacheActions
from requests_cache.models import CachedResponse
-from requests_cache.settings import CacheSettings, RequestSettings
-from tests.conftest import ETAG, HTTPDATE_STR, LAST_MODIFIED, get_mock_response
+from requests_cache.settings import CacheSettings
+from tests.conftest import ETAG, HTTPDATE_STR, LAST_MODIFIED, MOCKED_URL, get_mock_response
IGNORED_DIRECTIVES = [
'no-transform',
@@ -44,12 +44,7 @@
get_url_expiration.return_value = url_expire_after
settings = CacheSettings(cache_control=True, expire_after=1)
- settings = RequestSettings(settings, expire_after=request_expire_after)
- actions = CacheActions.from_request(
- cache_key='key',
- request=request,
- settings=settings,
- )
+ actions = CacheActions.from_request(cache_key='key', request=request, settings=settings)
assert actions.expire_after == expected_expiration
@@ -60,18 +55,17 @@
({'Expires': HTTPDATE_STR}, None), # Only valid for response headers
({'Cache-Control': 'max-age=60'}, 60),
({'Cache-Control': 'public, max-age=60'}, 60),
- ({'Cache-Control': 'max-age=0'}, DO_NOT_CACHE),
+ ({'Cache-Control': 'max-age=0'}, EXPIRE_IMMEDIATELY),
],
)
def test_init_from_headers(headers, expected_expiration):
"""Test with Cache-Control request headers"""
- settings = RequestSettings(cache_control=True)
- actions = CacheActions.from_request('key', MagicMock(headers=headers), settings)
+ settings = CacheSettings(cache_control=True)
+ request = Request(method='GET', url=MOCKED_URL, headers=headers).prepare()
+ actions = CacheActions.from_request('key', request, settings)
assert actions.cache_key == 'key'
- if expected_expiration == DO_NOT_CACHE:
- assert actions.skip_read is True
- else:
+ if expected_expiration != EXPIRE_IMMEDIATELY:
assert actions.expire_after == expected_expiration
assert actions.skip_read is False
assert actions.skip_write is False
@@ -79,10 +73,9 @@
def test_init_from_headers__no_store():
"""Test with Cache-Control request headers"""
- settings = RequestSettings(cache_control=True)
- actions = CacheActions.from_request(
- 'key', MagicMock(headers={'Cache-Control': 'no-store'}), settings
- )
+ settings = CacheSettings(cache_control=True)
+ request = Request(method='GET', url=MOCKED_URL, headers={'Cache-Control': 'no-store'}).prepare()
+ actions = CacheActions.from_request('key', request, settings)
assert actions.skip_read is True
assert actions.skip_write is True
@@ -91,19 +84,19 @@
@pytest.mark.parametrize(
'url, request_expire_after, expected_expiration',
[
- ('img.site_1.com', None, timedelta(hours=12)),
- ('img.site_1.com', 60, 60),
- ('http://img.site.com/base/', None, 1),
+ ('https://img.site_1.com', None, timedelta(hours=12)),
+ ('https://img.site_1.com', 60, 60),
+ ('https://img.site.com/base/', None, 1),
('https://img.site.com/base/img.jpg', None, 1),
- ('site_2.com/resource_1', None, timedelta(hours=20)),
- ('http://site_2.com/resource_1/index.html', None, timedelta(hours=20)),
+ ('http://site_2.com/resource_1', None, timedelta(hours=20)),
+ ('ftp://site_2.com/resource_1/index.html', None, timedelta(hours=20)),
('http://site_2.com/resource_2/', None, timedelta(days=7)),
('http://site_2.com/static/', None, -1),
('http://site_2.com/static/img.jpg', None, -1),
- ('site_2.com', None, 1),
- ('site_2.com', 60, 60),
- ('some_other_site.com', None, 1),
- ('some_other_site.com', 60, 60),
+ ('http://site_2.com', None, 1),
+ ('http://site_2.com', 60, 60),
+ ('https://some_other_site.com', None, 1),
+ ('https://some_other_site.com', 60, 60),
],
)
def test_init_from_settings(url, request_expire_after, expected_expiration):
@@ -117,11 +110,11 @@
'site_2.com/static': -1,
},
)
- request = MagicMock(url=url)
+ request = Request(method='GET', url=url)
if request_expire_after:
request.headers = {'Cache-Control': f'max-age={request_expire_after}'}
- actions = CacheActions.from_request('key', request, RequestSettings(settings))
+ actions = CacheActions.from_request('key', request.prepare(), settings)
assert actions.expire_after == expected_expiration
@@ -130,20 +123,20 @@
[
({'Cache-Control': 'max-age=60'}, 1, 60, False),
({}, 1, 1, False),
- ({}, 0, 0, True),
+ ({}, 0, 0, False),
({'Cache-Control': 'max-age=60'}, 1, 60, False),
- ({'Cache-Control': 'max-age=0'}, 1, 0, True),
+ ({'Cache-Control': 'max-age=0'}, 1, 0, False),
({'Cache-Control': 'no-store'}, 1, 1, True),
- ({'Cache-Control': 'no-cache'}, 1, 1, False),
+ ({'Cache-Control': 'no-cache'}, 1, 1, True),
],
)
def test_init_from_settings_and_headers(
headers, expire_after, expected_expiration, expected_skip_read
):
"""Test behavior with both cache settings and request headers."""
- request = get_mock_response(headers=headers)
+ request = Request(method='GET', url=MOCKED_URL, headers=headers)
settings = CacheSettings(expire_after=expire_after)
- actions = CacheActions.from_request('key', request, RequestSettings(settings))
+ actions = CacheActions.from_request('key', request, settings)
assert actions.expire_after == expected_expiration
assert actions.skip_read == expected_skip_read
@@ -164,9 +157,7 @@
def test_update_from_cached_response(response_headers, expected_validation_headers):
"""Conditional request headers should be added if the cached response is expired"""
actions = CacheActions.from_request(
- 'key',
- MagicMock(url='https://img.site.com/base/img.jpg', headers={}),
- RequestSettings(),
+ 'key', MagicMock(url='https://img.site.com/base/img.jpg', headers={})
)
cached_response = CachedResponse(
headers=response_headers, expires=datetime.now() - timedelta(1)
@@ -179,7 +170,6 @@
@pytest.mark.parametrize(
'request_headers, response_headers',
[
- ({'Cache-Control': 'no-cache'}, {}),
({}, {'Cache-Control': 'no-cache'}),
({}, {'Cache-Control': 'max-age=0,must-revalidate'}),
],
@@ -188,9 +178,7 @@
"""Conditional request headers should be added if requested by headers (even if the response
is not expired)"""
actions = CacheActions.from_request(
- 'key',
- MagicMock(url='https://img.site.com/base/img.jpg', headers=request_headers),
- RequestSettings(),
+ 'key', MagicMock(url='https://img.site.com/base/img.jpg', headers=request_headers)
)
cached_response = CachedResponse(headers={'ETag': ETAG, **response_headers}, expires=None)
@@ -202,9 +190,7 @@
"""Conditional request headers should NOT be added if the cached response is not expired and
revalidation is otherwise not requested"""
actions = CacheActions.from_request(
- 'key',
- MagicMock(url='https://img.site.com/base/img.jpg', headers={}),
- RequestSettings(),
+ 'key', MagicMock(url='https://img.site.com/base/img.jpg', headers={})
)
cached_response = CachedResponse(
headers={'ETag': ETAG, 'Last-Modified': LAST_MODIFIED}, expires=None
@@ -233,18 +219,18 @@
"""Test with Cache-Control response headers"""
url = 'https://img.site.com/base/img.jpg'
actions = CacheActions.from_request(
- 'key', MagicMock(url=url), RequestSettings(cache_control=True)
+ 'key', MagicMock(url=url), CacheSettings(cache_control=True)
)
actions.update_from_response(get_mock_response(headers=headers))
assert actions.expire_after == expected_expiration
- assert actions.skip_write is (expected_expiration == DO_NOT_CACHE)
+ assert actions.skip_write is (expected_expiration == EXPIRE_IMMEDIATELY)
def test_update_from_response__no_store():
url = 'https://img.site.com/base/img.jpg'
actions = CacheActions.from_request(
- 'key', MagicMock(url=url), RequestSettings(cache_control=True)
+ 'key', MagicMock(url=url), CacheSettings(cache_control=True)
)
actions.update_from_response(get_mock_response(headers={'Cache-Control': 'no-store'}))
assert actions.skip_write is True
@@ -253,7 +239,7 @@
def test_update_from_response__ignored():
url = 'https://img.site.com/base/img.jpg'
actions = CacheActions.from_request(
- 'key', MagicMock(url=url), RequestSettings(cache_control=False)
+ 'key', MagicMock(url=url), CacheSettings(cache_control=False)
)
actions.update_from_response(get_mock_response(headers={'Cache-Control': 'max-age=5'}))
assert actions.expire_after is None
@@ -268,7 +254,7 @@
"""
url = 'https://img.site.com/base/img.jpg'
actions = CacheActions.from_request(
- 'key', MagicMock(url=url), RequestSettings(cache_control=True)
+ 'key', MagicMock(url=url), CacheSettings(cache_control=True)
)
response = get_mock_response(headers={**cache_headers, **validator_headers})
actions.update_from_response(response)
@@ -280,11 +266,12 @@
@pytest.mark.parametrize('directive', IGNORED_DIRECTIVES)
def test_ignored_headers(directive):
"""Ensure that currently unimplemented Cache-Control headers do not affect behavior"""
- request = PreparedRequest()
- request.url = 'https://img.site.com/base/img.jpg'
- request.headers = {'Cache-Control': directive}
+ request = Request(
+ method='GET', url='https://img.site.com/base/img.jpg', headers={'Cache-Control': directive}
+ ).prepare()
+
settings = CacheSettings(expire_after=1, cache_control=True)
- actions = CacheActions.from_request('key', request, RequestSettings(settings))
+ actions = CacheActions.from_request('key', request, settings)
assert actions.expire_after == 1
assert actions.skip_read is False
diff --git a/tests/unit/test_expiration.py b/tests/unit/test_expiration.py
index 05490dd..037444d 100644
--- a/tests/unit/test_expiration.py
+++ b/tests/unit/test_expiration.py
@@ -3,7 +3,11 @@
import pytest
-from requests_cache.expiration import DO_NOT_CACHE, get_expiration_datetime, get_url_expiration
+from requests_cache.expiration import (
+ EXPIRE_IMMEDIATELY,
+ get_expiration_datetime,
+ get_url_expiration,
+)
from tests.conftest import HTTPDATE_DATETIME, HTTPDATE_STR
@@ -11,7 +15,7 @@
def test_get_expiration_datetime__no_expiration(mock_datetime):
assert get_expiration_datetime(None) is None
assert get_expiration_datetime(-1) is None
- assert get_expiration_datetime(DO_NOT_CACHE) == mock_datetime.utcnow()
+ assert get_expiration_datetime(EXPIRE_IMMEDIATELY) == mock_datetime.utcnow()
@pytest.mark.parametrize(
diff --git a/tests/unit/test_patcher.py b/tests/unit/test_patcher.py
index 1004dd8..6043c86 100644
--- a/tests/unit/test_patcher.py
+++ b/tests/unit/test_patcher.py
@@ -73,6 +73,14 @@
assert original_request.call_count == 0
+def test_is_installed():
+ assert requests_cache.is_installed() is False
+ requests_cache.install_cache(name=CACHE_NAME, use_temp=True)
+ assert requests_cache.is_installed() is True
+ requests_cache.uninstall_cache()
+ assert requests_cache.is_installed() is False
+
+
@patch.object(BaseCache, 'remove_expired_responses')
def test_remove_expired_responses(remove_expired_responses, tempfile_path):
requests_cache.install_cache(tempfile_path, expire_after=360)
diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py
index 6a20107..4fa1221 100644
--- a/tests/unit/test_session.py
+++ b/tests/unit/test_session.py
@@ -18,9 +18,11 @@
from requests_cache.backends import BACKEND_CLASSES, BaseCache, SQLiteDict, SQLitePickleDict
from requests_cache.backends.base import DESERIALIZE_ERRORS
from requests_cache.cache_keys import create_key
+from requests_cache.expiration import DO_NOT_CACHE, EXPIRE_IMMEDIATELY
from tests.conftest import (
MOCKED_URL,
MOCKED_URL_404,
+ MOCKED_URL_500,
MOCKED_URL_ETAG,
MOCKED_URL_HTTPS,
MOCKED_URL_JSON,
@@ -52,11 +54,11 @@
assert session.cache.cache_dir == Path("~").expanduser()
-@patch.dict(BACKEND_CLASSES, {'mongo': get_placeholder_class()})
+@patch.dict(BACKEND_CLASSES, {'mongodb': get_placeholder_class()})
def test_init_missing_backend_dependency():
"""Test that the correct error is thrown when a user does not have a dependency installed"""
with pytest.raises(ImportError):
- CachedSession(backend='mongo')
+ CachedSession(backend='mongodb')
def test_repr(mock_session):
@@ -412,13 +414,46 @@
# -----------------------------------------------------
+def test_allowable_codes(mock_session):
+ mock_session.settings.allowable_codes = (200, 404)
+
+ # This request should be cached
+ mock_session.get(MOCKED_URL_404)
+ assert mock_session.cache.has_url(MOCKED_URL_404)
+ assert mock_session.get(MOCKED_URL_404).from_cache is True
+
+ # This request should be filtered out on both read and write
+ mock_session.get(MOCKED_URL_500)
+ assert not mock_session.cache.has_url(MOCKED_URL_500)
+ assert mock_session.get(MOCKED_URL_500).from_cache is False
+
+
+def test_allowable_methods(mock_session):
+ mock_session.settings.allowable_methods = ['GET', 'OPTIONS']
+
+ # This request should be cached
+ mock_session.options(MOCKED_URL)
+ assert mock_session.cache.has_url(MOCKED_URL, method='OPTIONS')
+ assert mock_session.options(MOCKED_URL).from_cache is True
+
+ # This request should be filtered out on both read and write
+ mock_session.put(MOCKED_URL)
+ assert not mock_session.cache.has_url(MOCKED_URL, method='PUT')
+ assert mock_session.put(MOCKED_URL).from_cache is False
+
+
def test_filter_fn(mock_session):
mock_session.settings.filter_fn = lambda r: r.request.url != MOCKED_URL_JSON
- mock_session.get(MOCKED_URL)
- mock_session.get(MOCKED_URL_JSON)
+ # This request should be cached
+ mock_session.get(MOCKED_URL)
assert mock_session.cache.has_url(MOCKED_URL)
+ assert mock_session.get(MOCKED_URL).from_cache is True
+
+ # This request should be filtered out on both read and write
+ mock_session.get(MOCKED_URL_JSON)
assert not mock_session.cache.has_url(MOCKED_URL_JSON)
+ assert mock_session.get(MOCKED_URL_JSON).from_cache is False
def test_filter_fn__retroactive(mock_session):
@@ -426,7 +461,6 @@
mock_session.get(MOCKED_URL_JSON)
mock_session.settings.filter_fn = lambda r: r.request.url != MOCKED_URL_JSON
mock_session.get(MOCKED_URL_JSON)
-
assert not mock_session.cache.has_url(MOCKED_URL_JSON)
@@ -457,19 +491,41 @@
assert state[hook] == 5
+def test_expire_after_alias(mock_session):
+ """CachedSession has an `expire_after` property for backwards-compatibility"""
+ mock_session.expire_after = 60
+ assert mock_session.expire_after == mock_session.settings.expire_after == 60
+
+
def test_do_not_cache(mock_session):
- """expire_after=0 should bypass the cache on both read and write"""
- # Skip read
+ """DO_NOT_CACHE should bypass the cache on both read and write"""
mock_session.get(MOCKED_URL)
assert mock_session.cache.has_url(MOCKED_URL)
- assert mock_session.get(MOCKED_URL, expire_after=0).from_cache is False
+
+ # Skip read
+ response = mock_session.get(MOCKED_URL, expire_after=DO_NOT_CACHE)
+ assert response.from_cache is False
# Skip write
- mock_session.settings.expire_after = 0
+ mock_session.settings.expire_after = DO_NOT_CACHE
mock_session.get(MOCKED_URL_JSON)
assert not mock_session.cache.has_url(MOCKED_URL_JSON)
+def test_expire_immediately(mock_session):
+ """EXPIRE_IMMEDIATELY should save a response only if it has a validator"""
+ # Without validator
+ mock_session.settings.expire_after = EXPIRE_IMMEDIATELY
+ mock_session.get(MOCKED_URL)
+ response = mock_session.get(MOCKED_URL)
+ assert not mock_session.cache.has_url(MOCKED_URL)
+ assert response.from_cache is False
+
+ # With validator
+ mock_session.get(MOCKED_URL_ETAG)
+ response = mock_session.get(MOCKED_URL_ETAG)
+
+
@pytest.mark.parametrize(
'response_code, cache_hit, cache_expired, expected_from_cache',
[
@@ -502,12 +558,13 @@
"""If the default is 0, only URLs matching patterns in urls_expire_after should be cached"""
mock_session.settings.urls_expire_after = {
MOCKED_URL_JSON: 60,
- '*': 0,
+ '*': DO_NOT_CACHE,
}
mock_session.get(MOCKED_URL_JSON)
assert mock_session.get(MOCKED_URL_JSON).from_cache is True
mock_session.get(MOCKED_URL)
assert mock_session.get(MOCKED_URL).from_cache is False
+ assert not mock_session.cache.has_url(MOCKED_URL)
def test_remove_expired_responses(mock_session):
@@ -685,55 +742,32 @@
def test_request_refresh(mock_session):
- """The refresh option should send and cache a new request. Any expire_after value provided
- should overwrite the previous value."""
- response_1 = mock_session.get(MOCKED_URL, expire_after=-1)
- response_2 = mock_session.get(MOCKED_URL, expire_after=360, refresh=True)
- response_3 = mock_session.get(MOCKED_URL)
-
- assert response_1.from_cache is False
- assert response_2.from_cache is False
- assert response_3.from_cache is True
- assert response_3.expires is not None
-
-
-def test_request_refresh__prepared_request(mock_session):
- """The refresh option should also work for PreparedRequests with CachedSession.send()"""
- request = Request(method='GET', url=MOCKED_URL, headers={}, data=None).prepare()
- response_1 = mock_session.send(request)
- response_2 = mock_session.send(request, expire_after=360, refresh=True)
- response_3 = mock_session.send(request)
-
- assert response_1.from_cache is False
- assert response_2.from_cache is False
- assert response_3.from_cache is True
- assert response_3.expires is not None
-
-
-def test_request_revalidate(mock_session):
- """The revalidate option should immediately send a conditional request, if possible"""
+ """The refresh option should send a conditional request, if possible"""
response_1 = mock_session.get(MOCKED_URL_ETAG, expire_after=60)
response_2 = mock_session.get(MOCKED_URL_ETAG)
mock_session.mock_adapter.register_uri('GET', MOCKED_URL_ETAG, status_code=304)
- response_3 = mock_session.get(MOCKED_URL_ETAG, revalidate=True, expire_after=60)
+ response_3 = mock_session.get(MOCKED_URL_ETAG, refresh=True, expire_after=60)
response_4 = mock_session.get(MOCKED_URL_ETAG)
assert response_1.from_cache is False
assert response_2.from_cache is True
assert response_3.from_cache is True
+ assert response_4.from_cache is True
# Expect expiration to get reset after revalidation
assert response_2.expires < response_4.expires
-def test_request_revalidate__no_validator(mock_session):
- """The revalidate option should have no effect if the cached response has no validator"""
+def test_request_refresh__no_validator(mock_session):
+ """The refresh option should result in a new (unconditional) request if the cached response has
+ no validator
+ """
response_1 = mock_session.get(MOCKED_URL, expire_after=60)
response_2 = mock_session.get(MOCKED_URL)
mock_session.mock_adapter.register_uri('GET', MOCKED_URL, status_code=304)
- response_3 = mock_session.get(MOCKED_URL, revalidate=True, expire_after=60)
+ response_3 = mock_session.get(MOCKED_URL, refresh=True, expire_after=60)
response_4 = mock_session.get(MOCKED_URL)
assert response_1.from_cache is False
@@ -742,14 +776,15 @@
assert response_2.expires == response_4.expires
-def test_request_revalidate__prepared_request(mock_session):
- """The revalidate option should also work for PreparedRequests with CachedSession.send()"""
+def test_request_refresh__prepared_request(mock_session):
+ """The refresh option should also work for PreparedRequests with CachedSession.send()"""
+ mock_session.settings.expire_after = 60
request = Request(method='GET', url=MOCKED_URL_ETAG, headers={}, data=None).prepare()
- response_1 = mock_session.send(request, expire_after=60)
+ response_1 = mock_session.send(request)
response_2 = mock_session.send(request)
mock_session.mock_adapter.register_uri('GET', MOCKED_URL_ETAG, status_code=304)
- response_3 = mock_session.send(request, revalidate=True, expire_after=60)
+ response_3 = mock_session.send(request, refresh=True)
response_4 = mock_session.send(request)
assert response_1.from_cache is False
@@ -758,3 +793,30 @@
# Expect expiration to get reset after revalidation
assert response_2.expires < response_4.expires
+
+
+def test_request_force_refresh(mock_session):
+ """The force_refresh option should send and cache a new request. Any expire_after value provided
+ should overwrite the previous value."""
+ response_1 = mock_session.get(MOCKED_URL, expire_after=-1)
+ response_2 = mock_session.get(MOCKED_URL, expire_after=360, force_refresh=True)
+ response_3 = mock_session.get(MOCKED_URL)
+
+ assert response_1.from_cache is False
+ assert response_2.from_cache is False
+ assert response_3.from_cache is True
+ assert response_3.expires is not None
+
+
+def test_request_force_refresh__prepared_request(mock_session):
+ """The force_refresh option should also work for PreparedRequests with CachedSession.send()"""
+ mock_session.settings.expire_after = 60
+ request = Request(method='GET', url=MOCKED_URL, headers={}, data=None)
+ response_1 = mock_session.send(request.prepare())
+ response_2 = mock_session.send(request.prepare(), force_refresh=True)
+ response_3 = mock_session.send(request.prepare())
+
+ assert response_1.from_cache is False
+ assert response_2.from_cache is False
+ assert response_3.from_cache is True
+ assert response_3.expires is not None