Repository: GoogleCloudPlatform/google-auth-library-python
Pull request #106 (2017-02-08 21:42:22)

Refactor common code from google.oauth2.flow to google.oauth2.oauthlib


Patch:
diff --git a/docs/reference/google.oauth2.oauthlib.rst b/docs/reference/google.oauth2.oauthlib.rst
new file mode 100644
index 0000000..c687490
--- /dev/null
+++ b/docs/reference/google.oauth2.oauthlib.rst
@@ -0,0 +1,7 @@
+google.oauth2.oauthlib module
+=============================
+
+.. automodule:: google.oauth2.oauthlib
+    :members:
+    :inherited-members:
+    :show-inheritance:
diff --git a/docs/reference/google.oauth2.rst b/docs/reference/google.oauth2.rst
index 5dc2406..4dd1ebd 100644
--- a/docs/reference/google.oauth2.rst
+++ b/docs/reference/google.oauth2.rst
@@ -14,5 +14,6 @@ Submodules
    google.oauth2.credentials
    google.oauth2.flow
    google.oauth2.id_token
+   google.oauth2.oauthlib
    google.oauth2.service_account
 
diff --git a/google/oauth2/flow.py b/google/oauth2/flow.py
index a4dcfca..e70ee3d 100644
--- a/google/oauth2/flow.py
+++ b/google/oauth2/flow.py
@@ -55,12 +55,9 @@ authorization flow::
 
 import json
 
-import requests_oauthlib
-
 import google.auth.transport.requests
 import google.oauth2.credentials
-
-_REQUIRED_CONFIG_KEYS = frozenset(('auth_uri', 'token_uri', 'client_id'))
+import google.oauth2.oauthlib
 
 
 class Flow(object):
@@ -82,8 +79,32 @@ class Flow(object):
         https://console.developers.google.com/apis/credentials
     """
 
-    def __init__(self, client_config, scopes, **kwargs):
+    def __init__(self, oauth2session, client_type, client_config):
         """
+        Args:
+            oauth2session (requests_oauthlib.OAuth2Session):
+                The OAuth 2.0 session from ``requests-oauthlib``.
+            client_type (str): The client type, either ``web`` or
+                ``installed``.
+            client_config (Mapping[str, Any]): The client
+                configuration in the Google `client secrets`_ format.
+
+        .. _client secrets:
+            https://developers.google.com/api-client-library/python/guide
+            /aaa_client_secrets
+        """
+        self.client_type = client_type
+        """str: The client type, either ``'web'`` or ``'installed'``"""
+        self.client_config = client_config[client_type]
+        """Mapping[str, Any]: The OAuth 2.0 client configuration."""
+        self.oauth2session = oauth2session
+        """requests_oauthlib.OAuth2Session: The OAuth 2.0 session."""
+
+    @classmethod
+    def from_client_config(cls, client_config, scopes, **kwargs):
+        """Creates a :class:`requests_oauthlib.OAuth2Session` from client
+        configuration loaded from a Google-format client secrets file.
+
         Args:
             client_config (Mapping[str, Any]): The client
                 configuration in the Google `client secrets`_ format.
@@ -92,6 +113,9 @@ class Flow(object):
             kwargs: Any additional parameters passed to
                 :class:`requests_oauthlib.OAuth2Session`
 
+        Returns:
+            Flow: The constructed Flow instance.
+
         Raises:
             ValueError: If the client configuration is not in the correct
                 format.
