Repository: niconoe/pyinaturalist
Pull request #160 (2021-06-02 01:43:17)

More data model updates
* Add `Annotation` model
* Convert `Observation.annotations` into `Annotation` objects
* Convert `Observation.ofvs` into `ObservationFieldValue` objects
* Add a `@define_model` class decorator to reuse some common attrs options


Patch:
diff --git a/examples/sample_responses.py b/examples/sample_responses.py
index 1fcf409..b140867 100755
--- a/examples/sample_responses.py
+++ b/examples/sample_responses.py
@@ -6,6 +6,7 @@ from os.path import join
 from pyinaturalist import *
 from pyinaturalist.constants import SAMPLE_DATA_DIR
 from pyinaturalist.models import (
+    Annotation,
     Comment,
     Identification,
     Observation,
@@ -34,9 +35,8 @@ def load_sample_data(filename):
 
 # Sample JSON
 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]
-ofv_json_taxon = load_sample_data('get_observation_with_ofvs.json')['results'][0]['ofvs'][0]
-ofv_json_numeric = load_sample_data('get_observation_with_ofvs.json')['results'][0]['ofvs'][1]
 place_json = load_sample_data('get_places_by_id.json')['results'][0]
 places_nearby_json = load_sample_data('get_places_nearby.json')['results']
 project_json = load_sample_data('get_projects_obs_fields.json')['results'][0]
@@ -44,15 +44,21 @@ user_json = load_sample_data('get_user_by_id.json')['results'][0]
 user_json_partial = load_sample_data('get_users_autocomplete.json')['results'][0]
 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]
 identification_json = obs_json['identifications'][0]
+ofv_json_numeric = obs_json_ofvs['ofvs'][1]
+ofv_json_taxon = obs_json_ofvs['ofvs'][0]
 photo_json = taxon_json['taxon_photos'][0]
 photo_json = taxon_json['default_photo']
 
 # Sample model objects
+annotation = Annotation.from_json(annotation_json)
 comment = Comment.from_json(comment_json)
 identification = Identification.from_json(identification_json)
 observation = Observation.from_json(obs_json)
+observation_with_ofvs = Observation.from_json(obs_json_ofvs)
 observation_field = ObservationField.from_json(obs_field_json)
 ofv = ObservationFieldValue.from_json(ofv_json_numeric)
 photo = Photo.from_json(photo_json)
diff --git a/pyinaturalist/models/__init__.py b/pyinaturalist/models/__init__.py
index c073f0e..4e617d8 100644
--- a/pyinaturalist/models/__init__.py
+++ b/pyinaturalist/models/__init__.py
@@ -1,8 +1,10 @@
 """Dataclasses for modeling iNaturalist API response objects"""
 # flake8: noqa: F401
-
 # TODO: Add formatters as model methods
 # TODO: Validators for multiple choice fields?
