From c71f8cd4a9f15e4ca06ff5190e3b0b7df21dff47 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Thu, 5 Nov 2020 16:00:25 -0600 Subject: [PATCH] WIP: Add support for providing credentials via system keyring --- README.md | 7 ++ pyinaturalist/auth.py | 159 +++++++++++++++++++++++++++++++++++++ pyinaturalist/constants.py | 1 + pyinaturalist/rest_api.py | 117 +-------------------------- setup.cfg | 6 ++ setup.py | 7 +- 6 files changed, 181 insertions(+), 116 deletions(-) create mode 100644 pyinaturalist/auth.py diff --git a/README.md b/README.md index 80a9c2ce..9962a851 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyinaturalist/auth.py b/pyinaturalist/auth.py new file mode 100644 index 00000000..818b1d5d --- /dev/null +++ b/pyinaturalist/auth.py @@ -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.") diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py index ed5b224a..1c9d8a57 100644 --- a/pyinaturalist/constants.py +++ b/pyinaturalist/constants.py @@ -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 diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py index 7e64ff9c..a804a486 100644 --- a/pyinaturalist/rest_api.py +++ b/pyinaturalist/rest_api.py @@ -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, @@ -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 ( @@ -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, diff --git a/setup.cfg b/setup.cfg index 77c8f31e..add03d37 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/setup.py b/setup.py index 711ddf94..723b72f8 100644 --- a/setup.py +++ b/setup.py @@ -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, )