Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Observation tests, docs, and other minor improvements #240

Merged
merged 5 commits into from
Aug 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ repos:
rev: 21.7b0
hooks:
- id: black
- repo: https://github.com/asottile/blacken-docs
rev: v1.10.0
hooks:
- id: blacken-docs
args: [--skip-errors, --skip-string-normalization]
- repo: https://github.com/timothycrosley/isort
rev: 5.8.0
hooks:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,9 @@ response = create_observation(
tag_list='wasp, Belgium',
latitude=50.647143,
longitude=4.360216,
positional_accuracy=50, # GPS accuracy in meters
positional_accuracy=50, # GPS accuracy in meters
access_token=token,
photos=['~/observations/wasp1.jpg', '~/observations/wasp2.jpg']
photos=['~/observations/wasp1.jpg', '~/observations/wasp2.jpg'],
)

# Save the new observation ID
Expand Down
1 change: 1 addition & 0 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ You can configure logging for pyinaturalist using the standard Python `logging`
with {py:func}`logging.basicConfig`:
```python
import logging

logging.basicConfig()
logging.getLogger('pyinaturalist').setLevel('INFO')
```
Expand Down
2 changes: 1 addition & 1 deletion pyinaturalist/controllers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def __init__(self, client):

def authenticated(func):
"""Decorator that will add an authentication token to request params, unless one has already
been manually provided. This requires credentials to be provided either via :yp:class:`.iNatClient`
been manually provided. This requires credentials to be provided either via :py:class:`.iNatClient`
arguments, environment variables, or keyring.
"""

Expand Down
25 changes: 11 additions & 14 deletions pyinaturalist/controllers/observations.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from typing import Dict, List
from typing import List

from pyinaturalist.constants import HistogramResponse, ListResponse
from pyinaturalist.controllers import BaseController, authenticated
from pyinaturalist.docs import document_controller_params
from pyinaturalist.models import LifeList, Observation, TaxonCounts, User
from pyinaturalist.models import LifeList, Observation, TaxonCounts, UserCounts
from pyinaturalist.v1 import (
create_observation,
delete_observation,
Expand Down Expand Up @@ -42,24 +42,21 @@ def histogram(self, **params) -> HistogramResponse:
return get_observation_histogram(**params, **self.client.settings)

@document_controller_params(get_observation_identifiers)
def identifiers(self, **params) -> Dict[int, User]:
def identifiers(self, **params) -> UserCounts:
response = get_observation_identifiers(**params, **self.client.settings)
return {r['count']: User.from_json(r['user']) for r in response['results']}
return UserCounts.from_json(response)

@document_controller_params(get_observation_observers)
def observers(self, **params) -> UserCounts:
response = get_observation_observers(**params, **self.client.settings)
return UserCounts.from_json(response)

@document_controller_params(get_observation_taxonomy, add_common_args=False)
def life_list(self, *args, **params) -> LifeList:
response = get_observation_taxonomy(*args, **params, **self.client.settings)
return LifeList.from_json(response.json())

# TODO: Separate model for these results? (maybe a User subclass)
# TODO: Include species_counts
@document_controller_params(get_observation_observers)
def observers(self, **params) -> Dict[int, User]:
response = get_observation_observers(**params, **self.client.settings)
return {r['count']: User.from_json(r['user']) for r in response['results']}
return LifeList.from_json(response)

@document_controller_params(get_observation_species_counts)
@authenticated
def species_counts(self, **params) -> TaxonCounts:
response = get_observation_species_counts(**params, **self.client.settings)
return TaxonCounts.from_json(response)
Expand All @@ -72,7 +69,7 @@ def create(self, **params) -> Observation:

# TODO: create observations with Observation objects; requires model updates
# @authenticated
# def _reate(self, *observations: Observation, **params):
# def create(self, *observations: Observation, **params):
# for obs in observations:
# create_observation(obs.to_json(), **params, **self.client.settings)

Expand Down
1 change: 1 addition & 0 deletions pyinaturalist/docs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@
copy_signatures,
document_controller_params,
document_request_params,
extend_init_signature,
)
6 changes: 4 additions & 2 deletions pyinaturalist/docs/docstrings.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def _indent(value: str, level: int = 1) -> str:
@staticmethod
def _split_sections(docstring: str) -> Dict[str, str]:
"""Split a docstring into a dict of ``{section_title: section_content}``"""
docstring = docstring or ''
sections = {k: '' for k in DEFAULT_SECTIONS}
current_section = 'Description'

