Skip to content

Commit

Permalink
Merge pull request #15 from JWCook/feature/add-taxa-endpoints
Browse files Browse the repository at this point in the history
Add taxa endpoints
  • Loading branch information
Nicolas Noé authored May 6, 2020
2 parents 51ecdf5 + 12ab3de commit 96e1524
Show file tree
Hide file tree
Showing 14 changed files with 3,112 additions and 24 deletions.
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Development Lead
Contributors
------------

* Jordan Cook
* Peter Desmet
* Stijn Van Hoey

7 changes: 7 additions & 0 deletions docs/infrastructure.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ To build the doc locally::

$ tox -e docs

To preview::

# MacOS:
$ open docs/_build/index.html
# Linux:
$ xdg-open docs/_build/index.html

Hosted documentation (https://pyinaturalist.readthedocs.io/) is automatically updated when code gets pushed to GitHub.

Testing
Expand Down
8 changes: 7 additions & 1 deletion docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,10 @@ Simply use pip::

Or if you prefer using the development version::

$ pip install git+https://github.com/niconoe/pyinaturalist.git
$ pip install git+https://github.com/niconoe/pyinaturalist.git

Or, to set up for local development (preferably in a new virtualenv)::

$ git clone https://github.com/niconoe/pyinaturalist.git
$ cd pyinaturalist
$ pip install -Ue ".[dev]"
5 changes: 4 additions & 1 deletion pyinaturalist/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
import logging

__author__ = "Nicolas Noé"
__email__ = "[email protected]"
Expand All @@ -7,3 +7,6 @@
DEFAULT_USER_AGENT = "Pyinaturalist/{version}".format(version=__version__)

user_agent = DEFAULT_USER_AGENT

# Enable logging for urllib and other external loggers
logging.basicConfig(level="DEBUG")
28 changes: 28 additions & 0 deletions pyinaturalist/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,31 @@
INAT_BASE_URL = "https://www.inaturalist.org"

THROTTLING_DELAY = 1 # In seconds, support <0 floats such as 0.1

# Taxonomic ranks from Node API Swagger spec
RANKS = [
"form",
"variety",
"subspecies",
"hybrid",
"species",
"genushybrid",
"genus",
"subtribe",
"tribe",
"supertribe",
"subfamily",
"family",
"epifamily",
"superfamily",
"infraorder",
"suborder",
"order",
"superorder",
"subclass",
"class",
"superclass",
"subphylum",
"phylum",
"kingdom",
]
16 changes: 16 additions & 0 deletions pyinaturalist/helpers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pyinaturalist
from typing import Dict, Any


# For Python < 3.5 compatibility
Expand All @@ -14,3 +15,18 @@ def get_user_agent(user_agent: str = None) -> str:
return user_agent
else: # Otherwise we have a global one in __init__.py (configurable, with sensible defaults)
return pyinaturalist.user_agent


def concat_list_params(params) -> Dict[str, Any]:
"""Convert any list parameters into an API-compatible (comma-delimited) string.
Will be url-encoded by requests. For example: `['k1', 'k2', 'k3'] -> k1%2Ck2%2Ck3`
"""
for k, v in params.items():
if isinstance(v, list):
params[k] = ",".join(v)
return params


def strip_empty_params(params) -> Dict[str, Any]:
"""Remove any request parameters with empty or ``None`` values."""
return {k: v for k, v in params.items() if v}
102 changes: 99 additions & 3 deletions pyinaturalist/node_api.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,38 @@
# Code to access the (read-only, but fast) Node based public iNaturalist API
# See: http://api.inaturalist.org/v1/docs/
from logging import getLogger
from time import sleep
from typing import Dict, Any, List

import requests
from urllib.parse import urljoin

from pyinaturalist.constants import THROTTLING_DELAY, INAT_NODE_API_BASE_URL
from pyinaturalist.constants import THROTTLING_DELAY, INAT_NODE_API_BASE_URL, RANKS
from pyinaturalist.exceptions import ObservationNotFound
from pyinaturalist.helpers import merge_two_dicts, get_user_agent
from pyinaturalist.helpers import (
merge_two_dicts,
get_user_agent,
concat_list_params,
strip_empty_params,
)

PER_PAGE_RESULTS = 30 # Paginated queries: how many records do we ask per page?

logger = getLogger(__name__)


def make_inaturalist_api_get_call(
endpoint: str, params: Dict, user_agent: str = None, **kwargs
) -> requests.Response:
"""Make an API call to iNaturalist.
endpoint is a string such as 'observations' !! do not put / in front
endpoint is a string such as 'observations'
method: 'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'
kwargs are passed to requests.request
Returns a requests.Response object
"""
params = strip_empty_params(params)
params = concat_list_params(params)
headers = {"Accept": "application/json", "User-Agent": get_user_agent(user_agent)}

response = requests.get(
Expand Down Expand Up @@ -94,3 +104,89 @@ def get_all_observations(params: Dict, user_agent: str = None) -> List[Dict[str,

sleep(THROTTLING_DELAY)
id_above = results[-1]["id"]


def get_taxa_by_id(taxon_id, user_agent: str = None) -> Dict[str, Any]:
"""
Get one or more taxa by ID.
See: https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa_id
:param: taxon_id: Get taxa with this ID. Multiple values are allowed.
:returns: A list of dicts containing taxa results
"""
r = make_inaturalist_api_get_call(
"taxa/{}".format(taxon_id), {}, user_agent=user_agent
)
r.raise_for_status()
return r.json()


def get_taxa(
user_agent: str = None, min_rank: str = None, max_rank: str = None, **params
) -> Dict[str, Any]:
"""Given zero to many of following parameters, returns taxa matching the search criteria.
See https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa
:param q: Name must begin with this value
:param is_active: Taxon is active
:param taxon_id: Only show taxa with this ID, or its descendants
:param parent_id: Taxon's parent must have this ID
:param rank: Taxon must have this exact rank
:param min_rank: Taxon must have this rank or higher; overrides ``rank``
:param max_rank: Taxon must have this rank or lower; overrides ``rank``
:param rank_level: Taxon must have this rank level. Some example values are 70 (kingdom),
60 (phylum), 50 (class), 40 (order), 30 (family), 20 (genus), 10 (species), 5 (subspecies)
:param id_above: Must have an ID above this value
:param id_below: Must have an ID below this value
:param per_page: Number of results to return in a page. The maximum value is generally 200
unless otherwise noted
:param locale: Locale preference for taxon common names
:param preferred_place_id: Place preference for regional taxon common names
:param only_id: Return only the record IDs
:param all_names: Include all taxon names in the response
:returns: A list of dicts containing taxa results
"""
if min_rank or max_rank:
params["rank"] = get_rank_range(min_rank, max_rank)
r = make_inaturalist_api_get_call("taxa", params, user_agent=user_agent)
r.raise_for_status()
return r.json()


def get_taxa_autocomplete(user_agent: str = None, **params) -> Dict[str, Any]:
"""Given a query string, returns taxa with names starting with the search term
See: https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa_autocomplete
:param q: Name must begin with this value
:param is_active: Taxon is active
:param taxon_id: Only show taxa with this ID, or its descendants
:param rank: Taxon must have this rank
:param rank_level: Taxon must have this rank level. Some example values are 70 (kingdom),
60 (phylum), 50 (class), 40 (order), 30 (family), 20 (genus), 10 (species), 5 (subspecies)
:param per_page: Number of results to return in a page. The maximum value is generally 200 unless otherwise noted
:param locale: Locale preference for taxon common names
:param preferred_place_id: Place preference for regional taxon common names
:param all_names: Include all taxon names in the response
:returns: A list of dicts containing taxa results
"""
r = make_inaturalist_api_get_call(
"taxa/autocomplete", params, user_agent=user_agent
)
r.raise_for_status()
return r.json()


def get_rank_range(min_rank: str = None, max_rank: str = None) -> List[str]:
""" Translate min and/or max rank into a list of ranks """
min_rank_index = _get_rank_index(min_rank) if min_rank else 0
max_rank_index = _get_rank_index(max_rank) + 1 if max_rank else len(RANKS)
return RANKS[min_rank_index:max_rank_index]


def _get_rank_index(rank: str) -> int:
if rank not in RANKS:
raise ValueError("Invalid rank")
return RANKS.index(rank)
5 changes: 4 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ pyparsing==2.2.2
pytz==2018.5
readme-renderer==22.0
requests==2.20.0
requests-mock==1.5.2
requests-mock==1.7.0
requests-toolbelt==0.8.0
six==1.11.0
snowballstemmer==1.2.1
sphinx==2.4.3
sphinx-rtd-theme==0.4.3
sphinx-autodoc-typehints==1.10.3
sphinx-rtd-theme==0.4.3
sphinxcontrib-websupport==1.1.0
toml==0.10.0
tox==3.5.2
Expand Down
15 changes: 14 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,23 @@
author="Nicolas Noé",
author_email="[email protected]",
url="https://github.com/niconoe/pyinaturalist",
packages=["pyinaturalist",],
packages=["pyinaturalist"],
package_dir={"pyinaturalist": "pyinaturalist"},
include_package_data=True,
install_requires=["requests>=2.21.0", "typing>=3.7.4"],
extras_require={
"dev": [
"black",
"flake8",
"mypy",
"pytest",
"requests-mock>=1.7",
"Sphinx",
"sphinx-autodoc-typehints",
"sphinx-rtd-theme",
"tox",
]
},
license="MIT",
zip_safe=False,
keywords="pyinaturalist",
Expand Down
Loading

0 comments on commit 96e1524

Please sign in to comment.