From 16b59e155236f3398aec1cb6030696bfab90c6b1 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Fri, 4 Jun 2021 19:01:56 -0500 Subject: [PATCH 1/6] API client class outline --- pyinaturalist/__init__.py | 1 + pyinaturalist/client.py | 226 +++++++++++++++++++++++ pyinaturalist/controllers/__init__.py | 3 + pyinaturalist/controllers/base.py | 5 + pyinaturalist/controllers/observation.py | 26 +++ 5 files changed, 261 insertions(+) create mode 100644 pyinaturalist/client.py create mode 100644 pyinaturalist/controllers/__init__.py create mode 100644 pyinaturalist/controllers/base.py create mode 100644 pyinaturalist/controllers/observation.py diff --git a/pyinaturalist/__init__.py b/pyinaturalist/__init__.py index 8c154449..f8bdffbf 100644 --- a/pyinaturalist/__init__.py +++ b/pyinaturalist/__init__.py @@ -6,6 +6,7 @@ # Ignore ImportErrors if this is imported outside a virtualenv try: from pyinaturalist.auth import get_access_token + from pyinaturalist.client import iNatClient from pyinaturalist.constants import * from pyinaturalist.formatters import enable_logging, format_table, pprint from pyinaturalist.models import * diff --git a/pyinaturalist/client.py b/pyinaturalist/client.py new file mode 100644 index 00000000..38bd2681 --- /dev/null +++ b/pyinaturalist/client.py @@ -0,0 +1,226 @@ +"""TODO: +Lots of details to figure out here. Should this use features from api_requests.py and other modules, +or the other way around? Are they better as static functions or instance methods? +Currently leaning towards the latter. + +Main features include: +* Pagination +* Rate-limiting +* Caching (with #158) +* Dry-run mode +* Thread-local session factory +""" +import threading +from contextlib import contextmanager +from datetime import date, datetime +from logging import getLogger +from typing import Dict, Optional, Tuple +from unittest.mock import Mock + +from pyrate_limiter import Duration, Limiter, RequestRate +from requests import Response, Session + +from pyinaturalist import DEFAULT_USER_AGENT +from pyinaturalist.constants import ( + MAX_DELAY, + REQUESTS_PER_DAY, + REQUESTS_PER_MINUTE, + REQUESTS_PER_SECOND, + WRITE_HTTP_METHODS, + MultiInt, + RequestParams, +) +from pyinaturalist.controllers import ObservationController +from pyinaturalist.forge_utils import copy_signature +from pyinaturalist.request_params import prepare_request, preprocess_request_params, validate_ids + +# Mock response content to return in dry-run mode +MOCK_RESPONSE = Mock(spec=Response) +MOCK_RESPONSE.json.return_value = {'results': [], 'total_results': 0, 'access_token': ''} + +# Default rate-limiting settings +REQUEST_RATES = [ + RequestRate(REQUESTS_PER_SECOND, Duration.SECOND), + RequestRate(REQUESTS_PER_MINUTE, Duration.MINUTE), + RequestRate(REQUESTS_PER_DAY, Duration.DAY), +] +RATE_LIMITER = Limiter(*REQUEST_RATES) + +logger = getLogger(__name__) + + +class iNatClient: + """API Client class. + 'iNatClient' is nonstandard casing, but 'InatClient' just looks wrong. Deal with it, pep8. + + Args: + dry_run: Mock and log all requests + dry_run_write_only: Mock and log POST, PUT, and DELETE requests + limiter: Rate-limiting settings to use instead of the default + session: Session object to use instead of creating a new one + user_agent: User-Agent string to pass to API requests + """ + + def __init__( + self, + dry_run: bool = False, + dry_run_write_only: bool = False, + limiter: Limiter = RATE_LIMITER, + session: Session = None, + user_agent: str = DEFAULT_USER_AGENT, + ): + self.access_token = None # TODO: Create and refresh access tokens on demand (if using keyring) + self.dry_run = dry_run + self.dry_run_write_only = dry_run_write_only + self.limiter = limiter + self.local_ctx = threading.local() + self.session = session # TODO: requests_cache.CachedSession options + self.user_agent = user_agent + + # Controllers + self.observations = ObservationController(self) + # self.taxa = TaxonController(self) + # etc. + + def _is_dry_run_enabled(self, method: str) -> bool: + """Determine if dry-run (aka test mode) has been enabled""" + return self.dry_run or (self.dry_run_write_only and method in WRITE_HTTP_METHODS) + + def prepare_request( + self, + url: str, + access_token: str = None, + ids: MultiInt = None, + params: RequestParams = None, + headers: Dict = None, + json: Dict = None, + ) -> Tuple[str, RequestParams, Dict, Optional[Dict]]: + """Translate some ``pyinaturalist``-specific params into standard request params, headers, + and body. This is made non-``requests``-specific so it could potentially be reused for + use with other HTTP clients. + + Returns: + Tuple of ``(URL, params, headers, body)`` + """ + # Prepare request params + params = preprocess_request_params(params) + json = preprocess_request_params(json) + + # Prepare user and authentication headers + headers = headers or {} + headers['Accept'] = 'application/json' + headers['User-Agent'] = params.pop('user_agent', self.user_agent) + if access_token: + headers['Authorization'] = f'Bearer {access_token}' + if json: + headers['Content-type'] = 'application/json' + + # If one or more resources are requested by ID, valudate and update the request URL accordingly + if ids: + url = url.rstrip('/') + '/' + validate_ids(ids) + return url, params, headers, json + + # TODO: Handle error 429 if we still somehow exceed the rate limit? + @contextmanager + def ratelimit(self, bucket: str = None): + """Add delays in between requests to stay within the rate limits""" + if self.limiter: + with self.limiter.ratelimit(bucket or self.user_agent, delay=True, max_delay=MAX_DELAY): + yield + else: + yield + + def request( + self, + method: str, + url: str, + access_token: str = None, + user_agent: str = None, + ids: MultiInt = None, + params: RequestParams = None, + headers: Dict = None, + json: Dict = None, + session: Session = None, + raise_for_status: bool = True, + **kwargs, + ) -> 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 + json: JSON request body + session: Existing Session object to use instead of creating a new one + kwargs: Additional keyword arguments for :py:meth:`requests.Session.request` + + Returns: + API response + """ + url, params, headers, json = prepare_request( + url, + access_token, + user_agent, + ids, + params, + headers, + json, + ) + + # Run either real request or mock request depending on settings + if self._is_dry_run_enabled(method): + log_request(method, url, params=params, headers=headers, **kwargs) + return MOCK_RESPONSE + else: + with self.ratelimit(): + response = self.session.request( + method, url, params=params, headers=headers, json=json, **kwargs + ) + if raise_for_status: + response.raise_for_status() + return response + + # @copy_signature(iNatClient.request, exclude='method') + def delete(self, url: str, **kwargs) -> Response: + """Wrapper around :py:func:`requests.delete` that supports dry-run mode and rate-limiting""" + return self.request('DELETE', url, **kwargs) + + # @copy_signature(iNatClient.request, exclude='method') + def get(self, url: str, **kwargs) -> Response: + """Wrapper around :py:func:`requests.get` that supports dry-run mode and rate-limiting""" + return self.request('GET', url, **kwargs) + + # @copy_signature(iNatClient.request, exclude='method') + def post(self, url: str, **kwargs) -> Response: + """Wrapper around :py:func:`requests.post` that supports dry-run mode and rate-limiting""" + return self.request('POST', url, **kwargs) + + # @copy_signature(iNatClient.request, exclude='method') + def put(self, url: str, **kwargs) -> Response: + """Wrapper around :py:func:`requests.put` that supports dry-run mode and rate-limiting""" + return self.request('PUT', url, **kwargs) + + @property + def session(self) -> Session: + """Get a Session object that will be reused across requests to take advantage of connection + pooling. If used in a multi-threaded context (for example, a + :py:class:`~concurrent.futures.ThreadPoolExecutor`), a separate session is used per thread. + """ + if not hasattr(self.local_ctx, "session"): + self.local_ctx.session = Session() + return self.local_ctx.session + + @session.setter + def session(self, session: Session): + self.local_ctx.session = session + + +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))) diff --git a/pyinaturalist/controllers/__init__.py b/pyinaturalist/controllers/__init__.py new file mode 100644 index 00000000..cc8522ae --- /dev/null +++ b/pyinaturalist/controllers/__init__.py @@ -0,0 +1,3 @@ +# flake8: noqa: F401 +from pyinaturalist.controllers.base import BaseController +from pyinaturalist.controllers.observation import ObservationController diff --git a/pyinaturalist/controllers/base.py b/pyinaturalist/controllers/base.py new file mode 100644 index 00000000..315321a3 --- /dev/null +++ b/pyinaturalist/controllers/base.py @@ -0,0 +1,5 @@ +class BaseController: + """Base class for resource-specific controllers""" + + def __init__(self, client): + self.client = client diff --git a/pyinaturalist/controllers/observation.py b/pyinaturalist/controllers/observation.py new file mode 100644 index 00000000..0232375c --- /dev/null +++ b/pyinaturalist/controllers/observation.py @@ -0,0 +1,26 @@ +from typing import List + +from pyinaturalist.controllers import BaseController +from pyinaturalist.models import Observation +from pyinaturalist.v1 import ( + get_observation_histogram, + get_observation_identifiers, + get_observation_observers, + get_observation_species_counts, + get_observation_taxonomy, + get_observations, +) + + +class ObservationController(BaseController): + """Controller for observation requests""" + + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + + # TODO: Use forge to reuse function signatures + # TODO: Support passing session to all API functions + def search(self, *args, **kwargs) -> List[Observation]: + """Wrapper for :py:func:`.v1.get_observations()`""" + results = get_observations(*args, **kwargs, session=self.session) + return Observation.from_json_list(results) From 757c5f0eac2f126cb4aea0baf54f59752c1e877a Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Thu, 10 Jun 2021 20:36:16 -0500 Subject: [PATCH 2/6] Add observation observers, identifiers, and species_counts --- pyinaturalist/controllers/observation.py | 161 ++++++++++++++++++++--- 1 file changed, 143 insertions(+), 18 deletions(-) diff --git a/pyinaturalist/controllers/observation.py b/pyinaturalist/controllers/observation.py index 0232375c..653c3d9d 100644 --- a/pyinaturalist/controllers/observation.py +++ b/pyinaturalist/controllers/observation.py @@ -1,26 +1,151 @@ -from typing import List +# TODO: Just copying code from original API functions for now; will figure out the best means of code reuse later +# TODO: Update examples and example responses +from typing import Dict, List +from pyinaturalist import api_docs as docs +from pyinaturalist.constants import API_V1_BASE_URL, NODE_OBS_ORDER_BY_PROPERTIES from pyinaturalist.controllers import BaseController -from pyinaturalist.models import Observation -from pyinaturalist.v1 import ( - get_observation_histogram, - get_observation_identifiers, - get_observation_observers, - get_observation_species_counts, - get_observation_taxonomy, - get_observations, -) +from pyinaturalist.forge_utils import document_request_params +from pyinaturalist.models import Observation, Taxon, User +from pyinaturalist.pagination import add_paginate_all +from pyinaturalist.request_params import validate_multiple_choice_param class ObservationController(BaseController): """Controller for observation requests""" - # def __init__(self, *args, **kwargs): - # super().__init__(*args, **kwargs) + @document_request_params([*docs._get_observations, docs._pagination, docs._only_id]) + @add_paginate_all(method='id') + def search(self, **params) -> List[Observation]: + """Search observations. - # TODO: Use forge to reuse function signatures - # TODO: Support passing session to all API functions - def search(self, *args, **kwargs) -> List[Observation]: - """Wrapper for :py:func:`.v1.get_observations()`""" - results = get_observations(*args, **kwargs, session=self.session) - return Observation.from_json_list(results) + **API reference:** http://api.inaturalist.org/v1/docs/#!/Observations/get_observations + + Example: + + Get observations of Monarch butterflies with photos + public location info, + on a specific date in the provice of Saskatchewan, CA (place ID 7953): + + >>> response = get_observations( + >>> taxon_name='Danaus plexippus', + >>> created_on='2020-08-27', + >>> photos=True, + >>> geo=True, + >>> geoprivacy='open', + >>> place_id=7953, + >>> ) + + Get basic info for observations in response: + + >>> from pyinaturalist.formatters import format_observations + >>> print(format_observations(response)) + '[57754375] Species: Danaus plexippus (Monarch) observed by samroom on 2020-08-27 at Railway Ave, Wilcox, SK' + '[57707611] Species: Danaus plexippus (Monarch) observed by ingridt3 on 2020-08-26 at Michener Dr, Regina, SK' + + .. admonition:: Example Response + :class: toggle + + .. literalinclude:: ../sample_data/get_observations_node.py + + Returns: + Response dict containing observation records + """ + validate_multiple_choice_param(params, 'order_by', NODE_OBS_ORDER_BY_PROPERTIES) + response = self.client.get(f'{API_V1_BASE_URL}/observations', params=params) + return Observation.from_json_list(response.json()) + + @document_request_params([*docs._get_observations, docs._pagination]) + def identifiers(self, **params) -> Dict[int, User]: + """Get identifiers of observations matching the search criteria and the count of + observations they have identified. By default, results are sorted by ID count in descending. + + **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_identifiers + + Note: This endpoint will only return up to 500 results. + + Example: + >>> response = get_observation_identifiers(place_id=72645) + >>> print(format_users(response, align=True)) + [409010 ] jdoe42 (Jane Doe) + [691216 ] jbrown252 (James Brown) + [3959037 ] tnsparkleberry + + .. admonition:: Example Response + :class: toggle + + .. literalinclude:: ../sample_data/get_observation_identifiers_ex_results.json + :language: JSON + + Returns: + Response dict of identifiers + """ + params.setdefault('per_page', 500) + response = self.client.get(f'{API_V1_BASE_URL}/observations/identifiers', params=params) + results = response.json()['results'] + + return {r['count']: User.from_json(r['user']) for r in results} + + # TODO: Separate model for these results? (maybe a User subclass) + # TODO: Include species_counts + @document_request_params([*docs._get_observations, docs._pagination]) + def observers(self, **params) -> Dict[int, User]: + """Get observers of observations matching the search criteria and the count of + observations and distinct taxa of rank species they have observed. + + Notes: + * Options for ``order_by`` are 'observation_count' (default) or 'species_count' + * This endpoint will only return up to 500 results + * See this issue for more details: https://github.com/inaturalist/iNaturalistAPI/issues/235 + + **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_observers + + Example: + >>> response = get_observation_observers(place_id=72645, order_by='species_count') + >>> print(format_users(response, align=True)) + [1566366 ] fossa1211 + [674557 ] schurchin + [5813 ] fluffberger (Fluff Berger) + + + .. admonition:: Example Response + :class: toggle + + .. literalinclude:: ../sample_data/get_observation_observers_ex_results.json + :language: JSON + + Returns: + Response dict of observers + """ + params.setdefault('per_page', 500) + response = self.client.get(f'{API_V1_BASE_URL}/observations/observers', params=params) + results = response.json()['results'] + return {r['observation_count']: User.from_json(r['user']) for r in results} + + @document_request_params([*docs._get_observations, docs._pagination]) + @add_paginate_all(method='page') + def species_counts(self, **params) -> Dict[int, Taxon]: + """Get all species (or other 'leaf taxa') associated with observations matching the search + criteria, and the count of observations they are associated with. + **Leaf taxa** are the leaves of the taxonomic tree, e.g., species, subspecies, variety, etc. + + **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_species_counts + + Example: + >>> response = get_observation_species_counts(user_login='my_username', quality_grade='research') + >>> print(format_species_counts(response)) + [62060] Species: Palomena prasina (Green Shield Bug): 10 + [84804] Species: Graphosoma italicum (European Striped Shield Bug): 8 + [55727] Species: Cymbalaria muralis (Ivy-leaved toadflax): 3 + ... + + .. admonition:: Example Response + :class: toggle + + .. literalinclude:: ../sample_data/get_observation_species_counts.py + + Returns: + Response dict containing taxon records with counts + """ + response = self.client.get(f'{API_V1_BASE_URL}/observations/species_counts', params=params) + results = response.json()['results'] + return {r['count']: Taxon.from_json(r['taxon']) for r in results} From 9c51bf26d07242c8ba3386b18d7a11334ba73def Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Thu, 10 Jun 2021 20:30:59 -0500 Subject: [PATCH 3/6] Add observation histogram and life list --- pyinaturalist/controllers/observation.py | 92 +++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/pyinaturalist/controllers/observation.py b/pyinaturalist/controllers/observation.py index 653c3d9d..da575dcf 100644 --- a/pyinaturalist/controllers/observation.py +++ b/pyinaturalist/controllers/observation.py @@ -3,12 +3,18 @@ from typing import Dict, List from pyinaturalist import api_docs as docs -from pyinaturalist.constants import API_V1_BASE_URL, NODE_OBS_ORDER_BY_PROPERTIES +from pyinaturalist.constants import ( + API_V1_BASE_URL, + NODE_OBS_ORDER_BY_PROPERTIES, + HistogramResponse, + IntOrStr, +) from pyinaturalist.controllers import BaseController from pyinaturalist.forge_utils import document_request_params -from pyinaturalist.models import Observation, Taxon, User +from pyinaturalist.models import LifeList, Observation, Taxon, User from pyinaturalist.pagination import add_paginate_all from pyinaturalist.request_params import validate_multiple_choice_param +from pyinaturalist.response_format import format_histogram class ObservationController(BaseController): @@ -54,6 +60,60 @@ def search(self, **params) -> List[Observation]: response = self.client.get(f'{API_V1_BASE_URL}/observations', params=params) return Observation.from_json_list(response.json()) + # TODO: Does this need a model with utility functions, or is {datetime: count} sufficient? + @document_request_params([*docs._get_observations, docs._observation_histogram]) + def histogram(self, **params) -> HistogramResponse: + """Search observations and return histogram data for the given time interval + + **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_histogram + + **Notes:** + + * Search parameters are the same as :py:func:`.get_observations()`, with the addition of + ``date_field`` and ``interval``. + * ``date_field`` may be either 'observed' (default) or 'created'. + * Observed date ranges can be filtered by parameters ``d1`` and ``d2`` + * Created date ranges can be filtered by parameters ``created_d1`` and ``created_d2`` + * ``interval`` may be one of: 'year', 'month', 'week', 'day', 'hour', 'month_of_year', or + 'week_of_year'; spaces are also allowed instead of underscores, e.g. 'month of year'. + * The year, month, week, day, and hour interval options will set default values for ``d1`` and + ``created_d1``, to limit the number of groups returned. You can override those values if you + want data from a longer or shorter time span. + * The 'hour' interval only works with ``date_field='created'`` + + Example: + + Get observations per month during 2020 in Austria (place ID 8057) + + >>> response = get_observation_histogram( + >>> interval='month', + >>> d1='2020-01-01', + >>> d2='2020-12-31', + >>> place_id=8057, + >>> ) + + .. admonition:: Example Response (observations per month of year) + :class: toggle + + .. literalinclude:: ../sample_data/get_observation_histogram_month_of_year.py + + .. admonition:: Example Response (observations per month) + :class: toggle + + .. literalinclude:: ../sample_data/get_observation_histogram_month.py + + .. admonition:: Example Response (observations per day) + :class: toggle + + .. literalinclude:: ../sample_data/get_observation_histogram_day.py + + Returns: + 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 = self.client.get(f'{API_V1_BASE_URL}/observations/histogram', params=params) + return format_histogram(response.json()) + @document_request_params([*docs._get_observations, docs._pagination]) def identifiers(self, **params) -> Dict[int, User]: """Get identifiers of observations matching the search criteria and the count of @@ -85,6 +145,34 @@ def identifiers(self, **params) -> Dict[int, User]: return {r['count']: User.from_json(r['user']) for r in results} + @add_paginate_all(method='page') + def life_list(self, user_id: IntOrStr, user_agent: str = None) -> LifeList: + """Get observation counts for all taxa in a full taxonomic tree. In the web UI, these are used + for life lists. + + Args: + user_id: iNaturalist user ID or username + + Example: + >>> response = get_observation_taxonomy(user_id='my_username') + ... + + .. admonition:: Example Response + :class: toggle + + .. literalinclude:: ../sample_data/get_observation_taxonomy.json + :language: JSON + + Returns: + Response dict containing taxon records with counts + """ + response = self.client.get( + f'{API_V1_BASE_URL}/observations/taxonomy', + params={'user_id': user_id}, + user_agent=user_agent, + ) + return LifeList.from_taxonomy_json(response.json()) + # TODO: Separate model for these results? (maybe a User subclass) # TODO: Include species_counts @document_request_params([*docs._get_observations, docs._pagination]) From 5da68d4fffd279dcd77f8d3491a0aedbe2e8bf04 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Sun, 25 Jul 2021 19:16:59 -0500 Subject: [PATCH 4/6] Fix client function signatures and imports --- pyinaturalist/client.py | 51 ++++++++---------------- pyinaturalist/controllers/observation.py | 10 ++--- 2 files changed, 22 insertions(+), 39 deletions(-) diff --git a/pyinaturalist/client.py b/pyinaturalist/client.py index 38bd2681..9a0dc3e3 100644 --- a/pyinaturalist/client.py +++ b/pyinaturalist/client.py @@ -4,15 +4,12 @@ Currently leaning towards the latter. Main features include: +* Caching +* Dry-run mode * Pagination * Rate-limiting -* Caching (with #158) -* Dry-run mode -* Thread-local session factory """ -import threading from contextlib import contextmanager -from datetime import date, datetime from logging import getLogger from typing import Dict, Optional, Tuple from unittest.mock import Mock @@ -31,7 +28,7 @@ RequestParams, ) from pyinaturalist.controllers import ObservationController -from pyinaturalist.forge_utils import copy_signature +from pyinaturalist.docs import copy_signature from pyinaturalist.request_params import prepare_request, preprocess_request_params, validate_ids # Mock response content to return in dry-run mode @@ -73,8 +70,7 @@ def __init__( self.dry_run = dry_run self.dry_run_write_only = dry_run_write_only self.limiter = limiter - self.local_ctx = threading.local() - self.session = session # TODO: requests_cache.CachedSession options + self.session = session or Session() # TODO: Use requests_cache.CachedSession by default? self.user_agent = user_agent # Controllers @@ -140,12 +136,10 @@ def request( params: RequestParams = None, headers: Dict = None, json: Dict = None, - session: Session = None, raise_for_status: bool = True, **kwargs, ) -> Response: - """Wrapper around :py:func:`requests.request` that supports dry-run mode and rate-limiting, - and adds appropriate headers. + """Wrapper around :py:func:`requests.request` with additional options for iNat API requests Args: method: HTTP method @@ -156,7 +150,6 @@ def request( params: Requests parameters headers: Request headers json: JSON request body - session: Existing Session object to use instead of creating a new one kwargs: Additional keyword arguments for :py:meth:`requests.Session.request` Returns: @@ -185,42 +178,32 @@ def request( response.raise_for_status() return response - # @copy_signature(iNatClient.request, exclude='method') def delete(self, url: str, **kwargs) -> Response: - """Wrapper around :py:func:`requests.delete` that supports dry-run mode and rate-limiting""" + """Wrapper around :py:func:`requests.delete` with additional options for iNat API requests""" return self.request('DELETE', url, **kwargs) - # @copy_signature(iNatClient.request, exclude='method') def get(self, url: str, **kwargs) -> Response: - """Wrapper around :py:func:`requests.get` that supports dry-run mode and rate-limiting""" + """Wrapper around :py:func:`requests.get` with additional options for iNat API requests""" return self.request('GET', url, **kwargs) - # @copy_signature(iNatClient.request, exclude='method') def post(self, url: str, **kwargs) -> Response: - """Wrapper around :py:func:`requests.post` that supports dry-run mode and rate-limiting""" + """Wrapper around :py:func:`requests.post` with additional options for iNat API requests""" return self.request('POST', url, **kwargs) - # @copy_signature(iNatClient.request, exclude='method') def put(self, url: str, **kwargs) -> Response: - """Wrapper around :py:func:`requests.put` that supports dry-run mode and rate-limiting""" + """Wrapper around :py:func:`requests.put` with additional options for iNat API requests""" return self.request('PUT', url, **kwargs) - @property - def session(self) -> Session: - """Get a Session object that will be reused across requests to take advantage of connection - pooling. If used in a multi-threaded context (for example, a - :py:class:`~concurrent.futures.ThreadPoolExecutor`), a separate session is used per thread. - """ - if not hasattr(self.local_ctx, "session"): - self.local_ctx.session = Session() - return self.local_ctx.session - - @session.setter - def session(self, session: Session): - self.local_ctx.session = session - 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))) + + +# Apply function signature changes after class definition +extend_request = copy_signature(iNatClient.request, exclude='method') +iNatClient.delete = extend_request(iNatClient.delete) # type: ignore +iNatClient.get = extend_request(iNatClient.get) # type: ignore +iNatClient.post = extend_request(iNatClient.post) # type: ignore +iNatClient.put = extend_request(iNatClient.put) # type: ignore diff --git a/pyinaturalist/controllers/observation.py b/pyinaturalist/controllers/observation.py index da575dcf..e920c607 100644 --- a/pyinaturalist/controllers/observation.py +++ b/pyinaturalist/controllers/observation.py @@ -2,7 +2,6 @@ # TODO: Update examples and example responses from typing import Dict, List -from pyinaturalist import api_docs as docs from pyinaturalist.constants import ( API_V1_BASE_URL, NODE_OBS_ORDER_BY_PROPERTIES, @@ -10,11 +9,12 @@ IntOrStr, ) from pyinaturalist.controllers import BaseController -from pyinaturalist.forge_utils import document_request_params +from pyinaturalist.converters import convert_histogram +from pyinaturalist.docs import document_request_params +from pyinaturalist.docs import templates as docs from pyinaturalist.models import LifeList, Observation, Taxon, User from pyinaturalist.pagination import add_paginate_all from pyinaturalist.request_params import validate_multiple_choice_param -from pyinaturalist.response_format import format_histogram class ObservationController(BaseController): @@ -112,7 +112,7 @@ def histogram(self, **params) -> HistogramResponse: 'week of year' intervals, and :py:class:`~datetime.datetime` objects for all other intervals. """ response = self.client.get(f'{API_V1_BASE_URL}/observations/histogram', params=params) - return format_histogram(response.json()) + return convert_histogram(response.json()) @document_request_params([*docs._get_observations, docs._pagination]) def identifiers(self, **params) -> Dict[int, User]: @@ -171,7 +171,7 @@ def life_list(self, user_id: IntOrStr, user_agent: str = None) -> LifeList: params={'user_id': user_id}, user_agent=user_agent, ) - return LifeList.from_taxonomy_json(response.json()) + return LifeList.from_json(response.json()) # TODO: Separate model for these results? (maybe a User subclass) # TODO: Include species_counts From 0502e729298cc3a6c892ea039ec4464c1e658ee4 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Sun, 25 Jul 2021 20:22:06 -0500 Subject: [PATCH 5/6] Apply function signatures + docs from basic request functions to client methods --- pyinaturalist/controllers/observation.py | 199 ++--------------------- 1 file changed, 17 insertions(+), 182 deletions(-) diff --git a/pyinaturalist/controllers/observation.py b/pyinaturalist/controllers/observation.py index e920c607..029674d5 100644 --- a/pyinaturalist/controllers/observation.py +++ b/pyinaturalist/controllers/observation.py @@ -1,5 +1,6 @@ # TODO: Just copying code from original API functions for now; will figure out the best means of code reuse later # TODO: Update examples and example responses +# TODO: Don't override return signature from typing import Dict, List from pyinaturalist.constants import ( @@ -10,230 +11,64 @@ ) from pyinaturalist.controllers import BaseController from pyinaturalist.converters import convert_histogram -from pyinaturalist.docs import document_request_params -from pyinaturalist.docs import templates as docs +from pyinaturalist.docs import copy_doc_signature from pyinaturalist.models import LifeList, Observation, Taxon, User from pyinaturalist.pagination import add_paginate_all from pyinaturalist.request_params import validate_multiple_choice_param +from pyinaturalist.v1 import ( + get_observation_histogram, + get_observation_identifiers, + get_observation_observers, + get_observation_species_counts, + get_observation_taxonomy, + get_observations, +) class ObservationController(BaseController): """Controller for observation requests""" - @document_request_params([*docs._get_observations, docs._pagination, docs._only_id]) + @copy_doc_signature(get_observations) @add_paginate_all(method='id') def search(self, **params) -> List[Observation]: - """Search observations. - - **API reference:** http://api.inaturalist.org/v1/docs/#!/Observations/get_observations - - Example: - - Get observations of Monarch butterflies with photos + public location info, - on a specific date in the provice of Saskatchewan, CA (place ID 7953): - - >>> response = get_observations( - >>> taxon_name='Danaus plexippus', - >>> created_on='2020-08-27', - >>> photos=True, - >>> geo=True, - >>> geoprivacy='open', - >>> place_id=7953, - >>> ) - - Get basic info for observations in response: - - >>> from pyinaturalist.formatters import format_observations - >>> print(format_observations(response)) - '[57754375] Species: Danaus plexippus (Monarch) observed by samroom on 2020-08-27 at Railway Ave, Wilcox, SK' - '[57707611] Species: Danaus plexippus (Monarch) observed by ingridt3 on 2020-08-26 at Michener Dr, Regina, SK' - - .. admonition:: Example Response - :class: toggle - - .. literalinclude:: ../sample_data/get_observations_node.py - - Returns: - Response dict containing observation records - """ validate_multiple_choice_param(params, 'order_by', NODE_OBS_ORDER_BY_PROPERTIES) response = self.client.get(f'{API_V1_BASE_URL}/observations', params=params) return Observation.from_json_list(response.json()) # TODO: Does this need a model with utility functions, or is {datetime: count} sufficient? - @document_request_params([*docs._get_observations, docs._observation_histogram]) + @copy_doc_signature(get_observation_histogram) def histogram(self, **params) -> HistogramResponse: - """Search observations and return histogram data for the given time interval - - **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_histogram - - **Notes:** - - * Search parameters are the same as :py:func:`.get_observations()`, with the addition of - ``date_field`` and ``interval``. - * ``date_field`` may be either 'observed' (default) or 'created'. - * Observed date ranges can be filtered by parameters ``d1`` and ``d2`` - * Created date ranges can be filtered by parameters ``created_d1`` and ``created_d2`` - * ``interval`` may be one of: 'year', 'month', 'week', 'day', 'hour', 'month_of_year', or - 'week_of_year'; spaces are also allowed instead of underscores, e.g. 'month of year'. - * The year, month, week, day, and hour interval options will set default values for ``d1`` and - ``created_d1``, to limit the number of groups returned. You can override those values if you - want data from a longer or shorter time span. - * The 'hour' interval only works with ``date_field='created'`` - - Example: - - Get observations per month during 2020 in Austria (place ID 8057) - - >>> response = get_observation_histogram( - >>> interval='month', - >>> d1='2020-01-01', - >>> d2='2020-12-31', - >>> place_id=8057, - >>> ) - - .. admonition:: Example Response (observations per month of year) - :class: toggle - - .. literalinclude:: ../sample_data/get_observation_histogram_month_of_year.py - - .. admonition:: Example Response (observations per month) - :class: toggle - - .. literalinclude:: ../sample_data/get_observation_histogram_month.py - - .. admonition:: Example Response (observations per day) - :class: toggle - - .. literalinclude:: ../sample_data/get_observation_histogram_day.py - - Returns: - 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 = self.client.get(f'{API_V1_BASE_URL}/observations/histogram', params=params) return convert_histogram(response.json()) - @document_request_params([*docs._get_observations, docs._pagination]) + @copy_doc_signature(get_observation_identifiers) def identifiers(self, **params) -> Dict[int, User]: - """Get identifiers of observations matching the search criteria and the count of - observations they have identified. By default, results are sorted by ID count in descending. - - **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_identifiers - - Note: This endpoint will only return up to 500 results. - - Example: - >>> response = get_observation_identifiers(place_id=72645) - >>> print(format_users(response, align=True)) - [409010 ] jdoe42 (Jane Doe) - [691216 ] jbrown252 (James Brown) - [3959037 ] tnsparkleberry - - .. admonition:: Example Response - :class: toggle - - .. literalinclude:: ../sample_data/get_observation_identifiers_ex_results.json - :language: JSON - - Returns: - Response dict of identifiers - """ params.setdefault('per_page', 500) response = self.client.get(f'{API_V1_BASE_URL}/observations/identifiers', params=params) results = response.json()['results'] - return {r['count']: User.from_json(r['user']) for r in results} + @copy_doc_signature(get_observation_taxonomy, add_common_args=False) @add_paginate_all(method='page') - def life_list(self, user_id: IntOrStr, user_agent: str = None) -> LifeList: - """Get observation counts for all taxa in a full taxonomic tree. In the web UI, these are used - for life lists. - - Args: - user_id: iNaturalist user ID or username - - Example: - >>> response = get_observation_taxonomy(user_id='my_username') - ... - - .. admonition:: Example Response - :class: toggle - - .. literalinclude:: ../sample_data/get_observation_taxonomy.json - :language: JSON - - Returns: - Response dict containing taxon records with counts - """ + def life_list(self, user_id: IntOrStr, **params) -> LifeList: response = self.client.get( f'{API_V1_BASE_URL}/observations/taxonomy', params={'user_id': user_id}, - user_agent=user_agent, ) return LifeList.from_json(response.json()) # TODO: Separate model for these results? (maybe a User subclass) # TODO: Include species_counts - @document_request_params([*docs._get_observations, docs._pagination]) + @copy_doc_signature(get_observation_observers) def observers(self, **params) -> Dict[int, User]: - """Get observers of observations matching the search criteria and the count of - observations and distinct taxa of rank species they have observed. - - Notes: - * Options for ``order_by`` are 'observation_count' (default) or 'species_count' - * This endpoint will only return up to 500 results - * See this issue for more details: https://github.com/inaturalist/iNaturalistAPI/issues/235 - - **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_observers - - Example: - >>> response = get_observation_observers(place_id=72645, order_by='species_count') - >>> print(format_users(response, align=True)) - [1566366 ] fossa1211 - [674557 ] schurchin - [5813 ] fluffberger (Fluff Berger) - - - .. admonition:: Example Response - :class: toggle - - .. literalinclude:: ../sample_data/get_observation_observers_ex_results.json - :language: JSON - - Returns: - Response dict of observers - """ params.setdefault('per_page', 500) response = self.client.get(f'{API_V1_BASE_URL}/observations/observers', params=params) results = response.json()['results'] return {r['observation_count']: User.from_json(r['user']) for r in results} - @document_request_params([*docs._get_observations, docs._pagination]) + @copy_doc_signature(get_observation_species_counts) @add_paginate_all(method='page') def species_counts(self, **params) -> Dict[int, Taxon]: - """Get all species (or other 'leaf taxa') associated with observations matching the search - criteria, and the count of observations they are associated with. - **Leaf taxa** are the leaves of the taxonomic tree, e.g., species, subspecies, variety, etc. - - **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_species_counts - - Example: - >>> response = get_observation_species_counts(user_login='my_username', quality_grade='research') - >>> print(format_species_counts(response)) - [62060] Species: Palomena prasina (Green Shield Bug): 10 - [84804] Species: Graphosoma italicum (European Striped Shield Bug): 8 - [55727] Species: Cymbalaria muralis (Ivy-leaved toadflax): 3 - ... - - .. admonition:: Example Response - :class: toggle - - .. literalinclude:: ../sample_data/get_observation_species_counts.py - - Returns: - Response dict containing taxon records with counts - """ response = self.client.get(f'{API_V1_BASE_URL}/observations/species_counts', params=params) results = response.json()['results'] return {r['count']: Taxon.from_json(r['taxon']) for r in results} From e9c986dc013be64bed2bc1fa4b69dbafd302a2f2 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Mon, 26 Jul 2021 19:21:26 -0500 Subject: [PATCH 6/6] Refactor client and controller to remove duplicate code, and pass client settings to lower-level request functions --- pyinaturalist/client.py | 191 ++-------------------- pyinaturalist/controllers/__init__.py | 2 +- pyinaturalist/controllers/observation.py | 74 --------- pyinaturalist/controllers/observations.py | 54 ++++++ 4 files changed, 72 insertions(+), 249 deletions(-) delete mode 100644 pyinaturalist/controllers/observation.py create mode 100644 pyinaturalist/controllers/observations.py diff --git a/pyinaturalist/client.py b/pyinaturalist/client.py index 9a0dc3e3..9ff58e6b 100644 --- a/pyinaturalist/client.py +++ b/pyinaturalist/client.py @@ -1,47 +1,13 @@ -"""TODO: -Lots of details to figure out here. Should this use features from api_requests.py and other modules, -or the other way around? Are they better as static functions or instance methods? -Currently leaning towards the latter. - -Main features include: -* Caching -* Dry-run mode -* Pagination -* Rate-limiting -""" -from contextlib import contextmanager +# TODO: Use requests_cache.CachedSession by default? +# TODO: Create and refresh access tokens on demand (if using keyring)? from logging import getLogger -from typing import Dict, Optional, Tuple -from unittest.mock import Mock -from pyrate_limiter import Duration, Limiter, RequestRate -from requests import Response, Session +from pyrate_limiter import Limiter +from requests import Session from pyinaturalist import DEFAULT_USER_AGENT -from pyinaturalist.constants import ( - MAX_DELAY, - REQUESTS_PER_DAY, - REQUESTS_PER_MINUTE, - REQUESTS_PER_SECOND, - WRITE_HTTP_METHODS, - MultiInt, - RequestParams, -) +from pyinaturalist.api_requests import RATE_LIMITER from pyinaturalist.controllers import ObservationController -from pyinaturalist.docs import copy_signature -from pyinaturalist.request_params import prepare_request, preprocess_request_params, validate_ids - -# Mock response content to return in dry-run mode -MOCK_RESPONSE = Mock(spec=Response) -MOCK_RESPONSE.json.return_value = {'results': [], 'total_results': 0, 'access_token': ''} - -# Default rate-limiting settings -REQUEST_RATES = [ - RequestRate(REQUESTS_PER_SECOND, Duration.SECOND), - RequestRate(REQUESTS_PER_MINUTE, Duration.MINUTE), - RequestRate(REQUESTS_PER_DAY, Duration.DAY), -] -RATE_LIMITER = Limiter(*REQUEST_RATES) logger = getLogger(__name__) @@ -51,8 +17,7 @@ class iNatClient: 'iNatClient' is nonstandard casing, but 'InatClient' just looks wrong. Deal with it, pep8. Args: - dry_run: Mock and log all requests - dry_run_write_only: Mock and log POST, PUT, and DELETE requests + dry_run: Just log all requests instead of sending real requests limiter: Rate-limiting settings to use instead of the default session: Session object to use instead of creating a new one user_agent: User-Agent string to pass to API requests @@ -61,16 +26,14 @@ class iNatClient: def __init__( self, dry_run: bool = False, - dry_run_write_only: bool = False, limiter: Limiter = RATE_LIMITER, session: Session = None, user_agent: str = DEFAULT_USER_AGENT, ): - self.access_token = None # TODO: Create and refresh access tokens on demand (if using keyring) + self.access_token = None self.dry_run = dry_run - self.dry_run_write_only = dry_run_write_only self.limiter = limiter - self.session = session or Session() # TODO: Use requests_cache.CachedSession by default? + self.session = session or Session() self.user_agent = user_agent # Controllers @@ -78,132 +41,12 @@ def __init__( # self.taxa = TaxonController(self) # etc. - def _is_dry_run_enabled(self, method: str) -> bool: - """Determine if dry-run (aka test mode) has been enabled""" - return self.dry_run or (self.dry_run_write_only and method in WRITE_HTTP_METHODS) - - def prepare_request( - self, - url: str, - access_token: str = None, - ids: MultiInt = None, - params: RequestParams = None, - headers: Dict = None, - json: Dict = None, - ) -> Tuple[str, RequestParams, Dict, Optional[Dict]]: - """Translate some ``pyinaturalist``-specific params into standard request params, headers, - and body. This is made non-``requests``-specific so it could potentially be reused for - use with other HTTP clients. - - Returns: - Tuple of ``(URL, params, headers, body)`` - """ - # Prepare request params - params = preprocess_request_params(params) - json = preprocess_request_params(json) - - # Prepare user and authentication headers - headers = headers or {} - headers['Accept'] = 'application/json' - headers['User-Agent'] = params.pop('user_agent', self.user_agent) - if access_token: - headers['Authorization'] = f'Bearer {access_token}' - if json: - headers['Content-type'] = 'application/json' - - # If one or more resources are requested by ID, valudate and update the request URL accordingly - if ids: - url = url.rstrip('/') + '/' + validate_ids(ids) - return url, params, headers, json - - # TODO: Handle error 429 if we still somehow exceed the rate limit? - @contextmanager - def ratelimit(self, bucket: str = None): - """Add delays in between requests to stay within the rate limits""" - if self.limiter: - with self.limiter.ratelimit(bucket or self.user_agent, delay=True, max_delay=MAX_DELAY): - yield - else: - yield - - def request( - self, - method: str, - url: str, - access_token: str = None, - user_agent: str = None, - ids: MultiInt = None, - params: RequestParams = None, - headers: Dict = None, - json: Dict = None, - raise_for_status: bool = True, - **kwargs, - ) -> Response: - """Wrapper around :py:func:`requests.request` with additional options for iNat API requests - - 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 - json: JSON request body - kwargs: Additional keyword arguments for :py:meth:`requests.Session.request` - - Returns: - API response - """ - url, params, headers, json = prepare_request( - url, - access_token, - user_agent, - ids, - params, - headers, - json, - ) - - # Run either real request or mock request depending on settings - if self._is_dry_run_enabled(method): - log_request(method, url, params=params, headers=headers, **kwargs) - return MOCK_RESPONSE - else: - with self.ratelimit(): - response = self.session.request( - method, url, params=params, headers=headers, json=json, **kwargs - ) - if raise_for_status: - response.raise_for_status() - return response - - def delete(self, url: str, **kwargs) -> Response: - """Wrapper around :py:func:`requests.delete` with additional options for iNat API requests""" - return self.request('DELETE', url, **kwargs) - - def get(self, url: str, **kwargs) -> Response: - """Wrapper around :py:func:`requests.get` with additional options for iNat API requests""" - return self.request('GET', url, **kwargs) - - def post(self, url: str, **kwargs) -> Response: - """Wrapper around :py:func:`requests.post` with additional options for iNat API requests""" - return self.request('POST', url, **kwargs) - - def put(self, url: str, **kwargs) -> Response: - """Wrapper around :py:func:`requests.put` with additional options for iNat API requests""" - return self.request('PUT', url, **kwargs) - - -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))) - - -# Apply function signature changes after class definition -extend_request = copy_signature(iNatClient.request, exclude='method') -iNatClient.delete = extend_request(iNatClient.delete) # type: ignore -iNatClient.get = extend_request(iNatClient.get) # type: ignore -iNatClient.post = extend_request(iNatClient.post) # type: ignore -iNatClient.put = extend_request(iNatClient.put) # type: ignore + @property + def settings(self): + """Get client settings to pass to an API request""" + return { + 'dry_run': self.dry_run, + 'limiter': self.limiter, + 'session': self.session, + 'user_agent': self.user_agent, + } diff --git a/pyinaturalist/controllers/__init__.py b/pyinaturalist/controllers/__init__.py index cc8522ae..1f66214c 100644 --- a/pyinaturalist/controllers/__init__.py +++ b/pyinaturalist/controllers/__init__.py @@ -1,3 +1,3 @@ # flake8: noqa: F401 from pyinaturalist.controllers.base import BaseController -from pyinaturalist.controllers.observation import ObservationController +from pyinaturalist.controllers.observations import ObservationController diff --git a/pyinaturalist/controllers/observation.py b/pyinaturalist/controllers/observation.py deleted file mode 100644 index 029674d5..00000000 --- a/pyinaturalist/controllers/observation.py +++ /dev/null @@ -1,74 +0,0 @@ -# TODO: Just copying code from original API functions for now; will figure out the best means of code reuse later -# TODO: Update examples and example responses -# TODO: Don't override return signature -from typing import Dict, List - -from pyinaturalist.constants import ( - API_V1_BASE_URL, - NODE_OBS_ORDER_BY_PROPERTIES, - HistogramResponse, - IntOrStr, -) -from pyinaturalist.controllers import BaseController -from pyinaturalist.converters import convert_histogram -from pyinaturalist.docs import copy_doc_signature -from pyinaturalist.models import LifeList, Observation, Taxon, User -from pyinaturalist.pagination import add_paginate_all -from pyinaturalist.request_params import validate_multiple_choice_param -from pyinaturalist.v1 import ( - get_observation_histogram, - get_observation_identifiers, - get_observation_observers, - get_observation_species_counts, - get_observation_taxonomy, - get_observations, -) - - -class ObservationController(BaseController): - """Controller for observation requests""" - - @copy_doc_signature(get_observations) - @add_paginate_all(method='id') - def search(self, **params) -> List[Observation]: - validate_multiple_choice_param(params, 'order_by', NODE_OBS_ORDER_BY_PROPERTIES) - response = self.client.get(f'{API_V1_BASE_URL}/observations', params=params) - return Observation.from_json_list(response.json()) - - # TODO: Does this need a model with utility functions, or is {datetime: count} sufficient? - @copy_doc_signature(get_observation_histogram) - def histogram(self, **params) -> HistogramResponse: - response = self.client.get(f'{API_V1_BASE_URL}/observations/histogram', params=params) - return convert_histogram(response.json()) - - @copy_doc_signature(get_observation_identifiers) - def identifiers(self, **params) -> Dict[int, User]: - params.setdefault('per_page', 500) - response = self.client.get(f'{API_V1_BASE_URL}/observations/identifiers', params=params) - results = response.json()['results'] - return {r['count']: User.from_json(r['user']) for r in results} - - @copy_doc_signature(get_observation_taxonomy, add_common_args=False) - @add_paginate_all(method='page') - def life_list(self, user_id: IntOrStr, **params) -> LifeList: - response = self.client.get( - f'{API_V1_BASE_URL}/observations/taxonomy', - params={'user_id': user_id}, - ) - return LifeList.from_json(response.json()) - - # TODO: Separate model for these results? (maybe a User subclass) - # TODO: Include species_counts - @copy_doc_signature(get_observation_observers) - def observers(self, **params) -> Dict[int, User]: - params.setdefault('per_page', 500) - response = self.client.get(f'{API_V1_BASE_URL}/observations/observers', params=params) - results = response.json()['results'] - return {r['observation_count']: User.from_json(r['user']) for r in results} - - @copy_doc_signature(get_observation_species_counts) - @add_paginate_all(method='page') - def species_counts(self, **params) -> Dict[int, Taxon]: - response = self.client.get(f'{API_V1_BASE_URL}/observations/species_counts', params=params) - results = response.json()['results'] - return {r['count']: Taxon.from_json(r['taxon']) for r in results} diff --git a/pyinaturalist/controllers/observations.py b/pyinaturalist/controllers/observations.py new file mode 100644 index 00000000..848cb21b --- /dev/null +++ b/pyinaturalist/controllers/observations.py @@ -0,0 +1,54 @@ +# TODO: Update examples and example responses +# TODO: Don't override return signature +from typing import Dict, List + +from pyinaturalist.constants import HistogramResponse +from pyinaturalist.controllers import BaseController +from pyinaturalist.docs import copy_doc_signature +from pyinaturalist.models import LifeList, Observation, Taxon, User +from pyinaturalist.models.taxon import TaxonCounts +from pyinaturalist.v1 import ( + get_observation_histogram, + get_observation_identifiers, + get_observation_observers, + get_observation_species_counts, + get_observation_taxonomy, + get_observations, +) + + +# TODO: Fix type checking for return types +class ObservationController(BaseController): + """Controller for observation requests""" + + @copy_doc_signature(get_observations) + def search(self, **params) -> List[Observation]: + response = get_observations(**params, **self.client.settings) + return Observation.from_json_list(response) # type: ignore + + # TODO: Does this need a model with utility functions, or is {datetime: count} sufficient? + @copy_doc_signature(get_observation_histogram) + def histogram(self, **params) -> HistogramResponse: + return get_observation_histogram(**params, **self.client.settings) + + @copy_doc_signature(get_observation_identifiers) + def identifiers(self, **params) -> Dict[int, User]: + response = get_observation_identifiers(**params, **self.client.settings) + return {r['count']: User.from_json(r['user']) for r in response['results']} # type: ignore + + @copy_doc_signature(get_observation_taxonomy, add_common_args=False) + def life_list(self, *args, **params) -> LifeList: + response = get_observation_taxonomy(*args, **params, **self.client.settings) + return LifeList.from_json(response.json()) # type: ignore + + # TODO: Separate model for these results? (maybe a User subclass) + # TODO: Include species_counts + @copy_doc_signature(get_observation_observers) + def observers(self, **params) -> Dict[int, User]: + response = get_observation_observers(**params, **self.client.settings) + return {r['count']: User.from_json(r['user']) for r in response['results']} # type: ignore + + @copy_doc_signature(get_observation_species_counts) + def species_counts(self, **params) -> Dict[int, Taxon]: + response = get_observation_species_counts(**params, **self.client.settings) + return TaxonCounts.from_json(response) # type: ignore