Skip to content

Commit

Permalink
Merge pull request #187 from JWCook/session
Browse files Browse the repository at this point in the history
Support passing a custom Session object to all API request functions
  • Loading branch information
JWCook committed Jun 27, 2021
2 parents 302f62d + a253e18 commit 9a1cda2
Show file tree
Hide file tree
Showing 17 changed files with 101 additions and 122 deletions.
1 change: 1 addition & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Added new function for **Life list** endpoint: `get_observation_taxonomy()`

### Modified Endpoints
* Added support for passing a `requests.Session` object to all API request functions
* Added a `photos` parameter `create_observation()` and `update_observation()` to upload photos
* Added a `sounds` parameter `create_observation()` and `update_observation()` to upload sounds
* Renamed `add_photo_to_observation()` to `upload_photos()`
Expand Down
12 changes: 10 additions & 2 deletions pyinaturalist/api_docs/forge_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from logging import getLogger
from typing import Callable, List

from requests import Session

from pyinaturalist.constants import TemplateFunction

logger = getLogger(__name__)
Expand Down Expand Up @@ -49,7 +51,7 @@ def copy_doc_signature(template_functions: List[TemplateFunction]) -> Callable:
template_functions: Template functions containing docstrings and params to apply to the
wrapped function
"""
template_functions += [_user_agent]
template_functions += [_user_agent, _session]

def wrapper(func):
# Modify docstring
Expand Down Expand Up @@ -147,8 +149,14 @@ def _get_combined_revision(target_function: Callable, template_functions: List[T
return forge.sign(*fparams.values())


# Param template that's added to every function signature by default
# Param templates that are added to every function signature by default
def _user_agent(user_agent: str = None):
"""
user_agent: A custom user-agent string to provide to the iNaturalist API
"""


def _session(session: Session = None):
"""
session: Allows managing your own `Session object <https://docs.python-requests.org/en/latest/user/advanced/>`_
"""
34 changes: 15 additions & 19 deletions pyinaturalist/api_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@
from typing import Dict
from unittest.mock import Mock

import requests
from requests import Response, Session

import pyinaturalist

# from pyinaturalist.exceptions import TooManyRequests
from pyinaturalist.api_docs import copy_signature
from pyinaturalist.constants import (
MAX_DELAY,
Expand All @@ -24,7 +22,7 @@
from pyinaturalist.request_params import prepare_request

# Mock response content to return in dry-run mode
MOCK_RESPONSE = Mock(spec=requests.Response)
MOCK_RESPONSE = Mock(spec=Response)
MOCK_RESPONSE.json.return_value = {'results': [], 'total_results': 0, 'access_token': ''}

logger = getLogger(__name__)
Expand All @@ -37,30 +35,28 @@ def request(
access_token: str = None,
user_agent: str = None,
ids: MultiInt = None,
params: RequestParams = None,
headers: Dict = None,
json: Dict = None,
session: requests.Session = None,
session: Session = None,
raise_for_status: bool = True,
timeout: float = 5,
**kwargs,
) -> requests.Response:
**params: RequestParams,
) -> 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
user_agent: A custom user-agent string to provide to the iNaturalist API
ids: One or more integer IDs used as REST resource(s) to request
params: Requests parameters
headers: Request headers
json: JSON request body
session: Existing Session object to use instead of creating a new one
timeout: Time (in seconds) to wait for a response from the server; if exceeded, a
:py:exc:`requests.exceptions.Timeout` will be raised.
kwargs: Additional keyword arguments for :py:meth:`requests.Session.request`
params: All other keyword arguments are interpreted as request parameters
Returns:
API response
Expand All @@ -79,38 +75,38 @@ def request(
# 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)
log_request(method, url, params=params, headers=headers)
return MOCK_RESPONSE
else:
with ratelimit():
response = session.request(
method, url, params=params, headers=headers, json=json, timeout=timeout, **kwargs
method, url, params=params, headers=headers, json=json, timeout=timeout
)
if raise_for_status:
response.raise_for_status()
return response


@copy_signature(request, exclude='method')
def delete(url: str, **kwargs) -> requests.Response:
def delete(url: str, **kwargs) -> Response:
"""Wrapper around :py:func:`requests.delete` that supports dry-run mode and rate-limiting"""
return request('DELETE', url, **kwargs)


@copy_signature(request, exclude='method')
def get(url: str, **kwargs) -> requests.Response:
def get(url: str, **kwargs) -> Response:
"""Wrapper around :py:func:`requests.get` that supports dry-run mode and rate-limiting"""
return request('GET', url, **kwargs)


@copy_signature(request, exclude='method')
def post(url: str, **kwargs) -> requests.Response:
def post(url: str, **kwargs) -> Response:
"""Wrapper around :py:func:`requests.post` that supports dry-run mode and rate-limiting"""
return request('POST', url, **kwargs)


@copy_signature(request, exclude='method')
def put(url: str, **kwargs) -> requests.Response:
def put(url: str, **kwargs) -> Response:
"""Wrapper around :py:func:`requests.put` that supports dry-run mode and rate-limiting"""
return request('PUT', url, **kwargs)

Expand Down Expand Up @@ -143,14 +139,14 @@ def get_limiter():
return None


def get_session() -> requests.Session:
def get_session() -> 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()
thread_local.session = Session()
return thread_local.session


Expand Down
2 changes: 1 addition & 1 deletion pyinaturalist/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def get_access_token(
password: iNaturalist password (same as the one you use to login on inaturalist.org)
app_id: OAuth2 application ID
app_secret: OAuth2 application secret
user_agent: a user-agent string that will be passed to iNaturalist.
user_agent: A custom user-agent string to provide to the iNaturalist API
Raises:
:py:exc:`requests.HTTPError` (401) if credentials are invalid
Expand Down
8 changes: 2 additions & 6 deletions pyinaturalist/request_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,13 @@ def prepare_request(
# Prepare request params
params = preprocess_request_params(params)

# Prepare user and authentication headers
# Prepare user-agent and authentication headers
headers = headers or {}
headers['User-Agent'] = user_agent or pyinaturalist.user_agent
headers['Accept'] = 'application/json'
if access_token:
headers['Authorization'] = f'Bearer {access_token}'

# Allow user agent to be passed either in params or as a separate kwarg
if 'user_agent' in params:
user_agent = params.pop('user_agent')
headers['User-Agent'] = user_agent or pyinaturalist.user_agent

# If one or more resources are requested by ID, valudate and update the request URL accordingly
if ids:
url = url.rstrip('/') + '/' + validate_ids(ids)
Expand Down
12 changes: 5 additions & 7 deletions pyinaturalist/v0/observation_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def get_observation_fields(**params) -> JsonResponse:
Returns:
Observation fields as a list of dicts
"""
response = get(f'{API_V0_BASE_URL}/observation_fields.json', params=params)
response = get(f'{API_V0_BASE_URL}/observation_fields.json', **params)
obs_fields = response.json()
obs_fields = convert_all_timestamps(obs_fields)
return {'results': obs_fields}
Expand All @@ -42,7 +42,7 @@ def put_observation_field_values(
observation_field_id: int,
value: Any,
access_token: str,
user_agent: str = None,
**kwargs,
) -> JsonResponse:
# TODO: Also implement a put_or_update_observation_field_values() that deletes then recreates the field_value?
# TODO: Return some meaningful exception if it fails because the field is already set.
Expand Down Expand Up @@ -79,13 +79,11 @@ def put_observation_field_values(
observation_field_id: ID of the observation field for this observation field value
value: Value for the observation field
access_token: The access token, as returned by :func:`get_access_token()`
user_agent: A user-agent string that will be passed to iNaturalist.
Returns:
The newly updated field value record
"""

payload = {
json_body = {
'observation_field_value': {
'observation_id': observation_id,
'observation_field_id': observation_field_id,
Expand All @@ -96,7 +94,7 @@ def put_observation_field_values(
response = put(
f'{API_V0_BASE_URL}/observation_field_values/{observation_field_id}',
access_token=access_token,
user_agent=user_agent,
json=payload,
json=json_body,
**kwargs,
)
return response.json()
56 changes: 20 additions & 36 deletions pyinaturalist/v0/observations.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,7 @@ def get_observations(**params) -> Union[List, str]:
raise ValueError('Invalid response format')
validate_multiple_choice_param(params, 'order_by', REST_OBS_ORDER_BY_PROPERTIES)

response = get(
f'{API_V0_BASE_URL}/observations.{converters}',
params=params,
)

response = get(f'{API_V0_BASE_URL}/observations.{converters}', **params)
if converters == 'json':
observations = response.json()
observations = convert_all_coordinates(observations)
Expand Down Expand Up @@ -141,11 +137,13 @@ def create_observation(access_token: str, **params) -> ListResponse:
``response`` attribute gives more details about the errors.
"""

params, photos, sounds = _process_observation_params(params)
params, photos, sounds, user_agent, session = _process_observation_params(params)
response = post(
url=f'{API_V0_BASE_URL}/observations.json',
json={'observation': params},
access_token=access_token,
user_agent=user_agent,
session=session,
)
response_json = response.json()
observation_id = response_json[0]['id']
Expand All @@ -165,11 +163,7 @@ def create_observation(access_token: str, **params) -> ListResponse:
docs._update_observation,
]
)
def update_observation(
observation_id: int,
access_token: str,
**params,
) -> ListResponse:
def update_observation(observation_id: int, access_token: str, **params) -> ListResponse:
"""
Update a single observation.
Expand Down Expand Up @@ -204,11 +198,13 @@ def update_observation(
:py:exc:`requests.HTTPError`, if the call is not successful. iNaturalist returns an
error 410 if the observation doesn't exists or belongs to another user.
"""
params, photos, sounds = _process_observation_params(params)
params, photos, sounds, user_agent, session = _process_observation_params(params)
response = put(
url=f'{API_V0_BASE_URL}/observations/{observation_id}.json',
json={'observation': params},
access_token=access_token,
user_agent=user_agent,
session=session,
)

