Repository: pydata/pydata-google-auth

Issue:
Implement CredentialsCache base classes
From: https://github.com/pydata/pandas-gbq/issues/161

The credentials caching behavior should be configurable via `CredentialsCache`,`WriteOnlyCredentialsCache`, and `NoopCredentialsCache`. This replaces the `reauth` argument and `PANDAS_GBQ_CREDENTIALS_FILE` environment variables.

Discussion:

Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index f5bcf95..8d90617 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -1,19 +1,18 @@
-.. currentmodule:: pandas_gbq
+.. currentmodule:: pydata_google_auth
 .. _api:
 
 *************
 API Reference
 *************
 
-.. note::
-
-  Only functions and classes which are members of the ``pandas_gbq`` module
-  are considered public. Submodules and their members are considered private.
-
 .. autosummary::
 
-   read_gbq
-   to_gbq
+   default
+   get_user_credentials
+   cache
 
-.. autofunction:: read_gbq
-.. autofunction:: to_gbq
+.. autofunction:: default
+.. autofunction:: get_user_credentials
+.. automodule:: pydata_google_auth.cache
+    :members:
+    :show-inheritance:
diff --git a/pydata_google_auth/auth.py b/pydata_google_auth/auth.py
index 47090e6..ae5197f 100644
--- a/pydata_google_auth/auth.py
+++ b/pydata_google_auth/auth.py
@@ -13,6 +13,7 @@ import oauthlib.oauth2.rfc6749.errors
 import google.auth.transport.requests
 
 from pydata_google_auth import exceptions
+from pydata_google_auth import cache
 
 
 logger = logging.getLogger(__name__)
@@ -21,17 +22,13 @@ CLIENT_ID = (
     "262006177488-3425ks60hkk80fssi9vpohv88g6q1iqd" ".apps.googleusercontent.com"
 )
 CLIENT_SECRET = "JSF-iczmzEgbTR-XK-2xaWAc"
-CREDENTIALS_DIRNAME = "pydata"
-CREDENTIALS_FILENAME = "pydata_google_credentials.json"
 
 
 def default(
     scopes,
-    client_id=CLIENT_ID,
-    client_secret=CLIENT_SECRET,
-    credentials_dirname=CREDENTIALS_DIRNAME,
-    credentials_filename=CREDENTIALS_FILENAME,
-    reauth=False,
+    client_id=None,
+    client_secret=None,
+    credentials_cache=cache.READ_WRITE,
     auth_local_webserver=False,
 ):
     # Try to retrieve Application Default Credentials
@@ -44,9 +41,7 @@ def default(
         scopes,
         client_id=client_id,
         client_secret=client_secret,
-        credentials_dirname=credentials_dirname,
-        credentials_filename=credentials_filename,
-        reauth=reauth,
+        credentials_cache=credentials_cache,
         auth_local_webserver=auth_local_webserver,
     )
 
@@ -92,14 +87,13 @@ def get_application_default_credentials(scopes):
 
 def get_user_credentials(
     scopes,
-    client_id=CLIENT_ID,
-    client_secret=CLIENT_SECRET,
-    credentials_dirname=CREDENTIALS_DIRNAME,
-    credentials_filename=CREDENTIALS_FILENAME,
-    reauth=False,
+    client_id=None,
+    client_secret=None,
+    credentials_cache=cache.READ_WRITE,
     auth_local_webserver=False,
 ):
-    """Gets user account credentials.
+    """
+    Gets user account credentials.
 
     This method authenticates using user credentials, either loading saved
     credentials from a file or by going through the OAuth flow.
