Skip to content

Commit

Permalink
Merge pull request #530 from pyinat/search-controller
Browse files Browse the repository at this point in the history
Add search controller / iNatClient.search()
  • Loading branch information
JWCook authored Dec 12, 2023
2 parents 769114f + 9e3cd01 commit fb23bf1
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 14 deletions.
16 changes: 10 additions & 6 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
17 changes: 11 additions & 6 deletions pyinaturalist/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ObservationController,
PlaceController,
ProjectController,
SearchController,
TaxonController,
UserController,
)
Expand All @@ -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
Expand Down Expand Up @@ -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>`
Expand Down
1 change: 1 addition & 0 deletions pyinaturalist/controllers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 55 additions & 0 deletions pyinaturalist/controllers/search_controller.py
Original file line number Diff line number Diff line change
@@ -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 <Search/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)
10 changes: 8 additions & 2 deletions pyinaturalist/models/project.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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']
Expand All @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions test/controllers/test_search_controller.py
Original file line number Diff line number Diff line change
@@ -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'

0 comments on commit fb23bf1

Please sign in to comment.