Repository: niconoe/pyinaturalist
Pull request #190 (2021-06-28 21:48:31)

Add some more minor models and model attribute documentation
Updates #188, #189


Patch:
diff --git a/HISTORY.md b/HISTORY.md
index a033806..2a646c2 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -28,8 +28,10 @@
 Added data models for all API response types, to support working with typed python objects instead of JSON.
 
 Models:
-* Annotation
 * Comment
+* ControlledTerm
+  * ControlledTermValue
+  * Annotation
 * Identification
 * LifeList
     * LifeListTaxon
@@ -44,6 +46,8 @@ Models:
     * ProjectUser
 * SearchResult
 * Taxon
+    * TaxonCount
+    * TaxonCounts
 * User
 
 Model features:
diff --git a/examples/sample_responses.py b/examples/sample_responses.py
index 774dad2..c1ddb53 100755
--- a/examples/sample_responses.py
+++ b/examples/sample_responses.py
@@ -9,6 +9,7 @@ from pyinaturalist.formatters import format_table, pprint
 from pyinaturalist.models import (
     Annotation,
     Comment,
+    ControlledTerm,
     Identification,
     Observation,
     ObservationField,
@@ -18,6 +19,7 @@ from pyinaturalist.models import (
     Project,
     SearchResult,
     Taxon,
+    TaxonCounts,
     User,
 )
 
@@ -28,9 +30,11 @@ def load_sample_data(filename):
 
 
 # Sample JSON
+controlled_term_json = load_sample_data('get_controlled_terms.json')['results']
 obs_json = load_sample_data('get_observations_node_page1.json')['results'][0]
 obs_json_ofvs = load_sample_data('get_observation_with_ofvs.json')['results'][0]
 obs_fields_json = load_sample_data('get_observation_fields_page1.json')
+obs_species_counts_json = load_sample_data('get_observation_species_counts.json')['results']
 obs_taxonomy_json = load_sample_data('get_observation_taxonomy.json')
 place_json = load_sample_data('get_places_by_id.json')['results'][0]
 places_nearby_json = load_sample_data('get_places_nearby.json')['results']
@@ -42,6 +46,7 @@ taxon_json = load_sample_data('get_taxa_by_id.json')['results'][0]
 taxon_json_partial = load_sample_data('get_taxa.json')['results'][0]
 annotation_json = obs_json_ofvs['annotations'][0]
 comment_json = obs_json['comments'][0]
+controlled_term_value_json = controlled_term_json[0]['values']
 identification_json = obs_json['identifications'][0]
 obs_field_json = obs_fields_json[0]
 ofv_json_numeric = obs_json_ofvs['ofvs'][1]
@@ -51,10 +56,10 @@ photo_json_partial = taxon_json['default_photo']
 search_results_json = load_sample_data('get_search.json')['results']
 user_json_partial = user_json_autocomplete[0]
 
-
 # Sample model objects
 annotation = Annotation.from_json(annotation_json)
 comment = Comment.from_json(comment_json)
+controlled_term = ControlledTerm.from_json(controlled_term_json[0])
 identification = Identification.from_json(identification_json)
 life_list = LifeList.from_json(obs_taxonomy_json)
 observation = Observation.from_json(obs_json)
@@ -69,11 +74,13 @@ project = Project.from_json(project_json)
 search_results = SearchResult.from_json_list(search_results_json)
 taxon = Taxon.from_json(taxon_json)
 taxon_partial = Taxon.from_json(taxon_json_partial)
+taxon_counts = TaxonCounts.from_json(obs_species_counts_json)
 user = User.from_json(user_json)
 
 # Sample tables
 annotation_table = format_table(observation_with_ofvs.annotations)
 comment_table = format_table(observation.comments)
+controlled_term_table = format_table(ControlledTerm.from_json_list(controlled_term_json))
 identification_table = format_table(observation.identifications)
 life_list_table = format_table(life_list)
 observation_table = format_table([observation, observation_with_ofvs])
@@ -83,5 +90,6 @@ photo_table = format_table([photo, photo_partial, observation.photos[0]])
 place_table = format_table(places_nearby)
 project_table = format_table([project, project, project])
 search_results_table = format_table(search_results)
+taxon_counts_table = format_table(taxon_counts)
 taxon_table = format_table([taxon, observation_with_ofvs.taxon, observation.taxon])
 user_table = format_table(User.from_json_list(user_json_autocomplete))
diff --git a/pyinaturalist/converters.py b/pyinaturalist/converters.py
index 7190e31..c9fb487 100644
--- a/pyinaturalist/converters.py
+++ b/pyinaturalist/converters.py
@@ -1,4 +1,5 @@
 """Type conversion utilities"""
+from collections import UserList
 from datetime import date, datetime, timedelta
 from dateutil.parser import UnknownTimezoneWarning  # type: ignore  # (missing from type stubs)
 from dateutil.parser import parse as parse_date
@@ -268,7 +269,7 @@ def ensure_list(value: Any, convert_csv: bool = False, delimiter: str = ',') ->
     elif convert_csv and isinstance(value, str) and delimiter in value:
         return [s.strip() for s in value.split(delimiter)]
 
-    if isinstance(value, (list, tuple, set)):
+    if isinstance(value, (UserList, list, tuple, set)):
         return list(value)
     else:
         return [value]
diff --git a/pyinaturalist/formatters.py b/pyinaturalist/formatters.py
index 32c3a54..fe2a975 100644
--- a/pyinaturalist/formatters.py
+++ b/pyinaturalist/formatters.py
@@ -18,6 +18,8 @@ from pyinaturalist.models import (
     Annotation,
     BaseModel,
     Comment,
+    ControlledTerm,
+    ControlledTermValue,
     Identification,
     LifeList,
     Observation,
@@ -29,6 +31,7 @@ from pyinaturalist.models import (
     ResponseOrObjects,
     SearchResult,
     Taxon,
+    TaxonCount,
     User,
     get_model_fields,
 )
@@ -56,6 +59,7 @@ HEADER_COLORS = {
     'From CV': 'white',
     'ID count': 'blue',
     'ID': 'cyan',
+    'Label': 'green',
     'Latitude': 'blue',
     'License': 'green',
     'Location': 'white',
@@ -82,6 +86,8 @@ UNIQUE_RESPONSE_ATTRS = {
     'vote_score': Annotation,
     'disagreement': Identification,
     'moderator_actions': Comment,  # Subset of ID attrs; if it's not an ID, assume it's a comment
+    'multivalued': ControlledTerm,
+    'blocking': ControlledTermValue,
     'count_without_taxon': LifeList,
     'captive': Observation,
     'allowed_values': ObservationField,
@@ -92,6 +98,7 @@ UNIQUE_RESPONSE_ATTRS = {
     'project_type': Project,
     'score': SearchResult,
     'rank': Taxon,
+    'count': TaxonCount,
     'roles': User,
 }
 
@@ -117,8 +124,6 @@ def ensure_model_list(values: ResponseOrObjects) -> List[BaseModel]:
     """If the given values are raw JSON responses, attempt to detect their type and convert to
     model objects
     """
-    if isinstance(values, LifeList):
-        return values.taxa  # type: ignore
     values = ensure_list(values)
     if isinstance(values[0], BaseModel):
         return values
@@ -155,16 +160,6 @@ def format_table(values: ResponseOrObjects):
     return table
 
 
-def format_controlled_terms(terms: ResponseOrResults, **kwargs) -> str:
-    """Format controlled term results into a condensed list of terms and values"""
-    return _format_results(terms, _format_controlled_term)
-
-
-def _format_controlled_term(term: ResponseResult, **kwargs) -> str:
-    term_values = [f'    {value["id"]}: {value["label"]}' for value in term['values']]
-    return f'{term["id"]}: {term["label"]}\n' + '\n'.join(term_values)
-
-
 def format_species_counts(species_counts: ResponseOrResults, **kwargs) -> str:
     """Format observation species counts into a condensed list of names and # of observations"""
     return _format_results(species_counts, _format_species_count)
@@ -219,6 +214,7 @@ def _format_model_objects(obj: ResponseOrResults, cls: Type[BaseModel], **kwargs
 
 
 # TODO: Figure out type annotations for these. Or just replace with pprint()?
+format_controlled_terms = partial(_format_model_objects, cls=ControlledTerm)
 format_identifications = partial(_format_model_objects, cls=Identification)
 format_observations = partial(_format_model_objects, cls=Observation)
 format_places = partial(_format_model_objects, cls=Place)
diff --git a/pyinaturalist/models/__init__.py b/pyinaturalist/models/__init__.py
index 5009cea..bfe9cb7 100644
--- a/pyinaturalist/models/__init__.py
+++ b/pyinaturalist/models/__init__.py
@@ -9,6 +9,7 @@ from pyinaturalist.models.base import (
     ResponseOrObject,
     ResponseOrObjects,
     BaseModel,
+    BaseModelCollection,
     datetime_attr,
     datetime_now_attr,
     coordinate_pair,
@@ -22,9 +23,9 @@ define_model: Callable = define(auto_attribs=False, field_transformer=add_lazy_a
 
 # Imported in order of model dependencies
 from pyinaturalist.models.photo import Photo
-from pyinaturalist.models.taxon import Taxon
+from pyinaturalist.models.taxon import Taxon, TaxonCount, TaxonCounts
 from pyinaturalist.models.user import User
-from pyinaturalist.models.annotation import Annotation
+from pyinaturalist.models.controlled_term import Annotation, ControlledTerm, ControlledTermValue
 from pyinaturalist.models.comment import Comment
 from pyinaturalist.models.identification import Identification
 from pyinaturalist.models.life_list import LifeList, LifeListTaxon
diff --git a/pyinaturalist/models/annotation.py b/pyinaturalist/models/annotation.py
deleted file mode 100644
index 65ace8a..0000000
--- a/pyinaturalist/models/annotation.py
+++ /dev/null
@@ -1,44 +0,0 @@
-from typing import List
-
-from attr import field
-
-from pyinaturalist.constants import TableRow
-from pyinaturalist.models import BaseModel, LazyProperty, User, define_model, kwarg
-
-
-@define_model
-class Annotation(BaseModel):
-    """An annotation, e.g. a controlled term value applied by a user to an observation. Based on the schema of annotations
-    from `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_.
-    """
-
-    concatenated_attr_val: str = kwarg
-    controlled_attribute_id: int = kwarg
-    controlled_value_id: int = kwarg
-    user_id: int = kwarg
-    uuid: str = kwarg
-    vote_score: int = kwarg
-    votes: List = field(factory=list)
-    user: property = LazyProperty(User.from_json)
-
-    @property
-    def values(self) -> List[str]:
-        """Split pipe-delimited annotation values into separate tokens"""
-        if not self.concatenated_attr_val:
-            return []
-        return self.concatenated_attr_val.split('|')
-
-    @property
-    def row(self) -> TableRow:
-        return {
-            'ID': self.controlled_attribute_id,
-            'Value': ', '.join(self.values),
-            'Votes': self.vote_score,
-            'User': self.user.login,
-        }
-
-    def __str__(self) -> str:
-        return (
-            f'[{self.controlled_attribute_id}] {self.concatenated_attr_val} '
-            f'({len(self.votes)} votes)'
-        )
diff --git a/pyinaturalist/models/base.py b/pyinaturalist/models/base.py
index b44faaf..b6b4c14 100644
--- a/pyinaturalist/models/base.py
+++ b/pyinaturalist/models/base.py
@@ -5,13 +5,14 @@ Note: when using classmethods as converters, mypy will raise a false positive er
 https://github.com/python/mypy/issues/6172
 https://github.com/python/mypy/issues/7912
 """
-# TODO: More refactoring and tests for load_json, from_json, and from_json_list
 import json
+
+from collections import UserList
 from datetime import datetime, timezone
 from logging import getLogger
 from os.path import expanduser
 from pathlib import Path
-from typing import Dict, Iterable, List, Type, TypeVar, Union
+from typing import Dict, Generic, Iterable, List, Type, TypeVar, Union
 
 from attr import asdict, define, field, fields_dict
 
@@ -75,8 +76,26 @@ class BaseModel:
         return asdict(self)
 
 
+# TODO: Better workaround for mypy not accepting subclasses in overridden attributes and methods
+@define(auto_attribs=False, order=False, slots=False)
+class BaseModelCollection(BaseModel, UserList, Generic[T]):
+    """Base class for collections of data models"""
+
+    data: List[T] = field(factory=list, init=False, repr=False)
+
+    @classmethod
+    def from_json_list(cls, value: JsonResponse, **kwargs) -> 'BaseModelCollection':  # type: ignore
+        """For model collections, initializing from a list should return an instance of ``cls``
+        instead of a builtin ``list``
+        """
+        return cls.from_json(value)  # type: ignore
+
+    def __attrs_post_init__(self):
+        self.data = self.taxa
+
+
 # Type aliases
-ModelObjects = Union[BaseModel, Iterable[BaseModel]]
+ModelObjects = Union[BaseModel, BaseModelCollection, Iterable[BaseModel]]
 ResponseOrObject = Union[BaseModel, JsonResponse]
 ResponseOrObjects = Union[ModelObjects, ResponseOrResults]
 
diff --git a/pyinaturalist/models/controlled_term.py b/pyinaturalist/models/controlled_term.py
new file mode 100644
index 0000000..e478300
--- /dev/null
+++ b/pyinaturalist/models/controlled_term.py
@@ -0,0 +1,97 @@
+from typing import List
+
+from attr import field
+
+from pyinaturalist.constants import TableRow
+from pyinaturalist.models import BaseModel, LazyProperty, User, define_model, kwarg
+
+
+@define_model
+class Annotation(BaseModel):
+    """An annotation, e.g. a **controlled term value** applied by a **user** to an **observation**.
+    Based on the schema of annotations from
+    `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_.
+    """
+
+    concatenated_attr_val: str = kwarg
+    controlled_attribute_id: int = kwarg
+    controlled_value_id: int = kwarg
+    user_id: int = kwarg
+    uuid: str = kwarg
+    vote_score: int = kwarg
+    votes: List = field(factory=list)
+    user: property = LazyProperty(User.from_json)
+
+    @property
+    def values(self) -> List[str]:
+        """Split pipe-delimited annotation values into separate tokens"""
+        if not self.concatenated_attr_val:
+            return []
+        return self.concatenated_attr_val.split('|')
+
+    @property
+    def row(self) -> TableRow:
+        return {
+            'ID': self.controlled_attribute_id,
+            'Values': ', '.join(self.values),
+            'Votes': self.vote_score,
+            'User': self.user.login,
+        }
+
+    def __str__(self) -> str:
+        return (
+            f'[{self.controlled_attribute_id}] {self.concatenated_attr_val} '
+            f'({len(self.votes)} votes)'
+        )
+
+
+@define_model
+class ControlledTermValue(BaseModel):
+    """A controlled term **value**, based on the schema of
+    `GET /controlled_terms <https://api.inaturalist.org/v1/docs/#!/Controlled_Terms/get_controlled_terms>`_.
+    """
+
+    blocking: bool = kwarg
+    id: int = kwarg
+    label: str = kwarg
+    ontology_uri: str = kwarg
+    uri: str = kwarg
+    uuid: str = kwarg
+    taxon_ids: List[int] = field(factory=list)
+
+    def __str__(self):
+        return f'[{self.id}] {self.label}'
+
+
+@define_model
+class ControlledTerm(BaseModel):
+    """A controlled term, based on the schema of
+    `GET /controlled_terms <https://api.inaturalist.org/v1/docs/#!/Controlled_Terms/get_controlled_terms>`_.
+    """
+
+    id: int = kwarg
+    is_value: bool = kwarg
+    multivalued: bool = kwarg
+    label: str = kwarg
+    ontology_uri: str = kwarg
+    uri: str = kwarg
+    uuid: str = kwarg
+    taxon_ids: List[int] = field(factory=list)
+
+    values: property = LazyProperty(ControlledTermValue.from_json_list)
+
+    @property
+    def value_labels(self) -> str:
+        """Combined labels from all controlled term values"""
+        return ', '.join([value.label for value in self.values])
+
+    @property
+    def row(self) -> TableRow:
+        return {
+            'ID': self.id,
+            'Label': self.label,
+            'Values': self.value_labels,
+        }
+
+    def __str__(self):
+        return f'[{self.id}] {self.label}: {self.value_labels}'
diff --git a/pyinaturalist/models/life_list.py b/pyinaturalist/models/life_list.py
index 4f4758b..e2de1ac 100644
--- a/pyinaturalist/models/life_list.py
+++ b/pyinaturalist/models/life_list.py
@@ -1,17 +1,16 @@
 from itertools import groupby
-from typing import Dict, List
+from typing import List
 
 from attr import field
 
-from pyinaturalist.constants import JsonResponse, TableRow
-from pyinaturalist.models import BaseModel, Taxon, define_model, kwarg
+from pyinaturalist.constants import JsonResponse
+from pyinaturalist.models import Taxon, TaxonCount, TaxonCounts, define_model, kwarg
 
 
 @define_model
-class LifeListTaxon(Taxon):
+class LifeListTaxon(TaxonCount):
     """A single taxon in a user's life list"""
 
-    count: int = field(default=0)
     descendant_obs_count: int = field(default=0)
     direct_obs_count: int = field(default=0)
 
@@ -20,31 +19,21 @@ class LifeListTaxon(Taxon):
         """Indentation level corresponding to this item's rank level"""
         return int(((70 - self.rank_level) / 5))
 
-    @property
-    def row(self) -> TableRow:
-        return {
-            'ID': self.id,
-            'Rank': self.rank,
-            'Name': self.name,
-            'Count': self.count,
-        }
-
     def __str__(self) -> str:
         padding = " " * self.indent_level
         return f'[{self.id:<8}] {padding} {self.rank.title()} {self.name}: {self.count}'
 
 
 @define_model
-class LifeList(BaseModel):
+class LifeList(TaxonCounts):
     """A user's life list, based on the schema of ``GET /observations/taxonomy``"""
 
     count_without_taxon: int = field(default=0)
-    taxa: List[LifeListTaxon] = field(factory=list, converter=LifeListTaxon.from_json_list)
+    taxa: List[LifeListTaxon] = field(factory=list, converter=LifeListTaxon.from_json_list)  # type: ignore
     user_id: int = kwarg
-    _taxon_counts: Dict[int, int] = field(default=None, init=False, repr=False)
 
     @classmethod
-    def from_json(cls, value: JsonResponse, user_id: int = None, **kwargs) -> BaseModel:
+    def from_json(cls, value: JsonResponse, user_id: int = None, **kwargs) -> 'LifeListTaxon':  # type: ignore
         count_without_taxon = value.get('count_without_taxon', 0)
         if 'results' in value:
             value = value['results']
@@ -52,16 +41,6 @@ class LifeList(BaseModel):
         life_list_json = {'taxa': value, 'user_id': user_id, 'count_without_taxon': count_without_taxon}
         return super(LifeList, cls).from_json(life_list_json)  # type: ignore
 
-    def count(self, taxon_id: int) -> int:
-        """Get an observation count for the specified taxon and its descendants.
-        **Note:** ``-1`` can be used an alias for ``count_without_taxon``.
-        """
-        # Make and cache an index of taxon IDs and observation counts
-        if self._taxon_counts is None:
-            self._taxon_counts = {t.id: t.descendant_obs_count for t in self.taxa}
-            self._taxon_counts[-1] = self.count_without_taxon
-        return self._taxon_counts.get(taxon_id, 0)
-
     def tree(self):
         """**Experimental**
         Organize this life list into a taxonomic tree
@@ -71,9 +50,6 @@ class LifeList(BaseModel):
         """
         return make_tree(self.taxa)
 
-    def __str__(self) -> str:
-        return '\n'.join([str(taxon) for taxon in self.taxa])
-
 
 def make_tree(taxa: List[Taxon]):
     """Organize a list of taxa into a taxonomic tree. Must contain at least one taxon with
@@ -102,5 +78,5 @@ def make_tree(taxa: List[Taxon]):
 
 
 def _sort_groupby(values, key):
-    """Apply sorting then groupby using the same key"""
+    """Apply sorting then grouping using the same key"""
     return {k: list(group) for k, group in groupby(sorted(values, key=key), key=key)}
diff --git a/pyinaturalist/models/taxon.py b/pyinaturalist/models/taxon.py
index b922eb4..1ef39a5 100644
--- a/pyinaturalist/models/taxon.py
+++ b/pyinaturalist/models/taxon.py
@@ -1,4 +1,5 @@
-from typing import Dict, List
+from datetime import datetime
+from typing import Any, Dict, List, Optional
 
 from attr import field, fields_dict
 
@@ -10,45 +11,56 @@ from pyinaturalist.constants import (
     JsonResponse,
     TableRow,
 )
-from pyinaturalist.models import BaseModel, LazyProperty, Photo, define_model, kwarg
+from pyinaturalist.models import (
+    BaseModel,
+    BaseModelCollection,
+    LazyProperty,
+    Photo,
+    datetime_attr,
+    define_model,
+    kwarg,
+)
 from pyinaturalist.v1 import get_taxa_by_id
 
 
 @define_model
 class Taxon(BaseModel):
-    """A dataclass containing information about a taxon, matching the schema of
+    """An iNaturalist taxon, based on the schema of
     `GET /taxa <https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa>`_.
 
     Can be constructed from either a full or partial JSON record. Examples of partial records
-    include nested ``ancestors``, ``children``, and results from :py:func:`get_taxa_autocomplete`.
+    include nested ``ancestors``, ``children``, and results from :py:func:`.get_taxa_autocomplete`.
     """
 
-    atlas_id: int = kwarg
-    complete_rank: str = kwarg
-    complete_species_count: int = kwarg
-    extinct: bool = kwarg
-    iconic_taxon_id: int = field(default=0)
-    iconic_taxon_name: str = field(default='unknown')
-    id: int = kwarg
-    is_active: bool = kwarg
-    listed_taxa_count: int = kwarg
-    name: str = kwarg
-    observations_count: int = kwarg
-    parent_id: int = kwarg
-    rank: str = kwarg
-    rank_level: int = kwarg
-    taxon_changes_count: int = kwarg
-    taxon_schemes_count: int = kwarg
-    wikipedia_summary: str = kwarg
-    wikipedia_url: str = kwarg
-    preferred_common_name: str = field(default='')
+    complete_rank: str = kwarg  #: Complete or "leaf taxon" rank, e.g. species or subspecies
+    complete_species_count: int = kwarg  #: Total number of species descended from this taxon
+    created_at: Optional[datetime] = datetime_attr  #: When the taxon was added to iNaturalist
+    extinct: bool = kwarg  #: Indicates if the taxon is extinct
+    iconic_taxon_id: int = field(default=0)  #: ID of the iconic taxon or taxon "category"
+    iconic_taxon_name: str = field(default='unknown')  #: Name of the iconic taxon or taxon "category"
+    id: int = kwarg  #: Taxon ID
+    is_active: bool = kwarg  #: Indicates if the taxon is active (and not renamed, moved, etc.)
+    listed_taxa_count: int = kwarg  #: Number of listed taxa from this taxon + descendants
+    name: str = kwarg  #: Taxon name; contains full scientific name at species level and below
+    observations_count: int = kwarg  #: Total number of observations of this taxon and its descendants
+    parent_id: int = kwarg  #: Taxon ID of immediate ancestor
+    preferred_common_name: str = field(default='')  #: Common name for the preferred place, if any
+    preferred_establishment_means: str = kwarg  #: Establishment means for this taxon + preferred place
+    rank_level: int = kwarg  #: Rank number for easier comparison between ranks (kingdom=higest)
+    rank: str = kwarg  #: Taxon rank (species, genus, etc.)
+    taxon_changes_count: int = kwarg  #: Number of curator changes to this taxon
+    taxon_schemes_count: int = kwarg  #: Taxon schemes that include this taxon
+    wikipedia_summary: str = kwarg  #: Taxon summary from Wikipedia article, if available
+    wikipedia_url: str = kwarg  #: URL to Wikipedia article for the taxon, if available
 
     # Nested collections
-    ancestor_ids: List[int] = field(factory=list)
-    conservation_statuses: List[str] = field(factory=list)
-    current_synonymous_taxon_ids: List[int] = field(factory=list)
-    flag_counts: Dict = field(factory=dict)
-    listed_taxa: List = field(factory=list)
+    # TODO: Models+enums for conservation status and establishment means?
+    ancestor_ids: List[int] = field(factory=list)  #: Taxon IDs of ancestors, from highest rank to lowest
+    conservation_status: Dict[str, Any] = field(factory=dict)
+    conservation_statuses: List[Dict] = field(factory=list)  #:
+    current_synonymous_taxon_ids: List[int] = field(factory=list)  #: Taxa that are accepted synonyms
+    establishment_means: Dict[str, Any] = field(factory=dict)  #: Establishment means details
+    listed_taxa: List = field(factory=list)  #: Listed taxon IDs
 
     # Lazy-loaded nested model objects
     ancestors: property = LazyProperty(BaseModel.from_json_list)
@@ -56,6 +68,14 @@ class Taxon(BaseModel):
     default_photo: property = LazyProperty(Photo.from_json)
     taxon_photos: property = LazyProperty(Photo.from_json_list)
 
+    # Unused attributes
+    # atlas_id: int = kwarg
+    # flag_counts: Dict[str, int] = field(factory=dict)  # {"unresolved": 1, "resolved": 2}
+    # min_species_ancestry: str = kwarg  #: Used internally by iNaturalist for Elasticsearch aggregations
+    # min_species_taxon_id: int = kwarg
+    # photos_locked: bool = kwarg
+    # universal_search_rank: int = kwarg
+
     @classmethod
     def from_sorted_json_list(cls, value: JsonResponse) -> List['Taxon']:
         """Sort Taxon objects by rank then by name"""
@@ -75,17 +95,24 @@ class Taxon(BaseModel):
         return ICONIC_EMOJI.get(self.iconic_taxon_id, '❓')
 
     @property
-    def full_name(taxon) -> str:
-        """Taxon rank, scientific name, and common name (if available)"""
-        if not taxon:
+    def full_name(self) -> str:
+        """Taxon rank, scientific name, and common name (if available).
+
+        Example:
+
+            >>> taxon.full_name
+            'Genus: Physcia (Rosette Lichens)'
+
+        """
+        if not self.name and not self.rank:
             return 'unknown taxon'
-        if not taxon.name:
-            name = str(taxon.id)
+        if not self.name:
+            name = str(self.id)
         else:
-            common_name = taxon.preferred_common_name
-            name = taxon.name + (f' ({common_name})' if common_name else '')
+            common_name = self.preferred_common_name
+            name = self.name + (f' ({common_name})' if common_name else '')
 
-        return f'{taxon.rank.title()}: {name}'
+        return f'{self.rank.title()}: {name}'
 
     @property
     def icon_url(self) -> str:
@@ -128,6 +155,66 @@ class Taxon(BaseModel):
         return f'[{self.id}] {self.full_name}' if self.name else self.full_name
 
 
+@define_model
+class TaxonCount(Taxon):
+    """A taxon with an associated count, used in a :py:class:`.TaxonCounts` collection"""
+
+    count: int = field(default=0)  #: Number of observations of this taxon
+
+    @classmethod
+    def from_json(cls, value: JsonResponse, user_id: int = None, **kwargs) -> 'TaxonCount':
+        """Flatten out count + taxon fields into a single-level dict before initializing"""
+        if 'results' in value:
+            value = value['results']
+        if 'taxon' in value:
+            value.update(value.pop('taxon'))
+        return super(TaxonCount, cls).from_json(value)  # type: ignore
+
+    @property
+    def row(self) -> TableRow:
+        return {
+            'ID': self.id,
+            'Rank': self.rank,
+            'Name': self.name,
+            'Count': self.count,
+        }
+
+    def __str__(self) -> str:
+        return f'[{self.id}] {self.full_name}: {self.count}'
+
+
+@define_model
+class TaxonCounts(BaseModelCollection):
+    """A collection of taxa with an associated counts. Used with
+    `GET /observations/species_counts <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_species_counts>`_.
+    as well as :py:class:`.LifeList`.
+    """
+
+    taxa: List[TaxonCount] = field(factory=list, converter=TaxonCount.from_json_list)
+    _taxon_counts: Dict[int, int] = field(default=None, init=False, repr=False)
+
+    @classmethod
+    def from_json(cls, value: JsonResponse, **kwargs) -> 'TaxonCounts':
+        if 'results' in value:
+            value = value['results']
+        if 'taxa' not in value:
+            value = {'taxa': value}
+        return super(TaxonCounts, cls).from_json(value)  # type: ignore
+
+    def count(self, taxon_id: int) -> int:
+        """Get an observation count for the specified taxon and its descendants, and handle unlisted taxa.
+        **Note:** ``-1`` can be used an alias for ``count_without_taxon``.
+        """
+        # Make and cache an index of taxon IDs and observation counts
+        if self._taxon_counts is None:
+            self._taxon_counts = {t.id: t.descendant_obs_count for t in self.taxa}
+            self._taxon_counts[-1] = self.count_without_taxon
+        return self._taxon_counts.get(taxon_id, 0)
+
+    def __str__(self) -> str:
+        return '\n'.join([str(taxon) for taxon in self.taxa])
+
+
 # Since these use Taxon classmethods, they must be added after Taxon is defined
 Taxon.ancestors = LazyProperty(Taxon.from_json_list, 'ancestors')
 Taxon.children = LazyProperty(Taxon.from_sorted_json_list, 'children')
diff --git a/scripts/unique_response_keys.py b/scripts/unique_response_keys.py
index 10136e9..49eb903 100755
--- a/scripts/unique_response_keys.py
+++ b/scripts/unique_response_keys.py
@@ -10,6 +10,8 @@ from examples.sample_responses import *
 RESPONSES = {
     'annotation': annotation_json,
     'comment': comment_json,
+    'controlled_term': controlled_term_json[0],
+    'controlled_term_value': controlled_term_value_json[0],
     'identification': identification_json,
     'observation_field': obs_field_json,
     'observation': obs_json,
@@ -21,6 +23,7 @@ RESPONSES = {
     'project': project_json,
     'search': search_results_json[0],
     'taxon': taxon_json_partial,
+    'taxon_counts': obs_species_counts_json[0],
     'user': user_json_partial,
 }
 


Test patch:
diff --git a/test/sample_data/get_taxa_with_preferred_place.json b/test/sample_data/get_taxa_with_preferred_place.json
new file mode 100644
index 0000000..99a08e4
--- /dev/null
+++ b/test/sample_data/get_taxa_with_preferred_place.json
@@ -0,0 +1,123 @@
+{
+  "total_results": 2,
+  "page": 1,
+  "per_page": 30,
+  "results": [
+    {
+      "observations_count": 33369,
+      "taxon_schemes_count": 4,
+      "ancestry": "48460/47126/211194/47125/47124/47605/47604/632790/568270/936861/56223",
+      "is_active": true,
+      "flag_counts": {
+        "unresolved": 0,
+        "resolved": 1
+      },
+      "wikipedia_url": "http://en.wikipedia.org/wiki/Tussilago",
+      "current_synonymous_taxon_ids": null,
+      "iconic_taxon_id": 47126,
+      "rank_level": 10,
+      "taxon_changes_count": 0,
+      "atlas_id": null,
+      "complete_species_count": null,
+      "parent_id": 56223,
+      "name": "Tussilago farfara",
+      "rank": "species",
+      "extinct": false,
+      "id": 56222,
+      "default_photo": {
+        "square_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/106477260/square.jpeg?1607102500",
+        "attribution": "(c) Serhii Koniakin, some rights reserved (CC BY-NC)",
+        "flags": [],
+        "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/106477260/medium.jpeg?1607102500",
+        "id": 106477260,
+        "license_code": "cc-by-nc",
+        "original_dimensions": {
+          "width": 2048,
+          "height": 1366
+        },
+        "url": "https://inaturalist-open-data.s3.amazonaws.com/photos/106477260/square.jpeg?1607102500"
+      },
+      "ancestor_ids": [
+        48460,
+        47126,
+        211194,
+        47125,
+        47124,
+        47605,
+        47604,
+        632790,
+        568270,
+        936861,
+        56223,
+        56222
+      ],
+      "matched_term": "colt's-foot",
+      "iconic_taxon_name": "Plantae",
+      "preferred_common_name": "colt's-foot",
+      "establishment_means": {
+        "establishment_means": "introduced",
+        "id": 5660131,
+        "place": {
+          "id": 6853,
+          "name": "Nova Scotia",
+          "display_name": "Nova Scotia, CA",
+          "ancestry": "97394/6712"
+        }
+      },
+      "preferred_establishment_means": "introduced"
+    },
+    {
+      "observations_count": 52,
+      "taxon_schemes_count": 1,
+      "ancestry": "48460/1/47120/372739/47158/184884/47157/47156/47155/124229/124232/143001",
+      "is_active": true,
+      "flag_counts": {
+        "unresolved": 0,
+        "resolved": 0
+      },
+      "wikipedia_url": "http://en.wikipedia.org/wiki/Epiblema_sticticana",
+      "current_synonymous_taxon_ids": null,
+      "iconic_taxon_id": 47158,
+      "rank_level": 10,
+      "taxon_changes_count": 0,
+      "atlas_id": null,
+      "complete_species_count": null,
+      "parent_id": 143001,
+      "name": "Epiblema sticticana",
+      "rank": "species",
+      "extinct": false,
+      "id": 504294,
+      "default_photo": {
+        "square_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/12206840/square.jpg?1512413808",
+        "attribution": "(c) Nikolai Vladimirov, some rights reserved (CC BY-NC)",
+        "flags": [],
+        "medium_url": "https://inaturalist-open-data.s3.amazonaws.com/photos/12206840/medium.jpg?1512413808",
+        "id": 12206840,
+        "license_code": "cc-by-nc",
+        "original_dimensions": {
+          "width": 1013,
+          "height": 674
+        },
+        "url": "https://inaturalist-open-data.s3.amazonaws.com/photos/12206840/square.jpg?1512413808"
+      },
+      "ancestor_ids": [
+        48460,
+        1,
+        47120,
+        372739,
+        47158,
+        184884,
+        47157,
+        47156,
+        47155,
+        124229,
+        124232,
+        143001,
+        504294
+      ],
+      "matched_term": "Colt's-foot Bell",
+      "iconic_taxon_name": "Insecta",
+      "preferred_common_name": "Colt's-foot Bell"
+    }
+  ]
+}
diff --git a/test/test_formatters.py b/test/test_formatters.py
index 6b504df..a273c3f 100644
--- a/test/test_formatters.py
+++ b/test/test_formatters.py
@@ -17,7 +17,6 @@ from pyinaturalist.formatters import (
     pprint,
     simplify_observations,
 )
-from pyinaturalist.models import LifeList
 from test.conftest import load_sample_data
 
 controlled_term_1 = load_sample_data('get_controlled_terms.json')['results'][0]
@@ -26,6 +25,7 @@ identification_1 = load_sample_data('get_identifications.json')['results'][0]
 identification_2 = load_sample_data('get_identifications.json')['results'][1]
 observation_1 = load_sample_data('get_observation.json')['results'][0]
 observation_2 = load_sample_data('get_observations_node_page1.json')['results'][0]
+obs_species_counts_json = load_sample_data('get_observation_species_counts.json')['results']
 obs_taxonomy_json = load_sample_data('get_observation_taxonomy.json')
 place_1 = load_sample_data('get_places_by_id.json')['results'][1]
 place_2 = load_sample_data('get_places_autocomplete.json')['results'][0]
@@ -43,13 +43,15 @@ user_1 = load_sample_data('get_user_by_id.json')['results'][0]
 user_2 = load_sample_data('get_users_autocomplete.json')['results'][0]
 
 comments = observation_2['comments']
-life_list = LifeList.from_json(obs_taxonomy_json)
 photo = taxon_1['default_photo']
 
 RESPONSES = [
     comments,
+    [controlled_term_1, controlled_term_2],
     [identification_1, identification_2],
     [observation_1, observation_2],
+    obs_species_counts_json,
+    obs_taxonomy_json,
     [photo, photo],
     [place_1, place_2],
     places_nearby,
@@ -57,7 +59,6 @@ RESPONSES = [
     search_results,
     [taxon_1, taxon_2],
     [user_1, user_2],
-    life_list,
 ]
 
 
@@ -66,21 +67,6 @@ def get_variations(response_object):
     return [{'results': [response_object]}, [response_object], response_object]
 
 
-controlled_term_str_1 = """
-12: Plant Phenology
-    21: No Evidence of Flowering
-    13: Flowering
-    14: Fruiting
-    15: Flower Budding
-""".strip()
-controlled_term_str_2 = """
-9: Sex
-    10: Female
-    11: Male
-    20: Cannot Be Determined
-""".strip()
-
-
 # TODO: More thorough tests for table content
 @pytest.mark.parametrize('response', RESPONSES)
 def test_format_table(response):
@@ -108,7 +94,10 @@ def test_pprint(response):
 
 @pytest.mark.parametrize('input', get_variations(controlled_term_1))
 def test_format_controlled_terms(input):
-    assert format_controlled_terms(input) == controlled_term_str_1
+    assert (
+        format_controlled_terms(input)
+        == '[12] Plant Phenology: No Evidence of Flowering, Flowering, Fruiting, Flower Budding'
+    )
 
 
 @pytest.mark.parametrize('input', get_variations(identification_1))
diff --git a/test/test_models.py b/test/test_models.py
index c796674..cb68c6d 100644
--- a/test/test_models.py
+++ b/test/test_models.py
@@ -1,3 +1,12 @@
+"""Things to test for each model:
+
+* Load with sample response JSON, make sure it doesn't explode
+   * Test with responses from different endpoints and/or response variations, if applicable
+* Initialize the model with no args, and expect sane defaults
+* Any data type conversions that run on init
+* Any additional properties or aliases on the model
+* Formatting in the model's __str__ method
+"""
 import pytest
 from datetime import datetime
 from dateutil.tz import tzoffset, tzutc
@@ -8,6 +17,8 @@ from pyinaturalist.models import (
     OFV,
     Annotation,
     Comment,
+    ControlledTerm,
+    ControlledTermValue,
     LifeList,
     LifeListTaxon,
     Observation,
@@ -20,14 +31,18 @@ from pyinaturalist.models import (
     ProjectUser,
     SearchResult,
     Taxon,
+    TaxonCounts,
     User,
 )
 from pyinaturalist.models.observation_field import ObservationFieldValue
+from pyinaturalist.models.taxon import TaxonCount
 from test.conftest import load_sample_data, sample_data_path
 
+controlled_term_json = load_sample_data('get_controlled_terms.json')['results'][0]
 obs_json = load_sample_data('get_observations_node_page1.json')['results'][0]
 obs_json_ofvs = load_sample_data('get_observation_with_ofvs.json')['results'][0]
 obs_field_json = load_sample_data('get_observation_fields_page1.json')[0]
+obs_species_counts_json = load_sample_data('get_observation_species_counts.json')['results']
 obs_taxonomy_json = load_sample_data('get_observation_taxonomy.json')
 place_json = load_sample_data('get_places_by_id.json')['results'][0]
 places_nearby_json = load_sample_data('get_places_nearby.json')['results']
@@ -41,6 +56,7 @@ taxon_json_partial = load_sample_data('get_taxa.json')['results'][0]
 
 annotation_json = obs_json_ofvs['annotations'][0]
 comment_json = obs_json['comments'][0]
+controlled_term_value_json = controlled_term_json['values'][0]
 identification_json = obs_json['identifications'][0]
 ofv_json_numeric = obs_json_ofvs['ofvs'][1]
 ofv_json_taxon = obs_json_ofvs['ofvs'][0]
@@ -72,49 +88,81 @@ def test_from_json_list():
     pass
 
 
-# Annotations
+# Controlled Terms
 # --------------------
 
 
-def test_annotation_converters():
-    """Test data type conversions"""
+def test_annotation__converters():
     annotation = Annotation.from_json(annotation_json)
     assert isinstance(annotation.user, User) and annotation.user.id == 2115051
 
 
-def test_annotation_empty():
-    """We should be able to initialize the model with no args, and get sane defaults"""
+def test_annotation__empty():
     annotation = Annotation()
     assert annotation.votes == []
     assert annotation.user is None
 
 
-def test_annotation_values():
+def test_annotation__values():
     annotation = Annotation.from_json(annotation_json)
     assert annotation.values == ['1', '2']
 
 
-def test_annotation_str():
+def test_annotation__str():
     annotation = Annotation.from_json(annotation_json)
     assert str(annotation) == '[1] 1|2 (0 votes)'
 
 
+def test_controlled_term__converters():
+    controlled_term = ControlledTerm.from_json(controlled_term_json)
+    value = controlled_term.values[0]
+    assert controlled_term.taxon_ids == [47126]
+    assert isinstance(value, ControlledTermValue) and value.id == 21
+
+
+def test_controlled_term__empty():
+    controlled_term = ControlledTerm()
+    assert controlled_term.taxon_ids == []
+    assert controlled_term.values == []
+
+
+def test_controlled_term__properties():
+    controlled_term = ControlledTerm.from_json(controlled_term_json)
+    assert (
+        controlled_term.value_labels == 'No Evidence of Flowering, Flowering, Fruiting, Flower Budding'
+    )
+
+
+def test_controlled_term__str():
+    controlled_term = ControlledTerm.from_json(controlled_term_json)
+    assert (
+        str(controlled_term)
+        == '[12] Plant Phenology: No Evidence of Flowering, Flowering, Fruiting, Flower Budding'
+    )
+
+
+def test_controlled_term__value():
+    controlled_term_value = ControlledTermValue.from_json(controlled_term_value_json)
+    assert controlled_term_value.taxon_ids == [47125]
+    assert str(controlled_term_value) == '[21] No Evidence of Flowering'
+
+
 # Comments
 # --------------------
 
 
-def test_comment_converters():
+def test_comment__converters():
     comment = Comment.from_json(comment_json)
     assert isinstance(comment.user, User) and comment.user.id == 2852555
 
 
-def test_comment_empty():
+def test_comment__empty():
     comment = Comment()
     assert isinstance(comment.created_at, datetime)
     assert comment.user is None
 
 
-def test_comment_str():
+def test_comment__str():
     comment = Comment.from_json(comment_json)
     assert str(comment) == 'samroom at 2020-08-28 12:04:18+00:00: Thankyou '
 
@@ -123,19 +171,19 @@ def test_comment_str():
 # --------------------
 
 
-def test_identification_converters():
+def test_identification__converters():
     identification = ID.from_json(identification_json)
     assert isinstance(identification.user, User) and identification.user.id == 2852555
 
 
-def test_identification_empty():
+def test_identification__empty():
     identification = ID()
     assert isinstance(identification.created_at, datetime)
     assert identification.taxon is None
     assert identification.user is None
 
 
-def test_identification_str():
+def test_identification__str():
     identification = ID.from_json(identification_json)
     assert str(identification) == (
         '[126501311] Species: Danaus plexippus (Monarch) (improving) added on '
@@ -147,18 +195,20 @@ def test_identification_str():
 # --------------------
 
 
-def test_life_list_converters():
+def test_life_list__converters():
     life_list = LifeList.from_json(obs_taxonomy_json)
+    assert life_list.taxa[0] == life_list[0]
+    assert len(life_list) == 9
     assert isinstance(life_list.taxa[0], LifeListTaxon) and life_list.taxa[0].id == 1
 
 
-def test_life_list_empty():
+def test_life_list__empty():
     life_list = LifeList()
     life_list.taxa == []
     life_list._taxon_counts is None
 
 
-def test_life_list_count():
+def test_life_list__count():
     life_list = LifeList.from_json(obs_taxonomy_json)
     assert life_list.count(1) == 3023  # Animalia
     assert life_list.count(981) == 2  # Phasianus colchicus
@@ -170,7 +220,7 @@ def test_life_list_count():
 # --------------------
 
 
-def test_observation_converters():
+def test_observation__converters():
     obs = Observation.from_json(obs_json)
     utc = tzoffset('Etc/UTC', 0)
     assert obs.created_at == datetime(2020, 8, 27, 0, 0, tzinfo=utc)
@@ -185,7 +235,18 @@ def test_observation_converters():
     assert obs.location == (50.0949055, -104.71929167)
 
 
-def test_observation_with_ofvs():
+def test_observation__empty():
+    obs = Observation()
+    assert isinstance(obs.created_at, datetime)
+    assert obs.comments == []
+    assert obs.identifications == []
+    assert obs.photos == []
+    assert obs.uuid is None
+    assert obs.taxon is None
+    assert obs.user is None
+
+
+def test_observation__with_ofvs():
     obs = Observation.from_json(obs_json_ofvs)
     ofv = obs.ofvs[0]
     assert isinstance(ofv, ObservationFieldValue)
@@ -193,24 +254,13 @@ def test_observation_with_ofvs():
     assert ofv.user.id == 2115051
 
 
-def test_project_observations():
+def test_observation__project_observations_():
     obs = Observation.from_json(obs_json_ofvs)
     proj_obs = obs.project_observations[0]
     assert isinstance(proj_obs, ProjectObservation) and proj_obs.id == 48899479
 
 
-def test_observation_empty():
-    obs = Observation()
-    assert isinstance(obs.created_at, datetime)
-    assert obs.comments == []
-    assert obs.identifications == []
-    assert obs.photos == []
-    assert obs.uuid is None
-    assert obs.taxon is None
-    assert obs.user is None
-
-
-def test_observation_thumbnail_url():
+def test_observation__thumbnail_url():
     obs = Observation.from_json(obs_json)
     assert obs.thumbnail_url == 'https://static.inaturalist.org/photos/92152429/square.jpg?1598551272'
 
@@ -219,24 +269,24 @@ def test_observation_thumbnail_url():
 # --------------------
 
 
-def test_observation_field_converters():
+def test_observation_field__converters():
     obs_field = ObservationField.from_json(obs_field_json)
     assert obs_field.allowed_values == ['Male', 'Female', 'Unknown']
     assert obs_field.created_at == datetime(2016, 5, 29, 16, 17, 8, 51000, tzinfo=tzutc())
 
 
-def test_observation_field_empty():
+def test_observation_field__empty():
     obs_field = ObservationField()
     assert obs_field.allowed_values == []
     assert isinstance(obs_field.created_at, datetime)
 
 
-def test_observation_field_str():
+def test_observation_field__str():
     obs_field = ObservationField.from_json(obs_field_json)
     assert str(obs_field) == '[4813] Sex (deer/turkey) (text)'
 
 
-def test_observation_field_value_converters():
+def test_observation_field_value__converters():
     ofv = OFV.from_json(ofv_json_numeric)
     assert ofv.datatype == 'numeric'
     assert ofv.value == 100
@@ -244,20 +294,20 @@ def test_observation_field_value_converters():
     assert isinstance(ofv.user, User) and ofv.user.id == 2115051
 
 
-def test_observation_field_value_taxon():
+def test_observation_field_value__taxon():
     ofv = OFV.from_json(ofv_json_taxon)
     assert ofv.datatype == 'taxon'
     assert ofv.value == 119900
     assert isinstance(ofv.taxon, Taxon) and ofv.taxon.id == 119900
 
 
-def test_observation_field_value_empty():
+def test_observation_field_value__empty():
     ofv = OFV()
     assert ofv.value is None
     assert ofv.taxon is None
 
 
-def test_observation_field_value_str():
+def test_observation_field_value__str():
     ofv = OFV.from_json(ofv_json_taxon)
     assert str(ofv) == 'Feeding on: 119900'
 
@@ -266,7 +316,7 @@ def test_observation_field_value_str():
 # --------------------
 
 
-def test_photo_converters():
+def test_photo__converters():
     photo = Photo.from_json(photo_json)
     assert photo.id == 38359335
     assert photo.license_code == 'CC-BY-NC'
@@ -274,12 +324,12 @@ def test_photo_converters():
     assert photo.info_url == f'{PHOTO_INFO_BASE_URL}/38359335'
 
 
-def test_photo_empty():
+def test_photo__empty():
     photo = Photo()
     assert photo.original_dimensions == (0, 0)
 
 
-def test_photo_license():
+def test_photo__license():
     photo = Photo.from_json(photo_json)
     assert photo.has_cc_license is True
     photo.license_code = 'CC0'
@@ -292,7 +342,7 @@ def test_photo_license():
 
 
 @pytest.mark.parametrize('size', PHOTO_SIZES)
-def test_photo_urls(size):
+def test_photo__urls(size):
     photo = Photo.from_json(photo_json)
     assert (
         photo.url_size(size)
@@ -302,7 +352,7 @@ def test_photo_urls(size):
     assert photo.url_size('embiggened') is None
 
 
-def test_photo_str():
+def test_photo__str():
     photo = Photo.from_json(photo_json)
     assert str(photo) == (
         '[38359335] https://static.inaturalist.org/photos/38359335/original.jpg?1557348751 '
@@ -314,20 +364,20 @@ def test_photo_str():
 # --------------------
 
 
-def test_place_converters():
+def test_place__converters():
     place = Place.from_json(place_json)
     assert place.category is None
     assert place.location == (-29.665119, 17.88583)
 
 
-def test_place_empty():
+def test_place__empty():
     place = Place()
     assert place.ancestor_place_ids == []
     assert place.bounding_box_geojson == {}
     assert place.geometry_geojson == {}
 
 
-def test_places_nearby():
+def test_places__nearby():
     """Results from /places/nearby should have an extra 'category' attribute"""
     places = Place.from_json_list(places_nearby_json)
     assert places[0].category == 'standard'
@@ -338,7 +388,7 @@ def test_places_nearby():
 # --------------------
 
 
-def test_project_converters():
+def test_project__converters():
     project = Project.from_json(project_json)
     assert project.location == (48.777404, -122.306929)
     assert project.project_observation_rules == project.obs_rules
@@ -351,16 +401,7 @@ def test_project_converters():
     assert isinstance(project.user, User) and project.user.id == 233188
 
 
-def test_project_with_obs_fields():
-    project = Project.from_json(project_json_obs_fields)
-    obs_field = project.project_observation_fields[0]
-    assert isinstance(obs_field, ProjectObservationField)
-    assert obs_field.id == 30
-    assert obs_field.position == 0
-    assert obs_field.required is False
-
-
-def test_project_empty():
+def test_project__empty():
     project = Project()
     assert project.admins == []
     assert isinstance(project.created_at, datetime)
@@ -370,54 +411,60 @@ def test_project_empty():
     assert project.user is None
 
 
+def test_project__with_obs_fields():
+    project = Project.from_json(project_json_obs_fields)
+    obs_field = project.project_observation_fields[0]
+    assert isinstance(obs_field, ProjectObservationField)
+    assert obs_field.id == 30
+    assert obs_field.position == 0
+    assert obs_field.required is False
+
+
 # Search
 # --------------------
 
 
-def test_search_empty():
+def test_search__empty():
     search_result = SearchResult()
     assert search_result.score == 0
     assert search_result.matches == []
     assert search_result.record is None
 
 
-def test_search_place():
+def test_search__place():
     search_result = SearchResult.from_json(search_result_json_place)
     assert search_result.score == 7.116488
     assert isinstance(search_result.record, Place) and search_result.record.id == 113562
 
 
-def test_search_project():
+def test_search__project():
     search_result = SearchResult.from_json(search_result_json_project)
     assert search_result.score == 6.9390197
     assert isinstance(search_result.record, Project) and search_result.record.id == 9978
 
 
-def test_search_taxon():
+def test_search__taxon():
     search_result = SearchResult.from_json(search_result_json_taxon)
     assert search_result.score == 9.062307
     assert isinstance(search_result.record, Taxon) and search_result.record.id == 47792
 
 
-def test_search_user():
+def test_search__user():
     search_result = SearchResult.from_json(search_result_json_user)
     assert search_result.score == 4.6454225
     assert isinstance(search_result.record, User) and search_result.record.id == 113886
 
 
-SearchResult
-
-
 # Taxa
 # --------------------
 
 
-def test_taxon_converters():
+def test_taxon__converters():
     taxon = Taxon.from_json(taxon_json_partial)
     assert isinstance(taxon.default_photo, Photo) and taxon.default_photo.id == 38359335
 
 
-def test_taxon_empty():
+def test_taxon__empty():
     taxon = Taxon()
     assert taxon.preferred_common_name == ''
     assert taxon.ancestors == []
@@ -426,7 +473,7 @@ def test_taxon_empty():
     assert taxon.taxon_photos == []
 
 
-def test_taxon_taxonomy():
+def test_taxon__taxonomy():
     taxon = Taxon.from_json(taxon_json)
     parent = taxon.ancestors[0]
     child = taxon.children[0]
@@ -434,7 +481,7 @@ def test_taxon_taxonomy():
     assert isinstance(child, Taxon) and child.id == 70115
 
 
-def test_taxon_properties():
+def test_taxon__properties():
     taxon = Taxon.from_json(taxon_json)
     assert taxon.url == f'{INAT_BASE_URL}/taxa/70118'
     assert taxon.ancestry.startswith('Animalia | Arthropoda | Hexapoda | ')
@@ -442,13 +489,13 @@ def test_taxon_properties():
 
 
 @pytest.mark.parametrize('taxon_id', ICONIC_TAXA.keys())
-def test_taxon_emoji(taxon_id):
+def test_taxon__emoji(taxon_id):
     taxon = Taxon(iconic_taxon_id=taxon_id)
     assert taxon.emoji is not None
 
 
 @pytest.mark.parametrize('taxon_name', ICONIC_TAXA.values())
-def test_taxon_icon_url(taxon_name):
+def test_taxon__icon_url(taxon_name):
     taxon = Taxon(iconic_taxon_name=taxon_name)
     assert taxon.icon_url is not None
 
@@ -460,37 +507,49 @@ def test_taxon_properties__partial():
 
 
 # TODO
-def test_update_from_full_record():
+def test_taxon__update_from_full_record():
     pass
 
 
+def test_taxon_counts__converters():
+    taxon_counts = TaxonCounts.from_json(obs_species_counts_json)
+    assert taxon_counts.taxa[0] == taxon_counts[0]
+    assert len(taxon_counts) == 9
+    assert isinstance(taxon_counts.taxa[0], TaxonCount) and taxon_counts.taxa[0].count == 31
+
+
+def test_taxon_counts__empty():
+    taxon_counts = TaxonCounts()
+    assert taxon_counts.taxa == []
+
+
 # Users
 # --------------------
 
 
-def test_user_converters():
+def test_user__converters():
     user = User.from_json(user_json)
     assert user.identifications_count == 95624
 
 
-def test_user_empty():
+def test_user__empty():
     user = User()
     assert isinstance(user.created_at, datetime)
     assert user.roles == []
 
 
-def test_user_partial():
+def test_user__partial():
     user = User.from_json(user_json_partial)
     assert user.id == 886482
     assert user.name == 'Nicolas Noé'
 
 
-def test_user_aliases():
+def test_user__properties():
     user = User.from_json(user_json)
     assert user.username == user.login == 'kueda'
     assert user.display_name == user.name == 'Ken-ichi Ueda'
 
 
-def test_user_str():
+def test_user__str():
     user = User.from_json(user_json)
     assert str(user) == '[1] kueda (Ken-ichi Ueda)'