Repository: niconoe/pyinaturalist
Pull request #377 (2022-05-11 19:38:13)

Misc model & controller improvements
* If `Taxon.default_photo` is missing, use iconic taxon icon instead           
* Move `Taxon.emoji` from `Taxon.full_name` to other `__str__` methods as needed   
* Add `Taxon.taxonomy` property to get ancestors + taxon as a {rank: name} dict
* Add `controller.__call__()` methods as a shortcut for getting a single record by ID
    * E.g., `iNatClient.observations(12345)` instead of `iNatClient.observations.from_ids(12345).one()`
* Add `Paginator.limit()` method                                               



Patch:
diff --git a/pyinaturalist/client.py b/pyinaturalist/client.py
index 1b975a8..b8483be 100644
--- a/pyinaturalist/client.py
+++ b/pyinaturalist/client.py
@@ -31,10 +31,16 @@ class iNatClient:
     Examples:
         **Basic usage**
 
+        Search for observations by taxon name:
+
         >>> from pyinaturalist import iNatClient
         >>> client = iNatClient()
         >>> observations = client.observations.search(taxon_name='Danaus plexippus')
 
+        Get a single observation by ID:
+
+        >>> observation = client.observations(12345)
+
         **Authentication**
 
         Add credentials needed for authenticated requests:
diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py
index 514570e..78ba9b3 100644
--- a/pyinaturalist/constants.py
+++ b/pyinaturalist/constants.py
@@ -63,7 +63,7 @@ RATELIMIT_FILE = join(CACHE_DIR, 'api_ratelimit.db')
 TOKEN_EXPIRATION = timedelta(hours=1)
 JWT_EXPIRATION = timedelta(days=1)
 
-# Response formats supported by GET /observations endpoint
+# Response formats supported by v0 GET /observations endpoint
 OBSERVATION_FORMATS = ['atom', 'csv', 'dwc', 'json', 'kml', 'widget']
 
 # IUCN Conservation status codes; for more info, see: https://www.iucnredlist.org
@@ -143,6 +143,7 @@ GEOPRIVACY_LEVELS = ['obscured', 'obscured_private', 'open', 'private']
 HAS_PROPERTIES = ['photo', 'geo']
 HISTOGRAM_DATE_FIELDS = ['created', 'observed']
 HISTOGRAM_INTERVALS = ['year', 'month', 'week', 'day', 'hour', 'month_of_year', 'week_of_year']
+ICON_SIZES = {'icon': 32, 'square': 75, 'small': 75, 'medium': 200, 'large': 200, 'original': 200}
 ID_CATEGORIES = ['improving', 'supporting', 'leading', 'maverick']
 INBOXES = ['inbox', 'sent', 'any']
 ORDER_DIRECTIONS = ['asc', 'desc']
diff --git a/pyinaturalist/controllers/base.py b/pyinaturalist/controllers/base.py
index 73c386f..23c60c8 100644
--- a/pyinaturalist/controllers/base.py
+++ b/pyinaturalist/controllers/base.py
@@ -1,3 +1,8 @@
+from abc import abstractmethod
+
+from pyinaturalist.paginator import Paginator
+
+
 class BaseController:
     """Base class for controllers. A controller provides an interface for all requests related to a
     single resource type. This is mainly for the purpose of splitting up :py:class:`.iNatClient`
@@ -7,3 +12,7 @@ class BaseController:
 
     def __init__(self, client):
         self.client = client
+
+    @abstractmethod
+    def from_ids(self, *object_ids, **params) -> Paginator:
+        """Get records by ID"""
diff --git a/pyinaturalist/controllers/observations.py b/pyinaturalist/controllers/observations.py
index 23ac149..c819352 100644
--- a/pyinaturalist/controllers/observations.py
+++ b/pyinaturalist/controllers/observations.py
@@ -1,3 +1,5 @@
+from typing import Optional
+
 from pyinaturalist.constants import HistogramResponse, IntOrStr, ListResponse
 from pyinaturalist.controllers import BaseController
 from pyinaturalist.docs import document_controller_params
@@ -29,7 +31,11 @@ from pyinaturalist.v1 import (
 class ObservationController(BaseController):
     """:fa:`binoculars` Controller for Observation requests"""
 
-    def from_id(self, *observation_ids, **params) -> Paginator[Observation]:
+    def __call__(self, observation_id, **kwargs) -> Optional[Observation]:
+        """Get a single observation by ID"""
+        return self.from_ids(observation_id, **kwargs).one()
+
+    def from_ids(self, *observation_ids, **params) -> Paginator[Observation]:
         """Get observations by ID
 
         Args:
diff --git a/pyinaturalist/controllers/places.py b/pyinaturalist/controllers/places.py
index ff9de08..bf48c05 100644
--- a/pyinaturalist/controllers/places.py
+++ b/pyinaturalist/controllers/places.py
@@ -1,3 +1,5 @@
+from typing import Optional
+
 from pyinaturalist.constants import IntOrStr
 from pyinaturalist.controllers import BaseController
 from pyinaturalist.docs import document_controller_params
@@ -9,7 +11,11 @@ from pyinaturalist.v1 import get_places_autocomplete, get_places_by_id, get_plac
 class PlaceController(BaseController):
     """:fa:`map-marker-alt,style=fas` Controller for Place requests"""
 
-    def from_id(self, *place_ids: IntOrStr, **params) -> Paginator[Place]:
+    def __call__(self, place_id, **kwargs) -> Optional[Place]:
+        """Get a single place by ID"""
+        return self.from_ids(place_id, **kwargs).one()
+
+    def from_ids(self, *place_ids: IntOrStr, **params) -> Paginator[Place]:
         """Get places by ID
 
         Args:
diff --git a/pyinaturalist/controllers/projects.py b/pyinaturalist/controllers/projects.py
index 4b957d2..ba8348e 100644
--- a/pyinaturalist/controllers/projects.py
+++ b/pyinaturalist/controllers/projects.py
@@ -1,3 +1,5 @@
+from typing import Optional
+
 from pyinaturalist.constants import IntOrStr, ListResponse
 from pyinaturalist.controllers import BaseController
 from pyinaturalist.docs import document_controller_params
@@ -16,7 +18,11 @@ from pyinaturalist.v1 import (
 class ProjectController(BaseController):
     """:fa:`users` Controller for Project requests"""
 
-    def from_id(self, *project_ids: IntOrStr, **params) -> Paginator[Project]:
+    def __call__(self, project_id, **kwargs) -> Optional[Project]:
+        """Get a single project by ID"""
+        return self.from_ids(project_id, **kwargs).one()
+
+    def from_ids(self, *project_ids: IntOrStr, **params) -> Paginator[Project]:
         """Get projects by ID
 
         Args:
diff --git a/pyinaturalist/controllers/taxa.py b/pyinaturalist/controllers/taxa.py
index d2dbba6..49cbee8 100644
--- a/pyinaturalist/controllers/taxa.py
+++ b/pyinaturalist/controllers/taxa.py
@@ -1,3 +1,5 @@
+from typing import Optional
+
 from pyinaturalist.controllers import BaseController
 from pyinaturalist.docs import document_controller_params
 from pyinaturalist.models import Taxon
@@ -8,7 +10,11 @@ from pyinaturalist.v1 import get_taxa, get_taxa_autocomplete, get_taxa_by_id
 class TaxonController(BaseController):
     """:fa:`dove,style=fas` Controller for Taxon requests"""
 
-    def from_id(self, *taxon_ids, **params) -> Paginator[Taxon]:
+    def __call__(self, taxon_id, **kwargs) -> Optional[Taxon]:
+        """Get a single taxon by ID"""
+        return self.from_ids(taxon_id, **kwargs).one()
+
+    def from_ids(self, *taxon_ids, **params) -> Paginator[Taxon]:
         """Get taxa by ID
 
         Args:
diff --git a/pyinaturalist/controllers/users.py b/pyinaturalist/controllers/users.py
index 5e69e8f..28827a7 100644
--- a/pyinaturalist/controllers/users.py
+++ b/pyinaturalist/controllers/users.py
@@ -1,4 +1,4 @@
-from typing import List
+from typing import List, Optional
 
 from pyinaturalist.constants import IntOrStr
 from pyinaturalist.controllers import BaseController
@@ -11,7 +11,11 @@ from pyinaturalist.v1 import get_user_by_id, get_users_autocomplete
 class UserController(BaseController):
     """:fa:`user` Controller for User requests"""
 
-    def from_id(self, *user_ids: IntOrStr, **params) -> Paginator[User]:
+    def __call__(self, user_id, **kwargs) -> Optional[User]:
+        """Get a single user by ID"""
+        return self.from_ids(user_id, **kwargs).one()
+
+    def from_ids(self, *user_ids: IntOrStr, **params) -> Paginator[User]:
         """Get users by ID
 
         Example:
diff --git a/pyinaturalist/models/__init__.py b/pyinaturalist/models/__init__.py
index 930a350..a967d3f 100644
--- a/pyinaturalist/models/__init__.py
+++ b/pyinaturalist/models/__init__.py
@@ -65,7 +65,7 @@ def upper(value) -> Optional[str]:
 # Models imported in order of dependencies
 # --------------------------------------------------------
 