@@ -100,29 +124,19 @@ class Flow(object):
             https://developers.google.com/api-client-library/python/guide
             /aaa_client_secrets
         """
-        self.client_config = None
-        """Mapping[str, Any]: The OAuth 2.0 client configuration."""
-        self.client_type = None
-        """str: The client type, either ``'web'`` or ``'installed'``"""
-
         if 'web' in client_config:
-            self.client_config = client_config['web']
-            self.client_type = 'web'
+            client_type = 'web'
         elif 'installed' in client_config:
-            self.client_config = client_config['installed']
-            self.client_type = 'installed'
+            client_type = 'installed'
         else:
             raise ValueError(
                 'Client secrets must be for a web or installed app.')
 
-        if not _REQUIRED_CONFIG_KEYS.issubset(self.client_config.keys()):
-            raise ValueError('Client secrets is not in the correct format.')
+        session, client_config = (
+            google.oauth2.oauthlib.session_from_client_config(
+                client_config, scopes, **kwargs))
 
-        self.oauth2session = requests_oauthlib.OAuth2Session(
-            client_id=self.client_config['client_id'],
-            scope=scopes,
-            **kwargs)
-        """requests_oauthlib.OAuth2Session: The OAuth 2.0 session."""
+        return cls(session, client_type, client_config)
 
     @classmethod
     def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs):
@@ -142,7 +156,7 @@ class Flow(object):
         with open(client_secrets_file, 'r') as json_file:
             client_config = json.load(json_file)
 
-        return cls(client_config, scopes=scopes, **kwargs)
+        return cls.from_client_config(client_config, scopes=scopes, **kwargs)
 
     @property
     def redirect_uri(self):
@@ -226,18 +240,8 @@ class Flow(object):
         Raises:
             ValueError: If there is no access token in the session.
         """
-        if not self.oauth2session.token:
-            raise ValueError(
-                'There is no access token for this session, did you call '
-                'fetch_token?')
-
-        return google.oauth2.credentials.Credentials(
-            self.oauth2session.token['access_token'],
-            refresh_token=self.oauth2session.token['refresh_token'],
-            token_uri=self.client_config['token_uri'],
-            client_id=self.client_config['client_id'],
-            client_secret=self.client_config['client_secret'],
-            scopes=self.oauth2session.scope)
+        return google.oauth2.oauthlib.credentials_from_session(
+            self.oauth2session, self.client_config)
 
     def authorized_session(self):
         """Returns a :class:`requests.Session` authorized with credentials.
diff --git a/google/oauth2/oauthlib.py b/google/oauth2/oauthlib.py
new file mode 100644
index 0000000..8f5c105
--- /dev/null
+++ b/google/oauth2/oauthlib.py
@@ -0,0 +1,142 @@
+# Copyright 2017 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Integration with oauthlib
+
+.. warning::
+    This module is experimental and is subject to change signficantly
+    within major version releases.
+
+This module provides helpers for integrating with `requests-oauthlib`_.
+Typically, you'll want to use the higher-level helpers in
+:mod:`google.oauth2.flow`.
+
+.. _requests-oauthlib: http://requests-oauthlib.readthedocs.io/en/stable/
+"""
+
+import json
+
+import requests_oauthlib
+
+import google.oauth2.credentials
+
+_REQUIRED_CONFIG_KEYS = frozenset(('auth_uri', 'token_uri', 'client_id'))
+
+
+def session_from_client_config(client_config, scopes, **kwargs):
+    """Creates a :class:`requests_oauthlib.OAuth2Session` from client
+    configuration loaded from a Google-format client secrets file.
+
+    Args:
+        client_config (Mapping[str, Any]): The client
+            configuration in the Google `client secrets`_ format.
+        scopes (Sequence[str]): The list of scopes to request during the
+            flow.
+        kwargs: Any additional parameters passed to
+            :class:`requests_oauthlib.OAuth2Session`
+
+    Raises:
+        ValueError: If the client configuration is not in the correct
+            format.
+
+    Returns:
+        Tuple[requests_oauthlib.OAuth2Session, Mapping[str, Any]]: The new
+            oauthlib session and the validated client configuration.
+
+    .. _client secrets:
+        https://developers.google.com/api-client-library/python/guide
+        /aaa_client_secrets
+    """
+
+    if 'web' in client_config:
+        config = client_config['web']
+    elif 'installed' in client_config:
+        config = client_config['installed']
+    else:
+        raise ValueError(
+            'Client secrets must be for a web or installed app.')
+
+    if not _REQUIRED_CONFIG_KEYS.issubset(config.keys()):
+        raise ValueError('Client secrets is not in the correct format.')
+
+    session = requests_oauthlib.OAuth2Session(
+        client_id=config['client_id'],
+        scope=scopes,
+        **kwargs)
+
+    return session, client_config
+
+
+def session_from_client_secrets_file(client_secrets_file, scopes, **kwargs):
+    """Creates a :class:`requests_oauthlib.OAuth2Session` instance from a
+    Google-format client secrets file.
+
+    Args:
+        client_secrets_file (str): The path to the `client secrets`_ .json
+            file.
+        scopes (Sequence[str]): The list of scopes to request during the
+            flow.
+        kwargs: Any additional parameters passed to
+            :class:`requests_oauthlib.OAuth2Session`
+
+    Returns:
+        Tuple[requests_oauthlib.OAuth2Session, Mapping[str, Any]]: The new
+            oauthlib session and the validated client configuration.
+
+    .. _client secrets:
+        https://developers.google.com/api-client-library/python/guide
+        /aaa_client_secrets
+    """
+    with open(client_secrets_file, 'r') as json_file:
+        client_config = json.load(json_file)
+
+    return session_from_client_config(client_config, scopes, **kwargs)
+
+
+def credentials_from_session(session, client_config=None):
+    """Creates :class:`google.oauth2.credentials.Credentials` from a
+    :class:`requests_oauthlib.OAuth2Session`.
+
+    :meth:`fetch_token` must be called on the session before before calling
+    this. This uses the session's auth token and the provided client
+    configuration to create :class:`google.oauth2.credentials.Credentials`.
+    This allows you to use the credentials from the session with Google
+    API client libraries.
+
+    Args:
+        session (requests_oauthlib.OAuth2Session): The OAuth 2.0 session.
+        client_config (Mapping[str, Any]): The subset of the client
+            configuration to use. For example, if you have a web client
+            you would pass in `client_config['web']`.
+
+    Returns:
+        google.oauth2.credentials.Credentials: The constructed credentials.
+
+    Raises:
+        ValueError: If there is no access token in the session.
+    """
+    client_config = client_config if client_config is not None else {}
+
+    if not session.token:
+        raise ValueError(
+            'There is no access token for this session, did you call '
+            'fetch_token?')
+
+    return google.oauth2.credentials.Credentials(
+        session.token['access_token'],
+        refresh_token=session.token.get('refresh_token'),
+        token_uri=client_config.get('token_uri'),
+        client_id=client_config.get('client_id'),
+        client_secret=client_config.get('client_secret'),
+        scopes=session.scope)


