Skip to content

Commit

Permalink
WIP: Add support for providing credentials via system keyring
Browse files Browse the repository at this point in the history
  • Loading branch information
JWCook committed Nov 5, 2020
1 parent eb0db99 commit c71f8cd
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 116 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ $ cd pyinaturalist
$ pip install -Ue ".[dev]"
```

To install with minimal dependencies (disables optional features):

```bash
$ pip install --no-deps pyinaturalist
$ pip install python-dateutil requests
```

## Development Status

Pyinaturalist is under active development. Currently, a handful of the most relevant API endpoints
Expand Down
159 changes: 159 additions & 0 deletions pyinaturalist/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from logging import getLogger
from os import getenv

from pyinaturalist.exceptions import AuthenticationError
from pyinaturalist.api_requests import post
from pyinaturalist.constants import INAT_KEYRING_KEY, INAT_BASE_URL

logger = getLogger(__name__)


def set_keyring_credentials(
username: str,
password: str,
app_id: str,
app_secret: str,
):
from keyring import set_password

set_password(INAT_KEYRING_KEY, "username", username)
set_password(INAT_KEYRING_KEY, "password", password)
set_password(INAT_KEYRING_KEY, "app_id", app_id)
set_password(INAT_KEYRING_KEY, "app_secret", app_secret)


def get_keyring_credentials():
# Quietly fail if keyring package is not installed
try:
from keyring import get_password
from keyring.errors import KeyringError
except ImportError:
logger.warning("Optional dependency `keyring` not installed")
return {}

# Quietly fail if keyring backend is not available
try:
return {
"username": get_password(INAT_KEYRING_KEY, "username"),
"password": get_password(INAT_KEYRING_KEY, "password"),
"client_id": get_password(INAT_KEYRING_KEY, "app_id"),
"client_secret": get_password(INAT_KEYRING_KEY, "app_secret"),
}
except KeyringError as e:
logger.warning(str(e))
return {}


def get_access_token(
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
**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
password: iNaturalist password
app_id: iNaturalist application ID
app_secret: iNaturalist application secret
user_agent: a user-agent string that will be passed to iNaturalist.
"""
payload = {
"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",
}
payload.update(get_keyring_credentials())

if not all(payload.values()):
raise AuthenticationError("Not all authentication parameters were provided")

response = post(
f"{INAT_BASE_URL}/oauth/token",
json=payload,
user_agent=user_agent,
)
try:
return response.json()["access_token"]
except KeyError:
raise AuthenticationError("Authentication error, please check credentials.")
1 change: 1 addition & 0 deletions pyinaturalist/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

INAT_NODE_API_BASE_URL = "https://api.inaturalist.org/v1/"
INAT_BASE_URL = "https://www.inaturalist.org"
INAT_KEYRING_KEY = "/inaturalist"

PER_PAGE_RESULTS = 30 # Number of records per page for paginated queries
THROTTLING_DELAY = 1 # In seconds, support <1 floats such as 0.1
Expand Down
117 changes: 2 additions & 115 deletions pyinaturalist/rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
:nosignatures:
"""
from os import getenv
from time import sleep
from typing import Dict, Any, List, Union

from pyinaturalist import api_docs as docs
from pyinaturalist.auth import get_access_token
from pyinaturalist.constants import (
THROTTLING_DELAY,
INAT_BASE_URL,
Expand All @@ -23,7 +23,7 @@
ListResponse,
RequestParams,
)
from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound
from pyinaturalist.exceptions import ObservationNotFound
from pyinaturalist.api_requests import delete, get, post, put
from pyinaturalist.forge_utils import document_request_params
from pyinaturalist.request_params import (
Expand All @@ -39,119 +39,6 @@
from pyinaturalist.response_format import convert_lat_long_to_float


def get_access_token(
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
**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
password: iNaturalist password
app_id: iNaturalist application ID
app_secret: iNaturalist application secret
user_agent: a user-agent string that will be passed to iNaturalist.
"""
payload = {
"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",
}
if not all(payload.values()):
raise AuthenticationError("Not all authentication parameters were provided")

response = post(
f"{INAT_BASE_URL}/oauth/token",
json=payload,
user_agent=user_agent,
)
try:
return response.json()["access_token"]
except KeyError:
raise AuthenticationError("Authentication error, please check credentials.")


@document_request_params(
[
docs._observation_common,
Expand Down
6 changes: 6 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ classifiers =
[mypy-forge]
ignore_missing_imports = True

[mypy-keyring]
ignore_missing_imports = True

[mypy-keyring.errors]
ignore_missing_imports = True

[mypy-pytest]
ignore_missing_imports = True

Expand Down
7 changes: 6 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@
url="https://github.com/niconoe/pyinaturalist",
packages=find_packages(),
include_package_data=True,
install_requires=["python-dateutil>=2.0", "python-forge", "requests>=2.24.0"],
install_requires=[
"keyring~=21.4.0",
"python-dateutil>=2.0",
"python-forge",
"requests>=2.24.0",
],
extras_require=extras_require,
zip_safe=False,
)

0 comments on commit c71f8cd

Please sign in to comment.