-from pyinaturalist.models.photo import Photo
+from pyinaturalist.models.photo import IconPhoto, Photo
 from pyinaturalist.models.place import Place
 from pyinaturalist.models.user import User, UserCount, UserCounts
 from pyinaturalist.models.taxon_meta import (
diff --git a/pyinaturalist/models/identification.py b/pyinaturalist/models/identification.py
index 0639728..192c7b3 100644
--- a/pyinaturalist/models/identification.py
+++ b/pyinaturalist/models/identification.py
@@ -65,7 +65,7 @@ class Identification(BaseModel):
     def __str__(self) -> str:
         """Format into a condensed summary: id, what, when, and who"""
         return (
-            f'[{self.id}] {self.taxon.full_name} ({self.category}) '
+            f'[{self.id}] {self.taxon.emoji} {self.taxon.full_name} ({self.category}) '
             f'added on {self.created_at.strftime(DATETIME_SHORT_FORMAT)} '
             f'by {self.user.login}'
         )
diff --git a/pyinaturalist/models/observation.py b/pyinaturalist/models/observation.py
index cee8318..a2f45b5 100644
--- a/pyinaturalist/models/observation.py
+++ b/pyinaturalist/models/observation.py
@@ -260,7 +260,7 @@ class Observation(BaseModel):
 
     def __str__(self) -> str:
         return (
-            f'[{self.id}] {self.taxon.full_name} '
+            f'[{self.id}] {self.taxon.emoji} {self.taxon.full_name} '
             f'observed on {self.observed_on.strftime(DATETIME_SHORT_FORMAT)} '
             f'by {self.user.login} at {self.place_guess or self.location}'
         )
diff --git a/pyinaturalist/models/photo.py b/pyinaturalist/models/photo.py
index bb733ac..5d86149 100644
--- a/pyinaturalist/models/photo.py
+++ b/pyinaturalist/models/photo.py
@@ -6,6 +6,8 @@ import requests
 from pyinaturalist.constants import (
     ALL_LICENSES,
     CC_LICENSES,
+    ICON_SIZES,
+    ICONIC_TAXA_BASE_URL,
     PHOTO_BASE_URL,
     PHOTO_CC_BASE_URL,
     PHOTO_INFO_BASE_URL,
@@ -136,3 +138,28 @@ class Photo(BaseModel):
 
     def __str__(self) -> str:
         return f'[{self.id}] {self.original_url} ({self.license_code}, {self.dimensions_str})'
+
+
+@define_model
+class IconPhoto(Photo):
+    """:fa:`camera` Class used for displaying an iconic taxon in place of a taxon photo"""
+
+    def __attrs_post_init__(self):
+        self._url_format = self.url.replace('.png', '-{size}px.png')
+
+    @classmethod
+    def from_iconic_taxon(cls, iconic_taxon_name: str):
+        url = f'{ICONIC_TAXA_BASE_URL}/{iconic_taxon_name.lower()}.png'
+        return cls(url=url)  # type: ignore  # A weird false positive as of mypy 0.950
+
+    @property
+    def icon_url(self) -> Optional[str]:
+        """Image URL (32px icon size)"""
+        return self.url_size('icon')
+
+    def url_size(self, size: str) -> str:
+        size = size.replace('thumbnail', 'square').replace('thumb', 'square')
+        return self._url_format.format(size=ICON_SIZES.get(size, 'square'))
+
+    def __str__(self) -> str:
+        return self.url
diff --git a/pyinaturalist/models/taxon.py b/pyinaturalist/models/taxon.py
index a9b4b7a..2722237 100644
--- a/pyinaturalist/models/taxon.py
+++ b/pyinaturalist/models/taxon.py
@@ -4,7 +4,7 @@ from attr import fields_dict
 
 from pyinaturalist.constants import (
     ICONIC_EMOJI,
-    ICONIC_TAXA_BASE_URL,
+    ICONIC_TAXA,
     INAT_BASE_URL,
     RANKS,
     DateTime,
@@ -25,6 +25,7 @@ from pyinaturalist.models import (
     define_model_collection,
     field,
 )
+from pyinaturalist.models.photo import IconPhoto
 
 
 @define_model
@@ -55,7 +56,7 @@ class Taxon(BaseModel):
         default=0, doc='ID of the iconic taxon (e.g., general taxon "category")'
     )
     iconic_taxon_name: str = field(
-        default='unknown', doc='Name of the iconic taxon (e.g., general taxon "category")'
+        default=None, doc='Name of the iconic taxon (e.g., general taxon "category")'
     )
     is_active: bool = field(
         default=None, doc='Indicates if the taxon is active (and not renamed, moved, etc.)'
@@ -134,6 +135,12 @@ class Taxon(BaseModel):
     # photos_locked: bool = field(default=None)
     # universal_search_rank: int = field(default=None)
 
+    def __attrs_post_init__(self):
+        if not self.iconic_taxon_name:
+            self.iconic_taxon_name = ICONIC_TAXA.get(self.iconic_taxon_id, 'Unknown')
+        if not self.default_photo:
+            self.default_photo = self.icon
+
     @classmethod
     def from_sorted_json_list(cls, value: JsonResponse) -> List['Taxon']:
         """Sort Taxon objects by rank then by name"""
@@ -171,12 +178,16 @@ class Taxon(BaseModel):
             return self.name
 
         common_name = f' ({self.preferred_common_name})' if self.preferred_common_name else ''
-        return f'{self.emoji} {self.rank.title()}: {self.name}{common_name}'
+        return f'{self.rank.title()}: {self.name}{common_name}'
+
+    @property
+    def icon(self) -> IconPhoto:
+        return IconPhoto.from_iconic_taxon(self.iconic_taxon_name)
 
     @property
     def icon_url(self) -> str:
-        """URL for the iconic taxon's icon"""
-        return f'{ICONIC_TAXA_BASE_URL}/{self.iconic_taxon_name.lower()}-75px.png'
+        """Iconic URL for the icon of the iconic taxon"""
+        return str(self.icon.thumbnail_url)
 
     @property
     def gbif_url(self) -> str:
@@ -188,11 +199,17 @@ class Taxon(BaseModel):
         """Immediate parent, if any"""
         return self.ancestors[-1] if self.ancestors else None
 
+    @property
+    def taxonomy(self) -> Dict[str, str]:
+        """Ancestor + current taxon as a ``{rank: name}`` dict"""
+        return {t.rank: t.name for t in self.ancestors + [self]}
+
     @property
     def url(self) -> str:
         """Info URL on iNaturalist.org"""
         return f'{INAT_BASE_URL}/taxa/{self.id}'
 
+    # TODO: Probably remove this
     @classmethod
     def from_id(cls, id: int) -> 'Taxon':
         """Lookup and create a new Taxon object by ID"""
@@ -218,7 +235,7 @@ class Taxon(BaseModel):
         }
 
     def __str__(self) -> str:
-        return f'[{self.id}] {self.full_name}'
+        return f'[{self.id}] {self.emoji} {self.full_name}'
 
 
 @define_model
@@ -250,7 +267,7 @@ class TaxonCount(Taxon):
         }
 
     def __str__(self) -> str:
-        return f'[{self.id}] {self.full_name}: {self.count}'
+        return f'[{self.id}] {self.emoji} {self.full_name}: {self.count}'
 
 
 @define_model_collection
diff --git a/pyinaturalist/paginator.py b/pyinaturalist/paginator.py
index 9def722..f425304 100644
--- a/pyinaturalist/paginator.py
+++ b/pyinaturalist/paginator.py
@@ -37,7 +37,7 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
         request_function: API request function to paginate
         model: Model class to use for results
         method: Pagination method; either 'page' or 'id' (see note below)
-        limit: Maximum number of results to fetch
+        limit: Maximum number of total results to fetch
         per_page: Maximum number of results to fetch per page
         kwargs: Original request parameters
 
@@ -61,12 +61,12 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
         per_page: int = None,
         **kwargs,
     ):
