diff --git a/HISTORY.md b/HISTORY.md index 564eba7f..55d59ecb 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -16,12 +16,6 @@ Add support for searching observations by observation fields, using a new `obser * `get_observation_species_counts()` ### Models -**Taxon:** -* Add `make_tree()` function to build a tree from `Taxon` objects or a `LifeList` -* Add `pprint_tree()` function to print a taxon tree on the console -* Add `Taxon.flatten()` method to return a taxon and its descendants as a flat list -* Fix initialization of `ListedTaxon.place` - **Observation:** * Add `Observation.ident_taxon_ids` dynamic property to get all identification taxon IDs (with ancestors) * Add `Observation.cumulative_ids` dynamic property to calculate agreements/total community identifications @@ -32,6 +26,16 @@ Add support for searching observations by observation fields, using a new `obser * Add `Sound` model for `Observation.sounds` * Add `Vote` model for `Observation.votes` +**Project:** +* Add `Project.last_post_at` datetime attribute +* Add `Project.observation_requirements_updated_at` datetime attribute + +**Taxon:** +* Add `make_tree()` function to build a tree from `Taxon` objects or a `LifeList` +* Add `pprint_tree()` function to print a taxon tree on the console +* Add `Taxon.flatten()` method to return a taxon and its descendants as a flat list +* Fix initialization of `ListedTaxon.place` + ### Other changes * Added support for python 3.12 diff --git a/pyinaturalist/client.py b/pyinaturalist/client.py index 4ad4e77d..b88df360 100644 --- a/pyinaturalist/client.py +++ b/pyinaturalist/client.py @@ -13,6 +13,7 @@ ObservationController, PlaceController, ProjectController, + SearchController, TaxonController, UserController, ) @@ -37,12 +38,13 @@ class iNatClient: Controllers: - * :py:class:`annotations <.AnnotationController>` - * :py:class:`observations <.ObservationController>` - * :py:class:`places <.PlaceController>` - * :py:class:`projects <.ProjectController>` - * :py:class:`taxa <.TaxonController>` - * :py:class:`users <.UserController>` + * :fa:`tag` :py:class:`annotations <.AnnotationController>` + * :fa:`binoculars` :py:class:`observations <.ObservationController>` + * :fa:`location-dot` :py:class:`places <.PlaceController>` + * :fa:`users` :py:class:`projects <.ProjectController>` + * :fa:`search` :py:class:`search <.SearchController>` + * :fa:`dove` :py:class:`taxa <.TaxonController>` + * :fa:`user` :py:class:`users <.UserController>` Args: creds: Optional arguments for :py:func:`.get_access_token`, used to get and refresh access @@ -85,6 +87,9 @@ def __init__( self.projects = ProjectController( self ) #: Interface for :py:class:`project requests <.ProjectController>` + self.search = SearchController( + self + ) #: Unified :py:meth:`text search <.SearchController.__call__>` self.taxa = TaxonController( self ) #: Interface for :py:class:`taxon requests <.TaxonController>` diff --git a/pyinaturalist/controllers/__init__.py b/pyinaturalist/controllers/__init__.py index 5756f4a0..150808f5 100644 --- a/pyinaturalist/controllers/__init__.py +++ b/pyinaturalist/controllers/__init__.py @@ -10,3 +10,4 @@ from pyinaturalist.controllers.project_controller import ProjectController from pyinaturalist.controllers.taxon_controller import TaxonController from pyinaturalist.controllers.user_controller import UserController +from pyinaturalist.controllers.search_controller import SearchController diff --git a/pyinaturalist/controllers/search_controller.py b/pyinaturalist/controllers/search_controller.py new file mode 100644 index 00000000..a7663f18 --- /dev/null +++ b/pyinaturalist/controllers/search_controller.py @@ -0,0 +1,55 @@ +from typing import List, Optional + +from pyinaturalist.constants import MultiInt, MultiStr +from pyinaturalist.controllers import BaseController +from pyinaturalist.models import SearchResult +from pyinaturalist.v1 import search + + +class SearchController(BaseController): + """:fa:`search` Unified text search""" + + def __call__( + self, + q: str, + sources: Optional[MultiStr] = None, + place_id: Optional[MultiInt] = None, + locale: Optional[str] = None, + preferred_place_id: Optional[int] = None, + **params, + ) -> List[SearchResult]: + """A unified text search endpoint for places, projects, taxa, and/or users + + .. rubric:: Notes + + * API reference: :v1:`GET /search ` + + Example: + >>> response = client.search(q='odonat') + >>> pprint(response) + ID Type Score Name + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 47792 Taxon 9.45 Order Odonata (Dragonflies And Damselflies) + 113562 Place 7.70 Odonates of Peninsular India and Sri Lanka + 9978 Project 7.27 Ohio Dragonfly Survey (Ohio Odonata Survey) + 5665218 User 6.10 odonatachr + + Args: + q: Search query + sources: Object types to search + place_id: Results must be associated with this place + locale: Locale preference for taxon common names + preferred_place_id: Place preference for regional taxon common names + + Returns: + Response dict containing search results + """ + response = search( + q, + sources=sources, + place_id=place_id, + locale=locale, + preferred_place_id=preferred_place_id, + **params, + ) + return SearchResult.from_json_list(response) diff --git a/pyinaturalist/models/project.py b/pyinaturalist/models/project.py index dda75a23..c2fcba10 100644 --- a/pyinaturalist/models/project.py +++ b/pyinaturalist/models/project.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Dict, List +from typing import Dict, List, Optional from pyinaturalist.constants import ( INAT_BASE_URL, @@ -72,7 +72,7 @@ class ProjectUser(User): @classmethod def from_json(cls, value: JsonResponse, **kwargs) -> 'ProjectUser': """Flatten out nested values""" - user = value['user'] + user = value.get('user', {}) user['project_id'] = value['project_id'] user['project_user_id'] = value['id'] user['role'] = value['role'] @@ -99,7 +99,13 @@ class Project(BaseModel): default=None, doc='Indicates if this is an umbrella project (containing observations from other projects)', ) + last_post_at: Optional[datetime] = datetime_field( + doc='Date and time of the last project journal post' + ) location: Coordinates = coordinate_pair() + observation_requirements_updated_at: Optional[datetime] = datetime_field( + doc='Date and time of last update for observation requirements' + ) place_id: int = field(default=None, doc='Project place ID') prefers_user_trust: bool = field( default=None, diff --git a/test/controllers/test_search_controller.py b/test/controllers/test_search_controller.py new file mode 100644 index 00000000..84c6e675 --- /dev/null +++ b/test/controllers/test_search_controller.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from pyinaturalist.client import iNatClient +from pyinaturalist.constants import API_V1 +from pyinaturalist.models import Place, Project, Taxon, User +from test.sample_data import SAMPLE_DATA + + +def test_search(requests_mock): + """Simulate /search results with one of each record type""" + requests_mock.get( + f'{API_V1}/search', + json=SAMPLE_DATA['get_search'], + status_code=200, + ) + + results = iNatClient().search([8348, 6432]) + assert len(results) == 4 + assert all(isinstance(result.score, float) for result in results) + + taxon_result = results[0] + place_result = results[1] + project_result = results[2] + user_result = results[3] + + # Test value conversions + assert taxon_result.type == 'Taxon' + assert isinstance(taxon_result.record, Taxon) + assert isinstance(taxon_result.record.created_at, datetime) + assert taxon_result.record_name == 'Order Odonata (Dragonflies And Damselflies)' + + assert place_result.type == 'Place' + assert isinstance(place_result.record, Place) + assert isinstance(place_result.record.location[0], float) + assert isinstance(place_result.record.location[1], float) + assert place_result.record_name == 'Odonates of Peninsular India and Sri Lanka' + + assert project_result.type == 'Project' + assert isinstance(project_result.record, Project) + assert isinstance(project_result.record.last_post_at, datetime) + assert project_result.record_name == 'Ohio Dragonfly Survey (Ohio Odonata Survey)' + + assert user_result.type == 'User' + assert isinstance(user_result.record, User) + assert isinstance(user_result.record.created_at, datetime) + assert user_result.record_name == 'odonatanb'