Expand Down Expand Up @@ -93,8 +94,9 @@ def copy_annotations(
annotations = get_type_hints(template_function)
if not include_return:
annotations.pop('return', None)
for k, v in annotations.items():
target_function.__annotations__[k] = v
if hasattr(target_function, '__annotations__'):
for k, v in annotations.items():
target_function.__annotations__[k] = v

return target_function

Expand Down
62 changes: 52 additions & 10 deletions pyinaturalist/docs/signatures.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Utilities for modifying function signatures using ``python-forge``"""
from functools import partial
from inspect import ismethod, signature
from inspect import Parameter, ismethod, signature
from logging import getLogger
from typing import Callable, Iterable, List
from typing import Callable, Dict, Iterable, List, Type

import forge
from pyrate_limiter import Limiter
Expand All @@ -12,13 +12,14 @@
from pyinaturalist.converters import ensure_list
from pyinaturalist.docs import copy_annotations, copy_docstrings

AUTOMETHOD_INIT = '.. automethod:: __init__'
COMMON_PARAMS = ['dry_run', 'limiter', 'user_agent', 'session']
logger = getLogger(__name__)


def copy_doc_signature(
*template_functions: TemplateFunction,
add_common_args: bool = True,
add_common_args: bool = False,
include_sections: Iterable[str] = None,
include_return_annotation: bool = True,
exclude_args: Iterable[str] = None,
Expand All @@ -29,10 +30,9 @@ def copy_doc_signature(
If used with other decorators, this should go first (e.g., last in the call order).

Example:

>>> # 1. Template function with individual request params + docs
>>> def get_foo_template(arg_1: str = None, arg_2: bool = False):
>>> '''
>>> '''Args:
>>> arg_1: Example request parameter 1
>>> arg_2: Example request parameter 2
>>> '''
Expand Down Expand Up @@ -78,22 +78,41 @@ def wrapper(func):


# Aliases specifically for basic request functions and controller functions, respectively
document_request_params = copy_doc_signature
document_request_params = partial(copy_doc_signature, add_common_args=True)
document_controller_params = partial(
copy_doc_signature,
add_common_args=False,
include_sections=['Description', 'Args'],
include_return_annotation=False,
exclude_args=COMMON_PARAMS,
)


def extend_init_signature(*template_functions: Callable) -> Callable:
"""A class decorator that behaves like :py:func:`.copy_doc_signature`, but modifies a class
docstring and its ``__init__`` function signature, and extends them instead of replacing them.
"""

def wrapper(target_class: Type):
# Modify init signature + docstring
revision = copy_doc_signature(*template_functions, target_class.__init__)
target_class.__init__ = revision(target_class.__init__)

# Include init docs in class docs
target_class.__doc__ = target_class.__doc__ or ''
if AUTOMETHOD_INIT not in target_class.__doc__:
target_class.__doc__ += f'\n\n {AUTOMETHOD_INIT}\n'
return target_class

return wrapper


def copy_signatures(
target_function: Callable,
template_functions: List[TemplateFunction],
exclude_args: Iterable[str] = None,
) -> Callable:
"""Decorator to copy function signatures from one or more template functions to a target function.
"""A decorator that copies function signatures from one or more template functions to a
target function.

Args:
target_function: Function to modify
Expand All @@ -104,18 +123,41 @@ def copy_signatures(
if 'self' in signature(target_function).parameters or ismethod(target_function):
fparams['self'] = forge.self

# Add and combine parameters from all template functions (also removes duplicates)
# Add and combine parameters from all template functions, excluding duplicates, self, and *args
for func in template_functions:
fparams.update(forge.copy(func).signature.parameters)
new_fparams = {
k: v
for k, v in forge.copy(func).signature.parameters.items()
if k != 'self' and v.kind != Parameter.VAR_POSITIONAL
}
fparams.update(new_fparams)

# Manually remove any excluded parameters
for key in ensure_list(exclude_args):
fparams.pop(key, None)

fparams = deduplicate_var_kwargs(fparams)
revision = forge.sign(*fparams.values())
return revision(target_function)


def deduplicate_var_kwargs(params: Dict) -> Dict:
"""If a list of params contains one or more variadic keyword args (e.g., ``**kwargs``),
ensure there are no duplicates and move it to the end.
"""
# Check for **kwargs by param type instead of by name
has_var_kwargs = False
for k, v in params.copy().items():
if v.kind == Parameter.VAR_KEYWORD:
has_var_kwargs = True
params.pop(k)

# If it was present, add **kwargs as the last param
if has_var_kwargs:
params.update(forge.kwargs)
return params


# Templates for common params that are added to every request function by default
# ----------

Expand Down
2 changes: 1 addition & 1 deletion pyinaturalist/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def upper(value) -> Optional[str]:

from pyinaturalist.models.photo import Photo
from pyinaturalist.models.place import Place
from pyinaturalist.models.user import User
from pyinaturalist.models.user import User, UserCount, UserCounts
from pyinaturalist.models.taxon import (
ConservationStatus,
EstablishmentMeans,
Expand Down
21 changes: 21 additions & 0 deletions pyinaturalist/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class BaseModelCollection(BaseModel, UserList, Generic[T]):
"""

data: List[T] = field(factory=list, init=False, repr=False)
_id_map: Dict[int, T] = field(default=None, init=False, repr=False)

@classmethod
def from_json(cls: Type[TC], value: JsonResponse, **kwargs) -> TC:
Expand All @@ -84,6 +85,26 @@ def from_json_list(cls: Type[TC], value: JsonResponse, **kwargs) -> TC: # type:
"""
return cls.from_json(value)

@property
def id_map(self) -> Dict[int, T]:
"""A mapping of objects by unique identifier"""
if self._id_map is None:
self._id_map = {obj.id: obj for obj in self.data}
return self._id_map

def deduplicate(self):
"""Remove any duplicates from this collection based on ID"""
self.data = list(self.id_map.values())

def get_count(self, id: int, count_field: str = 'count') -> int:
"""Get a count associated with the given ID.
Returns 0 if the collection type is not countable or the ID doesn't exist.
"""
return getattr(self.id_map.get(id), count_field, 0)

def __str__(self) -> str:
return '\n'.join([str(obj) for obj in self.data])


def load_json(value: ResponseOrFile) -> ResponseOrResults:
"""Load a JSON string, file path, or file-like object"""
Expand Down
12 changes: 10 additions & 2 deletions pyinaturalist/models/life_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def row(self) -> TableRow:
return {
'ID': self.id,
'Rank': self.rank,
'Name': {self.name},
'Name': self.name,
'Count': self.count,
}

Expand All @@ -44,8 +44,8 @@ def __str__(self) -> str:
class LifeList(TaxonCounts):
""":fa:`dove,style=fas` :fa:`list` A user's life list, based on the schema of ``GET /observations/taxonomy``"""

count_without_taxon: int = field(default=0)
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
Expand All @@ -57,6 +57,14 @@ def from_json(cls, value: JsonResponse, user_id: int = None, **kwargs) -> 'LifeL
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
Expand Down
8 changes: 6 additions & 2 deletions pyinaturalist/models/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
TableRow,
)
from pyinaturalist.converters import convert_observation_timestamp
from pyinaturalist.docs import extend_init_signature
from pyinaturalist.models import (
Annotation,
BaseModel,
Expand Down Expand Up @@ -175,8 +176,7 @@ class Observation(BaseModel):
'time_zone_offset',
]