-        self.limit = limit
         self.kwargs = kwargs
         self.request_function = request_function
         self.request_args = request_args
         self.method = method
         self.model = model
+        self.total_limit = limit
         self.per_page = per_page or PER_PAGE_RESULTS
 
         self.exhausted = False
@@ -104,6 +104,11 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
         """Get all results in a single list"""
         return list(self)
 
+    def limit(self, limit: int) -> List[T]:
+        """Get at most ``limit`` results"""
+        self.total_limit = limit
+        return list(self)
+
     def one(self) -> Optional[T]:
         """Get the first result from the query"""
         self.per_page = 1
@@ -124,20 +129,14 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
             self.total_results = int(count_response['total_results'])
         return self.total_results
 
-    def deduplicate(self, results):
-        """Deduplicate results by ID"""
-        unique_results = {result.id: result for result in results}
-        self.total_results = len(unique_results)
-        return list(unique_results.values())
-
     def next_page(self) -> List[ResponseResult]:
         """Get the next page of results"""
         if self.exhausted:
             return []
 
         # If a limit is specified, avoid fetching more results than needed
-        if self.limit and self.results_fetched + self.per_page > self.limit:
-            self.per_page = self.limit - self.results_fetched
+        if self.total_limit and self.results_fetched + self.per_page > self.total_limit:
+            self.per_page = self.total_limit - self.results_fetched
 
         # Fetch results; handle response object or dict
         response = self.request_function(*self.request_args, **self.kwargs, per_page=self.per_page)