if photos:
Expand All @@ -234,15 +230,12 @@ def _process_observation_params(params):
else:
params['ignore_photos'] = 1

return params, photos, sounds
user_agent = params.pop('user_agent', None)
session = params.pop('session', None)
return params, photos, sounds, user_agent, session


def upload_photos(
observation_id: int,
photos: MultiFile,
access_token: str,
user_agent: str = None,
) -> ListResponse:
def upload_photos(observation_id: int, photos: MultiFile, access_token: str, **params) -> ListResponse:
"""Upload a local photo and assign it to an existing observation.
Example:
Expand All @@ -268,7 +261,6 @@ def upload_photos(
observation_id: the ID of the observation
photo: An image file, file-like object, or path
access_token: the access token, as returned by :func:`get_access_token()`
user_agent: a user-agent string that will be passed to iNaturalist.
Returns:
Information about the uploaded photo(s)
Expand All @@ -279,10 +271,10 @@ def upload_photos(
response = post(
url=f'{API_V0_BASE_URL}/observation_photos',
access_token=access_token,
params={'observation_photo[observation_id]': observation_id},
files={'file': ensure_file_obj(photo)},
user_agent=user_agent,
raise_for_status=False,
**{'observation_photo[observation_id]': observation_id},
**params,
)
responses.append(response)

Expand All @@ -292,15 +284,8 @@ def upload_photos(
return [response.json() for response in responses]


def upload_sounds(
observation_id: int,
sounds: MultiFile,
access_token: str,
user_agent: str = None,
) -> ListResponse:
"""Upload a local photo and assign it to an existing observation.
**API reference:** https://www.inaturalist.org/pages/api+reference#post-observation_photos
def upload_sounds(observation_id: int, sounds: MultiFile, access_token: str, **params) -> ListResponse:
"""Upload a local sound file and assign it to an existing observation.
Example:
Expand All @@ -325,7 +310,6 @@ def upload_sounds(
observation_id: the ID of the observation
sound: An audio file, file-like object, or path
access_token: the access token, as returned by :func:`get_access_token()`
user_agent: a user-agent string that will be passed to iNaturalist.
Returns:
Information about the uploaded sound(s)
Expand All @@ -336,10 +320,10 @@ def upload_sounds(
response = post(
url=f'{API_V0_BASE_URL}/observation_sounds',
access_token=access_token,
params={'observation_sound[observation_id]': observation_id},
files={'file': ensure_file_obj(sound)},
user_agent=user_agent,
raise_for_status=False,
**{'observation_sound[observation_id]': observation_id},
**params,
)
responses.append(response)

Expand All @@ -350,7 +334,7 @@ def upload_sounds(


@document_request_params([docs._observation_id, docs._access_token])
def delete_observation(observation_id: int, access_token: str = None, user_agent: str = None):
def delete_observation(observation_id: int, access_token: str = None, **params):
"""
Delete an observation.
Expand All @@ -371,8 +355,8 @@ def delete_observation(observation_id: int, access_token: str = None, user_agent
response = delete(
url=f'{API_V0_BASE_URL}/observations/{observation_id}.json',
access_token=access_token,
user_agent=user_agent,
raise_for_status=False,
**params,
)
if response.status_code == 404:
raise ObservationNotFound
Expand Down
Loading

0 comments on commit 9a1cda2

Please sign in to comment.