@@ -113,16 +107,16 @@ def get_user_credentials(
     GoogleCredentials : credentials
         Credentials for the user with BigQuery access.
     """
-    # Use the default credentials location under ~/.config and the
-    # equivalent directory on windows if the user has not specified a
-    # credentials path.
-    credentials_path = get_default_credentials_path(
-        credentials_dirname, credentials_filename
-    )
+    # Use None as default for client_id and client_secret so that the values
+    # aren't included in the docs. A string of bytes isn't useful for the
+    # documentation and might encourage the values to be used outside of this
+    # library.
+    if client_id is None:
+        client_id = CLIENT_ID
+    if client_secret is None:
+        client_secret = CLIENT_SECRET
 
-    credentials = None
-    if not reauth:
-        credentials = load_user_credentials_from_file(credentials_path)
+    credentials = credentials_cache.load()
 
     client_config = {
         "installed": {
@@ -149,100 +143,10 @@ def get_user_credentials(
                 "Unable to get valid credentials: {0}".format(exc)
             )
 
-        save_user_account_credentials(credentials, credentials_path)
+        credentials_cache.save(credentials)
 
     if credentials and not credentials.valid:
         request = google.auth.transport.requests.Request()
         credentials.refresh(request)
 
     return credentials
-
-
-def load_user_credentials_from_info(credentials_json):
-    return google.oauth2.credentials.Credentials(
-        token=credentials_json.get("access_token"),
-        refresh_token=credentials_json.get("refresh_token"),
-        id_token=credentials_json.get("id_token"),
-        token_uri=credentials_json.get("token_uri"),
-        client_id=credentials_json.get("client_id"),
-        client_secret=credentials_json.get("client_secret"),
-        scopes=credentials_json.get("scopes"),
-    )
-
-
-def load_user_credentials_from_file(credentials_path):
-    """
-    Loads user account credentials from a local file.
-
-    .. versionadded 0.2.0
-
-    Parameters
-    ----------
-    None
-
-    Returns
-    -------
-    - GoogleCredentials,
-        If the credentials can loaded. The retrieved credentials should
-        also have access to the project (project_id) on BigQuery.
-    - OR None,
-        If credentials can not be loaded from a file. Or, the retrieved
-        credentials do not have access to the project (project_id)
-        on BigQuery.
-    """
-    try:
-        with open(credentials_path) as credentials_file:
-            credentials_json = json.load(credentials_file)
-    except (IOError, ValueError) as exc:
-        logger.debug(
-            "Error loading credentials from {}: {}".format(credentials_path, str(exc))
-        )
-        return None
-
-    return load_user_credentials_from_info(credentials_json)
-
-
-def get_default_credentials_path(credentials_dirname, credentials_filename):
-    """
-    Gets the default path to the Google user credentials
-
-    .. versionadded 0.3.0
-
-    Returns
-    -------
-    Path to the Google user credentials
-    """
-    if os.name == "nt":
-        config_path = os.environ["APPDATA"]
-    else:
-        config_path = os.path.join(os.path.expanduser("~"), ".config")
-
-    config_path = os.path.join(config_path, credentials_dirname)
-
-    # Create a pydata directory in an application-specific hidden
-    # user folder on the operating system.
-    if not os.path.exists(config_path):
-        os.makedirs(config_path)
-
-    return os.path.join(config_path, credentials_filename)
-
-
-def save_user_account_credentials(credentials, credentials_path):
-    """
-    Saves user account credentials to a local file.
-
-    .. versionadded 0.2.0
-    """
-    try:
-        with open(credentials_path, "w") as credentials_file:
-            credentials_json = {
-                "refresh_token": credentials.refresh_token,
-                "id_token": credentials.id_token,
-                "token_uri": credentials.token_uri,
-                "client_id": credentials.client_id,
-                "client_secret": credentials.client_secret,
-                "scopes": credentials.scopes,
-            }
-            json.dump(credentials_json, credentials_file)
-    except IOError:
-        logger.warning("Unable to save credentials.")
diff --git a/pydata_google_auth/cache.py b/pydata_google_auth/cache.py
new file mode 100644
index 0000000..c755dd0
--- /dev/null
+++ b/pydata_google_auth/cache.py
@@ -0,0 +1,233 @@
+"""Caching implementations for reading and writing user credentials."""
+
+import json
+import logging
+import os
+import os.path
+
+import google.oauth2.credentials
+
+
+logger = logging.getLogger(__name__)
+
+
+_DIRNAME = "pydata"
+_FILENAME = "pydata_google_credentials.json"
+
+
+def _get_default_credentials_path(credentials_dirname, credentials_filename):
+    """
+    Gets the default path to the Google user credentials
+
+    Returns
+    -------
+    str
+        Path to the Google user credentials
+    """
+    if os.name == "nt":
+        config_path = os.environ["APPDATA"]
+    else:
+        config_path = os.path.join(os.path.expanduser("~"), ".config")
+
+    config_path = os.path.join(config_path, credentials_dirname)
+
+    # Create a pydata directory in an application-specific hidden
+    # user folder on the operating system.
+    if not os.path.exists(config_path):
+        os.makedirs(config_path)
+
+    return os.path.join(config_path, credentials_filename)
+
+
+def _load_user_credentials_from_info(credentials_json):
+    return google.oauth2.credentials.Credentials(
+        token=credentials_json.get("access_token"),
+        refresh_token=credentials_json.get("refresh_token"),
+        id_token=credentials_json.get("id_token"),
+        token_uri=credentials_json.get("token_uri"),
+        client_id=credentials_json.get("client_id"),
+        client_secret=credentials_json.get("client_secret"),
+        scopes=credentials_json.get("scopes"),
+    )
+
+
+def _load_user_credentials_from_file(credentials_path):
+    """
+    Loads user account credentials from a local file.
+
+    Parameters
+    ----------
+    None
+
+    Returns
+    -------
+    - GoogleCredentials,
+        If the credentials can loaded. The retrieved credentials should
+        also have access to the project (project_id) on BigQuery.
+    - OR None,
+        If credentials can not be loaded from a file. Or, the retrieved
+        credentials do not have access to the project (project_id)
+        on BigQuery.
+    """
+    try:
+        with open(credentials_path) as credentials_file:
+            credentials_json = json.load(credentials_file)
+    except (IOError, ValueError) as exc:
+        logger.debug(
+            "Error loading credentials from {}: {}".format(credentials_path, str(exc))
+        )
+        return None
+
+    return _load_user_credentials_from_info(credentials_json)
+
+
+def _save_user_account_credentials(credentials, credentials_path):
+    """
+    Saves user account credentials to a local file.
+    """
+    try:
+        with open(credentials_path, "w") as credentials_file:
+            credentials_json = {
+                "refresh_token": credentials.refresh_token,
+                "id_token": credentials.id_token,
+                "token_uri": credentials.token_uri,
+                "client_id": credentials.client_id,
+                "client_secret": credentials.client_secret,
+                "scopes": credentials.scopes,
+            }
+            json.dump(credentials_json, credentials_file)
+    except IOError:
+        logger.warning("Unable to save credentials.")
+
+
+class CredentialsCache(object):
+    """
+    Shared base class for crentials classes.
+
+    This class also functions as a noop implementation of a credentials class.
+    """
+
+    def load(self):
+        """
+        Load credentials from disk.
+
+        Does nothing in this base class.
+
+        Returns
+        -------
+        google.oauth2.credentials.Credentials, optional
+            Returns user account credentials loaded from disk or ``None`` if no
+            credentials could be found.
+        """
+        pass
+
+    def save(self, credentials):
+        """
+        Write credentials to disk.
+
+        Does nothing in this base class.
+
+        Parameters
+        ----------
+        credentials : google.oauth2.credentials.Credentials
+            User credentials to save to disk.
+        """
+        pass
+
+
+class ReadWriteCredentialsCache(CredentialsCache):
+    """
+    A :class:`~pydata_google_auth.cache.CredentialsCache` which writes to
+    disk and reads cached credentials from disk.
+
+    Parameters
+    ----------
+    dirname : str, optional
+        Name of directory to write credentials to. This directory is created
+        within the ``.config`` subdirectory of the ``HOME`` (``APPDATA`` on
+        Windows) directory.
+    filename : str, optional
+        Name of the credentials file within the credentials directory.
+    """
+
+    def __init__(self, dirname=_DIRNAME, filename=_FILENAME):
+        super(ReadWriteCredentialsCache, self).__init__()
+        self._path = _get_default_credentials_path(_DIRNAME, _FILENAME)
+
+    def load(self):
+        """
+        Load credentials from disk.
+
+        Returns
+        -------
+        google.oauth2.credentials.Credentials, optional
+            Returns user account credentials loaded from disk or ``None`` if no
+            credentials could be found.
+        """
+        return _load_user_credentials_from_file(self._path)
+
+    def save(self, credentials):
+        """
+        Write credentials to disk.
+
+        Parameters
+        ----------
+        credentials : google.oauth2.credentials.Credentials
+            User credentials to save to disk.
+        """
+        _save_user_account_credentials(credentials, self._path)
+
+
+class WriteOnlyCredentialsCache(CredentialsCache):
+    """
+    A :class:`~pydata_google_auth.cache.CredentialsCache` which writes to
+    disk, but doesn't read from disk.
+
+    Use this class to reauthorize against Google APIs and cache your
+    credentials for later.
+
+    Parameters
+    ----------
+    dirname : str, optional
+        Name of directory to write credentials to. This directory is created
+        within the ``.config`` subdirectory of the ``HOME`` (``APPDATA`` on
+        Windows) directory.
+    filename : str, optional
+        Name of the credentials file within the credentials directory.
+    """
+
+    def __init__(self, dirname=_DIRNAME, filename=_FILENAME):
+        super(WriteOnlyCredentialsCache, self).__init__()
+        self._path = _get_default_credentials_path(_DIRNAME, _FILENAME)
+
+    def save(self, credentials):
+        """
+        Write credentials to disk.
+
+        Parameters
+        ----------
+        credentials : google.oauth2.credentials.Credentials
+            User credentials to save to disk.
+        """
+        _save_user_account_credentials(credentials, self._path)
+
+
+NOOP = CredentialsCache()
+"""
+Noop impmentation of credentials cache.
+
+This cache always reauthorizes and never save credentials to disk.
+Recommended for shared machines.
+"""
+
+READ_WRITE = ReadWriteCredentialsCache()
+"""
+Write credentials to disk and read cached credentials from disk.
+"""
+
+REAUTH = WriteOnlyCredentialsCache()
+"""
+Write credentials to disk. Never read cached credentials from disk.
+
+Use this to reauthenticate and refresh the cached credentials.
+"""


Test patch:
diff --git a/tests/system/test_auth.py b/tests/system/test_auth.py
index 52409cb..9c182bc 100644
--- a/tests/system/test_auth.py
+++ b/tests/system/test_auth.py
@@ -47,17 +47,29 @@ def test_get_user_credentials_gets_valid_credentials():
     assert credentials.has_scopes(TEST_SCOPES)
 
 
-def test_get_user_credentials_from_file_gets_valid_credentials():
+def test_get_user_credentials_noop_gets_valid_credentials():
     import pydata_google_auth
-    import pydata_google_auth.auth
+    import pydata_google_auth.cache
 
-    # Mock load_user_credentials_from_file to fail, forcing fresh credentials.
-    with mock.patch(
-        "pydata_google_auth.auth.load_user_credentials_from_file", return_value=None
-    ):
-        credentials = pydata_google_auth.get_user_credentials(
-            TEST_SCOPES, auth_local_webserver=True
-        )
+    credentials = pydata_google_auth.get_user_credentials(
+        TEST_SCOPES,
+        credentials_cache=pydata_google_auth.cache.NOOP,
+        auth_local_webserver=True,
+    )
+
+    assert credentials.valid
+    assert credentials.has_scopes(TEST_SCOPES)
+
+
+def test_get_user_credentials_reauth_gets_valid_credentials():
+    import pydata_google_auth
+    import pydata_google_auth.cache
+
+    credentials = pydata_google_auth.get_user_credentials(
+        TEST_SCOPES,
+        credentials_cache=pydata_google_auth.cache.REAUTH,
+        auth_local_webserver=True,
+    )
 
     assert credentials.valid
     assert credentials.has_scopes(TEST_SCOPES)
diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py
index 2c9ed11..89b882a 100644
--- a/tests/unit/test_auth.py
+++ b/tests/unit/test_auth.py
@@ -10,6 +10,7 @@ except ImportError:  # pragma: NO COVER
 
 import google.auth
 import google.auth.credentials
+import google.oauth2.credentials
 
 
 TEST_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
@@ -33,18 +34,17 @@ def test_default_returns_google_auth_credentials(monkeypatch):
 
 def test_default_loads_user_credentials(monkeypatch):
     from pydata_google_auth import auth
+    from pydata_google_auth import cache
 
     def mock_default_credentials(scopes=None, request=None):
         return (None, None)
 
     monkeypatch.setattr(google.auth, "default", mock_default_credentials)
-    mock_user_credentials = mock.create_autospec(google.auth.credentials.Credentials)
 
-    def mock_load_credentials(project_id=None, credentials_path=None):
-        return mock_user_credentials
+    mock_cache = mock.create_autospec(cache.CredentialsCache)
+    mock_user_credentials = mock.create_autospec(google.oauth2.credentials.Credentials)
+    mock_cache.load.return_value = mock_user_credentials
 
-    monkeypatch.setattr(auth, "load_user_credentials_from_file", mock_load_credentials)
-
-    credentials, project = auth.default(TEST_SCOPES)
+    credentials, project = auth.default(TEST_SCOPES, credentials_cache=mock_cache)
     assert project is None
     assert credentials is mock_user_credentials