@@ -159,11 +158,17 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
 
     def _check_exhausted(self):
         return (
-            (self.limit and self.results_fetched >= self.limit)
+            (self.total_limit and self.results_fetched >= self.total_limit)
             or (self.total_results and self.results_fetched >= self.total_results)
             or (self.per_page and self.results_fetched > self.per_page)
         )
 
+    def _deduplicate(self, results) -> List[T]:
+        """Deduplicate results by ID"""
+        unique_results = {result.id: result for result in results}
+        self.total_results = len(unique_results)
+        return list(unique_results.values())
+
     def _update_next_page_params(self, page_results):
         """Set params for next request, if there are more results. Also check page size, in case
         total_results is off due to race condition, outdated index, etc.
@@ -240,7 +245,7 @@ class AutocompletePaginator(Paginator):
 
     def all(self) -> List[T]:
         """Get all results in a single de-duplicated list"""
-        return self.deduplicate(list(self))
+        return self._deduplicate(list(self))
 
     def _update_next_page_params(self, page_results):
         """After the first request, update the order_by param. After the second request, we're done."""
@@ -271,7 +276,7 @@ class JsonPaginator(Paginator):
             'total_results': len(results),
         }
 
-    def deduplicate(self, results):
+    def _deduplicate(self, results) -> List[T]:
         """Deduplicate results by ID"""
         unique_results = {result['id']: result for result in results}
         self.total_results = len(unique_results)


Test patch:
diff --git a/test/controllers/test_observation_controller.py b/test/controllers/test_observation_controller.py
index 762d013..1470f2c 100644
--- a/test/controllers/test_observation_controller.py
+++ b/test/controllers/test_observation_controller.py
@@ -19,6 +19,29 @@ from test.sample_data import *
 
 
 def test_from_id(requests_mock):
+    observation_id = 57754375
+    requests_mock.get(
+        f'{API_V1_BASE_URL}/observations',
+        json=SAMPLE_DATA['get_observations_node_page1'],
+        status_code=200,
+    )
+    result = iNatClient().observations(observation_id)
+
+    assert isinstance(result, Observation)
+    assert result.id == observation_id
+
+
+def test_from_id__not_found(requests_mock):
+    observation_id = 57754375
+    requests_mock.get(
+        f'{API_V1_BASE_URL}/observations',
+        json={'results': [], 'total_results': 0},
+        status_code=200,
+    )
+    assert iNatClient().observations(observation_id) is None
+
+
+def test_from_ids(requests_mock):
     observation_id = 57754375
     requests_mock.get(
         f'{API_V1_BASE_URL}/observations',
@@ -27,23 +50,38 @@ def test_from_id(requests_mock):
             {'json': SAMPLE_DATA['get_observations_node_page2'], 'status_code': 200},
         ],
     )
-    client = iNatClient()
-    results = client.observations.from_id(observation_id).all()
+    results = iNatClient().observations.from_ids(observation_id).all()
 
     assert len(results) == 2 and isinstance(results[0], Observation)
     assert results[0].id == observation_id
 
 
+def test_from_ids__limit(requests_mock):
+    observation_id = 57754375
+    requests_mock.get(
+        f'{API_V1_BASE_URL}/observations',
+        [
+            {'json': SAMPLE_DATA['get_observations_node_page1'], 'status_code': 200},
+            {'json': SAMPLE_DATA['get_observations_node_page2'], 'status_code': 200},
+        ],
+    )
+    results = iNatClient().observations.from_ids(observation_id).limit(1)
+
+    assert len(results) == 1 and isinstance(results[0], Observation)
+    assert results[0].id == observation_id
+
+
 def test_search(requests_mock):
     requests_mock.get(
         f'{API_V1_BASE_URL}/observations',
         json=SAMPLE_DATA['get_observations_node_page1'],
         status_code=200,
     )
-    client = iNatClient()
-    results = client.observations.search(
-        taxon_name='Danaus plexippus', created_on='2020-08-27'
-    ).all()
+    results = (
+        iNatClient()
+        .observations.search(taxon_name='Danaus plexippus', created_on='2020-08-27')
+        .all()
+    )
 
     assert isinstance(results[0], Observation)
     assert results[0].id == 57754375
@@ -57,8 +95,7 @@ def test_histogram(requests_mock):
         json=SAMPLE_DATA['get_observation_histogram_day'],
         status_code=200,
     )
