Skip to content

Commit

Permalink
Merge pull request #72 from JWCook/envar-auth
Browse files Browse the repository at this point in the history
Add support for providing credentials via environment variables
  • Loading branch information
JWCook committed Nov 5, 2020
2 parents 3cd4a4f + 50e6889 commit eb0db99
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 56 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,14 @@ This requires creating an [iNaturalist app](https://www.inaturalist.org/oauth/ap
```python
from pyinaturalist.rest_api import get_access_token
token = get_access_token(
username='<your_inaturalist_username>',
password='<your_inaturalist_password>',
app_id='<your_inaturalist_app_id>',
app_secret='<your_inaturalist_app_secret>',
username='my_username',
password='my_password',
app_id='my_app_id',
app_secret='my_app_secret',
)
```
See [get_access_token()](https://pyinaturalist.readthedocs.io/en/latest/modules/pyinaturalist.rest_api.html#pyinaturalist.rest_api.get_access_token)
for additional authentication options.

#### Create a new observation
```python
Expand All @@ -102,9 +104,7 @@ response = create_observation(
longitude=4.360216,
positional_accuracy=50, # meters,
# sets vespawatch_id (an observation field whose ID is 9613) to the value '100'.
observation_field_values_attributes=[
{'observation_field_id': 9613,'value': 100},
],
observation_fields={9613: 100},
access_token=token,
)
new_observation_id = response[0]['id']
Expand Down
92 changes: 84 additions & 8 deletions pyinaturalist/rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
:nosignatures:
"""
from os import getenv
from time import sleep
from typing import Dict, Any, List, Union

Expand Down Expand Up @@ -39,16 +40,89 @@


def get_access_token(
username: str, password: str, app_id: str, app_secret: str, user_agent: str = None
username: str = None,
password: str = None,
app_id: str = None,
app_secret: str = None,
user_agent: str = None,
) -> str:
"""Get an access token using the user's iNaturalist username and password.
You still need an iNaturalist app to do this.
**API reference:** https://www.inaturalist.org/pages/api+reference#auth
Example:
>>> access_token = get_access_token('...')
>>> headers = {"Authorization": f"Bearer {access_token}"}
**Environment Variables**
Alternatively, you may provide credentials via environment variables instead. The
environment variable names are the keyword arguments in uppercase, prefixed with ``INAT_``:
* ``INAT_USERNAME``
* ``INAT_PASSWORD``
* ``INAT_APP_ID``
* ``INAT_APP_SECRET``
.. admonition:: Set environment variables in python:
:class: toggle
>>> import os
>>> os.environ['INAT_USERNAME'] = 'my_username'
>>> os.environ['INAT_PASSWORD'] = 'my_password'
>>> os.environ['INAT_APP_ID'] = '33f27dc63bdf27f4ca6cd95dd9dcd5df'
>>> os.environ['INAT_APP_SECRET'] = 'bbce628be722bfe2abd5fc566ba83de4'
.. admonition:: Set environment variables in a POSIX shell (bash, etc.):
:class: toggle
.. code-block:: bash
export INAT_USERNAME="my_username"
export INAT_PASSWORD="my_password"
export INAT_APP_ID="33f27dc63bdf27f4ca6cd95dd9dcd5df"
export INAT_APP_SECRET="bbce628be722bfe2abd5fc566ba83de4"
.. admonition:: Set environment variables in a Windows shell:
:class: toggle
.. code-block:: bat
set INAT_USERNAME="my_username"
set INAT_PASSWORD="my_password"
set INAT_APP_ID="33f27dc63bdf27f4ca6cd95dd9dcd5df"
set INAT_APP_SECRET="bbce628be722bfe2abd5fc566ba83de4"
.. admonition:: Set environment variables in PowerShell:
:class: toggle
.. code-block:: powershell
$Env:INAT_USERNAME="my_username"
$Env:INAT_PASSWORD="my_password"
$Env:INAT_APP_ID="33f27dc63bdf27f4ca6cd95dd9dcd5df"
$Env:INAT_APP_SECRET="bbce628be722bfe2abd5fc566ba83de4"
Examples:
With keyword arguments:
>>> access_token = get_access_token(
>>> username='my_username',
>>> password='my_password',
>>> app_id='33f27dc63bdf27f4ca6cd95dd9dcd5df',
>>> app_secret='bbce628be722bfe2abd5fc566ba83de4',
>>> )
With environment variables set:
>>> access_token = get_access_token()
If you would like to run custom requests for endpoints not yet implemented in pyinaturalist,
you can authenticate these requests by putting the token in your HTTP headers as follows:
>>> import requests
>>> requests.get(
>>> 'https://www.inaturalist.org/observations/1234',
>>> headers={'Authorization': f'Bearer {access_token}'},
>>> )
Args:
username: iNaturalist username
Expand All @@ -58,12 +132,14 @@ def get_access_token(
user_agent: a user-agent string that will be passed to iNaturalist.
"""
payload = {
"client_id": app_id,
"client_secret": app_secret,
"username": username or getenv("INAT_USERNAME"),
"password": password or getenv("INAT_PASSWORD"),
"client_id": app_id or getenv("INAT_APP_ID"),
"client_secret": app_secret or getenv("INAT_APP_SECRET"),
"grant_type": "password",
"username": username,
"password": password,
}
if not all(payload.values()):
raise AuthenticationError("Not all authentication parameters were provided")

response = post(
f"{INAT_BASE_URL}/oauth/token",
Expand Down
8 changes: 8 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@

HTTP_FUNC_PATTERN = re.compile(r"(get|put|post|delete)_.+")
SAMPLE_DATA_DIR = abspath(join(dirname(__file__), "sample_data"))

MOCK_CREDS = {
"INAT_USERNAME": "valid_username",
"INAT_PASSWORD": "valid_password",
"INAT_APP_ID": "valid_app_id",
"INAT_APP_SECRET": "valid_app_secret",
}

# Enable logging for urllib and other external loggers
logging.basicConfig(level="INFO")

Expand Down
30 changes: 13 additions & 17 deletions test/manual_tests/obs_crud_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python
"""A semi-automated script used to test all observation CRUD endpoints.
Must provide iNat credentials via environment variables. Usage example:
"""A semi-automated script used to test all observation CRUD endpoints. Must provide iNat
credentials via environment variables. See :py:func:`.get_access_token` for details.
Usage example:
```
export INAT_USERNAME=""
export INAT_PASSWORD=""
Expand Down Expand Up @@ -29,13 +30,7 @@


def run_observation_crud_test():
# TODO: Built-in support for using envars for auth instead of function args might be useful
token = get_access_token(
username=getenv("INAT_USERNAME"),
password=getenv("INAT_PASSWORD"),
app_id=getenv("INAT_APP_ID"),
app_secret=getenv("INAT_APP_SECRET"),
)
token = get_access_token()
print("Received access token")

test_obs_id = create_test_obs(token)
Expand Down Expand Up @@ -79,6 +74,7 @@ def update_test_obs(test_obs_id, token):

response = update_observation(
test_obs_id,
taxon_id=54327,
geoprivacy="obscured",
ignore_photos=True,
access_token=token,
Expand All @@ -88,14 +84,14 @@ def update_test_obs(test_obs_id, token):
print("Updated observation")
# pprint(response, indent=2)

response = put_observation_field_values(
observation_id=test_obs_id,
observation_field_id=297,
value=2,
access_token=token,
)
print("Added observation field value:")
pprint(response, indent=2)
# response = put_observation_field_values(
# observation_id=test_obs_id,
# observation_field_id=297,
# value=2,
# access_token=token,
# )
# print("Added observation field value:")
# pprint(response, indent=2)


def delete_test_obs(test_obs_id, token):
Expand Down
5 changes: 3 additions & 2 deletions test/test_request_params.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import pytest
from datetime import date, datetime
from dateutil.tz import gettz
Expand All @@ -19,8 +20,7 @@
)
import pyinaturalist.rest_api
import pyinaturalist.node_api
from test.conftest import get_module_http_functions, get_mock_args_for_signature

from test.conftest import MOCK_CREDS, get_module_http_functions, get_mock_args_for_signature

TEST_PARAMS = {
"is_active": False,
Expand Down Expand Up @@ -155,6 +155,7 @@ def test_all_node_requests_use_param_conversion(
@pytest.mark.parametrize(
"http_function", get_module_http_functions(pyinaturalist.rest_api).values()
)
@patch.dict(os.environ, MOCK_CREDS)
@patch("pyinaturalist.rest_api.convert_lat_long_to_float")
@patch("pyinaturalist.rest_api.sleep")
@patch("pyinaturalist.api_requests.preprocess_request_params")
Expand Down
80 changes: 58 additions & 22 deletions test/test_rest_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta
from io import BytesIO
from unittest.mock import patch
import os

import pytest
from requests import HTTPError
Expand All @@ -19,7 +20,7 @@
delete_observation,
)
from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound
from test.conftest import load_sample_data
from test.conftest import MOCK_CREDS, load_sample_data

PAGE_1_JSON_RESPONSE = load_sample_data("get_observation_fields_page1.json")
PAGE_2_JSON_RESPONSE = load_sample_data("get_observation_fields_page2.json")
Expand Down Expand Up @@ -127,7 +128,62 @@ def test_put_observation_field_values(requests_mock):
assert r["value"] == "fouraging"


def test_get_access_token_fail(requests_mock):
accepted_json = {
"access_token": "604e5df329b98eecd22bb0a84f88b68",
"token_type": "Bearer",
"scope": "write",
}


@patch.dict(os.environ, {}, clear=True)
def test_get_access_token(requests_mock):
requests_mock.post(
f"{INAT_BASE_URL}/oauth/token",
json=accepted_json,
status_code=200,
)

token = get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret")
assert token == "604e5df329b98eecd22bb0a84f88b68"


@patch.dict(os.environ, MOCK_CREDS)
def test_get_access_token__envars(requests_mock):
requests_mock.post(
f"{INAT_BASE_URL}/oauth/token",
json=accepted_json,
status_code=200,
)

token = get_access_token()
assert token == "604e5df329b98eecd22bb0a84f88b68"


@patch.dict(
os.environ,
{
"INAT_USERNAME": "valid_username",
"INAT_PASSWORD": "valid_password",
},
)
def test_get_access_token__mixed_args_and_envars(requests_mock):
requests_mock.post(
f"{INAT_BASE_URL}/oauth/token",
json=accepted_json,
status_code=200,
)

token = get_access_token(app_id="valid_app_id", app_secret="valid_app_secret")
assert token == "604e5df329b98eecd22bb0a84f88b68"


@patch.dict(os.environ, {}, clear=True)
def test_get_access_token__missing_creds():
with pytest.raises(AuthenticationError):
get_access_token("username")


def test_get_access_token__invalid_creds(requests_mock):
""" If we provide incorrect credentials to get_access_token(), an AuthenticationError is raised"""

rejection_json = {
Expand All @@ -147,26 +203,6 @@ def test_get_access_token_fail(requests_mock):
get_access_token("username", "password", "app_id", "app_secret")


def test_get_access_token(requests_mock):
""" Test a successful call to get_access_token() """

accepted_json = {
"access_token": "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08",
"token_type": "Bearer",
"scope": "write",
"created_at": 1539352135,
}
requests_mock.post(
f"{INAT_BASE_URL}/oauth/token",
json=accepted_json,
status_code=200,
)

token = get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret")

assert token == "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08"


def test_update_observation(requests_mock):
requests_mock.put(
f"{INAT_BASE_URL}/observations/17932425.json",
Expand Down

0 comments on commit eb0db99

Please sign in to comment.