Skip to content

Commit

Permalink
Merge pull request #529 from pyinat/controller-refactor
Browse files Browse the repository at this point in the history
Refactor controller methods
JWCook committed Dec 12, 2023
2 parents ab1d6a2 + 2d19325 commit 769114f
Showing 16 changed files with 731 additions and 135 deletions.
10 changes: 4 additions & 6 deletions pyinaturalist/client.py
Original file line number Diff line number Diff line change
@@ -6,8 +6,6 @@
from logging import getLogger
from typing import Any, Callable, Dict, Optional, Type

from requests import Session

from pyinaturalist.auth import get_access_token
from pyinaturalist.constants import RequestParams
from pyinaturalist.controllers import (
@@ -62,7 +60,7 @@ def __init__(
default_params: Optional[Dict[str, Any]] = None,
dry_run: bool = False,
loop: Optional[AbstractEventLoop] = None,
session: Optional[Session] = None,
session: Optional[ClientSession] = None,
**kwargs,
):
self.creds = creds or {}
@@ -94,7 +92,7 @@ def __init__(
self
) #: Interface for :py:class:`user requests <.UserController>`

def add_client_settings(
def add_defaults(
self, request_function, kwargs: Optional[RequestParams] = None, auth: bool = False
) -> RequestParams:
"""Add any applicable client settings to request parameters before sending a request.
@@ -133,7 +131,7 @@ def paginate(
cls: Alternative Paginator class to use
params: Original request parameters
"""
kwargs = self.add_client_settings(request_function, kwargs, auth)
kwargs = self.add_defaults(request_function, kwargs, auth)
return cls(request_function, model, loop=self.loop, **kwargs)

def request(self, request_function: Callable, *args, auth: bool = False, **kwargs):
@@ -147,5 +145,5 @@ def request(self, request_function: Callable, *args, auth: bool = False, **kwarg
Returns:
Results of ``request_function()``
"""
kwargs = self.add_client_settings(request_function, kwargs, auth)
kwargs = self.add_defaults(request_function, kwargs, auth)
return request_function(*args, **kwargs)
47 changes: 39 additions & 8 deletions pyinaturalist/controllers/annotation_controller.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from logging import getLogger
from typing import Dict, List

from pyinaturalist.constants import API_V2, IntOrStr
from pyinaturalist.controllers import BaseController
from pyinaturalist.docs import document_common_args, document_controller_params
from pyinaturalist.models import Annotation, ControlledTerm
from pyinaturalist.session import delete, post
from pyinaturalist.v1 import get_controlled_terms, get_controlled_terms_for_taxon

logger = getLogger(__name__)


class AnnotationController(BaseController):
""":fa:`tag` Controller for Annotation and ControlledTerm requests"""
@@ -22,13 +24,41 @@ def term_lookup(self) -> Dict[int, ControlledTerm]:
self._term_lookup = {term.id: term for term in self.all()}
return self._term_lookup

@document_controller_params(get_controlled_terms)
def all(self, **params) -> List[ControlledTerm]:
"""List controlled terms and their possible values
.. rubric:: Notes
* API reference: :v1:`GET /controlled_terms <Controlled_Terms/get_controlled_terms>`
Example:
>>> terms = client.annotations
>>> pprint(response[0])
1: Life Stage
2: Adult
3: Teneral
4: Pupa
...
"""
response = get_controlled_terms(**params)
return ControlledTerm.from_json_list(response['results'])

@document_controller_params(get_controlled_terms_for_taxon)
def for_taxon(self, taxon_id: int, **params) -> List[ControlledTerm]:
"""List controlled terms that are valid for the specified taxon.
.. rubric:: Notes
* API reference: :v1:`GET /controlled_terms/for_taxon <Controlled_Terms/get_controlled_terms_for_taxon>`
Example:
>>> client.annotations.for_taxon(12345)
Args:
taxon_id: Taxon ID to get controlled terms for
Raises:
:py:exc:`.TaxonNotFound`: If an invalid ``taxon_id`` is specified
"""
response = get_controlled_terms_for_taxon(taxon_id, **params)
return ControlledTerm.from_json_list(response['results'])

@@ -47,10 +77,12 @@ def lookup(self, annotations: List[Annotation]) -> List[Annotation]:
if term:
annotation.controlled_attribute = term
annotation.controlled_value = term.get_value_by_id(annotation.controlled_value.id)
else:
logger.warning(
f'No controlled attribute found for ID: {annotation.controlled_attribute.id}'
)
return annotations

# TODO: Allow passing labels instead of IDs
@document_common_args
def create(
self,
controlled_attribute_id: int,
@@ -68,10 +100,9 @@ def create(
resource_type: Resource type, if something other than an observation
Example:
Add a 'Plant phenology: Flowering' annotation to an observation (via IDs):
Add a 'Plant phenology: Flowering' annotation to an observation:
>>> annotation = client.annotations.create(12, 13, 164609837)
>>> client.annotations.create(12, 13, 164609837)
Returns:
The newly created Annotation object
346 changes: 309 additions & 37 deletions pyinaturalist/controllers/observation_controller.py

Large diffs are not rendered by default.

69 changes: 61 additions & 8 deletions pyinaturalist/controllers/place_controller.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from typing import Optional

from pyinaturalist.constants import MultiIntOrStr
from pyinaturalist.constants import IntOrStr, MultiIntOrStr
from pyinaturalist.controllers import BaseController
from pyinaturalist.converters import ensure_list
from pyinaturalist.docs import document_controller_params
from pyinaturalist.models import Place
from pyinaturalist.paginator import AutocompletePaginator, Paginator
from pyinaturalist.v1 import get_places_autocomplete, get_places_by_id, get_places_nearby
@@ -12,22 +11,47 @@
class PlaceController(BaseController):
""":fa:`location-dot` Controller for Place requests"""

def __call__(self, place_id, **kwargs) -> Optional[Place]:
"""Get a single place by ID"""
def __call__(self, place_id: IntOrStr, **kwargs) -> Optional[Place]:
"""Get a single place by ID
Example:
>>> client.places(67591)
Args:
place_ids: A single place ID
"""
return self.from_ids(place_id, **kwargs).one()

def from_ids(self, place_ids: MultiIntOrStr, **params) -> Paginator[Place]:
"""Get places by ID
.. rubric:: Notes
* API reference: :v1:`GET /places/{id} <Places/get_places_id>`
Example:
>>> client.places.from_ids([67591, 89191])
Args:
place_ids: One or more place IDs
"""
return self.client.paginate(
get_places_by_id, Place, place_id=ensure_list(place_ids), **params
)

@document_controller_params(get_places_autocomplete)
def autocomplete(self, q: Optional[str] = None, **params) -> Paginator[Place]:
"""Given a query string, get places with names starting with the search term
.. rubric:: Notes
* API reference: :v1:`GET /places/autocomplete <Places/get_places_autocomplete>`
Example:
>>> client.places.autocomplete('Irkutsk')
Args:
q: Search query
"""
return self.client.paginate(
get_places_autocomplete,
Place,
@@ -37,10 +61,39 @@ def autocomplete(self, q: Optional[str] = None, **params) -> Paginator[Place]:
**params,
)

@document_controller_params(get_places_nearby)
def nearby(
self, nelat: float, nelng: float, swlat: float, swlng: float, **params
self,
nelat: float,
nelng: float,
swlat: float,
swlng: float,
name: Optional[str] = None,
**params
) -> Paginator[Place]:
"""Search for places near a given location
.. rubric:: Notes
* API reference: :v1:`GET /places/nearby <get_places_nearby>`
Example:
>>> bounding_box = (150.0, -50.0, -149.999, -49.999)
>>> client.places.nearby(*bounding_box)
Args:
nelat: NE latitude of bounding box
nelng: NE longitude of bounding box
swlat: SW latitude of bounding box
swlng: SW longitude of bounding box
name: Name must match this value
"""
return self.client.paginate(
get_places_nearby, Place, nelat=nelat, nelng=nelng, swlat=swlat, swlng=swlng, **params
get_places_nearby,
Place,
nelat=nelat,
nelng=nelng,
swlat=swlat,
swlng=swlng,
name=name,
**params,
)
106 changes: 99 additions & 7 deletions pyinaturalist/controllers/project_controller.py
Original file line number Diff line number Diff line change
@@ -3,7 +3,8 @@
from pyinaturalist.constants import IntOrStr, ListResponse, MultiInt, MultiIntOrStr
from pyinaturalist.controllers import BaseController
from pyinaturalist.converters import ensure_list
from pyinaturalist.docs import document_controller_params
from pyinaturalist.docs import copy_doc_signature
from pyinaturalist.docs import templates as docs
from pyinaturalist.models import Project
from pyinaturalist.paginator import Paginator
from pyinaturalist.v1 import (
@@ -19,27 +20,63 @@
class ProjectController(BaseController):
""":fa:`users` Controller for Project requests"""

def __call__(self, project_id, **kwargs) -> Optional[Project]:
"""Get a single project by ID"""
def __call__(self, project_id: int, **kwargs) -> Optional[Project]:
"""Get a single project by ID
Example:
>>> client.projects(1234)
Args:
project_id: A single project ID
"""
return self.from_ids(project_id, **kwargs).one()

def from_ids(self, project_ids: MultiIntOrStr, **params) -> Paginator[Project]:
"""Get projects by ID
Example:
>>> client.projects.from_id([1234, 5678])
Args:
project_ids: One or more project IDs
"""
return self.client.paginate(get_projects_by_id, Project, project_id=project_ids, **params)

@document_controller_params(get_projects)
@copy_doc_signature(docs._projects_params)
def search(self, **params) -> Paginator[Project]:
"""Search projects
.. rubric:: Notes
* API reference: :v1:`GET /projects <Projects/get_projects>`
Example:
Search for projects about invasive species within 400km of Vancouver, BC:
>>> client.projects.search(
>>> q='invasive',
>>> lat=49.27,
>>> lng=-123.08,
>>> radius=400,
>>> order_by='distance',
>>> )
"""
return self.client.paginate(get_projects, Project, **params)

def add_observations(
self, project_id: int, observation_ids: MultiInt, **params
) -> ListResponse:
"""Add an observation to a project
.. rubric:: Notes
* :fa:`lock` :ref:`Requires authentication <auth>`
* API reference: :v1:`POST projects/{id}/add <Projects/post_projects_id_add>`
* API reference: :v1:`POST /project_observations <Project_Observations/post_project_observations>`
Example:
>>> client.projects.add_observations(24237, 1234)
Args:
project_id: ID of project to add onto
observation_ids: One or more observation IDs to add
@@ -56,18 +93,73 @@ def add_observations(
responses.append(response)
return responses

@document_controller_params(update_project)
@copy_doc_signature(docs._project_update_params)
def update(self, project_id: IntOrStr, **params) -> Project:
"""Update a project
.. rubric:: Notes
* :fa:`lock` :ref:`Requires authentication <auth>`
* Undocumented endpoint; may be subject to braking changes in the future
* ``admin_attributes`` and ``project_observation_rules_attributes`` each accept a list of dicts
in the formats shown below. These can be obtained from :py:func:`get_projects`, modified, and
then passed to this function::
{
"admin_attributes": [
{"id": int, "role": str, "user_id": int, "_destroy": bool},
],
"project_observation_rules_attributes": [
{"operator": str, "operand_type": str, "operand_id": int, "id": int, "_destroy": bool},
],
}
Example:
>>> client.projects.update(
... 'api-test-project',
... title='Test Project',
... description='This is a test project',
... prefers_rule_native=True,
... access_token=access_token,
... )
"""
response = self.client.request(update_project, project_id, auth=True, **params)
return Project.from_json(response)

@document_controller_params(add_project_users)
def add_users(self, project_id: IntOrStr, user_ids: MultiInt, **params) -> Project:
"""Add users to project observation rules
.. rubric:: Notes
* :fa:`lock` :ref:`Requires authentication <auth>`
* This only affects observation rules, **not** project membership
Example:
>>> client.projects.add_users(1234, [1234, 5678])
Args:
project_id: Either numeric project ID or URL slug
user_ids: One or more user IDs to add. Only accepts numeric IDs.
"""
response = self.client.request(add_project_users, project_id, user_ids, auth=True, **params)
return Project.from_json(response)

@document_controller_params(delete_project_users)
def delete_users(self, project_id: IntOrStr, user_ids: MultiInt, **params) -> Project:
"""Remove users from project observation rules
.. rubric:: Notes
* :fa:`lock` :ref:`Requires authentication <auth>`
* This only affects observation rules, **not** project membership
Example:
>>> client.projects.delete_users(1234, [1234, 5678])
Args:
project_id: Either numeric project ID or URL slug
user_ids: One or more user IDs to remove. Only accepts numeric IDs.
"""
response = self.client.request(
delete_project_users, project_id, user_ids, auth=True, **params
)
58 changes: 50 additions & 8 deletions pyinaturalist/controllers/taxon_controller.py
Original file line number Diff line number Diff line change
@@ -5,7 +5,8 @@
from pyinaturalist.constants import MultiInt
from pyinaturalist.controllers import BaseController
from pyinaturalist.converters import ensure_list
from pyinaturalist.docs import document_controller_params
from pyinaturalist.docs import copy_doc_signature
from pyinaturalist.docs import templates as docs
from pyinaturalist.models import Taxon
from pyinaturalist.paginator import IDPaginator, Paginator
from pyinaturalist.v1 import get_taxa, get_taxa_autocomplete, get_taxa_by_id
@@ -17,30 +18,71 @@ class TaxonController(BaseController):
""":fa:`dove` Controller for Taxon requests"""

def __call__(self, taxon_id: int, **kwargs) -> Optional[Taxon]:
"""Get a single taxon by ID"""
"""Get a single taxon by ID
Example:
>>> client.taxa(343248)
Args:
taxon_id: A single taxon ID
locale: Locale preference for taxon common names
preferred_place_id: Place preference for regional taxon common names
all_names: Include all taxon names in the response
"""
return self.from_ids(taxon_id, **kwargs).one()

def from_ids(self, taxon_ids: MultiInt, **params) -> Paginator[Taxon]:
"""Get taxa by ID
"""Get one or more taxa by ID
.. rubric:: Notes
* API reference: :v1:`GET /taxa/{id} <Taxa/get_taxa_id>`
Example:
>>> client.get_taxa_by_id([3, 343248])
Args:
taxon_ids: One or more taxon IDs
locale: Locale preference for taxon common names
preferred_place_id: Place preference for regional taxon common names
all_names: Include all taxon names in the response
"""
return self.client.paginate(

params = self.client.add_defaults(get_taxa_by_id, params)
return IDPaginator(
get_taxa_by_id,
Taxon,
cls=IDPaginator,
ids=ensure_list(taxon_ids),
ids_per_request=IDS_PER_REQUEST,
**params
**params,
)

@document_controller_params(get_taxa_autocomplete)
@copy_doc_signature(docs._taxon_params)
def autocomplete(self, **params) -> Paginator[Taxon]:
"""Given a query string, return taxa with names starting with the search term
.. rubric:: Notes
* API reference: :v1:`GET /taxa/autocomplete <Taxa/get_taxa_autocomplete>`
* There appears to currently be a bug in the API that causes ``per_page`` to not have any effect.
Example:
>>> client.taxa.autocomplete(q='vespi')
"""
return self.client.paginate(get_taxa_autocomplete, Taxon, **params)

@document_controller_params(get_taxa)
@copy_doc_signature(docs._taxon_params, docs._taxon_id_params)
def search(self, **params) -> Paginator[Taxon]:
"""Search taxa
.. rubric:: Notes
* API reference: :v1:`GET /taxa <Taxa/get_taxa>`
Example:
>>> client.taxa.search(q='vespi', rank=['genus', 'family'])
"""
return self.client.paginate(get_taxa, Taxon, **params)

def populate(self, taxon: Taxon, **params) -> Taxon:
56 changes: 42 additions & 14 deletions pyinaturalist/controllers/user_controller.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from typing import List, Optional
from typing import Optional

from pyinaturalist.constants import MultiIntOrStr
from pyinaturalist.constants import IntOrStr, MultiIntOrStr
from pyinaturalist.controllers import BaseController
from pyinaturalist.converters import ensure_list
from pyinaturalist.docs import document_controller_params
from pyinaturalist.models import User
from pyinaturalist.paginator import IDPaginator, Paginator
from pyinaturalist.v1 import get_current_user, get_user_by_id, get_users_autocomplete
@@ -12,8 +11,15 @@
class UserController(BaseController):
""":fa:`user` Controller for User requests"""

def __call__(self, user_id, **kwargs) -> Optional[User]:
"""Get a single user by ID"""
def __call__(self, user_id: IntOrStr, **kwargs) -> Optional[User]:
"""Get a single user by ID
Example:
>>> user = client.users(1)
Args:
user_id: A single user ID
"""
return self.from_ids(user_id, **kwargs).one()

def from_ids(self, user_ids: MultiIntOrStr, **params) -> Paginator[User]:
@@ -26,23 +32,45 @@ def from_ids(self, user_ids: MultiIntOrStr, **params) -> Paginator[User]:
Get multiple users by ID:
>>> users = client.users.from_id(1).all()
>>> users = client.users.from_id([1,2]).all()
Args:
user_ids: One or more project IDs
user_ids: One or more user IDs
"""
return self.client.paginate(
get_user_by_id, User, cls=IDPaginator, ids=ensure_list(user_ids), **params
)

# TODO: Wrap in paginator?
@document_controller_params(get_users_autocomplete)
def autocomplete(self, **params) -> List[User]:
response = self.client.request(get_users_autocomplete, **params)
return User.from_json_list(response)
def autocomplete(
self, q: Optional[str] = None, project_id: Optional[int] = None, **params
) -> Paginator[User]:
"""Given a query string, return users with names or logins starting with the search term
.. rubric:: Notes
* API reference: :v1:`GET /users/autocomplete <Users/get_users_autocomplete>`
Example:
>>> client.users.autocomplete(q='my_userna')
Args:
q: Search query
project_id: Only show users who are members of this project
"""
return self.client.paginate(
get_users_autocomplete, User, q=q, project_id=project_id, **params
)

@document_controller_params(get_current_user)
def me(self, **params) -> User:
"""Get your own user profile
.. rubric:: Notes
* :fa:`lock` :ref:`Requires authentication <auth>`
* API reference: :v1:`GET /users/me <Users/get_users_me>`
Example:
>>> client.users.me()
"""
response = self.client.request(get_current_user, auth=True, **params)
print(response)
return User.from_json(response)
13 changes: 11 additions & 2 deletions pyinaturalist/models/media.py
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@
TableRow,
)
from pyinaturalist.converters import format_dimensions, format_license
from pyinaturalist.models import BaseModel, define_model, field
from pyinaturalist.models import BaseModel, datetime_field, define_model, field


@define_model
@@ -27,6 +27,8 @@ class BaseMedia(BaseModel):
options=ALL_LICENSES,
doc='Creative Commons license code',
)
created_at: str = datetime_field(doc='Date the file was added to iNaturalist')
updated_at: str = datetime_field(doc='Date the file was last updated on iNaturalist')

@property
def ext(self) -> str:
@@ -80,7 +82,7 @@ def __attrs_post_init__(self):
def from_json(cls, value: JsonResponse, **kwargs) -> 'Photo':
"""Flatten out potentially nested photo field before initializing"""
if 'photo' in value:
value = value['photo']
value.update(value.pop('photo'))
return super(Photo, cls).from_json(value, **kwargs)

@property
@@ -203,6 +205,13 @@ class Sound(BaseMedia):
# flags: List = field(factory=list)
# play_local: bool = field(default=None)

@classmethod
def from_json(cls, value: JsonResponse, **kwargs) -> 'Sound':
"""Flatten out potentially nested sound field before initializing"""
if 'sound' in value:
value.update(value.pop('sound'))
return super(Sound, cls).from_json(value, **kwargs)

# Aliases
@property
def mimetype(self) -> str:
2 changes: 1 addition & 1 deletion pyinaturalist/v1/controlled_terms.py
Original file line number Diff line number Diff line change
@@ -61,7 +61,7 @@ def get_controlled_terms_for_taxon(taxon_id: int, **params) -> JsonResponse:
.. literalinclude:: ../sample_data/get_controlled_terms_for_taxon.json
:language: JSON
Args:
taxon_id: ID of taxon to get controlled terms for
taxon_id: Taxon ID to get controlled terms for
Returns:
A dict containing details on controlled terms and their values
86 changes: 49 additions & 37 deletions pyinaturalist/v1/observations.py
Original file line number Diff line number Diff line change
@@ -61,8 +61,10 @@ def get_observations(**params) -> JsonResponse:
Get basic info for observations in response:
>>> pprint(response)
'[57754375] Species: Danaus plexippus (Monarch) observed by samroom on 2020-08-27 at Railway Ave, Wilcox, SK'
'[57707611] Species: Danaus plexippus (Monarch) observed by ingridt3 on 2020-08-26 at Michener Dr, Regina, SK'
ID Taxon ID Taxon Observed on User Location
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
57707611 48662 Danaus plexippus (Monarch) Aug 26, 2020 ingridt3 Michener Dr, Regina, SK, CA
57754375 48662 Danaus plexippus (Monarch) Aug 27, 2020 samroom Railway Ave, Wilcox, SK, CA
Search for observations with a given observation field:
@@ -172,12 +174,14 @@ def get_observation_histogram(**params) -> HistogramResponse:
:icon: code-square
.. literalinclude:: ../sample_data/get_observation_histogram_month.py
:lines: 3-
.. dropdown:: Example Response (observations per day)
:color: primary
:icon: code-square
.. literalinclude:: ../sample_data/get_observation_histogram_day.py
:lines: 3-
Returns:
Dict of ``{time_key: observation_count}``. Keys are ints for 'month of year' and\
@@ -254,15 +258,45 @@ def get_observation_observers(**params) -> JsonResponse:
return response.json()


@document_request_params(*docs._get_observations)
def get_observation_popular_field_values(**params) -> JsonResponse:
"""Get controlled terms values and a monthly histogram of observations matching the search
criteria.
.. rubric:: Notes
* API reference: :v1:`GET /observations/popular_field_values <Observations/get_observations_popular_field_values>`
Example:
>>> response = get_observation_popular_field_values(
... species_name='Danaus plexippus', place_id=24,
... )
.. dropdown:: Example Response
:color: primary
:icon: code-square
.. literalinclude:: ../sample_data/get_observation_popular_field_values.py
Returns:
Response dict. Each record contains a ``count``, a ``month_of_year`` histogram, a
``controlled_attribute``, and a ``controlled_value``.
"""
response_json = get(f'{API_V1}/observations/popular_field_values', **params).json()
for r in response_json['results']:
r['month_of_year'] = convert_histogram(r['month_of_year'], interval='month_of_year')
return response_json


@document_request_params(*docs._get_observations, docs._pagination)
def get_observation_species_counts(**params) -> JsonResponse:
"""Get all species (or other 'leaf taxa') associated with observations matching the search
criteria, and the count of observations they are associated with.
**Leaf taxa** are the leaves of the taxonomic tree, e.g., species, subspecies, variety, etc.
.. rubric:: Notes
* API reference: :v1:`GET /observations/species_counts <Observations/get_observations_species_counts>`
* **Leaf taxa** are the leaves of the taxonomic tree, e.g., species, subspecies, variety, etc.
Example:
>>> response = get_observation_species_counts(user_login='my_username', quality_grade='research')
@@ -287,36 +321,6 @@ def get_observation_species_counts(**params) -> JsonResponse:
return get(f'{API_V1}/observations/species_counts', **params).json()


@document_request_params(*docs._get_observations)
def get_observation_popular_field_values(**params) -> JsonResponse:
"""Get controlled terms values and a monthly histogram of observations matching the search
criteria.
.. rubric:: Notes
* API reference: :v1:`GET /observations/popular_field_values <Observations/get_observations_popular_field_values>`
Example:
>>> response = get_observation_popular_field_values(
... species_name='Danaus plexippus', place_id=24,
... )
.. dropdown:: Example Response
:color: primary
:icon: code-square
.. literalinclude:: ../sample_data/get_observation_popular_field_values.py
Returns:
Response dict. Each record contains a ``count``, a ``month_of_year`` histogram, a
``controlled_attribute``, and a ``controlled_value``.
"""
response_json = get(f'{API_V1}/observations/popular_field_values', **params).json()
for r in response_json['results']:
r['month_of_year'] = convert_histogram(r['month_of_year'], interval='month_of_year')
return response_json


@document_request_params(*docs._get_observations)
def get_observation_taxonomy(**params) -> JsonResponse:
"""Get observation counts for all taxa in observations matching the search criteria.
@@ -374,7 +378,7 @@ def get_observation_taxon_summary(observation_id: int, **params) -> JsonResponse

@document_request_params(docs._access_token, docs._create_observation)
def create_observation(**params) -> JsonResponse:
"""Create or update a new observation.
"""Create or update an observation.
.. rubric:: Notes
@@ -513,6 +517,7 @@ def upload(
Information about the uploaded file(s)
"""
params['raise_for_status'] = False
session = params.pop('session', None)
responses = []
photos, sounds = ensure_list(photos), ensure_list(sounds)
logger.info(f'Uploading {len(photos)} photos and {len(sounds)} sounds')
@@ -521,20 +526,27 @@ def upload(
photo_params = deepcopy(params)
photo_params['observation_photo[observation_id]'] = observation_id
for photo in photos:
response = post(f'{API_V1}/observation_photos', files=photo, **photo_params)
response = post(
f'{API_V1}/observation_photos', files=photo, session=session, **photo_params
)
responses.append(response)

# Upload sounds
sound_params = deepcopy(params)
sound_params['observation_sound[observation_id]'] = observation_id
for sound in sounds:
response = post(f'{API_V1}/observation_sounds', files=sound, **sound_params)
response = post(
f'{API_V1}/observation_sounds', files=sound, session=session, **sound_params
)
responses.append(response)

# Attach previously uploaded photos by ID
if photo_ids:
response = update_observation(
observation_id, photo_ids=photo_ids, access_token=params.get('access_token', None)
observation_id,
photo_ids=photo_ids,
session=session,
access_token=params.get('access_token', None),
)
responses.append(response)

15 changes: 15 additions & 0 deletions test/controllers/test_annotation_controller.py
Original file line number Diff line number Diff line change
@@ -55,6 +55,21 @@ def test_lookup(requests_mock):
assert obs.annotations[0].controlled_value.label == 'Adult'


def test_lookup__doesnt_exist(requests_mock):
requests_mock.get(
f'{API_V1}/controlled_terms',
json=SAMPLE_DATA['get_controlled_terms'],
status_code=200,
)

client = iNatClient()
annotations = [Annotation(controlled_attribute_id=id) for id in [12, 999]]
annotations = client.annotations.lookup(annotations)
assert len(annotations) == 2
assert annotations[0].term == 'Plant Phenology'
assert annotations[1].term == '999' # Unable to look up; use ID as placeholder


@patch('pyinaturalist.controllers.annotation_controller.post')
def test_create(mock_post):
response = Response()
42 changes: 37 additions & 5 deletions test/controllers/test_observation_controller.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# ruff: noqa: F405
from datetime import datetime
from io import BytesIO
from unittest.mock import patch

from dateutil.tz import tzutc

@@ -281,6 +283,41 @@ def test_taxon_summary__with_listed_taxon(requests_mock):
assert 'western honey bee' in results.wikipedia_summary


@patch('pyinaturalist.client.get_access_token', return_value='token')
@patch('pyinaturalist.v1.observations.update_observation')
def test_upload(mock_update_observation, mock_get_access_token, requests_mock):
requests_mock.post(
f'{API_V1}/observation_photos',
json=SAMPLE_DATA['post_observation_photos'],
status_code=200,
)
requests_mock.post(
f'{API_V1}/observation_sounds',
json=SAMPLE_DATA['post_observation_sounds'],
status_code=200,
)

client = iNatClient()
client._access_token = 'token'
media_objs = client.observations.upload(
1234,
photos=BytesIO(),
sounds=BytesIO(),
photo_ids=[5678],
)
photo, sound = media_objs
assert photo.id == 1234
assert photo.observation_id == 1234
assert isinstance(photo.created_at, datetime)

assert sound.id == 239936
assert sound.file_content_type == 'audio/mpeg'
assert isinstance(sound.created_at, datetime)

# Attaching existing photos to the observation uses a separate endpoint
assert mock_update_observation.call_args[1]['photo_ids'] == [5678]


# TODO:
# def test_create():
# client = iNatClient()
@@ -290,8 +327,3 @@ def test_taxon_summary__with_listed_taxon(requests_mock):
# def test_delete():
# client = iNatClient()
# results = client.observations.delete()


# def test_upload():
# client = iNatClient()
# results = client.observations.upload()
2 changes: 1 addition & 1 deletion test/controllers/test_user_controller.py
Original file line number Diff line number Diff line change
@@ -45,7 +45,7 @@ def test_autocomplete(requests_mock):
status_code=200,
)

results = iNatClient().users.autocomplete(q='nico')
results = iNatClient().users.autocomplete(q='nico').all()
assert len(results) == 3 and isinstance(results[0], User)
assert results[0].id == 886482

2 changes: 2 additions & 0 deletions test/sample_data.py
Original file line number Diff line number Diff line change
@@ -79,6 +79,7 @@ def load_all_sample_data() -> Dict[str, Dict]:
j_quality_metric_1 = j_observation_6_metrics['quality_metrics'][0]
j_photo_1 = j_taxon_1['taxon_photos'][0]['photo']
j_photo_2_partial = j_taxon_1['default_photo']
j_photo_3_nested = SAMPLE_DATA['upload_photos_and_sounds'][1]
j_place_1 = SAMPLE_DATA['get_places_by_id']['results'][1]
j_place_2 = SAMPLE_DATA['get_places_autocomplete']['results'][0]
j_places_nearby = SAMPLE_DATA['get_places_nearby']['results']
@@ -94,6 +95,7 @@ def load_all_sample_data() -> Dict[str, Dict]:
j_search_result_3_project = j_search_results[2]
j_search_result_4_user = j_search_results[3]
j_sound_1 = j_observation_4_sounds['sounds'][0]
j_sound_2_nested = SAMPLE_DATA['upload_photos_and_sounds'][1]
j_species_count_1 = SAMPLE_DATA['get_observation_species_counts']['results'][0]
j_species_count_2 = SAMPLE_DATA['get_observation_species_counts']['results'][1]
j_users = SAMPLE_DATA['get_users_autocomplete']['results']
8 changes: 8 additions & 0 deletions test/test_models.py
Original file line number Diff line number Diff line change
@@ -835,6 +835,14 @@ def test_sound__aliases():
assert sound.mimetype == sound.file_content_type == 'audio/x-wav'


def test_sound__nested_record():
sound = Sound.from_json(j_sound_2_nested)
assert sound.uuid == '5c858ffa-696b-4bf2-beab-9f519901bd17'
assert sound.url.startswith('https://static.inaturalist.org/')
assert isinstance(sound.created_at, datetime)
assert isinstance(sound.updated_at, datetime)


# Taxa
# --------------------

4 changes: 3 additions & 1 deletion test/v1/test_observations.py
Original file line number Diff line number Diff line change
@@ -404,7 +404,9 @@ def test_upload(requests_mock):
@patch('pyinaturalist.v1.observations.update_observation')
def test_upload__with_photo_ids(mock_update_observation):
upload(1234, access_token='token', photo_ids=[5678])
mock_update_observation.assert_called_with(1234, access_token='token', photo_ids=[5678])
mock_update_observation.assert_called_with(
1234, access_token='token', session=None, photo_ids=[5678]
)


def test_delete_observation(requests_mock):

0 comments on commit 769114f

Please sign in to comment.