-    client = iNatClient()
-    results = client.observations.histogram(user_id='username')
+    results = iNatClient().observations.histogram(user_id='username')
 
     assert len(results) == 31
     assert results[datetime(2020, 1, 1, 0, 0)] == 11
@@ -70,8 +107,7 @@ def test_identifiers(requests_mock):
         json=j_observation_identifiers,
         status_code=200,
     )
-    client = iNatClient()
-    results = client.observations.identifiers(place_id=125323, iconic_taxa='Amphibia')
+    results = iNatClient().observations.identifiers(place_id=125323, iconic_taxa='Amphibia')
 
     assert len(results) == 3 and isinstance(results[0], User)
     assert results[0].id == 112514
@@ -85,8 +121,7 @@ def test_observers(requests_mock):
         json=j_observation_observers,
         status_code=200,
     )
-    client = iNatClient()
-    results = client.observations.observers(place_id=125323, iconic_taxa='Amphibia')
+    results = iNatClient().observations.observers(place_id=125323, iconic_taxa='Amphibia')
 
     assert len(results) == 2 and isinstance(results[0], User)
     assert results[0].id == 53153
diff --git a/test/controllers/test_place_controller.py b/test/controllers/test_place_controller.py
index 3c919b8..73e6647 100644
--- a/test/controllers/test_place_controller.py
+++ b/test/controllers/test_place_controller.py
@@ -5,13 +5,23 @@ from test.sample_data import SAMPLE_DATA, j_place_1, j_place_2
 
 
 def test_from_id(requests_mock):
+    requests_mock.get(
+        f'{API_V1_BASE_URL}/places/89191',
+        json={'results': [j_place_1]},
+        status_code=200,
+    )
+    result = iNatClient().places(89191)
+    assert isinstance(result, Place)
+    assert result.id == 89191
+
+
+def test_from_ids(requests_mock):
     requests_mock.get(
         f'{API_V1_BASE_URL}/places/89191,67591',
         json={'results': [j_place_1, j_place_2], 'total_results': 2},
         status_code=200,
     )
-    client = iNatClient()
-    results = client.places.from_id(89191, 67591).all()
+    results = iNatClient().places.from_ids(89191, 67591).all()
     place = results[0]
 
     assert len(results) == 2 and isinstance(place, Place)
@@ -26,8 +36,7 @@ def test_autocomplete(requests_mock):
         json=SAMPLE_DATA['get_places_autocomplete'],
         status_code=200,
     )
-    client = iNatClient()
-    place = client.places.autocomplete('springbok').one()
+    place = iNatClient().places.autocomplete('springbok').one()
 
     assert place.id == 93735
     assert place.name == 'Springbok'
@@ -45,8 +54,7 @@ def test_get_places_autocomplete__all_pages(requests_mock):
             {'json': page_2, 'status_code': 200},
         ],
     )
-    client = iNatClient()
-    results = client.places.autocomplete('springbok').all()
+    results = iNatClient().places.autocomplete('springbok').all()
 
     # Expect 2 requests, and for results to be deduplicated
     assert len(results) == 25
@@ -58,9 +66,7 @@ def test_nearby(requests_mock):
         json=SAMPLE_DATA['get_places_nearby'],
         status_code=200,
     )
-    client = iNatClient()
-
-    results = client.places.nearby(150.0, -50.0, -149.999, -49.999).all()
+    results = iNatClient().places.nearby(150.0, -50.0, -149.999, -49.999).all()
     place = results[0]
 
     assert isinstance(place, Place)
diff --git a/test/controllers/test_project_controller.py b/test/controllers/test_project_controller.py
index 0b7e939..2455255 100644
--- a/test/controllers/test_project_controller.py
+++ b/test/controllers/test_project_controller.py
@@ -11,14 +11,25 @@ from test.sample_data import SAMPLE_DATA, j_project_1, j_project_2, j_project_3_
 
 
 def test_from_id(requests_mock):
+    project_id = 8291
+    requests_mock.get(
+        f'{API_V1_BASE_URL}/projects/{project_id}',
+        json={'results': [j_project_1]},
+        status_code=200,
+    )
+    result = iNatClient().projects(project_id)
+    assert isinstance(result, Project)
+    assert result.id == project_id
+
+
+def test_from_ids(requests_mock):
     project_id = 8291
     requests_mock.get(
         f'{API_V1_BASE_URL}/projects/{project_id}',
         json={'results': [j_project_1, j_project_2], 'total_results': 2},
         status_code=200,
     )
-    client = iNatClient()
-    results = client.projects.from_id(project_id).all()
+    results = iNatClient().projects.from_ids(project_id).all()
     project = results[0]
     assert len(results) == 2 and isinstance(project, Project)
 
