"""Utilities for patching ``requests``. See :ref:`patching` for general usage info. .. warning:: These functions are not thread-safe. Use :py:class:`.CachedSession` if you want to use caching in a multi-threaded environment. .. automodsumm:: requests_cache.patcher :functions-only: :nosignatures: """ import inspect from contextlib import contextmanager from logging import getLogger from typing import TYPE_CHECKING, List, Optional, Type from warnings import warn import requests from .backends import BackendSpecifier, BaseCache, init_backend from .session import CachedSession, OriginalSession # TODO: This is going to require thinking through many more edge cases. # Lots of ways this could potentially go wrong. # TODO: What if we want to use module_only but patch an external library? Options: # 1. Check if enabled module is anywhere in the call stack (e.g., it called some other module that # made the request) # 2. Provide target module to patch (e.g., 'github.Requester') logger = getLogger(__name__) if TYPE_CHECKING: MIXIN_BASE = CachedSession else: MIXIN_BASE = object def install_cache( cache_name: str = 'http_cache', backend: Optional[BackendSpecifier] = None, module_only: bool = False, session: CachedSession = None, session_factory: Type[OriginalSession] = CachedSession, **kwargs, ): """ Install the cache for all ``requests`` functions by monkey-patching :py:class:`requests.Session` Example: >>> requests_cache.install_cache('demo_cache') Accepts all parameters for :py:class:`.CachedSession`. Additional parameters: Args: module_only: Only install the cache for the current module session: An existing session object to use session_factory: Session class to use. It must inherit from either :py:class:`.CachedSession` or :py:class:`.CacheMixin` """ # Patch with an existing session object if session: cls = _get_session_wrapper(session) _patch_session_factory(cls) # Patch only for the current module elif module_only: if not is_installed: session = ModuleCachedSession( cache_name=cache_name, backend=init_backend(cache_name, backend, **kwargs), **kwargs, ) cls = _get_session_wrapper(session) _patch_session_factory(cls) requests.Session._session.enable_module() # modules = get_installed_modules() + [_calling_module()] # _enable_module( # cache_name, # init_backend(cache_name, backend, **kwargs), # modules, # **kwargs, # ) # cls = _get_configured_session( # ModuleCachedSession, # cache_name=cache_name, # backend=backend, # include_modules=modules, # **kwargs, # ) # _patch_session_factory(cls) # Patch with CachedSession or session_factory else: cls = _get_configured_session( session_factory, cache_name=cache_name, backend=init_backend(cache_name, backend, **kwargs), **kwargs, ) _patch_session_factory(cls) def uninstall_cache(module_only: bool = False): """Disable the cache by restoring the original :py:class:`requests.Session` Args: module_only: Only uninstall the cache for the current module """ if not is_installed(): return elif module_only: requests.Session._session.disable_module() else: _patch_session_factory(OriginalSession) @contextmanager def disabled(): """ Context manager for temporarily disabling caching for all ``requests`` functions Example: >>> with requests_cache.disabled(): ... requests.get('https://httpbin.org/get') """ previous = requests.Session uninstall_cache() try: yield finally: _patch_session_factory(previous) @contextmanager def enabled(*args, **kwargs): """ Context manager for temporarily enabling caching for all ``requests`` functions Example: >>> with requests_cache.enabled('cache.db'): ... requests.get('https://httpbin.org/get') Accepts the same arguments as :py:class:`.CachedSession` and :py:func:`.install_cache`. """ install_cache(*args, **kwargs) try: yield finally: uninstall_cache() def get_cache() -> Optional[BaseCache]: """Get the internal cache object from the currently installed ``CachedSession`` (if any)""" return getattr(requests.Session(), 'cache', None) def get_installed_modules() -> List[str]: """Get all modules that have caching installed""" session = requests.Session() if isinstance(session, ModuleCachedSession): return list(session.include_modules) else: return [] def is_installed() -> bool: """Indicate whether or not requests-cache is currently installed""" session = requests.Session() if isinstance(session, ModuleCachedSession): return session.is_module_enabled() else: return isinstance(session, CachedSession) def clear(): """Clear the currently installed cache (if any)""" if get_cache(): get_cache().clear() def delete(*args, **kwargs): """Remove responses from the cache according one or more conditions. See :py:meth:`.BaseCache.delete for usage details. """ session = requests.Session() if isinstance(session, CachedSession): session.cache.delete(*args, **kwargs) def remove_expired_responses(): """Remove expired responses from the cache""" warn( 'remove_expired_responses() is deprecated; please use delete() instead', DeprecationWarning, ) delete(expired=True) class ModuleCachedSession(CachedSession): """Session mixin that only caches requests sent from specific modules. May be used in one of two modes: * Opt-in: caching is disabled by default, and enabled for modules in ``include_modules`` * Opt-out: caching is enabled by default, and disabled for modules in ``exclude_modules`` Args: include_modules: List of modules to enable caching for exclude_modules: List of modules to disable caching for opt_in: Whether to use opt-in mode (``True``) or opt-out mode (``False``) """ def __init__( self, *args, include_modules: List[str] = None, exclude_modules: List[str] = None, opt_in: bool = True, **kwargs, ): super().__init__(*args, **kwargs) self.include_modules = set(include_modules or []) self.exclude_modules = set(exclude_modules or []) self.opt_in = opt_in def close(self): """Only close adapter(s) and skip closing backend connections in CachedSession.close(), as the object may be reused after closing. :py:func:`requests.request` will create a session for the request and close it after use. We will keep this behavior to avoid memory leaks. Adapter(s) will create new connections if re-used after closing. """ for v in self.adapters.values(): v.close() def disable_module(self): module = _calling_module() if self.opt_in: self.include_modules -= {module} else: self.exclude_modules |= {module} logger.info(f'Caching disabled for {module}') def enable_module(self): module = _calling_module() if self.opt_in: self.include_modules |= {module} else: self.exclude_modules -= {module} logger.info(f'Caching enabled for {module}') def is_module_enabled(self, back: int = 2) -> bool: module = _calling_module(back=back) if self.opt_in: return module in self.include_modules else: return module not in self.exclude_modules def request(self, *args, **kwargs): if self.is_module_enabled(back=3): return super().request(*args, **kwargs) else: return OriginalSession.request(self, *args, **kwargs) def _calling_module(back: int = 2) -> str: """Get the name of the module ``back`` frames up in the call stack""" frame = inspect.stack()[back].frame module = inspect.getmodule(frame) return getattr(module, '__name__', '') # testing def _calling_module_2() -> str: """Get the name of the calling module (first module outside of requests-cache)""" for module in _stack_modules(): if not module.startswith('requests_cache'): return module raise RuntimeError('Could not determine calling module') def _stack_modules() -> Iterator[str]: for frame_info in inspect.stack(): module = inspect.getmodule(frame_info.frame) yield getattr(module, '__name__', '') # def _enable_module( # cache_name: str, # backend: BackendSpecifier, # modules: List[str], # **kwargs, # ): # """Install the cache for specific modules""" # if not is_installed(): # return # requests.Session._session.enable_module() # class _ConfiguredCachedSession(ModuleCachedSession): # type: ignore # See mypy issue #5865 # def __init__(self): # super().__init__( # cache_name=cache_name, backend=backend, include_modules=modules, **kwargs # ) # _patch_session_factory(_ConfiguredCachedSession) # def _disable_module(): # """Uninstall the cache for the current module""" # session = requests.Session() # if not isinstance(session, ModuleCachedSession): # return # modules = get_installed_modules() # modules.remove(_calling_module()) # # No enabled modules remaining; restore the original Session # if not modules: # uninstall_cache() # # Reinstall cache with updated modules # else: # _enable_module( # session.cache.cache_name, # session.cache, # CachedSession, # modules, # **session.settings.to_dict(), # ) def _get_configured_session( session_factory: Type[OriginalSession], *args, **kwargs ) -> Type[OriginalSession]: class _ConfiguredCachedSession(session_factory): # type: ignore # See mypy issue #5865 def __init__(self): super().__init__(*args, **kwargs) return _ConfiguredCachedSession def _get_session_wrapper(session: CachedSession): """Create a wrapper Session class that returns an existing session object when created""" class _SessionWrapper(OriginalSession): """Wrapper for an existing session object, as a mutable class attribute""" _session = session def __new__(cls, *args, **kwargs): return cls._session return _SessionWrapper def _patch_session_factory(session_factory: Type[OriginalSession]): logger.debug(f'Patching requests.Session with class: {session_factory.__name__}') requests.Session = requests.sessions.Session = session_factory # type: ignore