Test patch:
diff --git a/tests/oauth2/test_flow.py b/tests/oauth2/test_flow.py
index 7fc268c..e5d108f 100644
--- a/tests/oauth2/test_flow.py
+++ b/tests/oauth2/test_flow.py
@@ -27,44 +27,34 @@ with open(CLIENT_SECRETS_FILE, 'r') as fh:
     CLIENT_SECRETS_INFO = json.load(fh)
 
 
-def test_constructor_web():
-    instance = flow.Flow(CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes)
+def test_from_client_secrets_file():
+    instance = flow.Flow.from_client_secrets_file(
+        CLIENT_SECRETS_FILE, scopes=mock.sentinel.scopes)
     assert instance.client_config == CLIENT_SECRETS_INFO['web']
     assert (instance.oauth2session.client_id ==
             CLIENT_SECRETS_INFO['web']['client_id'])
     assert instance.oauth2session.scope == mock.sentinel.scopes
 
 
-def test_constructor_installed():
-    info = {'installed': CLIENT_SECRETS_INFO['web']}
-    instance = flow.Flow(info, scopes=mock.sentinel.scopes)
-    assert instance.client_config == info['installed']
-    assert instance.oauth2session.client_id == info['installed']['client_id']
+def test_from_client_config_installed():
+    client_config = {'installed': CLIENT_SECRETS_INFO['web']}
+    instance = flow.Flow.from_client_config(
+        client_config, scopes=mock.sentinel.scopes)
+    assert instance.client_config == client_config['installed']
+    assert (instance.oauth2session.client_id ==
+            client_config['installed']['client_id'])
     assert instance.oauth2session.scope == mock.sentinel.scopes
 
 
-def test_constructor_bad_format():
-    with pytest.raises(ValueError):
-        flow.Flow({}, scopes=[])
-
-
-def test_constructor_missing_keys():
+def test_from_client_config_bad_format():
     with pytest.raises(ValueError):