@@ -40,14 +51,17 @@ def test_search(requests_mock):
         json=SAMPLE_DATA['get_projects'],
         status_code=200,
     )
-    client = iNatClient()
-    results = client.projects.search(
-        q='invasive',
-        lat=49.27,
-        lng=-123.08,
-        radius=400,
-        order_by='distance',
-    ).all()
+    results = (
+        iNatClient()
+        .projects.search(
+            q='invasive',
+            lat=49.27,
+            lng=-123.08,
+            radius=400,
+            order_by='distance',
+        )
+        .all()
+    )
 
     project = results[0]
     assert len(results) == 5 and isinstance(project, Project)
@@ -65,8 +79,7 @@ def test_search__with_obs_fields(requests_mock):
         json={'results': [j_project_3_obs_fields], 'total_results': 1},
         status_code=200,
     )
-    client = iNatClient()
-    results = client.projects.search(id=1234).all()
+    results = iNatClient().projects.search(id=1234).all()
     obs_field = results[0].project_observation_fields[0]
 
     assert isinstance(obs_field, ProjectObservationField)
@@ -82,23 +95,19 @@ def test_add_observation(get_access_token, requests_mock):
         json=SAMPLE_DATA['add_project_observation'],
         status_code=200,
     )
-    client = iNatClient()
     observation_ids = 5678, 9012
-    client.projects.add_observations(1234, *observation_ids)
+    iNatClient().projects.add_observations(1234, *observation_ids)
     get_access_token.assert_called()
 
 
 # TODO:
 # def test_update():
-#     client = iNatClient()
-#     results = client.projects.update()
+#     results = iNatClient().projects.update()
 
 
 # def test_add_users():
-#     client = iNatClient()
-#     results = client.project.add_users()
+#     results = iNatClient().project.add_users()
 
 
 # def test_delete_users():
-#     client = iNatClient()
-#     results = client.project.delete_users()
+#     results = iNatClient().project.delete_users()
diff --git a/test/controllers/test_taxon_controller.py b/test/controllers/test_taxon_controller.py
index 51bba19..7c91572 100644
--- a/test/controllers/test_taxon_controller.py
+++ b/test/controllers/test_taxon_controller.py
@@ -12,8 +12,20 @@ def test_from_id(requests_mock):
         status_code=200,
     )
 
-    client = iNatClient()
-    results = client.taxa.from_id(taxon_id).all()
+    result = iNatClient().taxa(taxon_id)
+    assert isinstance(result, Taxon)
+    assert result.id == taxon_id
+
+
+def test_from_ids(requests_mock):
+    taxon_id = 70118
+    requests_mock.get(
+        f'{API_V1_BASE_URL}/taxa/{taxon_id}',
+        json=SAMPLE_DATA['get_taxa_by_id'],
+        status_code=200,
+    )
+
+    results = iNatClient().taxa.from_ids(taxon_id).all()
     assert len(results) == 1 and isinstance(results[0], Taxon)
     assert results[0].id == taxon_id
 
@@ -25,8 +37,7 @@ def test_autocomplete(requests_mock):
         status_code=200,
     )
 
-    client = iNatClient()
-    results = client.taxa.autocomplete(q='vespi').all()
+    results = iNatClient().taxa.autocomplete(q='vespi').all()
     assert len(results) == 10 and isinstance(results[0], Taxon)
     assert results[0].id == 52747
 
@@ -38,7 +49,6 @@ def test_search(requests_mock):
         status_code=200,
     )
 
-    client = iNatClient()
-    results = client.taxa.search(q='vespi', rank=['genus', 'subgenus', 'species']).all()
+    results = iNatClient().taxa.search(q='vespi', rank=['genus', 'subgenus', 'species']).all()
     assert len(results) == 30 and isinstance(results[0], Taxon)
     assert results[0].id == 70118
diff --git a/test/controllers/test_user_controller.py b/test/controllers/test_user_controller.py
index 2f91d24..14dcca5 100644
--- a/test/controllers/test_user_controller.py
+++ b/test/controllers/test_user_controller.py
@@ -4,20 +4,19 @@ from pyinaturalist.models import User
 from test.sample_data import SAMPLE_DATA
 
 
-def test_from_id__one(requests_mock):
+def test_from_id(requests_mock):
     requests_mock.get(
         f'{API_V1_BASE_URL}/users/1',
         json=SAMPLE_DATA['get_user_by_id'],
         status_code=200,
     )
 
-    client = iNatClient()
-    result = client.users.from_id(1).one()
+    result = iNatClient().users(1)
     assert isinstance(result, User)
     assert result.id == 1
 
 