# Convert observation timestamps prior to attrs init
# TODO: better function signature for docs; use forge?
# Convert observation timestamps prior to __attrs_init__
def __init__(
self,
created_at_details: Dict = None,
Expand Down Expand Up @@ -267,3 +267,7 @@ def taxa(self) -> List[Taxon]:
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]


# Fix __init__ and class docstring
Observation = extend_init_signature(Observation.__attrs_init__)(Observation) # type: ignore
14 changes: 0 additions & 14 deletions pyinaturalist/models/taxon.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,20 +306,6 @@ class TaxonCounts(BaseModelCollection, Generic[T]):
"""

data: List[T] = field(factory=list, converter=TaxonCount.from_json_list)
_taxon_counts: Dict[int, int] = field(default=None, init=False, repr=False)

def count(self, taxon_id: int) -> int:
"""Get an observation count for the specified taxon and its descendants, and handle unlisted taxa.
**Note:** ``-1`` can be used an alias for ``count_without_taxon``.
"""
# Make and cache an index of taxon IDs and observation counts
if self._taxon_counts is None:
self._taxon_counts = {t.id: t.descendant_obs_count for t in self.data}
self._taxon_counts[-1] = self.count_without_taxon
return self._taxon_counts.get(taxon_id, 0)

def __str__(self) -> str:
return '\n'.join([str(taxon) for taxon in self.data])


# Since these use Taxon classmethods, they must be added after Taxon is defined
Expand Down
Loading