-        flow.Flow({'web': {}}, scopes=[])
-
-
-def test_from_client_secrets_file():
-    instance = flow.Flow.from_client_secrets_file(
-        CLIENT_SECRETS_FILE, scopes=mock.sentinel.scopes)
-    assert instance.client_config == CLIENT_SECRETS_INFO['web']
-    assert (instance.oauth2session.client_id ==
-            CLIENT_SECRETS_INFO['web']['client_id'])
-    assert instance.oauth2session.scope == mock.sentinel.scopes
+        flow.Flow.from_client_config({}, scopes=mock.sentinel.scopes)
 
 
 @pytest.fixture
 def instance():
-    yield flow.Flow(CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes)
+    yield flow.Flow.from_client_config(
+        CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes)
 
 
 def test_redirect_uri(instance):
@@ -123,11 +113,6 @@ def test_credentials(instance):
     assert credentials._token_uri == CLIENT_SECRETS_INFO['web']['token_uri']
 
 
-def test_bad_credentials(instance):
-    with pytest.raises(ValueError):
-        assert instance.credentials
-
-
 def test_authorized_session(instance):
     instance.oauth2session.token = {
         'access_token': mock.sentinel.access_token,
diff --git a/tests/oauth2/test_oauthlib.py b/tests/oauth2/test_oauthlib.py
new file mode 100644
index 0000000..a16c904
--- /dev/null
+++ b/tests/oauth2/test_oauthlib.py
@@ -0,0 +1,92 @@
+# Copyright 2017 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+import os
+
+import mock
+import pytest
+
+from google.oauth2 import oauthlib
+
+DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
+CLIENT_SECRETS_FILE = os.path.join(DATA_DIR, 'client_secrets.json')
+
+with open(CLIENT_SECRETS_FILE, 'r') as fh:
+    CLIENT_SECRETS_INFO = json.load(fh)
+
+
+def test_session_from_client_config_web():
+    session, config = oauthlib.session_from_client_config(
+        CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes)
+
+    assert config == CLIENT_SECRETS_INFO
+    assert session.client_id == CLIENT_SECRETS_INFO['web']['client_id']
+    assert session.scope == mock.sentinel.scopes
+
+
+def test_session_from_client_config_installed():
+    info = {'installed': CLIENT_SECRETS_INFO['web']}
+    session, config = oauthlib.session_from_client_config(
+        info, scopes=mock.sentinel.scopes)
+    assert config == info
+    assert session.client_id == info['installed']['client_id']
+    assert session.scope == mock.sentinel.scopes
+
+
+def test_session_from_client_config_bad_format():
+    with pytest.raises(ValueError):
+        oauthlib.session_from_client_config({}, scopes=[])
+
+
+def test_session_from_client_config_missing_keys():
+    with pytest.raises(ValueError):
+        oauthlib.session_from_client_config({'web': {}}, scopes=[])
+
+
+def test_session_from_client_secrets_file():
+    session, config = oauthlib.session_from_client_secrets_file(
+        CLIENT_SECRETS_FILE, scopes=mock.sentinel.scopes)
+    assert config == CLIENT_SECRETS_INFO
+    assert session.client_id == CLIENT_SECRETS_INFO['web']['client_id']
+    assert session.scope == mock.sentinel.scopes
+
+
+@pytest.fixture
+def session():
+    session, _ = oauthlib.session_from_client_config(
+        CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes)
+    yield session
+
+
+def test_credentials_from_session(session):
+    session.token = {
+        'access_token': mock.sentinel.access_token,
+        'refresh_token': mock.sentinel.refresh_token
+    }
+
+    credentials = oauthlib.credentials_from_session(
+        session, CLIENT_SECRETS_INFO['web'])
+
+    assert credentials.token == mock.sentinel.access_token
+    assert credentials._refresh_token == mock.sentinel.refresh_token
+    assert credentials._client_id == CLIENT_SECRETS_INFO['web']['client_id']
+    assert (credentials._client_secret ==
+            CLIENT_SECRETS_INFO['web']['client_secret'])
+    assert credentials._token_uri == CLIENT_SECRETS_INFO['web']['token_uri']
+
+
+def test_bad_credentials(session):
+    with pytest.raises(ValueError):
+        oauthlib.credentials_from_session(session)