-def test_from_id__multiple(requests_mock):
+def test_from_ids(requests_mock):
     requests_mock.get(
         f'{API_V1_BASE_URL}/users/1',
         json=SAMPLE_DATA['get_user_by_id'],
@@ -29,8 +28,7 @@ def test_from_id__multiple(requests_mock):
         status_code=200,
     )
 
-    client = iNatClient()
-    results = client.users.from_id(1, 2).all()
+    results = iNatClient().users.from_ids(1, 2).all()
     assert len(results) == 2 and isinstance(results[0], User)
     assert results[0].id == 1
 
@@ -42,7 +40,6 @@ def test_autocomplete(requests_mock):
         status_code=200,
     )
 
-    client = iNatClient()
-    results = client.users.autocomplete(q='nico')
+    results = iNatClient().users.autocomplete(q='nico')
     assert len(results) == 3 and isinstance(results[0], User)
     assert results[0].id == 886482
diff --git a/test/test_formatters.py b/test/test_formatters.py
index 81dc801..c4fced4 100644
--- a/test/test_formatters.py
+++ b/test/test_formatters.py
@@ -83,7 +83,7 @@ def test_format_controlled_terms(input):
 
 @pytest.mark.parametrize('input', get_variations(j_identification_1))
 def test_format_identifications(input):
-    expected_str = '[155554373] Species: 60132 (supporting) added on Feb 18, 2021 by jkcook'
+    expected_str = '[155554373] 🌼 Species: 60132 (supporting) added on Feb 18, 2021 by jkcook'
     assert format_identifications(input) == expected_str
 
 
diff --git a/test/test_models.py b/test/test_models.py
index 0e27e86..5e5928d 100644
--- a/test/test_models.py
+++ b/test/test_models.py
@@ -496,9 +496,9 @@ def test_taxon__converters():
 def test_taxon__empty():
     taxon = Taxon()
     assert taxon.preferred_common_name == ''
+    assert taxon.iconic_taxon_name == 'Unknown'
     assert taxon.ancestors == []
     assert taxon.children == []
-    assert taxon.default_photo is None
     assert taxon.taxon_photos == []
 
 
@@ -510,10 +510,10 @@ def test_taxon__str():
     assert str(taxon_2) == '[3] 🐦 Class: Aves'
 
     taxon_3 = Taxon(id=3, name='Aves')
-    assert str(taxon_3) == '[3] Aves'
+    assert str(taxon_3) == '[3] 🐦 Aves'
 
-    taxon_4 = Taxon(id=3)
-    assert str(taxon_4) == '[3] unknown taxon'
+    taxon_4 = Taxon(id=0)
+    assert str(taxon_4) == '[0] ❓ unknown taxon'
 
 
 def test_taxon__all_names():
@@ -573,7 +573,7 @@ def test_taxon__listed_taxa():
     assert str(listed_taxon) == '[70118] (native): 0 observations, 0 comments'
 
 
-def test_taxon__taxonomy():
+def test_taxon__children_ancestors():
     taxon = Taxon.from_json(j_taxon_1)
     parent = taxon.ancestors[0]
     child = taxon.children[0]
@@ -601,12 +601,41 @@ def test_taxon__icon_url(taxon_name):
     assert taxon.icon_url is not None
 
 
+@pytest.mark.parametrize('taxon_id', ICONIC_TAXA.keys())
+def test_taxon__no_default_photo(taxon_id):
+    taxon = Taxon(iconic_taxon_id=taxon_id)
+    photo = taxon.default_photo
+    assert isinstance(photo, IconPhoto)
+    assert taxon.icon_url is not None
+    assert taxon.iconic_taxon_name is not None
+    assert photo.url is not None
+
+
 def test_taxon_properties__partial():
     taxon = Taxon.from_json(j_taxon_2_partial)
     assert taxon.ancestry.startswith('48460 | 1 | 47120 | ')
     assert taxon.parent is None
 
 
+def test_taxon__taxonomy():
+    taxon = Taxon.from_json(j_taxon_1)
+    assert taxon.taxonomy == {
+        'kingdom': 'Animalia',
+        'phylum': 'Arthropoda',
+        'subphylum': 'Hexapoda',
+        'class': 'Insecta',
+        'subclass': 'Pterygota',
+        'order': 'Coleoptera',
+        'suborder': 'Polyphaga',
+        'infraorder': 'Staphyliniformia',
+        'superfamily': 'Staphylinoidea',
+        'family': 'Silphidae',
+        'subfamily': 'Nicrophorinae',
+        'genus': 'Nicrophorus',
+        'species': 'Nicrophorus vespilloides',
+    }
+
+
 # TODO
 def test_taxon__update_from_full_record():
     pass