class LifeListTaxon(TaxonCount): """:fa:`dove,style=fas` :fa:`list` A single :py:class:`.Taxon` in a user's :py:class:`.LifeList`""" descendant_obs_count: int = field( default=0, doc='Number of observations of taxon children') direct_obs_count: int = field( default=0, doc='Number of observations of this exact taxon (excluding children)') @property def indent_level(self) -> int: """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, } @property def _str_attrs(self) -> List[str]: return ['id', 'rank', 'name', 'count']
class ControlledTermCount(BaseModel): """:fa:`tag` A count + histogram of a controlled term and value""" count: int = field(default=0, doc='') histogram: Dict[int, int] = field(factory=dict) term = LazyProperty(ControlledTerm.from_json, type=ControlledTerm) value = LazyProperty(ControlledTermValue.from_json, type=ControlledTermValue) @classmethod def from_json(cls, value: JsonResponse, user_id: int = None, **kwargs) -> 'ControlledTermCount': """Rename some response fields before initializing""" value['histogram'] = value.pop('month_of_year', None) value['term'] = value.pop('controlled_attribute', None) value['value'] = value.pop('controlled_value', None) return super(ControlledTermCount, cls).from_json(value) @property def term_label(self) -> str: return self.term.label @property def value_label(self) -> str: return self.value.label @property def _row(self) -> TableRow: return { 'Term': self.term.label, 'Value': self.value.label, 'Count': self.count, } @property def _str_attrs(self) -> List[str]: return ['term_label', 'value_label', 'count']
class EstablishmentMeans(BaseModel): """:fa:`exclamation-triangle` The establishment means for a taxon in a given location""" establishment_means: str = field( default=None, options=ESTABLISTMENT_MEANS, converter=lambda x: str(x).lower(), doc='Establishment means label', ) establishment_means_description: str = field( default=None, options=ESTABLISTMENT_MEANS, doc='Establishment means description') place: property = LazyProperty( Place.from_json, type=Place, doc='Location that the establishment means applies to') # TODO: Is establishment_means_label ever different from establishment_means? @property def establishment_means_label(self) -> str: return self.establishment_means @establishment_means_label.setter def establishment_means_label(self, value: str): self.establishment_means = value def __str__(self) -> str: return f'EstablishmentMeans({self.establishment_means})'
class ObservationField(BaseModel): """:fa:`tag` An observation field **definition**, based on the schema of `GET /observation_fields <https://www.inaturalist.org/pages/api+reference#get-observation_fields>`_. """ allowed_values: List[str] = field(converter=safe_split, factory=list) created_at: datetime = datetime_now_field(doc='Date and time the observation field was created') datatype: str = field(default=None) # Enum description: str = field(default=None) name: str = field(default=None) updated_at: datetime = datetime_now_field( doc='Date and time the observation field was last updated' ) user_id: int = field(default=None) users_count: int = field(default=None) uuid: str = field(default=None) values_count: int = field(default=None) @property def _row(self) -> TableRow: return { 'ID': self.id, 'Type': self.datatype, 'Name': self.name, 'Description': self.description, } @property def _str_attrs(self) -> List[str]: return ['id', 'datatype', 'name', 'description']
class ProjectObservation(BaseModel): """:fa:`binoculars` Metadata about an observation that has been added to a project""" preferences: Dict = field(factory=dict) # Example: {'allows_curator_coordinate_access': True} project: Dict = field(factory=dict) # Example: {'id': 24237} user_id: int = field(default=None) uuid: str = field(default=None, doc='Universally unique identifier') user: property = LazyProperty( User.from_json, type=User, doc='User that added the observation to the project' ) @property def _str_attrs(self) -> List[str]: return ['project', 'user_id']
class ConservationStatus(IdWrapperMixin, BaseModel): """:fa:`exclamation-triangle` The conservation status of a taxon in a given location, based on the schema of: * ``Taxon.conservation_status`` from :v1:`GET /taxa <Taxa/get_taxa>` * ``Observation.taxon.conservation_statuses`` from :v1:`GET /observations <Observations/get_observations>` * ``conservation_status`` from :v1:`GET /observation/{id}/taxon_summary <Observations/get_observations_id_taxon_summary>` """ authority: str = field(default=None, doc='Data source for conservation status') created_at: DateTime = datetime_field( doc='Date and time the record was created') description: str = field(default=None, doc='Description of conservation status') geoprivacy: str = field( default=None, doc= 'Default geoprivacy level; may be obscured or private for protected species', ) iucn: int = field(default=None, doc='IUCD ID, if applicable') source_id: int = field(default=None) status: str = field( default=None, converter=upper, options=CONSERVATION_STATUSES, doc='Short code for conservation status', ) status_name: str = field(default=None, doc='Full name of conservation status') taxon_id: int = field(default=None, doc='Taxon ID') updated_at: DateTime = datetime_field( doc='Date and time the record was last updated') url: str = field(default=None, doc='Link to data source with more details') # Lazy-loaded nested model objects place: property = LazyProperty( # type: ignore Place.from_json, type=Place, doc='Location that the conservation status applies to') updater: User = LazyProperty( User.from_json, type=User, doc='User that last updated the record') # type: ignore user: User = LazyProperty( User.from_json, type=User, doc='User that created the record') # type: ignore # TODO: Are these needed? They appear to be redundant with `status` and `status_name` # iucn_status: str = field(default=None) # iucn_status_code: str = field(default=None) @property def _str_attrs(self) -> List[str]: return ['status_name', 'status', 'authority']
class TaxonCount(Taxon): """:fa:`dove,style=fas` :fa:`list` A :py:class:`.Taxon` with an associated count, used in a :py:class:`.TaxonCounts` collection """ count: int = field(default=0, doc='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 = value.copy() value.update(value.pop('taxon')) return super(TaxonCount, cls).from_json(value) @property def _row(self) -> TableRow: return { 'ID': self.id, 'Rank': self.rank, 'Scientific name': f'{self.emoji} {self.name}', 'Common name': self.preferred_common_name, 'Count': self.count, } @property def _str_attrs(self) -> List[str]: return ['id', 'full_name', 'count']
class Observations(BaseModelCollection): """:fa:`binoculars` A collection of observations""" data: List[Observation] = field(factory=list, converter=Observation.from_json_list) @property def identifiers(self) -> List[User]: """Get all unique identifiers""" unique_users = { i.user.id: i.user for obs in self.data for i in obs.identifications } return list(unique_users.values()) @property def observers(self) -> List[User]: """Get all unique observers""" unique_users = {obs.user.id: obs.user for obs in self.data} return list(unique_users.values()) @property def taxa(self) -> List[Taxon]: """Get all unique taxa""" unique_taxa = {obs.taxon.id: obs.taxon for obs in self.data} return list(unique_taxa.values()) @property def thumbnail_urls(self) -> List[str]: """Get thumbnails for all observation default photos""" return [obs.thumbnail_url for obs in self.data if obs.thumbnail_url]
class IconPhoto(Photo): """:fa:`camera` Class used for displaying an iconic taxon in place of a taxon photo""" iconic_taxon_name: str = field(default=None, doc='Iconic taxon name') def __attrs_post_init__(self): self._url_format = self.url.replace('.png', '-{size}px.png') self.url = self.medium_url @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')) @property def _str_attrs(self) -> List[str]: return ['iconic_taxon_name', 'url']
class TaxonCounts(BaseModelCollection): """:fa:`dove,style=fas` :fa:`list` A collection of taxa with an associated counts. Used with :v1:`GET /observations/species_counts <Observations/get_observations_species_counts>`. as well as :py:class:`.LifeList`. """ data: List[TaxonCount] = field(factory=list, converter=TaxonCount.from_json_list)
class ControlledTermCounts(BaseModelCollection): """:fa:`tag` :fa:`list` Used with :v1:`GET /observations/popular_field_values <Observations/get_observations_popular_field_values>`. """ data: List[ControlledTermCount] = field( factory=list, converter=ControlledTermCount.from_json_list )
class ControlledTerm(BaseModel): """:fa:`tag` A controlled term, based on the schema of `GET /controlled_terms <https://api.inaturalist.org/v1/docs/#!/Controlled_Terms/get_controlled_terms>`_. """ is_value: bool = field(default=None) multivalued: bool = field(default=None) label: str = field(default=None) ontology_uri: str = field(default=None) uri: str = field(default=None) uuid: str = field(default=None) taxon_ids: List[int] = field(factory=list) values: property = LazyProperty( ControlledTermValue.from_json_list, type=List[ControlledTermValue], doc='Allowed values for this controlled term', ) @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, } @property def _str_attrs(self) -> List[str]: return ['id', 'label', 'value_labels']
class Annotation(BaseModel): """:fa:`tag` An annotation, meaning 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 = field(default=None) controlled_attribute_id: int = field(default=None) controlled_value_id: int = field(default=None) user_id: int = field(default=None) uuid: str = field(default=None) vote_score: int = field(default=None) votes: List = field(factory=list) user: property = LazyProperty(User.from_json, type=User, doc='User who added the annotation') @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, } @property def _str_attrs(self) -> List[str]: return ['controlled_attribute_id', 'concatenated_attr_val']
class Comment(BaseModel): """:fa:`comment` An observation comment, based on the schema of comments from `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_. """ body: str = field(default='', doc='Comment text') created_at: datetime = datetime_now_field( doc='Date and time the comment was created') hidden: bool = field(default=None, doc='Indicates if the comment is hidden') uuid: str = field(default=None, doc='Universally unique identifier') user: property = LazyProperty(User.from_json, type=User, doc='User that added the comment') # Unused attributes # created_at_details: Dict = field(factory=dict) # flags: List = field(factory=list) # moderator_actions: List = field(factory=list) @property def truncated_body(self): """Comment text, truncated""" truncated_body = self.body.replace('\n', ' ').strip() if len(truncated_body) > 50: truncated_body = truncated_body[:47].strip() + '...' return truncated_body @property def username(self) -> str: return self.user.login @property def _row(self) -> TableRow: return { 'ID': self.id, 'User': self.username, 'Created at': self.created_at, 'Comment': self.truncated_body, } @property def _str_attrs(self) -> List[str]: return ['id', 'username', 'created_at', 'truncated_body']
class LifeList(BaseModelCollection): """:fa:`dove,style=fas` :fa:`list` A user's life list, based on the schema of ``GET /observations/taxonomy``""" data: List[LifeListTaxon] = field(factory=list, converter=LifeListTaxon.from_json_list) count_without_taxon: int = field( default=0, doc='Number of observations without a taxon') user_id: int = field(default=None) @classmethod def from_json(cls, value: JsonResponse, user_id: int = None, **kwargs) -> 'LifeList': count_without_taxon = value.get('count_without_taxon', 0) if 'results' in value: value = value['results'] life_list_json = { 'data': value, 'user_id': user_id, 'count_without_taxon': count_without_taxon, } return super(LifeList, cls).from_json(life_list_json) def get_count(self, taxon_id: int, count_field='descendant_obs_count') -> 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``. """ if taxon_id == -1: return self.count_without_taxon return super().get_count(taxon_id, count_field=count_field) def tree(self): """**Experimental** Organize this life list into a taxonomic tree Returns: :py:class:`rich.tree.Tree` """ return make_tree(self.data)
class ProjectUser(User): """:fa:`user` A :py:class:`.User` with additional project-specific information""" project_id: int = field(default=None) project_user_id: int = field(default=None) role: str = field(default=None) @classmethod def from_json(cls, value: JsonResponse, **kwargs) -> 'ProjectUser': """Flatten out nested values""" user = value['user'] user['project_id'] = value['project_id'] user['project_user_id'] = value['id'] user['role'] = value['role'] return super(ProjectUser, cls).from_json(user, **kwargs) @property def _str_attrs(self) -> List[str]: return ['project_id', 'project_user_id', 'role']
class ProjectObservationField(ObservationField): """:fa:`tag` An :py:class:`.ObservationField` with additional project-specific information""" project_observation_field_id: int = field(default=None) position: int = field(default=None) required: bool = field(default=None) @classmethod def from_json(cls, value: JsonResponse, **kwargs) -> 'ProjectObservationField': """Flatten out nested values""" obs_field = value['observation_field'] obs_field['project_observation_field_id'] = value['id'] obs_field['position'] = value['position'] obs_field['required'] = value['required'] return super(ProjectObservationField, cls).from_json(obs_field, **kwargs) @property def _str_attrs(self) -> List[str]: return ['project_observation_field_id', 'required']
class SearchResult(BaseModel): """:fa:`search` A search result of any type, based on the schema of `GET /search <https://api.inaturalist.org/v1/docs/#!/Search/get_search>`_. """ score: float = field(default=0, doc='Search result rank') type: str = field(default=None, options=SEARCH_RESULT_TYPES, doc='Search result type') matches: List[str] = field(factory=list, doc='Search terms matched') record: SearchResultRecord = field(default=None, doc='Search result object') # Convert value by datatype def __attrs_post_init__(self): if self.type in SEARCH_RESULT_TYPES and self.record is not None: result_cls = SEARCH_RESULT_TYPES[self.type] self.record = result_cls.from_json(self.record) @property def record_name(self) -> str: """Alias for type-specific name/title field""" name_attr = SEARCH_RESULT_TITLES[self.type] return getattr(self.record, name_attr) @property def _row(self) -> TableRow: return { 'ID': self.record.id, 'Type': self.type, 'Score': f'{self.score:.2f}', 'Name': self.record_name, } @property def _str_attrs(self) -> List[str]: return ['id', 'type', 'score', 'record_name']
class Message(BaseModel): """:fa:`user` A message from the user's inbox, based on the schema of :v1:`GET /messages <Messages/get_messages>` """ user_id: int = field(default=None, doc='Corresponding user ID') thread_id: int = field(default=None, doc='Message thread ID') subject: str = field(default=None, doc='Message subject') body: str = field(default=None, doc='Message body') read_at: DateTime = datetime_field(doc='When the message was read') created_at: DateTime = datetime_field(doc='When the message was sent') updated_at: DateTime = datetime_field( doc='When the message was last edited') thread_flags: List[str] = field(factory=list) thread_messages_count: int = field(default=None, doc='Number of messages in the thread') from_user: property = LazyProperty(User.from_json, type=User, doc='Message sender') to_user: property = LazyProperty(User.from_json, type=User, doc='Message recipient') @property def truncated_body(self): """Comment text, truncated""" truncated_body = self.body.replace('\n', ' ').strip() if len(truncated_body) > 50: truncated_body = truncated_body[:47].strip() + '...' return truncated_body @property def _row(self) -> TableRow: return { 'ID': self.id, 'Date': self.created_at, 'To': self.to_user.login, 'From': self.from_user.login, 'Subject': self.subject, } @property def _str_attrs(self) -> List[str]: return [ 'id', 'created_at', 'self.to_user', 'self.from_user', 'subject', 'truncated_body' ] def __str__(self) -> str: return ( f'[{self.id}] Sent {self.created_at.strftime(DATETIME_SHORT_FORMAT)} ' f'from {self.from_user.login} to {self.to_user.login}: {self.subject}' )
class ObservationFieldValue(BaseModel): """:fa:`tag` An observation field **value**, based on the schema of ``Observation.ofvs`` from `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_. """ datatype: str = field(default=None) # Enum field_id: int = field(default=None) name: str = field(default=None) taxon_id: int = field(default=None) user_id: int = field(default=None) uuid: str = field(default=None) value: OFVValue = field(default=None) taxon: property = LazyProperty( Taxon.from_json, type=Taxon, doc='Taxon that the observation field applies to' ) user: property = LazyProperty( User.from_json, type=User, doc='User that applied the observation field value' ) # Unused attrbiutes # name_ci: str = field(default=None) # value_ci: int = field(default=None) # Convert value by datatype def __attrs_post_init__(self): if self.datatype in OFV_DATATYPES and self.value is not None: converter = OFV_DATATYPES[self.datatype] try: self.value = converter(self.value) except ValueError: self.value = None @property def _row(self) -> TableRow: return { 'ID': self.id, 'Type': self.datatype, 'Name': self.name, 'Value': self.value, } @property def _str_attrs(self) -> List[str]: return ['id', 'datatype', 'name', 'value']
class ControlledTermValue(BaseModel): """:fa:`tag` 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 = field(default=None) label: str = field(default=None) ontology_uri: str = field(default=None) uri: str = field(default=None) uuid: str = field(default=None) taxon_ids: List[int] = field(factory=list) @property def _str_attrs(self) -> List[str]: return ['id', 'label']
class TaxonSummary(BaseModel): """:fa:`dove,style=fas` :fa:`list` Information about an observation's taxon, within the context of the observation's location. Based on the schema of :v1:`GET /observations/{id}/taxon_summary <Observations/get_observations_id_taxon_summary>` """ conservation_status: property = LazyProperty( ConservationStatus.from_json, type=ConservationStatus, doc='Conservation status of the taxon in the observation location', ) listed_taxon: property = LazyProperty( ListedTaxon.from_json, type=ListedTaxon, doc='Details about the taxon on an "original" life list', ) wikipedia_summary: str = field(default=None, doc='Taxon summary from Wikipedia article') @property def _str_attrs(self) -> List[str]: return ['conservation_status', 'listed_taxon']
class Project(BaseModel): """:fa:`users` An iNaturalist project, based on the schema of `GET /projects <https://api.inaturalist.org/v1/docs/#!/Projects/get_projects>`_. """ banner_color: str = field(default=None) created_at: datetime = datetime_now_field(doc='Date and time the project was created') description: str = field(default=None, doc='Project description') header_image_url: str = field(default=None) hide_title: bool = field(default=None) icon: str = field(default=None, doc='URL for project icon') is_umbrella: bool = field( default=None, doc='Indicates if this is an umbrella project (containing observations from other projects)', ) location: Coordinates = coordinate_pair() place_id: int = field(default=None, doc='Project place ID') prefers_user_trust: bool = field( default=None, doc='Indicates if the project wants users to share hidden coordinates with the project admins', ) project_observation_rules: List[Dict] = field(factory=list, doc='Project observation rules') project_type: str = field(default=None, options=PROJECT_TYPES, doc='Project type') # Enum rule_preferences: List[Dict] = field(factory=list) search_parameters: List[Dict] = field(factory=list, doc='Filters for observations to include') site_features: List[Dict] = field( factory=list, doc='Details about if/when the project was featured on inaturalist.org' ) slug: str = field(default=None, doc='URL slug') terms: str = field(default=None, doc='Project terms') title: str = field(default=None, doc='Project title') updated_at: DateTime = datetime_field(doc='Date and time the project was last updated') user_ids: List[int] = field(factory=list) # Lazy-loaded model objects admins: property = LazyProperty( ProjectUser.from_json_list, type=List[User], doc='Project admin users' ) project_observation_fields: property = LazyProperty( ProjectObservationField.from_json_list, type=List[ProjectObservationField], doc='Observation fields used by the project', ) user: property = LazyProperty(User.from_json, type=User, doc='User that created the project') # Unused attributes # flags: List = field(factory=list) # header_image_contain: bool = field(default=None) # header_image_file_name: str = field(default=None) # icon_file_name: str = field(default=None) # latitude: float = field(default=None) # longitude: float = field(default=None) # user_id: int = field(default=None) # Aliases @property def obs_fields(self): return self.project_observation_fields @property def obs_rules(self): return self.project_observation_rules @property def url(self) -> str: """Info URL on iNaturalist.org""" return f'{INAT_BASE_URL}/projects/{self.id}' @property def _row(self) -> TableRow: return { 'ID': self.id, 'Title': self.title, 'Type': self.project_type, 'URL': self.url, } @property def _str_attrs(self) -> List[str]: return ['id', 'title']
class Photo(BaseModel): """:fa:`camera` An observation photo, based on the schema of photos from: * `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_ * `GET /taxa <https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa>` """ attribution: str = field(default=None, doc='License attribution') license_code: str = field( default=None, converter=format_license, options=ALL_LICENSES, doc='Creative Commons license code', ) observation_id: int = field(default=None, doc='Associated observation ID') original_dimensions: Tuple[int, int] = field(converter=format_dimensions, default=(0, 0), doc='Dimensions of original image') url: str = field( default=None, doc='Image URL; see properties for URLs of specific image sizes') user_id: int = field(default=None, doc='Associated user ID') uuid: str = field(default=None) _url_format: str = field(init=False, repr=False, default=None) # Unused attributes # flags: List = field(factory=list) def __attrs_post_init__(self): # If there's no URL, make a guess based on ID and license: self.url = self.url or (f'{PHOTO_CC_BASE_URL}/{self.id}/original.jpg' if self.has_cc_license else f'{PHOTO_BASE_URL}/{self.id}?size=original') # Get a URL format string to get different photo sizes. Note: default URL may be any size. for size in PHOTO_SIZES: if f'{size}.' in self.url: self._url_format = self.url.replace(size, '{size}') @property def ext(self) -> str: """File extension from URL""" return self.url.lower().split('.')[-1].split('?')[0] @property def dimensions_str(self) -> str: """Dimensions as a string, formatted as ``{width}x{height}``""" return f'{self.original_dimensions[0]}x{self.original_dimensions[1]}' @property def has_cc_license(self) -> bool: """Determine if this photo has a Creative Commons license""" return self.license_code in CC_LICENSES @property def info_url(self) -> str: """Photo info URL on iNaturalist.org""" return f'{PHOTO_INFO_BASE_URL}/{self.id}' @property def large_url(self) -> Optional[str]: """Image URL (large size)""" return self.url_size('large') @property def medium_url(self) -> Optional[str]: """Image URL (medium size)""" return self.url_size('medium') @property def mimetype(self) -> str: """MIME type of the image""" return f'image/{self.ext.replace("jpg", "jpeg")}' @property def original_url(self) -> Optional[str]: """Image URL (original size)""" return self.url_size('original') @property def small_url(self) -> Optional[str]: """Image URL (small size)""" return self.url_size('small') @property def square_url(self) -> Optional[str]: """Image URL (thumbnail size)""" return self.url_size('square') @property def thumbnail_url(self) -> Optional[str]: """Image URL (thumbnail size)""" return self.url_size('square') def url_size(self, size: str) -> Optional[str]: size = size.replace('thumbnail', 'square').replace('thumb', 'square') if not self._url_format or size not in PHOTO_SIZES: return None return self._url_format.format(size=size) def open(self, size: str = 'large') -> BinaryIO: """Download the image and return as a file-like object""" url = self.url_size(size) or self.url return requests.get(url, stream=True).raw def show(self, size: str = 'large'): """Display the image with the system's default image viewer. Requires ``pillow``.""" from PIL import Image img = Image.open(self.open(size=size)) img.show() @property def _row(self) -> TableRow: return { 'ID': self.id, 'License': self.license_code, 'Dimensions': self.dimensions_str, 'URL': self.original_url, } @property def _str_attrs(self) -> List[str]: return ['id', 'license_code', 'url']
class Place(BaseModel): """:fa:`map-marker-alt,style=fas` A curated or community-contributed place. Handles data from the following endpoints: * `GET /places/{id} <https://api.inaturalist.org/v1/docs/#!/Places/get_places_id>`_ * `GET /places/nearby <https://api.inaturalist.org/v1/docs/#!/Places/get_places_nearby>`_ * `GET /taxa <https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa>`_ (``establishment_means.place``) """ admin_level: int = field(default=None, doc='Administrative level, if any') ancestor_place_ids: List[str] = field( factory=list, doc='IDs of places that this place is contained within' ) bbox_area: float = field(default=None, doc='Bounding box area') bounding_box_geojson: GeoJson = field( factory=dict, doc='Bounding box polygon that fully encloses the place' ) category: str = field( default=None, validator=is_in(PLACE_CATEGORIES), doc='Place category (only applies to /places/nearby)', ) display_name: str = field(default=None, doc='Place name as displayed on place info page') geometry_geojson: GeoJson = field(factory=dict, doc='Polygon representing place boundary') location: Coordinates = field( default=None, converter=convert_optional_lat_long, doc='Location in ``(latitude, logitude)`` decimal degrees', ) name: str = field(default=None, doc='Place name') place_type: int = field(default=None, doc='Place type ID') place_type_name: str = field(default=None, doc='Place type name') slug: str = field(default=None, doc='Place URL slug') @classmethod def from_json(cls, value: JsonResponse, category: str = None, **kwargs) -> 'Place': value.setdefault('category', category) return super(Place, cls).from_json(value) @classmethod def from_json_list(cls, value: ResponseOrResults, **kwargs) -> List['Place']: """Optionally use results from /places/nearby to set Place.category""" json_value = dict(ensure_list(value)[0]) if 'results' in json_value: json_value = json_value['results'] if 'standard' in json_value and 'community' in json_value: places = [cls.from_json(item, category='standard') for item in json_value['standard']] places += [ cls.from_json(item, category='community') for item in json_value['community'] ] return places else: return super(Place, cls).from_json_list(value) @property def ancestry(self) -> str: """Handle slash-delimited 'ancestry' string from ``establishment_means.place``""" return '/'.join(self.ancestor_place_ids) @ancestry.setter def ancestry(self, value: str): self.ancestor_place_ids = value.split('/') @property def url(self) -> str: """Place info URL on iNaturalist.org""" return f'{INAT_BASE_URL}/places/{self.id}' @property def _row(self) -> TableRow: return { 'ID': self.id, 'Latitude': f'{self.location[0]:9.4f}', 'Longitude': f'{self.location[1]:9.4f}', 'Name': self.name, 'Category': self.category, 'URL': self.url, } @property def _str_attrs(self) -> List[str]: return ['id', 'location', 'name']
class Identification(BaseModel): """:fa:`fingerprint,style=fas` An observation identification, based on the schema of `GET /identifications <https://api.inaturalist.org/v1/docs/#!/Identifications/get_identifications>`_. """ body: str = field(default=None, doc='Comment text') category: str = field(default=None, options=ID_CATEGORIES, doc='Identification category') created_at: datetime = datetime_now_field( doc='Date and time the identification was added') current: bool = field( default=None, doc='Indicates if the identification is the currently accepted one') current_taxon: bool = field(default=None) disagreement: bool = field( default=None, doc='Indicates if this identification disagrees with previous ones') hidden: bool = field(default=None) own_observation: bool = field( default=None, doc='Indicates if the indentifier is also the observer') previous_observation_taxon_id: int = field( default=None, doc='Previous observation taxon ID') taxon_change: bool = field(default=None) # TODO: confirm type taxon_id: int = field(default=None, doc='Identification taxon ID') uuid: str = field(default=None, doc='Universally unique identifier') vision: bool = field( default=None, doc='Indicates if the taxon was selected from computer vision results') taxon: property = LazyProperty(Taxon.from_json, type=Taxon, doc='Identification taxon') user: property = LazyProperty(User.from_json, type=User, doc='User that added the indentification') @property def taxon_name(self) -> str: return self.taxon.full_name # Unused attributes # created_at_details: {} # spam: bool = field(default=None) # flags: List = field(factory=list) # moderator_actions: List = field(factory=list) # observation: {} @property def _row(self) -> TableRow: return { 'ID': self.id, 'Taxon ID': self.taxon.id, 'Taxon': f'{self.taxon.emoji} {self.taxon.full_name}', 'User': self.user.login, 'Category': self.category.title(), 'From CV': self.vision, } @property def _str_attrs(self) -> List[str]: return ['id', 'taxon_name', 'created_at']
class Observation(BaseModel): """:fa:`binoculars` An observation, based the schema of :v1:`GET /observations <Observations/get_observations>` """ created_at: datetime = datetime_now_field( doc='Date and time the observation was created') captive: bool = field( default=None, doc='Indicates if the organism is non-wild (captive or cultivated)') community_taxon_id: int = field( default=None, doc='The current community identification taxon') description: str = field(default=None, doc='Observation description') faves: List[Dict] = field( factory=list, doc='Details on users who have favorited the observation') geoprivacy: str = field(default=None, options=GEOPRIVACY_LEVELS, doc='Location privacy level') identifications_count: int = field(default=None, doc='Total number of identifications') identifications_most_agree: bool = field( default=None, doc='Indicates if most identifications agree with the community ID') identifications_most_disagree: bool = field( default=None, doc='Indicates if most identifications disagree with the community ID') identifications_some_agree: bool = field( default=None, doc='Indicates if some identifications agree with the community ID') license_code: str = field(default=None, converter=upper, options=ALL_LICENSES, doc='Creative Commons license code') location: Coordinates = coordinate_pair() mappable: bool = field( default=None, doc='Indicates if the observation can be shown on a map') num_identification_agreements: int = field( default=None, doc='Total identifications that agree with the community ID') num_identification_disagreements: int = field( default=None, doc='Total identifications that disagree with the community ID') oauth_application_id: str = field( default=None, doc='ID of the OAuth application used to create the observation, if any' ) obscured: bool = field( default=None, doc= 'Indicates if coordinates are obscured (showing a broad approximate location on the map)', ) observed_on: DateTime = datetime_field( doc='Date and time the organism was observed') outlinks: List[Dict] = field( factory=list, doc='Linked observation pages on other sites (e.g., GBIF)') out_of_range: bool = field( default=None, doc='Indicates if the taxon is observed outside of its known range') owners_identification_from_vision: bool = field( default=None, doc= "Indicates if the owner's ID was selected from computer vision results" ) place_guess: str = field( default=None, doc='Place name determined from observation coordinates') place_ids: List[int] = field( factory=list, doc='Place IDs associated with the observation coordinates') positional_accuracy: int = field( default=None, doc='GPS accuracy in meters (real accuracy, if obscured)') preferences: Dict[str, Any] = field( factory=dict, doc= 'Any user observation preferences (related to community IDs, coordinate access, etc.)', ) private_location: Coordinates = coordinate_pair( doc= ':fa:`lock` Private location in ``(latitude, logitude)`` decimal degrees' ) private_place_ids: List[int] = field( factory=list, doc= ':fa:`lock` Place IDs associated with the private observation coordinates' ) private_place_guess: str = field( default=None, doc= ':fa:`lock` Place name determined from private observation coordinates' ) project_ids: List[int] = field(factory=list, doc='All associated project IDs') project_ids_with_curator_id: List[int] = field( factory=list, doc='Project IDs with a curator identification for this observation') project_ids_without_curator_id: List[int] = field( factory=list, doc='Project IDs without curator identification for this observation') public_positional_accuracy: int = field( default=None, doc='GPS accuracy in meters (not accurate if obscured)') quality_grade: str = field(default=None, options=QUALITY_GRADES, doc='Quality grade') quality_metrics: List[Dict] = field(factory=list, doc='Data quality assessment metrics') reviewed_by: List[int] = field( factory=list, doc='IDs of users who have reviewed the observation') site_id: int = field( default=None, doc= 'Site ID for iNaturalist network members, or ``1`` for inaturalist.org' ) sounds: List[Dict] = field(factory=list, doc='Observation sound files') species_guess: str = field( default=None, doc="Taxon name from observer's initial identification") tags: List[str] = field(factory=list, doc='Arbitrary user tags added to the observation') updated_at: DateTime = datetime_field( doc='Date and time the observation was last updated') uri: str = field(default=None, doc='Link to observation details page') uuid: str = field( default=None, doc= 'Universally unique ID; generally preferred over ``id`` where possible' ) votes: List[Dict] = field(factory=list, doc='Votes on data quality assessment metrics') # Lazy-loaded model objects annotations: property = LazyProperty(Annotation.from_json_list, type=List[Annotation], doc='Observation annotations') comments: property = LazyProperty(Comment.from_json_list, type=List[Comment], doc='Observation comments') identifications: property = LazyProperty(Identification.from_json_list, type=List[Identification], doc='Observation identifications') ofvs: property = LazyProperty( ObservationFieldValue.from_json_list, type=List[ObservationFieldValue], doc='Observation field values', ) photos: property = LazyProperty(Photo.from_json_list, type=List[Photo], doc='Observation photos') project_observations: property = LazyProperty( ProjectObservation.from_json_list, type=List[ProjectObservation], doc='Details on any projects that the observation has been added to', ) taxon: property = LazyProperty(Taxon.from_json, type=Taxon, doc='Observation taxon') user: property = LazyProperty(User.from_json, type=User, doc='Observer') # Additional attributes from API response that aren't needed; just left here for reference # cached_votes_total: int = field(default=None) # comments_count: int = field(default=None) # created_at_details: Dict = field(factory=dict) # created_time_zone: str = field(default=None) # faves_count: int = field(default=None) # flags: List = field(factory=list) # geojson: Dict = field(factory=dict) # id_please: bool = field(default=None) # map_scale: int = field(default=None) # non_owner_ids: List = field(factory=list) # observed_on_details: Dict = field(factory=dict) # observed_on_string: str = field(default=None) # observation_photos: List[Photo] = field(converter=Photo.from_dict_list, factory=list) # observed_time_zone: str = field(default=None) # spam: bool = field(default=None) # time_observed_at: DateTime = datetime_attr # time_zone_offset: str = field(default=None) # Attributes that will only be used during init and then omitted temp_attrs = [ 'created_at_details', 'observed_on_string', 'observed_time_zone', 'time_zone_offset', ] # Convert observation timestamps prior to __attrs_init__ def __init__( self, # created_at_details: Dict = None, # observed_on_string: str = None, # observed_time_zone: str = None, # time_zone_offset: str = None, **kwargs, ): created_at_details = kwargs.pop('created_at_details', None) observed_on_string = kwargs.pop('observed_on_string', None) observed_time_zone = kwargs.pop('observed_time_zone', None) time_zone_offset = kwargs.pop('time_zone_offset', None) tz_offset = time_zone_offset tz_name = observed_time_zone created_date = (created_at_details or {}).get('date') if not isinstance(kwargs.get('created_at'), datetime) and created_date: kwargs['created_at'] = convert_observation_timestamp( created_date, tz_offset, tz_name) if not isinstance(kwargs.get('observed_on'), datetime) and observed_on_string: kwargs['observed_on'] = convert_observation_timestamp( observed_on_string, tz_offset, tz_name, ignoretz=True) if not kwargs.get('uri'): kwargs[ 'uri'] = f'{INAT_BASE_URL}/observations/{kwargs.get("id", "")}' self.__attrs_init__(**kwargs) # type: ignore @classmethod def from_id(cls, id: int): """Lookup and create a new Observation object from an ID""" from pyinaturalist.v1 import get_observation json = get_observation(id) return cls.from_json(json) @property def photo_url(self) -> Optional[str]: """Original size photo URL for first observation photo (if any)""" if not self.photos: return None return self.photos[0].original_url @property def thumbnail_url(self) -> Optional[str]: """Thumbnail size photo URL for first observation photo (if any)""" if not self.photos: return None return self.photos[0].thumbnail_url @property def _row(self) -> TableRow: return { 'ID': self.id, 'Taxon ID': self.taxon.id, 'Taxon': self.taxon.full_name, 'Observed on': self.observed_on, 'User': self.user.login, 'Location': self.place_guess or self.location, } @property def _str_attrs(self) -> List[str]: return ['id', 'taxon', 'observed_on', 'user', 'place_guess']
class ListedTaxon(IdWrapperMixin, EstablishmentMeans): """:fa:`dove,style=fas` :fa:`list` A taxon with additional stats associated with a list (aka `"original life list" <https://www.inaturalist.org/blog/43337-upcoming-changes-to-lists>`_), based on the schema of: * ``Taxon.listed_taxa`` from :v1:`GET /taxa/{id} <Taxa/get_taxa_id>` * ``TaxonSummary.listed_taxon`` from :v1:`GET /observations/{id}/taxon_summary <Observations/get_observations_id_taxon_summary>` """ comments_count: int = field(default=0, doc='Number of comments for this listed taxon') created_at: DateTime = datetime_field( doc='Date and time the record was created') description: str = field(default=None, doc='Listed taxon description') first_observation_id: int = field( default=None, doc='Oldest recent observation ID in the list') last_observation_id: int = field( default=None, doc='Most recent observation ID in the list') list_id: int = field(default=None, doc='List ID') list_title: str = field(default=None, doc='List title') manually_added: bool = field( default=None, doc='Indicates if the taxon was manually added to the list') observations_count: int = field( default=0, doc='Number of observations of this taxon in the list') occurrence_status_level: int = field(default=None, doc='') primary_listing: bool = field( default=None, doc='Indicates if this is the primary listing for this taxon') source_id: int = field(default=None, doc='') taxon_id: int = field(default=None, doc='') taxon_range_id: int = field(default=None, doc='') updated_at: DateTime = datetime_field( doc='Date and time the record was last updated') # Lazy-loaded nested model objects updater: User = field(default=None, converter=User.from_json, doc='User that last updated the record') user: User = field(default=None, converter=User.from_json, doc='User that created the record') @property def list(self) -> Dict: """Alias to handle differences between taxa and taxon_summary endpoints""" return {'id': self.list_id, 'title': self.list_title} @list.setter def list(self, value: Dict): self.list_id = int(value.get('id', -1)) self.list_title = str(value.get('title', '')) @property def _row(self) -> TableRow: return { 'ID': self.id, 'Taxon ID': self.taxon_id, 'Place ID': self.place.id, 'Life list': self.list_title or self.list_id, 'Establishment means': self.establishment_means, 'Observations': self.observations_count, 'Comments': self.comments_count, } @property def _str_attrs(self) -> List[str]: return [ 'id', 'taxon_id', 'place', 'establishment_means', 'observations_count' ] def __str__(self) -> str: return BaseModel.__str__(self)
class Taxon(BaseModel): """:fa:`dove,style=fas` 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`. """ ancestry: str = field(default=None, doc='Slash-delimited string of ancestor IDs', repr=False) ancestor_ids: List[int] = field( factory=list, converter=ensure_list, # Handle arrays when converting from a dataframe doc='Taxon IDs of ancestors, from highest rank to lowest', ) complete_rank: str = field( default=None, doc='Complete or "leaf taxon" rank, e.g. species or subspecies') complete_species_count: int = field( default=None, doc='Total number of species descended from this taxon') created_at: DateTime = datetime_field( doc='Date and time the taxon was added to iNaturalist') current_synonymous_taxon_ids: List[int] = field( factory=list, doc='Taxon IDs of taxa that are accepted synonyms') extinct: bool = field(default=None, doc='Indicates if the taxon is extinct') gbif_id: int = field(default=None, doc='GBIF taxon ID') iconic_taxon_id: int = field( default=0, doc='ID of the iconic taxon (e.g., general taxon "category")') iconic_taxon_name: str = field( 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.)') listed_places: bool = field( default=None, doc='Indicates if there are listed places for this taxon') listed_taxa_count: int = field( default=None, doc='Number of listed taxa from this taxon + descendants') matched_term: str = field( default=None, doc='Matched search term, from autocomplete results') name: str = field( default=None, doc= 'Taxon name; contains full scientific name at species level and below') names: List[Dict] = field( factory=list, doc= 'All regional common names; only returned if ``all_names`` is specified' ) observations_count: int = field( default=None, doc='Total number of observations of this taxon and its descendants') parent_id: int = field(default=None, doc='Taxon ID of immediate ancestor') preferred_common_name: str = field( default='', doc='Common name for the preferred place, if any') preferred_establishment_means: str = field( default=None, doc= 'Establishment means for this taxon in the given preferred place (if any)' ) rank_level: int = field( default=None, doc= 'Number indicating rank level, for easier comparison between ranks (kingdom=higest)', ) ranges: bool = field(default=None, doc='Indicates if there is range data for this taxon') rank: str = field(default=None, options=RANKS, doc='Taxon rank') taxon_changes_count: int = field( default=None, doc='Number of curator changes to this taxon') taxon_schemes_count: int = field( default=None, doc='Taxon schemes that include this taxon') vision: bool = field( default=None, doc='Indicates if this taxon is included in the computer vision model') wikipedia_summary: str = field(default=None, doc='Taxon summary from Wikipedia article') wikipedia_url: str = field(default=None, doc='URL to Wikipedia article for the taxon') # Lazy-loaded model objects ancestors: property = LazyProperty(BaseModel.from_json_list) children: property = LazyProperty(BaseModel.from_json_list) conservation_status: property = LazyProperty( ConservationStatus.from_json, type=ConservationStatus, doc='Conservation status of the taxon in a given location', ) conservation_statuses: property = LazyProperty( ConservationStatus.from_json_list, type=List[ConservationStatus], doc='Conservation statuses of the taxon in different locations', ) default_photo: property = LazyProperty(Photo.from_json, type=Photo, doc='Taxon default photo') establishment_means: property = LazyProperty( EstablishmentMeans.from_json, type=EstablishmentMeans, doc='Establishment means for a taxon in a given location', ) listed_taxa: property = LazyProperty( ListedTaxon.from_json_list, type=List[ListedTaxon], doc='Details about this taxon associated with a list', ) taxon_photos: property = LazyProperty( Photo.from_json_list, type=List[Photo], doc='All taxon photos shown on taxon info page') # Unused attributes # atlas_id: int = field(default=None) # flag_counts: Dict[str, int] = field(factory=dict) # {"unresolved": 1, "resolved": 2} # min_species_ancestry: str = field(default=None) # Used internally by iNaturalist for Elasticsearch aggregations # min_species_taxon_id: int = field(default=None) # partial: bool = field(default=None, repr=False) # photos_locked: bool = field(default=None) # universal_search_rank: int = field(default=None) def __attrs_post_init__(self): # Look up iconic taxon name, if only ID is provided if not self.iconic_taxon_name: self.iconic_taxon_name = ICONIC_TAXA.get(self.iconic_taxon_id, 'Unknown') # If default photo is missing, use iconic taxon icon if not self.default_photo: self.default_photo = self.icon # If only ancestor string (or objects) are provided, split into IDs if self.ancestry and not self.ancestor_ids: delimiter = ',' if ',' in self.ancestry else '/' self.ancestor_ids = [ int(x) for x in self.ancestry.split(delimiter) ] elif self.ancestors and not self.ancestor_ids: self.ancestor_ids = [t.name for t in self.ancestors] @classmethod def from_sorted_json_list(cls, value: JsonResponse) -> List['Taxon']: """Sort Taxon objects by rank then by name""" taxa = cls.from_json_list(value) taxa.sort(key=_get_rank_name_idx) return taxa @property def child_ids(self) -> List[int]: """Taxon IDs of direct children, sorted by rank then name""" return [t.id for t in self.children] @property def emoji(self) -> str: """Get an emoji representing the taxon""" for taxon_id in [self.id] + list(reversed(self.ancestor_ids)): if taxon_id in EMOJI: return EMOJI[taxon_id] return ICONIC_EMOJI.get(self.iconic_taxon_id, '❓') @property def full_name(self) -> str: """Taxon rank, scientific name, common name (if available), and emoji""" if not self.name and not self.rank: return 'unknown taxon' elif not self.name: return f'{self.rank.title()}: {self.id}' elif not self.rank: return self.name common_name = f' ({self.preferred_common_name})' if self.preferred_common_name else '' 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: """Iconic URL for the icon of the iconic taxon""" return str(self.icon.thumbnail_url) @property def gbif_url(self) -> str: """URL for the GBIF info page for this taxon""" return f'https://www.gbif.org/species/{self.gbif_id}' @property def parent(self) -> 'Taxon': """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""" from pyinaturalist.v1 import get_taxa_by_id r = get_taxa_by_id(id) return cls.from_json(r['results'][0]) def load_full_record(self): """Update this Taxon with full taxon info, including ancestors + children""" t = Taxon.from_id(self.id) for key in fields_dict(Taxon).keys(): key = key.lstrip( '_' ) # Use getters/setters for LazyProperty instead of temp attrs setattr(self, key, getattr(t, key)) @property def _row(self) -> TableRow: return { 'ID': self.id, 'Rank': self.rank, 'Scientific name': f'{self.emoji} {self.name}', 'Common name': self.preferred_common_name, } @property def _str_attrs(self) -> List[str]: return ['id', 'full_name']