From be310c29f305ce93a5134e6d9ef5e851d6daa07f Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Sat, 15 Jul 2023 11:30:41 -0500 Subject: [PATCH 1/3] Update get_observation_taxonomy() with full request param docs --- pyinaturalist/docs/templates.py | 2 +- pyinaturalist/v1/observations.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pyinaturalist/docs/templates.py b/pyinaturalist/docs/templates.py index 5a0d184e..108e0796 100644 --- a/pyinaturalist/docs/templates.py +++ b/pyinaturalist/docs/templates.py @@ -677,7 +677,7 @@ def _observation_id(observation_id: int): """ -def _project_id(observation_id: Optional[int] = None): +def _project_id(project_id: Optional[int] = None): """Args: project_id: Only show users who are members of this project """ diff --git a/pyinaturalist/v1/observations.py b/pyinaturalist/v1/observations.py index 0334d177..e1961d74 100644 --- a/pyinaturalist/v1/observations.py +++ b/pyinaturalist/v1/observations.py @@ -7,7 +7,6 @@ API_V1, V1_OBS_ORDER_BY_PROPERTIES, HistogramResponse, - IntOrStr, JsonResponse, ListResponse, MultiFile, @@ -284,6 +283,7 @@ def get_observation_species_counts(**params) -> JsonResponse: @document_request_params(*docs._get_observations) def get_observation_popular_field_values(**params) -> JsonResponse: """Get controlled terms values and a monthly histogram of observations matching the search + criteria. .. rubric:: Notes @@ -309,12 +309,15 @@ def get_observation_popular_field_values(**params) -> JsonResponse: return response_json -def get_observation_taxonomy(user_id: Optional[IntOrStr] = None, **params) -> JsonResponse: - """Get observation counts for all taxa in a full taxonomic tree. In the web UI, these are used - for life lists. +@document_request_params(*docs._get_observations) +def get_observation_taxonomy(**params) -> JsonResponse: + """Get observation counts for all taxa in observations matching the search criteria. - Args: - user_id: iNaturalist user ID or username + .. rubric:: Notes + + * Undocumented in API reference + * On iNaturalist.org, this is mainly used for dynamic life lists + * Results are returned in a flat list, but are ordered as they would be in a taxonomic tree Example: >>> response = get_observation_taxonomy(user_id='my_username') @@ -328,10 +331,7 @@ def get_observation_taxonomy(user_id: Optional[IntOrStr] = None, **params) -> Js Returns: Response dict containing taxon records with counts """ - if params.get('page') == 'all': - return paginate_all(get, f'{API_V1}/observations/taxonomy', user_id=user_id, **params) - else: - return get(f'{API_V1}/observations/taxonomy', user_id=user_id, **params).json() + return get(f'{API_V1}/observations/taxonomy', **params).json() @document_common_args From 7a296227ac5d0150eece074898e2ef5d30f2caa8 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Sat, 15 Jul 2023 11:52:37 -0500 Subject: [PATCH 2/3] Add /taxa/lifelist_metadata endpoint --- docs/endpoints.md | 111 ++++++++++---------- pyinaturalist/v1/__init__.py | 1 + pyinaturalist/v1/taxa.py | 33 +++++- test/sample_data.py | 1 + test/sample_data/get_lifelist_metadata.json | 82 +++++++++++++++ test/v1/test_taxa.py | 25 ++++- 6 files changed, 196 insertions(+), 57 deletions(-) create mode 100644 test/sample_data/get_lifelist_metadata.json diff --git a/docs/endpoints.md b/docs/endpoints.md index 2567b592..fe4ac0cf 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -1,79 +1,80 @@ (endpoints)= # {fa}`table` Endpoint Summary -Below is a list of iNaturalist API endpoints that have either been added or may be added in the -future, along with their corresponding functions in pyinaturalist. +Below is a list of iNaturalist API endpoints that have either been added or are likely be added in +the future, along with their corresponding functions in pyinaturalist. ## v1 API For all available endpoints, see: -| Method | Endpoint | Function -| ------ | -------- | -------- +| Method | Endpoint | Function | +| ------ | ---------------------------------- | ------------------------------------------------------------ | | POST | /annotations | | DELETE | /annotations/{id} | | POST | /comments | | DELETE | /comments/{id} | | PUT | /comments/{id} | -| GET | /controlled_terms | {py:func}`.get_controlled_terms` -| GET | /controlled_terms/for_taxon | {py:func}`.get_controlled_terms` -| GET | /identifications | {py:func}`.get_identifications` -| GET | /identifications/{id} | {py:func}`.get_identifications_by_id` +| GET | /controlled_terms | {py:func}`.get_controlled_terms` | +| GET | /controlled_terms/for_taxon | {py:func}`.get_controlled_terms` | +| GET | /identifications | {py:func}`.get_identifications` | +| GET | /identifications/{id} | {py:func}`.get_identifications_by_id` | | GET | /identifications/species_counts | | GET | /identifications/identifiers | | GET | /identifications/observers | | GET | /identifications/similar_species | -| GET | /messages | {py:func}`.get_messages` -| GET | /messages/{id} | {py:func}`.get_message_by_id` -| GET | /messages/unread | {py:func}`.get_unread_meassage_count` -| DELETE | /observation_field_values/{id} | {py:func}`.delete_observation_field` -| PUT | /observation_field_values/{id} | {py:func}`.set_observation_field` -| POST | /observation_field_values | {py:func}`.set_observation_field` -| POST | /observation_photos | {py:func}`.upload` -| POST | /observation_sounds | {py:func}`.upload` -| DELETE | /observations/{id} | {py:func}`~pyinaturalist.v1.observations.delete_observation` -| GET | /observations/{id} | {py:func}`.get_observations_by_id` -| PUT | /observations/{id} | {py:func}`~pyinaturalist.v1.observations.update_observation` -| GET | /observations/{id}/taxon_summary | {py:func}`.get_observation_taxon_summary` -| GET | /observations | {py:func}`~pyinaturalist.v1.observations.get_observations` -| POST | /observations | {py:func}`~pyinaturalist.v1.observations.create_observation` -| GET | /observations/histogram | {py:func}`.get_observation_histogram` -| GET | /observations/identifiers | {py:func}`.get_observation_identifiers` -| GET | /observations/observers | {py:func}`.get_observation_observers` -| GET | /observations/popular_field_values | {py:func}`.get_observation_popular_field_values` -| GET | /observations/species_counts | {py:func}`.get_observation_species_counts` -| GET | /observations/taxonomy | {py:func}`.get_observation_taxonomy` -| GET | /places/{id} | {py:func}`.get_places_by_id` -| GET | /places/autocomplete | {py:func}`.get_places_autocomplete` -| GET | /places/nearby | {py:func}`.get_places_nearby` -| GET | /posts | {py:func}`.get_posts` -| GET | /projects | {py:func}`.get_projects` -| GET | /projects/{id} | {py:func}`.get_projects_by_id` -| PUT | /projects/{id} | {py:func}`.update_project` +| GET | /messages | {py:func}`.get_messages` | +| GET | /messages/{id} | {py:func}`.get_message_by_id` | +| GET | /messages/unread | {py:func}`.get_unread_meassage_count` | +| DELETE | /observation_field_values/{id} | {py:func}`.delete_observation_field` | +| PUT | /observation_field_values/{id} | {py:func}`.set_observation_field` | +| POST | /observation_field_values | {py:func}`.set_observation_field` | +| POST | /observation_photos | {py:func}`.upload` | +| POST | /observation_sounds | {py:func}`.upload` | +| DELETE | /observations/{id} | {py:func}`~pyinaturalist.v1.observations.delete_observation` | +| GET | /observations/{id} | {py:func}`.get_observations_by_id` | +| PUT | /observations/{id} | {py:func}`~pyinaturalist.v1.observations.update_observation` | +| GET | /observations/{id}/taxon_summary | {py:func}`.get_observation_taxon_summary` | +| GET | /observations | {py:func}`~pyinaturalist.v1.observations.get_observations` | +| POST | /observations | {py:func}`~pyinaturalist.v1.observations.create_observation` | +| GET | /observations/histogram | {py:func}`.get_observation_histogram` | +| GET | /observations/identifiers | {py:func}`.get_observation_identifiers` | +| GET | /observations/observers | {py:func}`.get_observation_observers` | +| GET | /observations/popular_field_values | {py:func}`.get_observation_popular_field_values` | +| GET | /observations/species_counts | {py:func}`.get_observation_species_counts` | +| GET | /observations/taxonomy | {py:func}`.get_observation_taxonomy` | +| GET | /places/{id} | {py:func}`.get_places_by_id` | +| GET | /places/autocomplete | {py:func}`.get_places_autocomplete` | +| GET | /places/nearby | {py:func}`.get_places_nearby` | +| GET | /posts | {py:func}`.get_posts` | +| GET | /projects | {py:func}`.get_projects` | +| GET | /projects/{id} | {py:func}`.get_projects_by_id` | +| PUT | /projects/{id} | {py:func}`.update_project` | | GET | /projects/{id}/members | | GET | /projects/{id}/subscriptions | -| POST | /projects/{id}/add | {py:func}`.add_project_observation` -| DELETE | /projects/{id}/remove | {py:func}`.delete_project_observation` +| POST | /projects/{id}/add | {py:func}`.add_project_observation` | +| DELETE | /projects/{id}/remove | {py:func}`.delete_project_observation` | | GET | /projects/autocomplete | -| GET | /search | {py:func}`.search` -| GET | /taxa | {py:func}`.get_taxa` -| GET | /taxa/{id} | {py:func}`.get_taxa_by_id` -| GET | /taxa/{id}/map_layers | {py:func}`.get_taxa_map_layers` -| GET | /taxa/autocomplete | {py:func}`.get_taxa_autocomplete` -| GET | /users/{id} | {py:func}`.get_user_by_id` +| GET | /search | {py:func}`.search` | +| GET | /taxa | {py:func}`.get_taxa` | +| GET | /taxa/{id} | {py:func}`.get_taxa_by_id` | +| GET | /taxa/{id}/map_layers | {py:func}`.get_taxa_map_layers` | +| GET | /taxa/autocomplete | {py:func}`.get_taxa_autocomplete` | +| GET | /taxa/lifelist_metadata | {py:func}`.get_lifelist_metadata` | +| GET | /users/{id} | {py:func}`.get_user_by_id` | | GET | /users/{id}/projects | -| GET | /users/autocomplete | {py:func}`.get_users_autocomplete` -| GET | /users/me | {py:func}`.get_current_user` +| GET | /users/autocomplete | {py:func}`.get_users_autocomplete` | +| GET | /users/me | {py:func}`.get_current_user` | ## v0 API For all available endpoints, see: -| Method | Endpoint | Function -| ------ | -------- | -------- -| GET | /observations | {py:func}`~pyinaturalist.v0.observations.get_observations` -| POST | /observations | {py:func}`~pyinaturalist.v0.observations.create_observation` -| PUT | /observations/{id} | {py:func}`~pyinaturalist.v0.observations.update_observation` -| DELETE | /observations/{id} | {py:func}`~pyinaturalist.v0.observations.delete_observation` -| GET | /observation_fields | {py:func}`.get_observation_fields` -| PUT | /observation_field_values/{id} | {py:func}`.put_observation_field_values` -| POST | /observation_photos | {py:func}`.upload_photos` -| POST | /observation_sounds | {py:func}`.upload_sounds` +| Method | Endpoint | Function | +| ------ | ------------------------------ | ------------------------------------------------------------ | +| GET | /observations | {py:func}`~pyinaturalist.v0.observations.get_observations` | +| POST | /observations | {py:func}`~pyinaturalist.v0.observations.create_observation` | +| PUT | /observations/{id} | {py:func}`~pyinaturalist.v0.observations.update_observation` | +| DELETE | /observations/{id} | {py:func}`~pyinaturalist.v0.observations.delete_observation` | +| GET | /observation_fields | {py:func}`.get_observation_fields` | +| PUT | /observation_field_values/{id} | {py:func}`.put_observation_field_values` | +| POST | /observation_photos | {py:func}`.upload_photos` | +| POST | /observation_sounds | {py:func}`.upload_sounds` | diff --git a/pyinaturalist/v1/__init__.py b/pyinaturalist/v1/__init__.py index cdb468f8..79663bb5 100644 --- a/pyinaturalist/v1/__init__.py +++ b/pyinaturalist/v1/__init__.py @@ -38,6 +38,7 @@ ) from pyinaturalist.v1.search import search from pyinaturalist.v1.taxa import ( + get_life_list_metadata, get_taxa, get_taxa_autocomplete, get_taxa_by_id, diff --git a/pyinaturalist/v1/taxa.py b/pyinaturalist/v1/taxa.py index 9a57d791..a88ce366 100644 --- a/pyinaturalist/v1/taxa.py +++ b/pyinaturalist/v1/taxa.py @@ -1,6 +1,6 @@ from typing import Optional -from pyinaturalist.constants import API_V1, JsonResponse, MultiInt +from pyinaturalist.constants import API_V1, IntOrStr, JsonResponse, MultiInt from pyinaturalist.converters import convert_all_timestamps from pyinaturalist.docs import document_request_params from pyinaturalist.docs import templates as docs @@ -166,3 +166,34 @@ def get_taxa_map_layers(taxon_id: int, **params) -> JsonResponse: response = get(f'{API_V1}/taxa/{taxon_id}/map_layers', **params).json() response['gbif_url'] = f'https://www.gbif.org/species/{response["gbif_id"]}' return response + + +def get_life_list_metadata( + user_id: IntOrStr, locale: Optional[str] = None, **params +) -> JsonResponse: + """Get common names and default taxon photos for a user's life list + + .. rubric:: Notes + + * Undocumented in API reference + * On iNaturalist.org, this is used for dynamic life lists + + Args: + user_id: iNaturalist user ID or username + locale: Locale preference for taxon common names + + Example: + >>> response = get_life_list_metadata(user_id='my_username') + + .. admonition:: Example Response + :class: toggle + + .. literalinclude:: ../sample_data/get_lifelist_metadata.json + :language: JSON + + Returns: + Response dict containing taxon common names and photo URLs + """ + return get( + f'{API_V1}/taxa/lifelist_metadata', observed_by_user_id=user_id, locale=locale, **params + ).json() diff --git a/test/sample_data.py b/test/sample_data.py index 835c91ae..3eece571 100644 --- a/test/sample_data.py +++ b/test/sample_data.py @@ -64,6 +64,7 @@ def load_all_sample_data() -> Dict[str, Dict]: j_identification_3 = j_observation_2['identifications'][0] j_life_list_1 = SAMPLE_DATA['get_observation_taxonomy'] j_life_list_2 = SAMPLE_DATA['get_observation_taxonomy_by_genus'] +j_life_list_metadata = SAMPLE_DATA['get_lifelist_metadata'] j_listed_taxon_1 = j_taxon_summary_2_listed['listed_taxon'] j_listed_taxon_2_partial = j_taxon_1['listed_taxa'][0] j_message = SAMPLE_DATA['get_messages']['results'][0] diff --git a/test/sample_data/get_lifelist_metadata.json b/test/sample_data/get_lifelist_metadata.json new file mode 100644 index 00000000..02dd412e --- /dev/null +++ b/test/sample_data/get_lifelist_metadata.json @@ -0,0 +1,82 @@ +{ + "total_results": 10, + "results": [ + { + "name": "Life", + "id": 48460, + "default_photo": { + "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/196425367/medium.jpeg" + } + }, + { + "name": "Animalia", + "id": 1, + "default_photo": { + "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/80678745/medium.jpg" + }, + "preferred_common_name": "Animals" + }, + { + "name": "Chordata", + "id": 2, + "default_photo": { + "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/80551845/medium.jpg" + }, + "preferred_common_name": "Chordates" + }, + { + "name": "Aves", + "id": 3, + "default_photo": { + "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/2967053/medium.jpg" + }, + "preferred_common_name": "Birds" + }, + { + "name": "Galliformes", + "id": 573, + "default_photo": { + "medium_url": "https://static.inaturalist.org/photos/101730709/medium.jpeg" + }, + "preferred_common_name": "Landfowl" + }, + { + "name": "Phasianidae", + "id": 574, + "default_photo": { + "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/53053041/medium.jpg" + }, + "preferred_common_name": "Pheasants, Grouse, and Allies" + }, + { + "name": "Bonasa", + "id": 889, + "default_photo": { + "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/2871/medium.jpg" + } + }, + { + "name": "Bonasa umbellus", + "id": 890, + "default_photo": { + "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/171717411/medium.jpeg" + }, + "preferred_common_name": "Ruffed Grouse" + }, + { + "name": "Phasianus", + "id": 980, + "default_photo": { + "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/4735/medium.jpg" + } + }, + { + "name": "Phasianus colchicus", + "id": 981, + "default_photo": { + "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/176733796/medium.jpg" + }, + "preferred_common_name": "Ring-necked Pheasant" + } + ] +} diff --git a/test/v1/test_taxa.py b/test/v1/test_taxa.py index 24f9b69b..793a34f4 100644 --- a/test/v1/test_taxa.py +++ b/test/v1/test_taxa.py @@ -4,8 +4,15 @@ import pytest from pyinaturalist.constants import API_V1 -from pyinaturalist.v1 import get_taxa, get_taxa_autocomplete, get_taxa_by_id, get_taxa_map_layers +from pyinaturalist.v1 import ( + get_life_list_metadata, + get_taxa, + get_taxa_autocomplete, + get_taxa_by_id, + get_taxa_map_layers, +) from test.conftest import load_sample_data +from test.sample_data import j_life_list_metadata CLASS_AND_HIGHER = ['class', 'superclass', 'subphylum', 'phylum', 'kingdom'] SPECIES_AND_LOWER = ['infrahybrid', 'form', 'variety', 'subspecies', 'hybrid', 'species'] @@ -109,3 +116,19 @@ def test_get_taxa_map_layers(requests_mock): assert response['gbif_url'] == 'https://www.gbif.org/species/2820380' assert response['ranges'] is False assert response['listed_places'] is True + + +def test_get_lifelist_metadata(requests_mock): + requests_mock.get( + f'{API_V1}/taxa/lifelist_metadata', + json=j_life_list_metadata, + status_code=200, + ) + + response = get_life_list_metadata(user_id=545640) + t = response['results'][1] + assert t['name'] == 'Animalia' + assert t['preferred_common_name'] == 'Animals' + assert t['default_photo']['medium_url'].startswith( + 'https://inaturalist-open-data.s3.amazonaws.com' + ) From 5bad51de81eb888a33e90238870f497d2f1d4cad Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Sat, 15 Jul 2023 21:44:20 -0500 Subject: [PATCH 3/3] Integrate lifelist_metadata into ObservationController.life_list() --- .github/workflows/build.yml | 1 + .../controllers/observation_controller.py | 23 ++++++++++- .../test_observation_controller.py | 38 ++++++++++++++++--- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e0aafacc..8324f03f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,7 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] diff --git a/pyinaturalist/controllers/observation_controller.py b/pyinaturalist/controllers/observation_controller.py index 3fdcef9c..fb6b4920 100644 --- a/pyinaturalist/controllers/observation_controller.py +++ b/pyinaturalist/controllers/observation_controller.py @@ -25,6 +25,7 @@ from pyinaturalist.v1 import ( create_observation, delete_observation, + get_life_list_metadata, get_observation_histogram, get_observation_identifiers, get_observation_observers, @@ -89,9 +90,27 @@ def identifiers(self, **params) -> UserCounts: response = self.client.request(get_observation_identifiers, **params) return UserCounts.from_json(response) - @document_controller_params(get_observation_taxonomy, add_common_args=False) - def life_list(self, user_id: IntOrStr, **params) -> LifeList: + @document_controller_params(get_observation_taxonomy) + def life_list( + self, user_id: Optional[IntOrStr] = None, locale: Optional[str] = None, **params + ) -> LifeList: + """Get taxa from a user's dynamic life list + + Args: + user_id: iNaturalist user ID or username + locale: Locale preference for taxon common names + """ response = self.client.request(get_observation_taxonomy, user_id=user_id, **params) + + # Add additional metadata to a life list response; requires a user ID + if user_id: + metadata = self.client.request( + get_life_list_metadata, user_id=user_id, locale=locale, **params + ) + meta_by_id = {item['id']: item for item in metadata['results']} + for taxon in response['results']: + taxon.update(meta_by_id.get(taxon['id'], {})) + return LifeList.from_json(response) @document_controller_params(get_observation_observers) diff --git a/test/controllers/test_observation_controller.py b/test/controllers/test_observation_controller.py index c30ff926..9dbfefdc 100644 --- a/test/controllers/test_observation_controller.py +++ b/test/controllers/test_observation_controller.py @@ -176,16 +176,42 @@ def test_life_list(requests_mock): status_code=200, ) client = iNatClient() - results = client.observations.life_list(user_id=545640, taxon_id=52775) + results = client.observations.life_list(taxon_id=52775) assert isinstance(results, LifeList) assert len(results) == 31 - bombus = results[8] - assert bombus.id == 52775 - assert bombus.name == 'Bombus' - assert bombus.count == 4 - assert bombus.descendant_obs_count == results.get_count(52775) == 154 + t = results[8] + assert t.id == 52775 + assert t.name == 'Bombus' + assert t.count == 4 + assert t.descendant_obs_count == results.get_count(52775) == 154 + + +def test_life_list__with_user_id(requests_mock): + """With user_id, the results should include extra metadata""" + requests_mock.get( + f'{API_V1}/observations/taxonomy', + json=j_life_list_1, + status_code=200, + ) + requests_mock.get( + f'{API_V1}/taxa/lifelist_metadata', + json=j_life_list_metadata, + status_code=200, + ) + client = iNatClient() + results = client.observations.life_list(user_id=545640, taxon_id=574) + + assert isinstance(results, LifeList) + assert len(results) == 10 + + t = results[4] + assert t.id == 573 + assert t.name == 'Galliformes' + assert t.preferred_common_name == 'Landfowl' + assert t.count == 0 + assert t.descendant_obs_count == results.get_count(573) == 4 def test_popular_fields(requests_mock):