+from typing import Callable
+from attr import define
+
 from pyinaturalist.models.base import (
     BaseModel,
     datetime_attr,
@@ -14,18 +16,29 @@ from pyinaturalist.models.base import (
 )
 from pyinaturalist.models.lazy_property import LazyProperty, add_lazy_attrs
 
+# attrs class decorator with the most commonly used options
+define_model: Callable = define(auto_attribs=False, field_transformer=add_lazy_attrs)
+
 # Imported in order of model dependencies
 from pyinaturalist.models.photo import Photo
 from pyinaturalist.models.taxon import Taxon
 from pyinaturalist.models.user import User
+from pyinaturalist.models.annotation import Annotation
 from pyinaturalist.models.comment import Comment
-from pyinaturalist.models.identification import ID, Identification
+from pyinaturalist.models.identification import Identification
 from pyinaturalist.models.place import Place
-from pyinaturalist.models.project import Project, ProjectObservationField, POF, ProjectUser
-from pyinaturalist.models.observation_field import ObservationField, OF, ObservationFieldValue, OFV
+from pyinaturalist.models.project import Project, ProjectObservationField, ProjectUser
+from pyinaturalist.models.observation_field import ObservationField, ObservationFieldValue
 from pyinaturalist.models.observation import Observation
 
 
+# Add aliases for some of the longer class names
+ID = Identification
+OF = ObservationField
+OFV = ObservationFieldValue
+POF = ProjectObservationField
+
+
 # If rich is installed, update its pretty-printer to include model @properties. Since this is only
 # cosmetic, it's not a big deal, but it would be preferable to do this without monkey-patching.
 try:
diff --git a/pyinaturalist/models/annotation.py b/pyinaturalist/models/annotation.py
new file mode 100644
index 0000000..320ce46
--- /dev/null
+++ b/pyinaturalist/models/annotation.py
@@ -0,0 +1,28 @@
+from typing import List
+
+from attr import field
+
+from pyinaturalist.models import BaseModel, LazyProperty, User, define_model, kwarg
+
+
+@define_model
+class Annotation(BaseModel):
+    """A dataclass containing information about an annotation, matching 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('|')
diff --git a/pyinaturalist/models/comment.py b/pyinaturalist/models/comment.py
index aa40b4b..bd784db 100644
--- a/pyinaturalist/models/comment.py
+++ b/pyinaturalist/models/comment.py
@@ -1,11 +1,9 @@
 from datetime import datetime
 
-from attr import define
+from pyinaturalist.models import BaseModel, LazyProperty, User, datetime_now_attr, define_model, kwarg
 
-from pyinaturalist.models import BaseModel, LazyProperty, User, add_lazy_attrs, datetime_now_attr, kwarg
 
-
-@define(auto_attribs=False, field_transformer=add_lazy_attrs)
+@define_model
 class Comment(BaseModel):
     """A dataclass containing information about a comment, matching the schema of observation comments
     from `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_.
diff --git a/pyinaturalist/models/identification.py b/pyinaturalist/models/identification.py
index 90f3357..9f41c41 100644
--- a/pyinaturalist/models/identification.py
+++ b/pyinaturalist/models/identification.py
@@ -1,19 +1,17 @@
 from datetime import datetime
 
-from attr import define
-
 from pyinaturalist.models import (
     BaseModel,
     LazyProperty,
     Taxon,
     User,
-    add_lazy_attrs,
     datetime_now_attr,
+    define_model,
     kwarg,
 )
 
 
-@define(auto_attribs=False, field_transformer=add_lazy_attrs)
+@define_model
 class Identification(BaseModel):
     """A dataclass containing information about an identification, matching the schema of
     `GET /identifications <https://api.inaturalist.org/v1/docs/#!/Identifications/get_identifications>`_.
@@ -44,6 +42,3 @@ class Identification(BaseModel):
     # flags: List = field(factory=list)
     # moderator_actions: List = field(factory=list)
     # observation: {}  # TODO: Is this actually needed?
-
-
-ID = Identification
diff --git a/pyinaturalist/models/observation.py b/pyinaturalist/models/observation.py
index ff23fa7..c74f6a4 100644
--- a/pyinaturalist/models/observation.py
+++ b/pyinaturalist/models/observation.py
@@ -5,9 +5,11 @@ from attr import define, field
 
 from pyinaturalist.constants import Coordinates
 from pyinaturalist.models import (
-    ID,
+    Annotation,
     BaseModel,
+    Identification,
     LazyProperty,
+    ObservationFieldValue,
     Photo,
     Taxon,
     User,
@@ -66,9 +68,7 @@ class Observation(BaseModel):
     uuid: str = kwarg
 
     # Nested collections
-    annotations: List[Dict] = field(factory=list)  # TODO: Annotation model?
     faves: List = field(factory=list)
-    ofvs: List = field(factory=list)  # TODO: Use ObservationFieldValue model
     outlinks: List = field(factory=list)
     place_ids: List = field(factory=list)
     preferences: Dict = field(factory=dict)
@@ -83,8 +83,10 @@ class Observation(BaseModel):
     votes: List = field(factory=list)
 
     # Lazy-loaded nested model objects
+    annotations: property = LazyProperty(Annotation.from_json_list)
     comments: property = LazyProperty(Comment.from_json_list)
-    identifications: property = LazyProperty(ID.from_json_list)
+    identifications: property = LazyProperty(Identification.from_json_list)
+    ofvs: property = LazyProperty(ObservationFieldValue.from_json_list)
     photos: property = LazyProperty(Photo.from_json_list)
     taxon: property = LazyProperty(Taxon.from_json)
     user: property = LazyProperty(User.from_json)
diff --git a/pyinaturalist/models/observation_field.py b/pyinaturalist/models/observation_field.py
index aee47b2..33a0449 100644
--- a/pyinaturalist/models/observation_field.py
+++ b/pyinaturalist/models/observation_field.py
@@ -1,15 +1,15 @@
 from datetime import date, datetime
 from typing import List, Union
 
-from attr import define, field
+from attr import field
 
 from pyinaturalist.models import (
     BaseModel,
     LazyProperty,
     Taxon,
     User,
-    add_lazy_attrs,
     datetime_now_attr,
+    define_model,
     kwarg,
 )
 from pyinaturalist.response_format import safe_split, try_int_or_float
@@ -27,7 +27,7 @@ OFV_DATATYPES = {
 OFVValue = Union[date, datetime, float, int, str]
 
 
-@define(auto_attribs=False)
+@define_model
 class ObservationField(BaseModel):
     """A dataclass containing information about an observation field **definition**, matching the schema of
     `GET /observation_fields <https://www.inaturalist.org/pages/api+reference#get-observation_fields>`_.
@@ -46,7 +46,7 @@ class ObservationField(BaseModel):
     values_count: int = kwarg
 
 
-@define(auto_attribs=False, field_transformer=add_lazy_attrs)
+@define_model
 class ObservationFieldValue(BaseModel):
     """A dataclass containing information about an observation field **value**, matching the schema of ``ofvs``
     from `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_.
@@ -74,8 +74,3 @@ class ObservationFieldValue(BaseModel):
         if self.datatype in OFV_DATATYPES and self.value is not None:
             converter = OFV_DATATYPES[self.datatype]
             self.value = converter(self.value)
-
-
-# The names are a little verbose, so let's alias them
-OF = ObservationField
-OFV = ObservationFieldValue
diff --git a/pyinaturalist/models/photo.py b/pyinaturalist/models/photo.py
index a9a499a..aa9b782 100644
--- a/pyinaturalist/models/photo.py
+++ b/pyinaturalist/models/photo.py
@@ -1,14 +1,14 @@
 from typing import Optional, Tuple
 
-from attr import define, field
+from attr import field
 
 from pyinaturalist.constants import PHOTO_INFO_BASE_URL, PHOTO_SIZES
-from pyinaturalist.models import BaseModel, kwarg
+from pyinaturalist.models import BaseModel, define_model, kwarg
 from pyinaturalist.request_params import CC_LICENSES
 from pyinaturalist.response_format import format_dimensions, format_license
 
 
-@define(auto_attribs=False)
+@define_model
 class Photo(BaseModel):
     """A data class containing information about a remote observation photo"""
 
diff --git a/pyinaturalist/models/place.py b/pyinaturalist/models/place.py
index c9055fa..e5c9ba0 100644
--- a/pyinaturalist/models/place.py
+++ b/pyinaturalist/models/place.py
@@ -1,15 +1,15 @@
 from typing import Dict, List, Optional
 
-from attr import define, field
+from attr import field
 
 from pyinaturalist.constants import Coordinates
-from pyinaturalist.models import BaseModel, coordinate_pair, kwarg
+from pyinaturalist.models import BaseModel, coordinate_pair, define_model, kwarg
 
 # TODO: Optionally use `geojson` library for users who want to use place geojson?
 GeoJson = Dict
 
 
-@define(auto_attribs=False)
+@define_model
 class Place(BaseModel):
     admin_level: int = kwarg
     ancestor_place_ids: List[str] = field(factory=list)
diff --git a/pyinaturalist/models/project.py b/pyinaturalist/models/project.py
index 3726d3d..abbb0c1 100644
--- a/pyinaturalist/models/project.py
+++ b/pyinaturalist/models/project.py
@@ -1,23 +1,23 @@
 from datetime import datetime
 from typing import Dict, List, Optional
 
-from attr import define, field
+from attr import field
 
 from pyinaturalist.constants import Coordinates, JsonResponse
 from pyinaturalist.models import (
     BaseModel,
     LazyProperty,
     User,
-    add_lazy_attrs,
     coordinate_pair,
     datetime_attr,
     datetime_now_attr,
+    define_model,
     kwarg,
 )
 from pyinaturalist.models.observation_field import ObservationField
 
 
-@define(auto_attribs=False)
+@define_model
 class ProjectObservationField(ObservationField):
     """A :py:class:`.ObservationField` with additional project-specific information"""
 
@@ -41,7 +41,7 @@ class ProjectObservationField(ObservationField):
         return [cls.from_project_json(pof, **kwargs) for pof in value]  # type: ignore
 
 
-@define(auto_attribs=False)
+@define_model
 class ProjectUser(User):
     """A :py:class:`.User` with additional project-specific information"""
 
@@ -63,7 +63,7 @@ class ProjectUser(User):
         return [cls.from_project_json(project_user, **kwargs) for project_user in value]  # type: ignore
 
 
-@define(auto_attribs=False, field_transformer=add_lazy_attrs)
+@define_model
 class Project(BaseModel):
     """A dataclass containing information about a project, matching the schema of
     `GET /projects <https://api.inaturalist.org/v1/docs/#!/Projects/get_projects>`_.
@@ -115,7 +115,3 @@ class Project(BaseModel):
     @property
     def obs_rules(self):
         return self.project_observation_rules
-
-
-# Alias for a rather verbose class name
-POF = ProjectObservationField
diff --git a/pyinaturalist/models/taxon.py b/pyinaturalist/models/taxon.py
index 3a8233a..55f48f6 100644
--- a/pyinaturalist/models/taxon.py
+++ b/pyinaturalist/models/taxon.py
@@ -1,14 +1,14 @@
 from typing import Dict, List
 
-from attr import define, field, fields_dict
+from attr import field, fields_dict
 
 from pyinaturalist.constants import API_V1_BASE_URL, JsonResponse
-from pyinaturalist.models import BaseModel, LazyProperty, Photo, add_lazy_attrs, kwarg
+from pyinaturalist.models import BaseModel, LazyProperty, Photo, define_model, kwarg
 from pyinaturalist.request_params import RANKS
 from pyinaturalist.v1 import get_taxa_by_id
 
 
-@define(auto_attribs=False, field_transformer=add_lazy_attrs)
+@define_model
 class Taxon(BaseModel):
     """A dataclass containing information about a taxon, matching the schema of
     `GET /taxa <https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa>`_.
diff --git a/pyinaturalist/models/user.py b/pyinaturalist/models/user.py
index 4f81783..475239e 100644
--- a/pyinaturalist/models/user.py
+++ b/pyinaturalist/models/user.py
@@ -1,12 +1,12 @@
 from datetime import datetime
 from typing import List
 
-from attr import define, field
+from attr import field
 
-from pyinaturalist.models import BaseModel, datetime_now_attr, kwarg
+from pyinaturalist.models import BaseModel, datetime_now_attr, define_model, kwarg
 
 
-@define(auto_attribs=False)
+@define_model
 class User(BaseModel):
     """A dataclass containing information about an user, matching the schema of
     `GET /users/{id} <https://api.inaturalist.org/v1/docs/#!/Users/get_users_id>`_.


Test patch:
diff --git a/test/test_models.py b/test/test_models.py
index dd7a799..52d840f 100644
--- a/test/test_models.py
+++ b/test/test_models.py
@@ -6,6 +6,7 @@ from pyinaturalist.constants import API_V1_BASE_URL, PHOTO_INFO_BASE_URL, PHOTO_
 from pyinaturalist.models import (
     ID,
     OFV,
+    Annotation,
     Comment,
     Observation,
     ObservationField,
@@ -17,12 +18,12 @@ from pyinaturalist.models import (
     Taxon,
     User,
 )
+from pyinaturalist.models.observation_field import ObservationFieldValue
 from test.conftest import load_sample_data, sample_data_path
 
 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]
-ofv_json_taxon = load_sample_data('get_observation_with_ofvs.json')['results'][0]['ofvs'][0]
-ofv_json_numeric = load_sample_data('get_observation_with_ofvs.json')['results'][0]['ofvs'][1]
 place_json = load_sample_data('get_places_by_id.json')['results'][0]
 places_nearby_json = load_sample_data('get_places_nearby.json')['results']
 project_json = load_sample_data('get_projects.json')['results'][0]
@@ -32,8 +33,11 @@ user_json_partial = load_sample_data('get_users_autocomplete.json')['results'][0
 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]
 identification_json = obs_json['identifications'][0]
+ofv_json_numeric = obs_json_ofvs['ofvs'][1]
+ofv_json_taxon = obs_json_ofvs['ofvs'][0]
 photo_json = taxon_json['taxon_photos'][0]
 photo_json = taxon_json['default_photo']
 
@@ -59,6 +63,28 @@ def test_from_json_file__empty():
     assert Observation.from_json_list(None) == []
 
 
+# Annotations
+# --------------------
+
+
+def test_annotation_converters():
+    """Test data type conversions"""
+    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"""
+    annotation = Annotation()
+    assert annotation.votes == []
+    assert annotation.user is None
+
+
+def test_annotation_values():
+    annotation = Annotation.from_json(annotation_json)
+    assert annotation.values == ['1', '2']
+
+
 # Comments
 # --------------------
 
@@ -110,6 +136,14 @@ def test_observation_converters():
     assert obs.location == (50.0949055, -104.71929167)
 
 
+def test_observation_with_ofvs():
+    obs = Observation.from_json(obs_json_ofvs)
+    ofv = obs.ofvs[0]
+    assert isinstance(ofv, ObservationFieldValue)
+    assert ofv.id == 14106828
+    assert ofv.user.id == 2115051
+
+
 def test_observation_empty():
     obs = Observation()
     assert isinstance(obs.created_at, datetime)