blob: b9571246cf620d21ca53c88a7f6ec297cf28818a [file] [log] [blame]
"""Main classes to add caching features to ``requests.Session``
.. autosummary::
:nosignatures:
CachedSession
CacheMixin
.. Explicitly show inherited method docs on CachedSession instead of CachedMixin
.. autoclass:: requests_cache.session.CachedSession
:show-inheritance:
:inherited-members:
.. autoclass:: requests_cache.session.CacheMixin
"""
from contextlib import contextmanager
from logging import getLogger
from threading import RLock
from typing import TYPE_CHECKING, Callable, MutableMapping, Optional
from requests import PreparedRequest
from requests import Session as OriginalSession
from requests.hooks import dispatch_hook
from urllib3 import filepost
from ._utils import get_valid_kwargs
from .backends import BackendSpecifier, init_backend
from .cache_control import CacheActions, append_directive
from .expiration import ExpirationTime, get_expiration_seconds
from .models import (
AnyResponse,
CachedResponse,
CacheSettings,
RequestSettings,
set_response_defaults,
)
__all__ = ['ALL_METHODS', 'CachedSession', 'CacheMixin']
ALL_METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
FILTER_FN = Callable[[AnyResponse], bool]
logger = getLogger(__name__)
if TYPE_CHECKING:
MIXIN_BASE = OriginalSession
else:
MIXIN_BASE = object
# TODO: Better docs for __init__
# TODO: Better function signatures (due to passing around **kwargs instead of explicit keyword args)
class CacheMixin(MIXIN_BASE):
"""Mixin class that extends :py:class:`requests.Session` with caching features.
See :py:class:`.CachedSession` for usage details.
"""
def __init__(
self,
cache_name: str = 'http_cache',
backend: BackendSpecifier = None,
**kwargs,
):
self.cache = init_backend(cache_name, backend, **kwargs)
self._lock = RLock()
# If the mixin superclass is custom Session, pass along any valid kwargs
super().__init__(**get_valid_kwargs(super().__init__, kwargs)) # type: ignore
def request( # type: ignore
self,
method: str,
url: str,
*args,
headers: MutableMapping[str, str] = None,
expire_after: ExpirationTime = None,
only_if_cached: bool = False,
refresh: bool = False,
revalidate: bool = False,
**kwargs,
) -> AnyResponse:
"""This method prepares and sends a request while automatically performing any necessary
caching operations. This will be called by any other method-specific ``requests`` functions
(get, post, etc.). This is not used by :py:class:`~requests.PreparedRequest` objects, which
are handled by :py:meth:`send()`.
See :py:meth:`requests.Session.request` for 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.
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")
Returns:
Either a new or cached response
**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)
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)
"""
# 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['requests-cache-refresh'] = 'true'
kwargs['headers'] = headers
with patch_form_boundary(**kwargs):
return super().request(method, url, *args, **kwargs)
def send(self, request: PreparedRequest, **kwargs) -> AnyResponse:
"""Send a prepared request, with caching. See :py:meth:`.request` for notes on behavior, and
see :py:meth:`requests.Session.send` for parameters.
"""
# Determine which actions to take based on settings and request info
actions = CacheActions.from_request(
self.cache.create_key(request, **kwargs),
request,
RequestSettings(self.settings, **kwargs),
**kwargs,
)
# Attempt to fetch a cached response
cached_response: Optional[CachedResponse] = None
if not actions.skip_read:
cached_response = self.cache.get_response(actions.cache_key)
actions.update_from_cached_response(cached_response)
# Handle missing and expired responses based on settings and headers
if actions.send_request:
response: AnyResponse = self._send_and_cache(
request, actions, cached_response, **kwargs
)
elif actions.resend_request:
response = self._resend(request, actions, cached_response, **kwargs) # type: ignore
elif actions.error_504:
response = get_504_response(request)
else:
response = cached_response # type: ignore # Guaranteed to be non-None by this point
# If the request has been filtered out and was previously cached, delete it
if self.settings.filter_fn is not None and not self.settings.filter_fn(response):
logger.debug(f'Deleting filtered response for URL: {response.url}')
self.cache.delete(actions.cache_key)
return response
# Dispatch any hooks here, because they are removed during serialization
return dispatch_hook('response', request.hooks, response, **kwargs)
def _send_and_cache(
self,
request: PreparedRequest,
actions: CacheActions,
cached_response: CachedResponse = None,
**kwargs,
) -> AnyResponse:
"""Send a request and cache the response, unless disabled by settings or headers.
If applicable, also handle conditional requests.
"""
request = actions.update_request(request)
response = super().send(request, **kwargs)
actions.update_from_response(response)
if not actions.skip_write:
self.cache.save_response(response, actions.cache_key, actions.expires)
elif cached_response is not None and response.status_code == 304:
cached_response = actions.update_revalidated_response(response, cached_response)
self.cache.save_response(cached_response, actions.cache_key, actions.expires)
return cached_response
else:
logger.debug(f'Skipping cache write for URL: {request.url}')
return set_response_defaults(response, actions.cache_key)
def _resend(
self,
request: PreparedRequest,
actions: CacheActions,
cached_response: CachedResponse,
**kwargs,
) -> AnyResponse:
"""Handle a stale cached response by attempting to resend the request and cache a fresh
response
"""
logger.debug('Stale response; attempting to re-send request')
try:
response = self._send_and_cache(request, actions, cached_response, **kwargs)
if self.settings.stale_if_error:
response.raise_for_status()
return response
except Exception:
return self._handle_error(cached_response, actions)
def _handle_error(self, cached_response: CachedResponse, actions: CacheActions) -> AnyResponse:
"""Handle a request error based on settings:
* Default behavior: delete the stale cache item and re-raise the error
* stale-if-error: Ignore the error and and return the stale cache item
"""
if self.settings.stale_if_error:
logger.warning(
f'Request for URL {cached_response.request.url} failed; using cached response',
exc_info=True,
)
return cached_response
else:
self.cache.delete(actions.cache_key)
raise
@contextmanager
def cache_disabled(self):
"""
Context manager for temporary disabling the cache
.. warning:: This method is not thread-safe.
Example:
>>> s = CachedSession()
>>> with s.cache_disabled():
... s.get('http://httpbin.org/ip')
"""
if self.settings.disabled:
yield
else:
self.settings.disabled = True
try:
yield
finally:
self.settings.disabled = False
@property
def settings(self) -> CacheSettings:
return self.cache.settings
@settings.setter
def settings(self, value: CacheSettings):
self.cache.settings = value
def remove_expired_responses(self, expire_after: ExpirationTime = None):
"""Remove expired responses from the cache, optionally with revalidation
Args:
expire_after: A new expiration time used to revalidate the cache
"""
self.cache.remove_expired_responses(expire_after)
def __repr__(self):
return f'<CachedSession(cache={self.cache}, settings={self.settings})>'
class CachedSession(CacheMixin, OriginalSession):
"""Session class that extends :py:class:`requests.Session` with caching features.
See individual :py:mod:`backend classes <requests_cache.backends>` for additional
backend-specific arguments. Also see :ref:`user-guide` for more details and examples on how the
following arguments affect cache behavior.
Args:
cache_name: Cache prefix or namespace, depending on backend
backend: Cache backend name or instance; name may be one of
``['sqlite', 'filesystem', 'mongodb', 'gridfs', 'redis', 'dynamodb', 'memory']``
serializer: Serializer name or instance; name may be one of
``['pickle', 'json', 'yaml', 'bson']``.
kwargs: Additional keyword arguments for :py:class:`.CacheSettings` or the selected backend
"""
def get_504_response(request: PreparedRequest) -> CachedResponse:
"""Get a 504: Not Cached error response, for use with only-if-cached option"""
return CachedResponse(
url=request.url or '',
status_code=504,
reason='Not Cached',
request=request, # type: ignore
)
@contextmanager
def patch_form_boundary(**request_kwargs):
"""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