Skip to content

Commit

Permalink
WIP: Add places endpoints from Node API
Browse files Browse the repository at this point in the history
* Make get_places_by_id accept multiple IDs and check that all are valid integers
* Update get_taxa_by_id to be consistent with get_places_by_id
  • Loading branch information
JWCook committed Jun 25, 2020
1 parent afbd074 commit 1365416
Show file tree
Hide file tree
Showing 7 changed files with 1,391 additions and 29 deletions.
28 changes: 21 additions & 7 deletions pyinaturalist/api_requests.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
""" Some common functions for HTTP requests used by both the Node and REST API modules """
from logging import getLogger
from os import getenv
from typing import Dict
from typing import Dict, List, Union
from unittest.mock import Mock
from urllib.parse import urljoin

import requests

import pyinaturalist
from pyinaturalist.constants import WRITE_HTTP_METHODS
from pyinaturalist.request_params import preprocess_request_params
from pyinaturalist.request_params import preprocess_request_params, convert_list

# Mock response content to return in dry-run mode
MOCK_RESPONSE = Mock(spec=requests.Response)
Expand Down Expand Up @@ -42,18 +43,25 @@ def request(
url: str,
access_token: str = None,
user_agent: str = None,
resources: Union[str, List] = None,
params: Dict = None,
headers: Dict = None,
**kwargs
) -> requests.Response:
""" Wrapper around :py:func:`requests.request` that supports dry-run mode and
adds appropriate headers.
:param method: HTTP method
:param url: Request URL
:param access_token: access_token: the access token, as returned by :func:`get_access_token()`
:param user_agent: a user-agent string that will be passed to iNaturalist
Args:
method: HTTP method
url: Request URL
access_token: access_token: the access token, as returned by :func:`get_access_token()`
user_agent: a user-agent string that will be passed to iNaturalist
resources: REST resource(s) to request (typically one or more IDs)
params: Requests parameters
headers: Request headers
Returns:
API response
"""
# Set user agent and authentication headers, if specified
headers = headers or {}
Expand All @@ -62,7 +70,13 @@ def request(
if access_token:
headers["Authorization"] = "Bearer %s" % access_token
params = preprocess_request_params(params)
print(params)

# If a resource is requested instead of params, convert to a list if specified
if resources:
url = url.rstrip("/") + "/" + convert_list(resources)

# Run either real request or mock request depending on settings
if is_dry_run_enabled(method):
logger.debug("Dry-run mode enabled; mocking request")
log_request(method, url, params=params, headers=headers, **kwargs)
Expand Down
90 changes: 75 additions & 15 deletions pyinaturalist/node_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
THROTTLING_DELAY,
)
from pyinaturalist.exceptions import ObservationNotFound
from pyinaturalist.request_params import is_int
from pyinaturalist.request_params import is_int, is_int_list
from pyinaturalist.response_format import (
format_taxon,
as_geojson_feature_collection,
Expand All @@ -28,19 +28,14 @@
logger = getLogger(__name__)


def make_inaturalist_api_get_call(
endpoint: str, params: Dict, user_agent: str = None, **kwargs
) -> requests.Response:
def make_inaturalist_api_get_call(endpoint: str, **kwargs) -> requests.Response:
"""Make an API call to iNaturalist.
Args:
endpoint: The name of an endpoint not including the base URL e.g. 'observations'
kwargs: Arguments for :py:func:`requests.request`
kwargs: Arguments for :py:func:`.api_requests.request`
"""
response = get(
urljoin(INAT_NODE_API_BASE_URL, endpoint), params=params, user_agent=user_agent, **kwargs
)
return response
return get(urljoin(INAT_NODE_API_BASE_URL, endpoint), **kwargs)


def get_observation(observation_id: int, user_agent: str = None) -> Dict[str, Any]:
Expand Down Expand Up @@ -142,6 +137,71 @@ def get_geojson_observations(properties: List[str] = None, **kwargs) -> Dict[str
)


def get_places_by_id(place_id: int, user_agent: str = None) -> Dict[str, Any]:
"""
Get one or more places by ID.
See: https://api.inaturalist.org/v1/docs/#!/Places/get_places_id
Args:
place_id: Get a place with this ID. Multiple values are allowed.
Returns:
A list of dicts containing places results
"""
if not (is_int(place_id) or is_int_list(place_id)):
raise ValueError("Invalid ID(s); must specify integers only")
r = make_inaturalist_api_get_call("places", resources=place_id, user_agent=user_agent)
r.raise_for_status()
return r.json()


def get_places_nearby(
nelat: float,
nelng: float,
swlat: float,
swlng: float,
name: str = None,
user_agent: str = None,
) -> Dict[str, Any]:
"""
Given an bounding box, and an optional name query, return standard iNaturalist curator approved
and community non-curated places nearby
See: https://api.inaturalist.org/v1/docs/#!/Places/get_places_nearby
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
Returns:
A list of dicts containing places results
"""
r = make_inaturalist_api_get_call(
"places/nearby",
params={"nelat": nelat, "nelng": nelng, "swlat": swlat, "swlng": swlng, "name": name},
user_agent=user_agent,
)
r.raise_for_status()
return r.json()


def get_places_autocomplete(q: str, user_agent: str = None) -> Dict[str, Any]:
"""Given a query string, get places with names starting with the search term
See: https://api.inaturalist.org/v1/docs/#!/Places/get_places_autocomplete
Args:
q: Name must begin with this value
Returns:
A list of dicts containing places results
"""
r = make_inaturalist_api_get_call("places/autocomplete", params={"q": q}, user_agent=user_agent)
r.raise_for_status()
return r.json()


def get_taxa_by_id(taxon_id: int, user_agent: str = None) -> Dict[str, Any]:
"""
Get one or more taxa by ID.
Expand All @@ -153,15 +213,15 @@ def get_taxa_by_id(taxon_id: int, user_agent: str = None) -> Dict[str, Any]:
Returns:
A list of dicts containing taxa results
"""
if not is_int(taxon_id):
raise ValueError("Please specify a single integer for the taxon ID")
r = make_inaturalist_api_get_call("taxa/{}".format(taxon_id), {}, user_agent=user_agent)
if not (is_int(taxon_id) or is_int_list(taxon_id)):
raise ValueError("Invalid ID(s); must specify integers only")
r = make_inaturalist_api_get_call("taxa", resources=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
min_rank: str = None, max_rank: str = None, user_agent: 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
Expand Down Expand Up @@ -190,7 +250,7 @@ def get_taxa(
"""
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 = make_inaturalist_api_get_call("taxa", params=params, user_agent=user_agent)
r.raise_for_status()
return r.json()

Expand Down Expand Up @@ -218,7 +278,7 @@ def get_taxa_autocomplete(user_agent: str = None, minify: bool = False, **params
Returns:
A list of dicts containing taxa results
"""
r = make_inaturalist_api_get_call("taxa/autocomplete", params, user_agent=user_agent)
r = make_inaturalist_api_get_call("taxa/autocomplete", params=params, user_agent=user_agent)
r.raise_for_status()
json_response = r.json()

Expand Down
19 changes: 15 additions & 4 deletions pyinaturalist/request_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ def is_int(value: Any) -> bool:
return False


def is_int_list(values: Any) -> bool:
"""Determine if a value is a list of valid integers"""
return isinstance(values, list) and all([is_int(v) for v in values])


def convert_bool_params(params: Dict[str, Any]) -> Dict[str, Any]:
"""Convert any boolean request parameters to javascript-style boolean strings"""
for k, v in params.items():
Expand Down Expand Up @@ -57,10 +62,16 @@ def convert_list_params(params: Dict[str, Any]) -> 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(map(str, v))
return params
return {k: convert_list(v) for k, v in params.items()}


def convert_list(obj) -> str:
""" Convert a list parameters into an API-compatible (comma-delimited) string """
if not obj:
return ""
if isinstance(obj, list):
return ",".join(map(str, obj))
return str(obj)


def strip_empty_params(params: Dict[str, Any]) -> Dict[str, Any]:
Expand Down
25 changes: 25 additions & 0 deletions test/sample_data/get_places_autocomplete.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"total_results": 1,
"page": 1,
"per_page": 1,
"results": [
{
"ancestor_place_ids": [
52929,
64964,
93736,
93735
],
"bounding_box_geojson": null,
"bbox_area": 0.000993854049,
"admin_level": null,
"place_type": 7,
"name": "Springbok",
"location": "-29.665119,17.88583",
"id": 93735,
"display_name": "Springbok, Northern Cape",
"slug": "springbok",
"geometry_geojson": null
}
]
}
Loading

0 comments on commit 1365416

Please sign in to comment.