"""Some common functions for HTTP requests used by all API modules"""
import threading
from contextlib import contextmanager
from logging import getLogger
from os import getenv
from typing import Dict
from unittest.mock import Mock
import requests
import pyinaturalist
from pyinaturalist.constants import (
MAX_DELAY,
REQUESTS_PER_DAY,
REQUESTS_PER_MINUTE,
REQUESTS_PER_SECOND,
WRITE_HTTP_METHODS,
MultiInt,
RequestParams,
)
# from pyinaturalist.exceptions import TooManyRequests
from pyinaturalist.forge_utils import copy_signature
from pyinaturalist.request_params import prepare_request
# Mock response content to return in dry-run mode
MOCK_RESPONSE = Mock(spec=requests.Response)
MOCK_RESPONSE.json.return_value = {'results': [], 'total_results': 0, 'access_token': ''}
logger = getLogger(__name__)
thread_local = threading.local()
[docs]def request(
method: str,
url: str,
access_token: str = None,
user_agent: str = None,
ids: MultiInt = None,
params: RequestParams = None,
headers: Dict = None,
session: requests.Session = None,
**kwargs,
) -> requests.Response:
"""Wrapper around :py:func:`requests.request` that supports dry-run mode and rate-limiting,
and adds appropriate headers.
Args:
method: HTTP method
url: Request URL
access_token: access_token: the access token, as returned by :func:`get_access_token()`
user_agent: a user-agent string that will be passed to iNaturalist
ids: One or more integer IDs used as REST resource(s) to request
params: Requests parameters
headers: Request headers
session: Existing Session object to use instead of creating a new one
Returns:
API response
"""
url, params, headers = prepare_request(url, access_token, user_agent, ids, params, headers)
session = session or get_session()
# Run either real request or mock request depending on settings
if is_dry_run_enabled(method):
logger.debug('Dry-run mode enabled; mocking request')
log_request(method, url, params=params, headers=headers, **kwargs)
return MOCK_RESPONSE
else:
with ratelimit():
return session.request(method, url, params=params, headers=headers, **kwargs)
[docs]@copy_signature(request, exclude='method')
def delete(url: str, **kwargs) -> requests.Response:
"""Wrapper around :py:func:`requests.delete` that supports dry-run mode and rate-limiting"""
return request('DELETE', url, **kwargs)
[docs]@copy_signature(request, exclude='method')
def get(url: str, **kwargs) -> requests.Response:
"""Wrapper around :py:func:`requests.get` that supports dry-run mode and rate-limiting"""
return request('GET', url, **kwargs)
[docs]@copy_signature(request, exclude='method')
def post(url: str, **kwargs) -> requests.Response:
"""Wrapper around :py:func:`requests.post` that supports dry-run mode and rate-limiting"""
return request('POST', url, **kwargs)
[docs]@copy_signature(request, exclude='method')
def put(url: str, **kwargs) -> requests.Response:
"""Wrapper around :py:func:`requests.put` that supports dry-run mode and rate-limiting"""
return request('PUT', url, **kwargs)
# TODO: Handle error 429 if we still somehow exceed the rate limit?
[docs]@contextmanager
def ratelimit(bucket=pyinaturalist.user_agent):
"""Add delays in between requests to stay within the rate limits. If pyrate-limiter is
not installed, this will quietly do nothing.
"""
if RATE_LIMITER:
with RATE_LIMITER.ratelimit(bucket, delay=True, max_delay=MAX_DELAY):
yield
else:
yield
[docs]def get_limiter():
"""Get a rate limiter object, if pyrate-limiter is installed"""
try:
from pyrate_limiter import Duration, Limiter, RequestRate
requst_rates = [
RequestRate(REQUESTS_PER_SECOND, Duration.SECOND),
RequestRate(REQUESTS_PER_MINUTE, Duration.MINUTE),
RequestRate(REQUESTS_PER_DAY, Duration.DAY),
]
return Limiter(*requst_rates)
except ImportError:
return None
[docs]def get_session() -> requests.Session:
"""Get a Session object that will be reused across requests to take advantage of connection
pooling. This is especially relevant for large paginated requests. If used in a multi-threaded
context (for example, a :py:class:`~concurrent.futures.ThreadPoolExecutor`), a separate session
is used for each thread.
"""
if not hasattr(thread_local, "session"):
thread_local.session = requests.Session()
return thread_local.session
[docs]def is_dry_run_enabled(method: str) -> bool:
"""A wrapper to determine if dry-run (aka test mode) has been enabled via either
a constant or an environment variable. Dry-run mode may be enabled for either write
requests, or all requests.
"""
dry_run_enabled = pyinaturalist.DRY_RUN_ENABLED or env_to_bool('DRY_RUN_ENABLED')
if method in WRITE_HTTP_METHODS:
return (
dry_run_enabled or pyinaturalist.DRY_RUN_WRITE_ONLY or env_to_bool('DRY_RUN_WRITE_ONLY')
)
return dry_run_enabled
[docs]def env_to_bool(environment_variable: str) -> bool:
"""Translate an environment variable to a boolean value, accounting for minor
variations (case, None vs. False, etc.)
"""
env_value = getenv(environment_variable)
return bool(env_value) and str(env_value).lower() not in ['false', 'none']
[docs]def log_request(*args, **kwargs):
"""Log all relevant information about an HTTP request"""
kwargs_strs = [f'{k}={v}' for k, v in kwargs.items()]
logger.info('Request: {}'.format(', '.join(list(args) + kwargs_strs)))
RATE_LIMITER = get_limiter()