diff --git a/HISTORY.md b/HISTORY.md index b46758a1..a033806a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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()` diff --git a/pyinaturalist/api_docs/forge_utils.py b/pyinaturalist/api_docs/forge_utils.py index 02812f85..72827c91 100644 --- a/pyinaturalist/api_docs/forge_utils.py +++ b/pyinaturalist/api_docs/forge_utils.py @@ -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__) @@ -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 @@ -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 `_ + """ diff --git a/pyinaturalist/api_requests.py b/pyinaturalist/api_requests.py index ca651aba..9942fe3c 100644 --- a/pyinaturalist/api_requests.py +++ b/pyinaturalist/api_requests.py @@ -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, @@ -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__) @@ -37,14 +35,13 @@ 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. @@ -52,15 +49,14 @@ def request( 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 @@ -79,12 +75,12 @@ 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() @@ -92,25 +88,25 @@ def request( @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) @@ -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 diff --git a/pyinaturalist/auth.py b/pyinaturalist/auth.py index 3688a283..87ee2664 100644 --- a/pyinaturalist/auth.py +++ b/pyinaturalist/auth.py @@ -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 diff --git a/pyinaturalist/request_params.py b/pyinaturalist/request_params.py index 360d551f..34f5117d 100644 --- a/pyinaturalist/request_params.py +++ b/pyinaturalist/request_params.py @@ -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) diff --git a/pyinaturalist/v0/observation_fields.py b/pyinaturalist/v0/observation_fields.py index d58e74e3..d2dc738b 100644 --- a/pyinaturalist/v0/observation_fields.py +++ b/pyinaturalist/v0/observation_fields.py @@ -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} @@ -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. @@ -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, @@ -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() diff --git a/pyinaturalist/v0/observations.py b/pyinaturalist/v0/observations.py index 71a770e0..49601d4c 100644 --- a/pyinaturalist/v0/observations.py +++ b/pyinaturalist/v0/observations.py @@ -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) @@ -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'] @@ -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. @@ -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: @@ -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: @@ -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) @@ -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) @@ -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: @@ -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) @@ -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) @@ -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. @@ -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 diff --git a/pyinaturalist/v1/controlled_terms.py b/pyinaturalist/v1/controlled_terms.py index 95e70ec0..b95f0188 100644 --- a/pyinaturalist/v1/controlled_terms.py +++ b/pyinaturalist/v1/controlled_terms.py @@ -3,7 +3,7 @@ from pyinaturalist.v1 import get_v1 -def get_controlled_terms(taxon_id: int = None, user_agent: str = None) -> JsonResponse: +def get_controlled_terms(taxon_id: int = None, **params) -> JsonResponse: """List controlled terms and their possible values. A taxon ID can optionally be provided to show only terms that are valid for that taxon. Otherwise, all controlled terms will be returned. @@ -36,7 +36,6 @@ def get_controlled_terms(taxon_id: int = None, user_agent: str = None) -> JsonRe :language: JSON Args: taxon_id: ID of taxon to get controlled terms for - user_agent: a user-agent string that will be passed to iNaturalist. Returns: A dict containing details on controlled terms and their values @@ -46,7 +45,7 @@ def get_controlled_terms(taxon_id: int = None, user_agent: str = None) -> JsonRe """ # This is actually two endpoints, but they are so similar it seems best to combine them endpoint = 'controlled_terms/for_taxon' if taxon_id else 'controlled_terms' - response = get_v1(endpoint, params={'taxon_id': taxon_id}, user_agent=user_agent) + response = get_v1(endpoint, params={'taxon_id': taxon_id}, **params) # controlled_terms/for_taxon returns a 422 if the specified taxon does not exist if response.status_code in (404, 422): diff --git a/pyinaturalist/v1/identifications.py b/pyinaturalist/v1/identifications.py index f28407b8..73cc8988 100644 --- a/pyinaturalist/v1/identifications.py +++ b/pyinaturalist/v1/identifications.py @@ -7,7 +7,7 @@ from pyinaturalist.v1 import get_v1 -def get_identifications_by_id(identification_id: MultiInt, user_agent: str = None) -> JsonResponse: +def get_identifications_by_id(identification_id: MultiInt, **params) -> JsonResponse: """Get one or more identification records by ID. **API reference:** https://api.inaturalist.org/v1/docs/#!/Identifications/get_identifications_id @@ -27,7 +27,7 @@ def get_identifications_by_id(identification_id: MultiInt, user_agent: str = Non Returns: Response dict containing identification records """ - response = get_v1('identifications', ids=identification_id, user_agent=user_agent) + response = get_v1('identifications', ids=identification_id, **params) identifications = response.json() identifications['results'] = convert_all_timestamps(identifications['results']) return identifications @@ -60,7 +60,7 @@ def get_identifications(**params) -> JsonResponse: Response dict containing identification records """ params = convert_rank_range(params) - response = get_v1('identifications', params=params) + response = get_v1('identifications', **params) identifications = response.json() identifications['results'] = convert_all_timestamps(identifications['results']) return identifications diff --git a/pyinaturalist/v1/observations.py b/pyinaturalist/v1/observations.py index 99bbbaad..0be10940 100644 --- a/pyinaturalist/v1/observations.py +++ b/pyinaturalist/v1/observations.py @@ -18,7 +18,7 @@ from pyinaturalist.v1 import get_v1 -def get_observation(observation_id: int, user_agent: str = None) -> JsonResponse: +def get_observation(observation_id: int, **params) -> JsonResponse: """Get details about a single observation by ID **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_id @@ -35,8 +35,7 @@ def get_observation(observation_id: int, user_agent: str = None) -> JsonResponse .. literalinclude:: ../sample_data/get_observation.py Args: - observation_id: Observation ID - user_agent: a user-agent string that will be passed to iNaturalist. + observation_id: Get the observation with this ID. Only a single value is allowed. Returns: A dict with details on the observation @@ -45,7 +44,7 @@ def get_observation(observation_id: int, user_agent: str = None) -> JsonResponse :py:exc:`.ObservationNotFound` If an invalid observation is specified """ - response = get_observations(id=observation_id, user_agent=user_agent) + response = get_observations(id=observation_id, **params) if response['results']: return convert_observation_timestamps(response['results'][0]) raise ObservationNotFound() @@ -101,7 +100,7 @@ def get_observation_histogram(**params) -> HistogramResponse: Dict of ``{time_key: observation_count}``. Keys are ints for 'month of year' and\ 'week of year' intervals, and :py:class:`~datetime.datetime` objects for all other intervals. """ - response = get_v1('observations/histogram', params=params) + response = get_v1('observations/histogram', **params) return convert_histogram(response.json()) @@ -141,7 +140,7 @@ def get_observations(**params) -> JsonResponse: Response dict containing observation records """ validate_multiple_choice_param(params, 'order_by', NODE_OBS_ORDER_BY_PROPERTIES) - response = get_v1('observations', params=params) + response = get_v1('observations', **params) observations = response.json() observations['results'] = convert_all_coordinates(observations['results']) @@ -175,7 +174,7 @@ def get_observation_species_counts(**params) -> JsonResponse: Returns: Response dict containing taxon records with counts """ - response = get_v1('observations/species_counts', params=params) + response = get_v1('observations/species_counts', **params) return response.json() @@ -209,7 +208,7 @@ def get_observation_observers(**params) -> JsonResponse: Response dict of observers """ params.setdefault('per_page', 500) - response = get_v1('observations/observers', params=params) + response = get_v1('observations/observers', **params) return response.json() @@ -239,12 +238,12 @@ def get_observation_identifiers(**params) -> JsonResponse: Response dict of identifiers """ params.setdefault('per_page', 500) - response = get_v1('observations/identifiers', params=params) + response = get_v1('observations/identifiers', **params) return response.json() @add_paginate_all(method='page') -def get_observation_taxonomy(user_id: IntOrStr, user_agent: str = None) -> JsonResponse: +def get_observation_taxonomy(user_id: IntOrStr, **params) -> JsonResponse: """Get observation counts for all taxa in a full taxonomic tree. In the web UI, these are used for life lists. @@ -264,5 +263,5 @@ def get_observation_taxonomy(user_id: IntOrStr, user_agent: str = None) -> JsonR Returns: Response dict containing taxon records with counts """ - response = get_v1('observations/taxonomy', params={'user_id': user_id}, user_agent=user_agent) + response = get_v1('observations/taxonomy', user_id=user_id, **params) return response.json() diff --git a/pyinaturalist/v1/places.py b/pyinaturalist/v1/places.py index 7e2c2d1e..19bab8b1 100644 --- a/pyinaturalist/v1/places.py +++ b/pyinaturalist/v1/places.py @@ -6,7 +6,7 @@ from pyinaturalist.v1 import get_v1 -def get_places_by_id(place_id: MultiInt, user_agent: str = None) -> JsonResponse: +def get_places_by_id(place_id: MultiInt, **params) -> JsonResponse: """ Get one or more places by ID. @@ -29,7 +29,7 @@ def get_places_by_id(place_id: MultiInt, user_agent: str = None) -> JsonResponse Returns: Response dict containing place records """ - response = get_v1('places', ids=place_id, user_agent=user_agent) + response = get_v1('places', ids=place_id, **params) # Convert coordinates to floats places = response.json() @@ -71,7 +71,7 @@ def get_places_nearby(**params) -> JsonResponse: Returns: Response dict containing place records, divided into 'standard' and 'community' places. """ - response = get_v1('places/nearby', params=params) + response = get_v1('places/nearby', **params) return convert_all_place_coordinates(response.json()) @@ -99,13 +99,10 @@ def get_places_autocomplete(q: str = None, **params) -> JsonResponse: .. literalinclude:: ../sample_data/get_places_autocomplete.py - Args: - q: Name must begin with this value - Returns: Response dict containing place records """ - response = get_v1('places/autocomplete', params={'q': q, **params}) + response = get_v1('places/autocomplete', q=q, **params) # Convert coordinates to floats places = response.json() diff --git a/pyinaturalist/v1/projects.py b/pyinaturalist/v1/projects.py index 027dfbfe..71ccdcf0 100644 --- a/pyinaturalist/v1/projects.py +++ b/pyinaturalist/v1/projects.py @@ -44,7 +44,7 @@ def get_projects(**params) -> JsonResponse: Response dict containing project records """ validate_multiple_choice_param(params, 'order_by', PROJECT_ORDER_BY_PROPERTIES) - response = get_v1('projects', params=params) + response = get_v1('projects', **params) projects = response.json() projects['results'] = convert_all_coordinates(projects['results']) @@ -52,9 +52,7 @@ def get_projects(**params) -> JsonResponse: return projects -def get_projects_by_id( - project_id: MultiInt, rule_details: bool = None, user_agent: str = None -) -> JsonResponse: +def get_projects_by_id(project_id: MultiInt, rule_details: bool = None, **params) -> JsonResponse: """Get one or more projects by ID. **API reference:** https://api.inaturalist.org/v1/docs/#!/Projects/get_projects_id @@ -79,12 +77,7 @@ def get_projects_by_id( Returns: Response dict containing project records """ - response = get_v1( - 'projects', - ids=project_id, - params={'rule_details': rule_details}, - user_agent=user_agent, - ) + response = get_v1('projects', ids=project_id, params={'rule_details': rule_details}, **params) projects = response.json() projects['results'] = convert_all_coordinates(projects['results']) diff --git a/pyinaturalist/v1/search.py b/pyinaturalist/v1/search.py index c72c3add..c5cb6ef0 100644 --- a/pyinaturalist/v1/search.py +++ b/pyinaturalist/v1/search.py @@ -29,7 +29,7 @@ def search(q: str, **params) -> JsonResponse: Returns: Response dict containing search results """ - response = get_v1('search', params={'q': q, **params}) + response = get_v1('search', q=q, **params) search_results = response.json() search_results['results'] = convert_all_timestamps(search_results['results']) search_results['results'] = convert_all_coordinates(search_results['results']) diff --git a/pyinaturalist/v1/taxa.py b/pyinaturalist/v1/taxa.py index ccc92e2d..a6de8156 100644 --- a/pyinaturalist/v1/taxa.py +++ b/pyinaturalist/v1/taxa.py @@ -33,13 +33,13 @@ def get_taxa(**params) -> JsonResponse: Response dict containing taxon records """ params = convert_rank_range(params) - response = get_v1('taxa', params=params) + response = get_v1('taxa', **params) taxa = response.json() taxa['results'] = convert_all_timestamps(taxa['results']) return taxa -def get_taxa_by_id(taxon_id: MultiInt, user_agent: str = None) -> JsonResponse: +def get_taxa_by_id(taxon_id: MultiInt, **params) -> JsonResponse: """Get one or more taxa by ID. **API reference:** https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa_id @@ -67,7 +67,7 @@ def get_taxa_by_id(taxon_id: MultiInt, user_agent: str = None) -> JsonResponse: Returns: Response dict containing taxon records """ - response = get_v1('taxa', ids=taxon_id, user_agent=user_agent) + response = get_v1('taxa', ids=taxon_id, **params) taxa = response.json() taxa['results'] = convert_all_timestamps(taxa['results']) return taxa @@ -121,5 +121,5 @@ def get_taxa_autocomplete(**params) -> JsonResponse: Response dict containing taxon records """ params = convert_rank_range(params) - response = get_v1('taxa/autocomplete', params=params) + response = get_v1('taxa/autocomplete', **params) return response.json() diff --git a/pyinaturalist/v1/users.py b/pyinaturalist/v1/users.py index 287561b0..0a434f0d 100644 --- a/pyinaturalist/v1/users.py +++ b/pyinaturalist/v1/users.py @@ -9,7 +9,7 @@ logger = getLogger(__name__) -def get_user_by_id(user_id: int, user_agent: str = None) -> JsonResponse: +def get_user_by_id(user_id: int, **params) -> JsonResponse: """Get a user by ID. **API reference:** https://api.inaturalist.org/v1/docs/#!/Users/get_users_id @@ -31,7 +31,7 @@ def get_user_by_id(user_id: int, user_agent: str = None) -> JsonResponse: Returns: Response dict containing user record """ - response = get_v1('users', ids=[user_id], user_agent=user_agent) + response = get_v1('users', ids=[user_id], **params) results = response.json()['results'] if not results: return {} @@ -61,7 +61,7 @@ def get_users_autocomplete(q: str, **params) -> JsonResponse: Returns: Response dict containing user records """ - response = get_v1('users/autocomplete', params={'q': q, **params}) + response = get_v1('users/autocomplete', q=q, **params) users = response.json() users['results'] = convert_all_timestamps(users['results']) return users diff --git a/test/test_api_requests.py b/test/test_api_requests.py index e3a78aa9..2428d32b 100644 --- a/test/test_api_requests.py +++ b/test/test_api_requests.py @@ -13,9 +13,9 @@ 'http_func, http_method', [(delete, 'DELETE'), (get, 'GET'), (post, 'POST'), (put, 'PUT')], ) -@patch('pyinaturalist.api_requests.requests.Session.request') +@patch('pyinaturalist.api_requests.Session.request') def test_http_methods(mock_request, http_func, http_method): - http_func('https://url', params={'key': 'value'}) + http_func('https://url', key='value', session=None) mock_request.assert_called_with( http_method, 'https://url', @@ -45,13 +45,21 @@ def test_http_methods(mock_request, http_func, http_method): ), ], ) -@patch('pyinaturalist.api_requests.requests.Session.request') +@patch('pyinaturalist.api_requests.Session.request') def test_request_headers(mock_request, input_kwargs, expected_headers): request('GET', 'https://url', **input_kwargs) request_kwargs = mock_request.call_args[1] assert request_kwargs['headers'] == expected_headers +@patch('pyinaturalist.api_requests.get_session') +def test_request_session(mock_get_session): + mock_session = MagicMock() + request('GET', 'https://url', session=mock_session) + mock_session.request.assert_called() + mock_get_session.assert_not_called() + + # Test relevant combinations of dry-run settings and HTTP methods @pytest.mark.parametrize( 'enabled_const, enabled_env, write_only_const, write_only_env, method, expected_real_request', @@ -85,7 +93,7 @@ def test_request_headers(mock_request, input_kwargs, expected_headers): ], ) @patch('pyinaturalist.api_requests.getenv') -@patch('pyinaturalist.api_requests.requests.Session.request') +@patch('pyinaturalist.api_requests.Session.request') def test_request_dry_run( mock_request, mock_getenv, diff --git a/test/v1/test_taxa.py b/test/v1/test_taxa.py index e6384159..842e13b1 100644 --- a/test/v1/test_taxa.py +++ b/test/v1/test_taxa.py @@ -49,17 +49,17 @@ def test_get_taxa_by_rank_range( ): # Make sure custom rank params result in the correct 'rank' param value get_taxa(**params) - kwargs = mock_get.call_args[1] - requested_rank = kwargs['params']['rank'] + params = mock_get.call_args[1] + requested_rank = params['rank'] assert requested_rank == expected_ranks # This is just a spot test of a case in which boolean params should be converted -@patch('pyinaturalist.api_requests.requests.Session.request') +@patch('pyinaturalist.api_requests.Session.request') def test_get_taxa_by_name_and_is_active(request): get_taxa(q='Lixus bardanae', is_active=False) - request_kwargs = request.call_args[1] - assert request_kwargs['params'] == {'q': 'Lixus bardanae', 'is_active': 'false'} + params = request.call_args[1]['params'] + assert params['q'] == 'Lixus bardanae' and params['is_active'] == 'false' def test_get_taxa_by_id(requests_mock):