14
0
Files
python-pydrive2/migrate-to-google-auth.patch

1794 lines
63 KiB
Diff
Raw Normal View History

From 0236bf917d3103e95eafdf5c839d652e2a75c790 Mon Sep 17 00:00:00 2001
From: junpeng-jp <junpeng.ong@gmail.com>
Date: Sun, 29 May 2022 00:12:34 +0800
Subject: [PATCH 1/9] Update setup.py with new google-auth dependencies
---
setup.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/setup.py b/setup.py
index 06982d0..2b2d952 100644
--- a/setup.py
+++ b/setup.py
@@ -33,6 +33,10 @@
"google-api-python-client >= 1.12.5",
"six >= 1.13.0",
"oauth2client >= 4.0.0",
+ "google-auth >= 2.6.6",
+ "google-auth-httplib2 >= 0.1.0",
+ "google-auth-oauthlib >= 0.5.1",
+ "filelock >= 3.7.0",
"PyYAML >= 3.0",
"pyOpenSSL >= 19.1.0",
],
From 0b675594b81092191a8b52ab7606b73df7773ed0 Mon Sep 17 00:00:00 2001
From: junpeng-jp <junpeng.ong@gmail.com>
Date: Sun, 29 May 2022 00:26:36 +0800
Subject: [PATCH 2/9] Update threadsafe code following google recommendations
---
pydrive2/auth.py | 52 +++++++++++++++--------------------------------
pydrive2/drive.py | 6 +++++-
pydrive2/files.py | 35 ++++++++++++++++++-------------
3 files changed, 42 insertions(+), 51 deletions(-)
diff --git a/pydrive2/auth.py b/pydrive2/auth.py
index a1b8b2f..08625e8 100644
--- a/pydrive2/auth.py
+++ b/pydrive2/auth.py
@@ -21,6 +21,7 @@
from .settings import SettingsError
from .settings import InvalidConfigError
+from google_auth_httplib2 import AuthorizedHttp
class AuthError(Exception):
"""Base error for authentication/authorization errors."""
@@ -61,23 +62,7 @@ def _decorated(self, *args, **kwargs):
if self.auth.service is None:
self.auth.Authorize()
- # Ensure that a thread-safe HTTP object is provided.
- if (
- kwargs is not None
- and "param" in kwargs
- and kwargs["param"] is not None
- and "http" in kwargs["param"]
- and kwargs["param"]["http"] is not None
- ):
- self.http = kwargs["param"]["http"]
- del kwargs["param"]["http"]
-
- else:
- # If HTTP object not specified, create or resuse an HTTP
- # object from the thread local storage.
- if not getattr(self.auth.thread_local, "http", None):
- self.auth.thread_local.http = self.auth.Get_Http_Object()
- self.http = self.auth.thread_local.http
+
return decoratee(self, *args, **kwargs)
@@ -173,15 +158,14 @@ class GoogleAuth(ApiAttributeMixin):
def __init__(self, settings_file="settings.yaml", http_timeout=None):
"""Create an instance of GoogleAuth.
- This constructor just sets the path of settings file.
- It does not actually read the file.
+ This constructor parses just he yaml settings file.
+ All other settings are lazy
:param settings_file: path of settings file. 'settings.yaml' by default.
:type settings_file: str.
"""
self.http_timeout = http_timeout
ApiAttributeMixin.__init__(self)
- self.thread_local = threading.local()
self.client_config = {}
try:
self.settings = LoadSettingsFile(settings_file)
@@ -196,6 +180,15 @@ def __init__(self, settings_file="settings.yaml", http_timeout=None):
# Only one (`file`) backend is supported now
self._default_storage = self._storages["file"]
+ self._service = None
+
+ # Lazy loading, read-only properties
+ @property
+ def service(self):
+ if not self._service:
+ self._service = build("drive", "v2", cache_discovery=False)
+ return self._service
+
@property
def access_token_expired(self):
"""Checks if access token doesn't exist or is expired.
@@ -597,12 +590,8 @@ def Refresh(self):
"No refresh_token found."
"Please set access_type of OAuth to offline."
)
- if self.http is None:
- self.http = self._build_http()
- try:
- self.credentials.refresh(self.http)
- except AccessTokenRefreshError as error:
- raise RefreshError("Access token refresh failed: %s" % error)
+
+
def GetAuthUrl(self):
"""Creates authentication url where user visits to grant access.
@@ -663,19 +652,10 @@ def Authorize(self):
"No valid credentials provided to authorize"
)
- if self.http is None:
- self.http = self._build_http()
- self.http = self.credentials.authorize(self.http)
- self.service = build(
- "drive", "v2", http=self.http, cache_discovery=False
- )
-
def Get_Http_Object(self):
"""Create and authorize an httplib2.Http object. Necessary for
thread-safety.
:return: The http object to be used in each call.
:rtype: httplib2.Http
"""
- http = self._build_http()
- http = self.credentials.authorize(http)
- return http
+ return AuthorizedHttp(self.credentials, http=self._build_http())
diff --git a/pydrive2/drive.py b/pydrive2/drive.py
index 8189ab1..b70462f 100644
--- a/pydrive2/drive.py
+++ b/pydrive2/drive.py
@@ -46,4 +46,8 @@ def GetAbout(self):
:returns: A dictionary of Google Drive information like user, usage, quota etc.
"""
- return self.auth.service.about().get().execute(http=self.http)
+ return (
+ self.auth.service.about()
+ .get()
+ .execute(http=self.auth.Get_Http_Object())
+ )
diff --git a/pydrive2/files.py b/pydrive2/files.py
index 4716c7d..abf4724 100644
--- a/pydrive2/files.py
+++ b/pydrive2/files.py
@@ -82,7 +82,7 @@ def _GetList(self):
self.metadata = (
self.auth.service.files()
.list(**dict(self))
- .execute(http=self.http)
+ .execute(http=self.auth.Get_Http_Object())
)
except errors.HttpError as error:
raise ApiRequestError(error)
@@ -440,7 +440,7 @@ def FetchMetadata(self, fields=None, fetch_all=False):
# Teamdrive support
supportsAllDrives=True,
)
- .execute(http=self.http)
+ .execute(http=self.auth.Get_Http_Object())
)
except errors.HttpError as error:
raise ApiRequestError(error)
@@ -543,7 +543,7 @@ def InsertPermission(self, new_permission, param=None):
permission = (
self.auth.service.permissions()
.insert(**param)
- .execute(http=self.http)
+ .execute(http=self.auth.Get_Http_Object())
)
except errors.HttpError as error:
raise ApiRequestError(error)
@@ -574,7 +574,7 @@ def GetPermissions(self):
# Teamdrive support
supportsAllDrives=True,
)
- .execute(http=self.http)
+ .execute(http=self.auth.Get_Http_Object())
).get("items")
if permissions:
@@ -594,13 +594,13 @@ def DeletePermission(self, permission_id):
return self._DeletePermission(permission_id)
def _WrapRequest(self, request):
- """Replaces request.http with self.http.
+ """Replaces request.http with self.auth.Get_Http_Object().
Ensures thread safety. Similar to other places where we call
`.execute(http=self.http)` to pass a client from the thread local storage.
"""
- if self.http:
- request.http = self.http
+ if self.auth:
+ request.http = self.auth.Get_Http_Object()
return request
@LoadAuth
@@ -624,7 +624,7 @@ def _FilesInsert(self, param=None):
metadata = (
self.auth.service.files()
.insert(**param)
- .execute(http=self.http)
+ .execute(http=self.auth.Get_Http_Object())
)
except errors.HttpError as error:
raise ApiRequestError(error)
@@ -648,7 +648,9 @@ def _FilesUnTrash(self, param=None):
param["supportsAllDrives"] = True
try:
- self.auth.service.files().untrash(**param).execute(http=self.http)
+ self.auth.service.files().untrash(**param).execute(
+ http=self.auth.Get_Http_Object()
+ )
except errors.HttpError as error:
raise ApiRequestError(error)
else:
@@ -672,7 +674,9 @@ def _FilesTrash(self, param=None):
param["supportsAllDrives"] = True
try:
- self.auth.service.files().trash(**param).execute(http=self.http)
+ self.auth.service.files().trash(**param).execute(
+ http=self.auth.Get_Http_Object()
+ )
except errors.HttpError as error:
raise ApiRequestError(error)
else:
@@ -697,7 +701,9 @@ def _FilesDelete(self, param=None):
param["supportsAllDrives"] = True
try:
- self.auth.service.files().delete(**param).execute(http=self.http)
+ self.auth.service.files().delete(**param).execute(
+ http=self.auth.Get_Http_Object()
+ )
except errors.HttpError as error:
raise ApiRequestError(error)
else:
@@ -726,7 +732,7 @@ def _FilesUpdate(self, param=None):
metadata = (
self.auth.service.files()
.update(**param)
- .execute(http=self.http)
+ .execute(http=self.auth.Get_Http_Object())
)
except errors.HttpError as error:
raise ApiRequestError(error)
@@ -756,7 +762,7 @@ def _FilesPatch(self, param=None):
metadata = (
self.auth.service.files()
.patch(**param)
- .execute(http=self.http)
+ .execute(http=self.auth.Get_Http_Object())
)
except errors.HttpError as error:
raise ApiRequestError(error)
@@ -785,7 +791,8 @@ def _DownloadFromUrl(self, url):
:returns: str -- content of downloaded file in string.
:raises: ApiRequestError
"""
- resp, content = self.http.request(url)
+ http = self.auth.Get_Http_Object()
+ resp, content = http.request(url)
if resp.status != 200:
raise ApiRequestError(errors.HttpError(resp, content, uri=url))
return content
From 799b00562c376f0b3a4b4cf6d518bd7d25df13da Mon Sep 17 00:00:00 2001
From: junpeng-jp <junpeng.ong@gmail.com>
Date: Sun, 29 May 2022 00:48:52 +0800
Subject: [PATCH 3/9] Migration to google-auth library (#89)
---
pydrive2/auth.py | 489 +++++++++++++++++----------------------
pydrive2/auth_helpers.py | 58 +++++
pydrive2/drive.py | 2 -
pydrive2/files.py | 14 --
pydrive2/settings.py | 5 +
5 files changed, 275 insertions(+), 293 deletions(-)
create mode 100644 pydrive2/auth_helpers.py
diff --git a/pydrive2/auth.py b/pydrive2/auth.py
index 08625e8..d70841e 100644
--- a/pydrive2/auth.py
+++ b/pydrive2/auth.py
@@ -1,9 +1,11 @@
-import webbrowser
import httplib2
+import json
import oauth2client.clientsecrets as clientsecrets
-import threading
+import google.oauth2.credentials
+import google.oauth2.service_account
from googleapiclient.discovery import build
+
from functools import wraps
from oauth2client.service_account import ServiceAccountCredentials
from oauth2client.client import FlowExchangeError
@@ -21,7 +23,30 @@
from .settings import SettingsError
from .settings import InvalidConfigError
+from .auth_helpers import verify_client_config
+from oauthlib.oauth2.rfc6749.errors import OAuth2Error, MissingCodeError
+from google_auth_oauthlib.flow import InstalledAppFlow
from google_auth_httplib2 import AuthorizedHttp
+from warnings import warn
+
+
+DEFAULT_SETTINGS = {
+ "client_config_backend": "file",
+ "client_config_file": "client_secrets.json",
+ "save_credentials": False,
+ "oauth_scope": ["https://www.googleapis.com/auth/drive"],
+}
+
+_CLIENT_AUTH_PROMPT_MESSAGE = "Please visit this URL:\n{url}\n"
+
+
+DEFAULT_SETTINGS = {
+ "client_config_backend": "file",
+ "client_config_file": "client_secrets.json",
+ "save_credentials": False,
+ "oauth_scope": ["https://www.googleapis.com/auth/drive"],
+}
+
class AuthError(Exception):
"""Base error for authentication/authorization errors."""
@@ -43,87 +68,6 @@ class RefreshError(AuthError):
"""Access token refresh error."""
-def LoadAuth(decoratee):
- """Decorator to check if the auth is valid and loads auth if not."""
-
- @wraps(decoratee)
- def _decorated(self, *args, **kwargs):
- # Initialize auth if needed.
- if self.auth is None:
- self.auth = GoogleAuth()
- # Re-create access token if it expired.
- if self.auth.access_token_expired:
- if getattr(self.auth, "auth_method", False) == "service":
- self.auth.ServiceAuth()
- else:
- self.auth.LocalWebserverAuth()
-
- # Initialise service if not built yet.
- if self.auth.service is None:
- self.auth.Authorize()
-
-
-
- return decoratee(self, *args, **kwargs)
-
- return _decorated
-
-
-def CheckServiceAuth(decoratee):
- """Decorator to authorize service account."""
-
- @wraps(decoratee)
- def _decorated(self, *args, **kwargs):
- self.auth_method = "service"
- dirty = False
- save_credentials = self.settings.get("save_credentials")
- if self.credentials is None and save_credentials:
- self.LoadCredentials()
- if self.credentials is None:
- decoratee(self, *args, **kwargs)
- self.Authorize()
- dirty = True
- elif self.access_token_expired:
- self.Refresh()
- dirty = True
- self.credentials.set_store(self._default_storage)
- if dirty and save_credentials:
- self.SaveCredentials()
-
- return _decorated
-
-
-def CheckAuth(decoratee):
- """Decorator to check if it requires OAuth2 flow request."""
-
- @wraps(decoratee)
- def _decorated(self, *args, **kwargs):
- dirty = False
- code = None
- save_credentials = self.settings.get("save_credentials")
- if self.credentials is None and save_credentials:
- self.LoadCredentials()
- if self.flow is None:
- self.GetFlow()
- if self.credentials is None:
- code = decoratee(self, *args, **kwargs)
- dirty = True
- else:
- if self.access_token_expired:
- if self.credentials.refresh_token is not None:
- self.Refresh()
- else:
- code = decoratee(self, *args, **kwargs)
- dirty = True
- if code is not None:
- self.Auth(code)
- self.credentials.set_store(self._default_storage)
- if dirty and save_credentials:
- self.SaveCredentials()
-
- return _decorated
-
-
class GoogleAuth(ApiAttributeMixin):
"""Wrapper class for oauth2client library in google-api-python-client.
@@ -132,20 +76,6 @@ class GoogleAuth(ApiAttributeMixin):
and authorization.
"""
- DEFAULT_SETTINGS = {
- "client_config_backend": "file",
- "client_config_file": "client_secrets.json",
- "save_credentials": False,
- "oauth_scope": ["https://www.googleapis.com/auth/drive"],
- }
- CLIENT_CONFIGS_LIST = [
- "client_id",
- "client_secret",
- "auth_uri",
- "token_uri",
- "revoke_uri",
- "redirect_uri",
- ]
SERVICE_CONFIGS_LIST = ["client_user_email"]
settings = ApiAttribute("settings")
client_config = ApiAttribute("client_config")
@@ -166,21 +96,23 @@ def __init__(self, settings_file="settings.yaml", http_timeout=None):
"""
self.http_timeout = http_timeout
ApiAttributeMixin.__init__(self)
- self.client_config = {}
+
try:
self.settings = LoadSettingsFile(settings_file)
except SettingsError:
- self.settings = self.DEFAULT_SETTINGS
+ self.settings = DEFAULT_SETTINGS
else:
- if self.settings is None:
- self.settings = self.DEFAULT_SETTINGS
- else:
- ValidateSettings(self.settings)
+ # if no exceptions
+ ValidateSettings(self.settings)
self._storages = self._InitializeStoragesFromSettings()
# Only one (`file`) backend is supported now
self._default_storage = self._storages["file"]
self._service = None
+ self._credentials = None
+ self._client_config = None
+ self._oauth_type = None
+ self._flow = None
# Lazy loading, read-only properties
@property
@@ -189,17 +121,51 @@ def service(self):
self._service = build("drive", "v2", cache_discovery=False)
return self._service
+ @property
+ def client_config(self):
+ if not self._client_config:
+ self.LoadClientConfig()
+ return self._client_config
+
+ @property
+ def oauth_type(self):
+ if not self._oauth_type:
+ self.LoadClientConfig()
+ return self._oauth_type
+
+ @property
+ def flow(self):
+ if not self._flow:
+ self.GetFlow()
+ return self._flow
+
+ @property
+ def credentials(self):
+ if not self._credentials:
+ if self.oauth_type in ("web", "installed"):
+ self.LocalWebserverAuth()
+
+ elif self.oauth_type == "service":
+ self.ServiceAuth()
+ else:
+ raise InvalidConfigError(
+ "Only web, installed, service oauth is supported"
+ )
+
+ return self._credentials
+
+ # Other properties
@property
def access_token_expired(self):
"""Checks if access token doesn't exist or is expired.
:returns: bool -- True if access token doesn't exist or is expired.
"""
- if self.credentials is None:
+ if not self.credentials:
return True
- return self.credentials.access_token_expired
- @CheckAuth
+ return not self.credentials.valid
+
def LocalWebserverAuth(
self, host_name="localhost", port_numbers=None, launch_browser=True
):
@@ -219,27 +185,53 @@ def LocalWebserverAuth(
:raises: AuthenticationRejected, AuthenticationError
"""
if port_numbers is None:
- port_numbers = [
- 8080,
- 8090,
- ] # Mutable objects should not be default
- # values, as each call's changes are global.
- success = False
- port_number = 0
- for port in port_numbers:
- port_number = port
- try:
- httpd = ClientRedirectServer(
- (host_name, port), ClientRedirectHandler
+ port_numbers = [8080, 8090]
+
+ additional_config = {}
+ # offline token request needed to obtain refresh token
+ # make sure that consent is requested
+ if self.settings.get("get_refresh_token"):
+ additional_config["access_type"] = "offline"
+ additional_config["prompt"] = "select_account"
+
+ try:
+ for port in port_numbers:
+ self._credentials = self.flow.run_local_server(
+ host=host_name,
+ port=port,
+ authorization_prompt_message=_CLIENT_AUTH_PROMPT_MESSAGE,
+ open_browser=launch_browser,
+ **additional_config,
)
- except OSError:
- pass
- else:
- success = True
+ # if any port results in successful auth, we're done
break
- if success:
- oauth_callback = f"http://{host_name}:{port_number}/"
- else:
+
+ except OSError as e:
+ # OSError: [WinError 10048] ...
+ # When WSGIServer.allow_reuse_address = False,
+ # raise OSError when binding to a used port
+
+ # If some other error besides the socket address reuse error
+ if e.errno != 10048:
+ raise
+
+ print("Port {} is in use. Trying a different port".format(port))
+
+ except MissingCodeError as e:
+ # if code is not found in the redirect uri's query parameters
+ print(
+ "Failed to find 'code' in the query parameters of the redirect."
+ )
+ print("Please check that your redirect uri is correct.")
+ raise AuthenticationError("No code found in redirect")
+
+ except OAuth2Error as e:
+ # catch oauth 2 errors
+ print("Authentication request was rejected")
+ raise AuthenticationRejected("User rejected authentication")
+
+ # If we have tried all ports and could not find a port
+ if not self._credentials:
print(
"Failed to start a local web server. Please check your firewall"
)
@@ -248,30 +240,7 @@ def LocalWebserverAuth(
)
print("using configured ports. Default ports are 8080 and 8090.")
raise AuthenticationError()
- self.flow.redirect_uri = oauth_callback
- authorize_url = self.GetAuthUrl()
- if launch_browser:
- webbrowser.open(authorize_url, new=1, autoraise=True)
- print("Your browser has been opened to visit:")
- else:
- print("Open your browser to visit:")
- print()
- print(" " + authorize_url)
- print()
- httpd.handle_request()
- if "error" in httpd.query_params:
- print("Authentication request was rejected")
- raise AuthenticationRejected("User rejected authentication")
- if "code" in httpd.query_params:
- return httpd.query_params["code"]
- else:
- print(
- 'Failed to find "code" in the query parameters of the redirect.'
- )
- print("Try command-line authentication")
- raise AuthenticationError("No code found in redirect")
- @CheckAuth
def CommandLineAuth(self):
"""Authenticate and authorize from user by printing authentication url
retrieving authentication code from command-line.
@@ -286,36 +255,31 @@ def CommandLineAuth(self):
print()
return input("Enter verification code: ").strip()
- @CheckServiceAuth
def ServiceAuth(self):
"""Authenticate and authorize using P12 private key, client id
and client email for a Service account.
:raises: AuthError, InvalidConfigError
"""
- if set(self.SERVICE_CONFIGS_LIST) - set(self.client_config):
- self.LoadServiceConfigSettings()
- scopes = scopes_to_string(self.settings["oauth_scope"])
client_service_json = self.client_config.get("client_json_file_path")
+
if client_service_json:
- self.credentials = (
- ServiceAccountCredentials.from_json_keyfile_name(
- filename=client_service_json, scopes=scopes
- )
+ additional_config = {}
+ additional_config["subject"] = self.client_config.get(
+ "client_user_email"
)
- else:
- service_email = self.client_config["client_service_email"]
- file_path = self.client_config["client_pkcs12_file_path"]
- self.credentials = ServiceAccountCredentials.from_p12_keyfile(
- service_account_email=service_email,
- filename=file_path,
- scopes=scopes,
+ additional_config["scopes"] = self.settings["oauth_scope"]
+
+ self._credentials = google.oauth2.service_account.Credentials.from_service_account_file(
+ client_service_json, **additional_config
)
- user_email = self.client_config.get("client_user_email")
- if user_email:
- self.credentials = self.credentials.create_delegated(
- sub=user_email
+ elif self.client_config.get("use_default"):
+ # if no service credential file in yaml settings
+ # default to checking env var `GOOGLE_APPLICATION_CREDENTIALS`
+ credentials, _ = google.auth.default(
+ scopes=self.settings["oauth_scope"]
)
+ self._credentials = credentials
def _InitializeStoragesFromSettings(self):
result = {"file": None}
@@ -463,144 +427,102 @@ def LoadClientConfigFile(self, client_config_file=None):
"""
if client_config_file is None:
client_config_file = self.settings["client_config_file"]
- try:
- client_type, client_info = clientsecrets.loadfile(
- client_config_file
- )
- except clientsecrets.InvalidClientSecretsError as error:
- raise InvalidConfigError("Invalid client secrets file %s" % error)
- if client_type not in (
- clientsecrets.TYPE_WEB,
- clientsecrets.TYPE_INSTALLED,
- ):
- raise InvalidConfigError(
- "Unknown client_type of client config file"
- )
- # General settings.
- try:
- config_index = [
- "client_id",
- "client_secret",
- "auth_uri",
- "token_uri",
- ]
- for config in config_index:
- self.client_config[config] = client_info[config]
-
- self.client_config["revoke_uri"] = client_info.get("revoke_uri")
- self.client_config["redirect_uri"] = client_info["redirect_uris"][
- 0
- ]
- except KeyError:
- raise InvalidConfigError("Insufficient client config in file")
-
- # Service auth related fields.
- service_auth_config = ["client_email"]
+ with open(client_config_file, "r") as json_file:
+ client_config = json.load(json_file)
+
try:
- for config in service_auth_config:
- self.client_config[config] = client_info[config]
- except KeyError:
- pass # The service auth fields are not present, handling code can go here.
+ # check the format of the loaded client config
+ client_type, checked_config = verify_client_config(client_config)
+ except ValueError as e:
+ raise InvalidConfigError("Invalid client secrets file: %s" % e)
+
+ self._client_config = checked_config
+ self._oauth_type = client_type
def LoadServiceConfigSettings(self):
"""Loads client configuration from settings file.
:raises: InvalidConfigError
"""
- for file_format in ["json", "pkcs12"]:
- config = f"client_{file_format}_file_path"
- value = self.settings["service_config"].get(config)
- if value:
- self.client_config[config] = value
- break
- else:
- raise InvalidConfigError(
- "Either json or pkcs12 file path required "
- "for service authentication"
+ service_config = self.settings["service_config"]
+
+ # see https://github.com/googleapis/google-auth-library-python/issues/288
+ if "client_pkcs12_file_path" in service_config:
+ raise DeprecationWarning(
+ "PKCS#12 files are no longer supported in the new google.auth library. "
+ "Please download a new json service credential file from google cloud console. "
+ "For more info, visit https://github.com/googleapis/google-auth-library-python/issues/288"
)
- if file_format == "pkcs12":
- self.SERVICE_CONFIGS_LIST.append("client_service_email")
-
- for config in self.SERVICE_CONFIGS_LIST:
- try:
- self.client_config[config] = self.settings["service_config"][
- config
- ]
- except KeyError:
- err = "Insufficient service config in settings"
- err += f"\n\nMissing: {config} key."
- raise InvalidConfigError(err)
+ self._client_config = service_config
+ self._oauth_type = "service"
def LoadClientConfigSettings(self):
"""Loads client configuration from settings file.
:raises: InvalidConfigError
"""
- for config in self.CLIENT_CONFIGS_LIST:
- try:
- self.client_config[config] = self.settings["client_config"][
- config
- ]
- except KeyError:
- raise InvalidConfigError(
- "Insufficient client config in settings"
- )
+
+ try:
+ client_config = self.settings["client_config"]
+ except KeyError as e:
+ raise InvalidConfigError(
+ "Settings does not contain 'client_config'"
+ )
+
+ try:
+ _, checked_config = verify_client_config(
+ client_config, with_oauth_type=False
+ )
+ except ValueError as e:
+ raise InvalidConfigError("Invalid client secrets file: %s" % e)
+
+ # assumed to be Installed App Flow as the Local Server Auth is appropriate for this type of device
+ self._client_config = checked_config
+ self._oauth_type = "installed"
def GetFlow(self):
"""Gets Flow object from client configuration.
:raises: InvalidConfigError
"""
- if not all(
- config in self.client_config for config in self.CLIENT_CONFIGS_LIST
- ):
- self.LoadClientConfig()
- constructor_kwargs = {
- "redirect_uri": self.client_config["redirect_uri"],
- "auth_uri": self.client_config["auth_uri"],
- "token_uri": self.client_config["token_uri"],
- "access_type": "online",
- }
- if self.client_config["revoke_uri"] is not None:
- constructor_kwargs["revoke_uri"] = self.client_config["revoke_uri"]
- self.flow = OAuth2WebServerFlow(
- self.client_config["client_id"],
- self.client_config["client_secret"],
- scopes_to_string(self.settings["oauth_scope"]),
- **constructor_kwargs,
- )
- if self.settings.get("get_refresh_token"):
- self.flow.params.update(
- {"access_type": "offline", "approval_prompt": "force"}
+
+ additional_config = {}
+ scopes = self.settings.get("oauth_scope")
+
+ if self.oauth_type in ("web", "installed"):
+ self._flow = InstalledAppFlow.from_client_config(
+ {self.oauth_type: self.client_config},
+ scopes,
+ **additional_config,
)
+ if self.oauth_type == "service":
+ # In a service oauth2 flow,
+ # the oauth subject does not have to provide any consent via the client
+ pass
+
def Refresh(self):
"""Refreshes the access_token.
:raises: RefreshError
"""
- if self.credentials is None:
- raise RefreshError("No credential to refresh.")
- if (
- self.credentials.refresh_token is None
- and self.auth_method != "service"
- ):
- raise RefreshError(
- "No refresh_token found."
- "Please set access_type of OAuth to offline."
- )
-
-
+ raise DeprecationWarning(
+ "Refresh is now handled automatically within the new google.auth Credential objects. "
+ "There's no need to manually refresh your credentials now."
+ )
def GetAuthUrl(self):
"""Creates authentication url where user visits to grant access.
:returns: str -- Authentication url.
"""
- if self.flow is None:
- self.GetFlow()
- return self.flow.step1_get_authorize_url()
+ if self.oauth_type == "service":
+ raise AuthenticationError(
+ "Authentication is not required for service client type."
+ )
+
+ return self.flow.authorization_url()
def Auth(self, code):
"""Authenticate, authorize, and build service.
@@ -619,13 +541,26 @@ def Authenticate(self, code):
:type code: str.
:raises: AuthenticationError
"""
- if self.flow is None:
- self.GetFlow()
+ if self.oauth_type == "service":
+ raise AuthenticationError(
+ "Authentication is not required for service client type."
+ )
+
try:
- self.credentials = self.flow.step2_exchange(code)
- except FlowExchangeError as e:
- raise AuthenticationError("OAuth2 code exchange failed: %s" % e)
- print("Authentication successful.")
+ self.flow.fetch_token(code=code)
+
+ except MissingCodeError as e:
+ # if code is not found in the redirect uri's query parameters
+ print(
+ "Failed to find 'code' in the query parameters of the redirect."
+ )
+ print("Please check that your redirect uri is correct.")
+ raise AuthenticationError("No code found in redirect")
+
+ except OAuth2Error as e:
+ # catch oauth 2 errors
+ print("Authentication request was rejected")
+ raise AuthenticationRejected("User rejected authentication")
def _build_http(self):
http = httplib2.Http(timeout=self.http_timeout)
diff --git a/pydrive2/auth_helpers.py b/pydrive2/auth_helpers.py
new file mode 100644
index 0000000..04e5f67
--- /dev/null
+++ b/pydrive2/auth_helpers.py
@@ -0,0 +1,58 @@
+_OLD_CLIENT_CONFIG_KEYS = frozenset(
+ (
+ "client_id",
+ "client_secret",
+ "auth_uri",
+ "token_uri",
+ "revoke_uri",
+ "redirect_uri",
+ )
+)
+
+_CLIENT_CONFIG_KEYS = frozenset(
+ (
+ "client_id",
+ "client_secret",
+ "auth_uri",
+ "token_uri",
+ "redirect_uris",
+ )
+)
+
+
+def verify_client_config(client_config, with_oauth_type=True):
+ """Verifies that format of the client config
+ loaded from a Google-format client secrets file.
+ """
+
+ oauth_type = None
+ config = client_config
+
+ if with_oauth_type:
+ if "web" in client_config:
+ oauth_type = "web"
+ config = config["web"]
+
+ elif "installed" in client_config:
+ oauth_type = "installed"
+ config = config["installed"]
+ else:
+ raise ValueError(
+ "Client secrets must be for a web or installed app"
+ )
+
+ # This is the older format of client config
+ if _OLD_CLIENT_CONFIG_KEYS.issubset(config.keys()):
+ config["redirect_uris"] = [config["redirect_uri"]]
+
+ # by default, the redirect uri is the first in the list
+ if "redirect_uri" not in config:
+ config["redirect_uri"] = config["redirect_uris"][0]
+
+ if "revoke_uri" not in config:
+ config["revoke_uri"] = "https://oauth2.googleapis.com/revoke"
+
+ if not _CLIENT_CONFIG_KEYS.issubset(config.keys()):
+ raise ValueError("Client secrets is not in the correct format.")
+
+ return oauth_type, config
diff --git a/pydrive2/drive.py b/pydrive2/drive.py
index b70462f..83ad391 100644
--- a/pydrive2/drive.py
+++ b/pydrive2/drive.py
@@ -1,7 +1,6 @@
from .apiattr import ApiAttributeMixin
from .files import GoogleDriveFile
from .files import GoogleDriveFileList
-from .auth import LoadAuth
class GoogleDrive(ApiAttributeMixin):
@@ -40,7 +39,6 @@ def ListFile(self, param=None):
"""
return GoogleDriveFileList(auth=self.auth, param=param)
- @LoadAuth
def GetAbout(self):
"""Return information about the Google Drive of the auth instance.
diff --git a/pydrive2/files.py b/pydrive2/files.py
index abf4724..ab26c72 100644
--- a/pydrive2/files.py
+++ b/pydrive2/files.py
@@ -12,7 +12,6 @@
from .apiattr import ApiAttributeMixin
from .apiattr import ApiResource
from .apiattr import ApiResourceList
-from .auth import LoadAuth
BLOCK_SIZE = 1024
# Usage: MIME_TYPE_TO_BOM['<Google Drive mime type>']['<download mimetype>'].
@@ -68,7 +67,6 @@ def __init__(self, auth=None, param=None):
"""Create an instance of GoogleDriveFileList."""
super().__init__(auth=auth, metadata=param)
- @LoadAuth
def _GetList(self):
"""Overwritten method which actually makes API call to list files.
@@ -287,7 +285,6 @@ def GetContentString(
self.FetchContent(mimetype, remove_bom)
return self.content.getvalue().decode(encoding)
- @LoadAuth
def GetContentFile(
self,
filename,
@@ -355,7 +352,6 @@ def download(fd, request):
if bom:
self._RemovePrefix(fd, bom)
- @LoadAuth
def GetContentIOBuffer(
self,
mimetype=None,
@@ -412,7 +408,6 @@ def GetContentIOBuffer(
chunksize=chunksize,
)
- @LoadAuth
def FetchMetadata(self, fields=None, fetch_all=False):
"""Download file's metadata from id using Files.get().
@@ -552,7 +547,6 @@ def InsertPermission(self, new_permission, param=None):
return permission
- @LoadAuth
def GetPermissions(self):
"""Get file's or shared drive's permissions.
@@ -603,7 +597,6 @@ def _WrapRequest(self, request):
request.http = self.auth.Get_Http_Object()
return request
- @LoadAuth
def _FilesInsert(self, param=None):
"""Upload a new file using Files.insert().
@@ -633,7 +626,6 @@ def _FilesInsert(self, param=None):
self.dirty["content"] = False
self.UpdateMetadata(metadata)
- @LoadAuth
def _FilesUnTrash(self, param=None):
"""Un-delete (Trash) a file using Files.UnTrash().
:param param: additional parameter to file.
@@ -658,7 +650,6 @@ def _FilesUnTrash(self, param=None):
self.metadata["labels"]["trashed"] = False
return True
- @LoadAuth
def _FilesTrash(self, param=None):
"""Soft-delete (Trash) a file using Files.Trash().
@@ -684,7 +675,6 @@ def _FilesTrash(self, param=None):
self.metadata["labels"]["trashed"] = True
return True
- @LoadAuth
def _FilesDelete(self, param=None):
"""Delete a file using Files.Delete()
(WARNING: deleting permanently deletes the file!)
@@ -709,7 +699,6 @@ def _FilesDelete(self, param=None):
else:
return True
- @LoadAuth
@LoadMetadata
def _FilesUpdate(self, param=None):
"""Update metadata and/or content using Files.Update().
@@ -741,7 +730,6 @@ def _FilesUpdate(self, param=None):
self.dirty["content"] = False
self.UpdateMetadata(metadata)
- @LoadAuth
@LoadMetadata
def _FilesPatch(self, param=None):
"""Update metadata using Files.Patch().
@@ -782,7 +770,6 @@ def _BuildMediaBody(self):
self.content, self["mimeType"], resumable=True
)
- @LoadAuth
def _DownloadFromUrl(self, url):
"""Download file from url using provided credential.
@@ -797,7 +784,6 @@ def _DownloadFromUrl(self, url):
raise ApiRequestError(errors.HttpError(resp, content, uri=url))
return content
- @LoadAuth
def _DeletePermission(self, permission_id):
"""Deletes the permission remotely, and from the file object itself.
diff --git a/pydrive2/settings.py b/pydrive2/settings.py
index f5e84ab..e3a46a6 100644
--- a/pydrive2/settings.py
+++ b/pydrive2/settings.py
@@ -75,6 +75,7 @@
"client_service_email": {"type": str, "required": False},
"client_pkcs12_file_path": {"type": str, "required": False},
"client_json_file_path": {"type": str, "required": False},
+ "use_default": {"type": bool, "required": False},
},
},
"oauth_scope": {
@@ -107,6 +108,10 @@ def LoadSettingsFile(filename=SETTINGS_FILE):
data = load(stream, Loader=Loader)
except (YAMLError, OSError) as e:
raise SettingsError(e)
+
+ if not data:
+ raise SettingsError("{} is an empty settings file.".format(filename))
+
return data
From 2a0a76709a33a6098bc9095896d3aaabc7b63e10 Mon Sep 17 00:00:00 2001
From: junpeng-jp <junpeng.ong@gmail.com>
Date: Sun, 29 May 2022 00:57:00 +0800
Subject: [PATCH 4/9] Implement lockfile storage (#89)
---
pydrive2/auth.py | 106 ++++++++++++++++++++++++++-------------
pydrive2/storage.py | 117 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 188 insertions(+), 35 deletions(-)
create mode 100644 pydrive2/storage.py
diff --git a/pydrive2/auth.py b/pydrive2/auth.py
index d70841e..32c1c18 100644
--- a/pydrive2/auth.py
+++ b/pydrive2/auth.py
@@ -104,10 +104,8 @@ def __init__(self, settings_file="settings.yaml", http_timeout=None):
else:
# if no exceptions
ValidateSettings(self.settings)
- self._storages = self._InitializeStoragesFromSettings()
- # Only one (`file`) backend is supported now
- self._default_storage = self._storages["file"]
+ self._storage = None
self._service = None
self._credentials = None
self._client_config = None
@@ -139,10 +137,28 @@ def flow(self):
self.GetFlow()
return self._flow
+ @property
+ def storage(self):
+ if not self.settings.get("save_credentials"):
+ return None
+
+ if not self._storage:
+ self._InitializeStoragesFromSettings()
+ return self._storage
+
@property
def credentials(self):
if not self._credentials:
if self.oauth_type in ("web", "installed"):
+ # try to load from backend if available
+ # credentials would auto-refresh if expired
+ if self.storage:
+ try:
+ self.LoadCredentials()
+ return self._credentials
+ except FileNotFoundError:
+ pass
+
self.LocalWebserverAuth()
elif self.oauth_type == "service":
@@ -282,21 +298,14 @@ def ServiceAuth(self):
self._credentials = credentials
def _InitializeStoragesFromSettings(self):
- result = {"file": None}
- backend = self.settings.get("save_credentials_backend")
- save_credentials = self.settings.get("save_credentials")
- if backend == "file":
- credentials_file = self.settings.get("save_credentials_file")
- if credentials_file is None:
+ if self.settings.get("save_credentials"):
+ backend = self.settings.get("save_credentials_backend")
+ if backend != "file":
raise InvalidConfigError(
- "Please specify credentials file to read"
+ "Unknown save_credentials_backend: %s" % backend
)
- result[backend] = Storage(credentials_file)
- elif save_credentials:
- raise InvalidConfigError(
- "Unknown save_credentials_backend: %s" % backend
- )
- return result
+
+ self._storage = FileBackend()
def LoadCredentials(self, backend=None):
"""Loads credentials or create empty credentials if it doesn't exist.
@@ -324,25 +333,55 @@ def LoadCredentialsFile(self, credentials_file=None):
:raises: InvalidConfigError, InvalidCredentialsError
"""
if credentials_file is None:
- self._default_storage = self._storages["file"]
- if self._default_storage is None:
+ credentials_file = self.settings.get("save_credentials_file")
+ if credentials_file is None:
raise InvalidConfigError(
- "Backend `file` is not configured, specify "
- "credentials file to read in the settings "
- "file or pass an explicit value"
+ "Please specify credentials file to read"
)
- else:
- self._default_storage = Storage(credentials_file)
try:
- self.credentials = self._default_storage.get()
+ auth_info = self.storage.read_credentials(credentials_file)
+ except FileNotFoundError:
+ # if credential found was not found, raise the error for handling
+ raise
except OSError:
+ # catch other errors
raise InvalidCredentialsError(
"Credentials file cannot be symbolic link"
)
- if self.credentials:
- self.credentials.set_store(self._default_storage)
+ try:
+ self._credentials = google.oauth2.credentials.Credentials.from_authorized_user_info(
+ auth_info, scopes=auth_info["scopes"]
+ )
+ except ValueError:
+ # if saved credentials lack a refresh token
+ # handled for backwards compatibility
+ warn(
+ "Loading authorized user credentials without a refresh token is "
+ "not officially supported by google auth library. We recommend that "
+ "you only store refreshable credentials moving forward."
+ )
+
+ self._credentials = google.oauth2.credentials.Credentials(
+ token=auth_info.get("token"),
+ token_uri="https://oauth2.googleapis.com/token", # always overrides
+ scopes=auth_info.get("scopes"),
+ client_id=auth_info.get("client_id"),
+ client_secret=auth_info.get("client_secret"),
+ )
+
+ # in-case reauth / consent required
+ # create a flow object so that reauth flow can be triggered
+ additional_config = {}
+ if self.credentials.refresh_token:
+ additional_config["access_type"] = "offline"
+
+ self._flow = InstalledAppFlow.from_client_config(
+ {self.oauth_type: self.client_config},
+ self.credentials.scopes,
+ **additional_config,
+ )
def SaveCredentials(self, backend=None):
"""Saves credentials according to specified backend.
@@ -370,22 +409,19 @@ def SaveCredentialsFile(self, credentials_file=None):
:type credentials_file: str.
:raises: InvalidConfigError, InvalidCredentialsError
"""
- if self.credentials is None:
+ if not self.credentials:
raise InvalidCredentialsError("No credentials to save")
if credentials_file is None:
- storage = self._storages["file"]
- if storage is None:
+ credentials_file = self.settings.get("save_credentials_file")
+ if credentials_file is None:
raise InvalidConfigError(
- "Backend `file` is not configured, specify "
- "credentials file to read in the settings "
- "file or pass an explicit value"
+ "Please specify credentials file to read"
)
- else:
- storage = Storage(credentials_file)
try:
- storage.put(self.credentials)
+ self.storage.store_credentials(self.credentials, credentials_file)
+
except OSError:
raise InvalidCredentialsError(
"Credentials file cannot be symbolic link"
diff --git a/pydrive2/storage.py b/pydrive2/storage.py
new file mode 100644
index 0000000..ad9f772
--- /dev/null
+++ b/pydrive2/storage.py
@@ -0,0 +1,117 @@
+import os
+import json
+import warnings
+from filelock import FileLock
+
+
+_SYM_LINK_MESSAGE = "File: {0}: Is a symbolic link."
+_IS_DIR_MESSAGE = "{0}: Is a directory"
+_MISSING_FILE_MESSAGE = "Cannot access {0}: No such file or directory"
+
+
+def validate_file(filename):
+ if os.path.islink(filename):
+ raise IOError(_SYM_LINK_MESSAGE.format(filename))
+ elif os.path.isdir(filename):
+ raise IOError(_IS_DIR_MESSAGE.format(filename))
+ elif not os.path.isfile(filename):
+ warnings.warn(_MISSING_FILE_MESSAGE.format(filename))
+
+
+class CredentialBackend(object):
+ """Adapter that provides a consistent interface to read and write credential files"""
+
+ def _read_credentials(self, rpath):
+ """Specific implementation of how the storage object should retrieve a file."""
+ return NotImplementedError
+
+ def _store_credentials(self, credential, rpath):
+ """Specific implementation of how the storage object should write a file"""
+ return NotImplementedError
+
+ def _delete_credentials(self, rpath):
+ """Specific implementation of how the storage object should delete a file."""
+ return NotImplementedError
+
+ def read_credentials(self, rpath):
+ """Reads a credential config file and returns the config as a dictionary
+ :param fname: host name of the local web server.
+ :type host_name: str.`
+ :return: A credential file
+ """
+ return self._read_credentials(rpath)
+
+ def store_credentials(self, credential, rpath):
+ """Write a credential to
+ The Storage lock must be held when this is called.
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ self._store_credentials(credential, rpath)
+
+ def delete_credentials(self, rpath):
+ """Delete credential.
+ Frees any resources associated with storing the credential.
+ The Storage lock must *not* be held when this is called.
+
+ Returns:
+ None
+ """
+ self._delete_credentials(rpath)
+
+
+class FileBackend(CredentialBackend):
+ # https://stackoverflow.com/questions/37084682/is-oauth-thread-safe
+ """Read and write credentials to a file backend with File Locking"""
+
+ def __init__(self):
+ self._locks = {}
+
+ def createLock(self, rpath):
+ self._locks[rpath] = FileLock("{}.lock".format(rpath))
+
+ def getLock(self, rpath):
+ if rpath not in self._locks:
+ self.createLock(rpath)
+ return self._locks[rpath]
+
+ def _create_file_if_needed(self, rpath):
+ """Create an empty file if necessary.
+ This method will not initialize the file. Instead it implements a
+ simple version of "touch" to ensure the file has been created.
+ """
+ if not os.path.exists(rpath):
+ old_umask = os.umask(0o177)
+ try:
+ open(rpath, "a+b").close()
+ finally:
+ os.umask(old_umask)
+
+ def _read_credentials(self, rpath):
+ """Reads a local json file and parses the information into a info dictionary.
+ Returns:
+ Raises:
+ """
+ with self.getLock(rpath):
+ with open(rpath, "r") as json_file:
+ return json.load(json_file)
+
+ def _store_credentials(self, credentials, rpath):
+ """Writes current credentials to a local json file.
+ Args:
+ Raises:
+ """
+ with self.getLock(rpath):
+ self._create_file_if_needed(rpath)
+ validate_file(rpath)
+
+ with open(rpath, "w") as json_file:
+ json_file.write(credentials.to_json())
+
+ def _delete_credentials(self, rpath):
+ """Delete Credentials file.
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ with self.getLock(rpath):
+ os.unlink(rpath)
From 9230969d92714251d9bed2dd4d5cd80a96e7d70d Mon Sep 17 00:00:00 2001
From: junpeng-jp <junpeng.ong@gmail.com>
Date: Sun, 29 May 2022 00:51:00 +0800
Subject: [PATCH 5/9] Cleanup of oauth tests & remove service config read/write
checks
---
pydrive2/test/settings/default.yaml | 2 +-
pydrive2/test/settings/test_oauth_test_07.yaml | 2 +-
pydrive2/test/settings/test_oauth_test_08.yaml | 2 +-
pydrive2/test/settings/test_oauth_test_09.yaml | 2 +-
pydrive2/test/test_oauth.py | 13 +++++++------
5 files changed, 11 insertions(+), 10 deletions(-)
diff --git a/pydrive2/test/settings/default.yaml b/pydrive2/test/settings/default.yaml
index ef6e654..b31b91f 100644
--- a/pydrive2/test/settings/default.yaml
+++ b/pydrive2/test/settings/default.yaml
@@ -1,6 +1,6 @@
client_config_backend: service
service_config:
- client_json_file_path: /tmp/pydrive2/credentials.json
+ client_json_file_path: tmp/pydrive2/credentials.json
save_credentials: True
save_credentials_backend: file
diff --git a/pydrive2/test/settings/test_oauth_test_07.yaml b/pydrive2/test/settings/test_oauth_test_07.yaml
index a806515..673a6ec 100644
--- a/pydrive2/test/settings/test_oauth_test_07.yaml
+++ b/pydrive2/test/settings/test_oauth_test_07.yaml
@@ -1,6 +1,6 @@
client_config_backend: service
service_config:
- client_json_file_path: /tmp/pydrive2/credentials.json
+ client_json_file_path: tmp/pydrive2/credentials.json
save_credentials: True
save_credentials_backend: file
diff --git a/pydrive2/test/settings/test_oauth_test_08.yaml b/pydrive2/test/settings/test_oauth_test_08.yaml
index 1fcbc05..f927e53 100644
--- a/pydrive2/test/settings/test_oauth_test_08.yaml
+++ b/pydrive2/test/settings/test_oauth_test_08.yaml
@@ -1,6 +1,6 @@
client_config_backend: service
service_config:
- client_json_file_path: /tmp/pydrive2/credentials.json
+ client_json_file_path: tmp/pydrive2/credentials.json
save_credentials: False
diff --git a/pydrive2/test/settings/test_oauth_test_09.yaml b/pydrive2/test/settings/test_oauth_test_09.yaml
index 09eca82..b131ca1 100644
--- a/pydrive2/test/settings/test_oauth_test_09.yaml
+++ b/pydrive2/test/settings/test_oauth_test_09.yaml
@@ -1,6 +1,6 @@
client_config_backend: service
service_config:
- client_json_file_path: /tmp/pydrive2/credentials.json
+ client_json_file_path: tmp/pydrive2/credentials.json
save_credentials: True
save_credentials_backend: file
diff --git a/pydrive2/test/test_oauth.py b/pydrive2/test/test_oauth.py
index d876661..b57b2bb 100644
--- a/pydrive2/test/test_oauth.py
+++ b/pydrive2/test/test_oauth.py
@@ -22,6 +22,7 @@ def test_01_LocalWebserverAuthWithClientConfigFromFile():
# Test if authentication works with config read from file
ga = GoogleAuth(settings_file_path("test_oauth_test_01.yaml"))
ga.LocalWebserverAuth()
+ assert ga.credentials
assert not ga.access_token_expired
# Test if correct credentials file is created
CheckCredentialsFile("credentials/1.dat")
@@ -35,6 +36,7 @@ def test_02_LocalWebserverAuthWithClientConfigFromSettings():
# Test if authentication works with config read from settings
ga = GoogleAuth(settings_file_path("test_oauth_test_02.yaml"))
ga.LocalWebserverAuth()
+ assert ga.credentials
assert not ga.access_token_expired
# Test if correct credentials file is created
CheckCredentialsFile("credentials/2.dat")
@@ -48,6 +50,7 @@ def test_03_LocalWebServerAuthWithNoCredentialsSaving():
ga = GoogleAuth(settings_file_path("test_oauth_test_03.yaml"))
assert not ga.settings["save_credentials"]
ga.LocalWebserverAuth()
+ assert ga.credentials
assert not ga.access_token_expired
time.sleep(1)
@@ -59,6 +62,7 @@ def test_04_CommandLineAuthWithClientConfigFromFile():
# Test if authentication works with config read from file
ga = GoogleAuth(settings_file_path("test_oauth_test_04.yaml"))
ga.CommandLineAuth()
+ assert ga.credentials
assert not ga.access_token_expired
# Test if correct credentials file is created
CheckCredentialsFile("credentials/4.dat")
@@ -70,6 +74,7 @@ def test_05_ConfigFromSettingsWithoutOauthScope():
# Test if authentication works without oauth_scope
ga = GoogleAuth(settings_file_path("test_oauth_test_05.yaml"))
ga.LocalWebserverAuth()
+ assert ga.credentials
assert not ga.access_token_expired
time.sleep(1)
@@ -79,6 +84,7 @@ def test_06_ServiceAuthFromSavedCredentialsP12File():
setup_credentials("credentials/6.dat")
ga = GoogleAuth(settings_file_path("test_oauth_test_06.yaml"))
ga.ServiceAuth()
+ assert ga.credentials
assert not ga.access_token_expired
time.sleep(1)
@@ -91,12 +97,9 @@ def test_07_ServiceAuthFromSavedCredentialsJsonFile():
delete_file(credentials_file)
assert not os.path.exists(credentials_file)
ga.ServiceAuth()
- assert os.path.exists(credentials_file)
- # Secondary auth should be made only using the previously saved
- # login info
ga = GoogleAuth(settings_file_path("test_oauth_test_07.yaml"))
ga.ServiceAuth()
- assert not ga.access_token_expired
+ assert ga.credentials
time.sleep(1)
@@ -120,8 +123,6 @@ def test_09_SaveLoadCredentialsUsesDefaultStorage(mocker):
assert not os.path.exists(credentials_file)
spy = mocker.spy(Storage, "__init__")
ga.ServiceAuth()
- ga.LoadCredentials()
- ga.SaveCredentials()
assert spy.call_count == 0
From 2a688e28b15cb310ea39faac4375029c62a574ef Mon Sep 17 00:00:00 2001
From: junpeng-jp <junpeng.ong@gmail.com>
Date: Sun, 29 May 2022 00:51:32 +0800
Subject: [PATCH 6/9] Add oauth test 10: google.auth.default auth
---
pydrive2/test/settings/test_oauth_test_10.yaml | 5 +++++
pydrive2/test/test_oauth.py | 10 ++++++++++
2 files changed, 15 insertions(+)
create mode 100644 pydrive2/test/settings/test_oauth_test_10.yaml
diff --git a/pydrive2/test/settings/test_oauth_test_10.yaml b/pydrive2/test/settings/test_oauth_test_10.yaml
new file mode 100644
index 0000000..2af85fc
--- /dev/null
+++ b/pydrive2/test/settings/test_oauth_test_10.yaml
@@ -0,0 +1,5 @@
+client_config_backend: service
+service_config:
+ use_default: True
+oauth_scope:
+ - https://www.googleapis.com/auth/drive
diff --git a/pydrive2/test/test_oauth.py b/pydrive2/test/test_oauth.py
index b57b2bb..40963c1 100644
--- a/pydrive2/test/test_oauth.py
+++ b/pydrive2/test/test_oauth.py
@@ -126,6 +126,16 @@ def test_09_SaveLoadCredentialsUsesDefaultStorage(mocker):
assert spy.call_count == 0
+def test_10_ServiceAuthFromEnvironmentDefault():
+ # Test fix for https://github.com/iterative/PyDrive2/issues/163
+ # Make sure that Load and Save credentials by default reuse the
+ # same Storage (since it defined lock which make it TS)
+ ga = GoogleAuth(settings_file_path("test_oauth_test_10.yaml"))
+ ga.ServiceAuth()
+ assert ga.credentials
+ time.sleep(1)
+
+
def CheckCredentialsFile(credentials, no_file=False):
ga = GoogleAuth(settings_file_path("test_oauth_default.yaml"))
ga.LoadCredentialsFile(credentials)
From a3bc162c59a5044df8983e953331a084a0bc9ffa Mon Sep 17 00:00:00 2001
From: junpeng-jp <junpeng.ong@gmail.com>
Date: Sun, 29 May 2022 00:52:54 +0800
Subject: [PATCH 7/9] Deprecate command line auth (#173)
---
pydrive2/auth.py | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/pydrive2/auth.py b/pydrive2/auth.py
index 32c1c18..646268c 100644
--- a/pydrive2/auth.py
+++ b/pydrive2/auth.py
@@ -263,13 +263,17 @@ def CommandLineAuth(self):
:returns: str -- code returned from commandline.
"""
- self.flow.redirect_uri = OOB_CALLBACK_URN
- authorize_url = self.GetAuthUrl()
- print("Go to the following link in your browser:")
- print()
- print(" " + authorize_url)
- print()
- return input("Enter verification code: ").strip()
+
+ warn(
+ (
+ "The command line auth has been deprecated. "
+ "The recommended alternative is to use local webserver auth with a loopback address."
+ ),
+ DeprecationWarning,
+ )
+
+ self.LocalWebserverAuth(host_name="127.0.0.1")
+
def ServiceAuth(self):
"""Authenticate and authorize using P12 private key, client id
From c9a0f90759df11f3c544c57f69edfc8ce936816e Mon Sep 17 00:00:00 2001
From: junpeng-jp <junpeng.ong@gmail.com>
Date: Sun, 29 May 2022 00:58:10 +0800
Subject: [PATCH 8/9] Remove dependencies on oauth2client
---
pydrive2/auth.py | 18 ++++--------------
setup.py | 1 -
2 files changed, 4 insertions(+), 15 deletions(-)
diff --git a/pydrive2/auth.py b/pydrive2/auth.py
index 646268c..cf59a29 100644
--- a/pydrive2/auth.py
+++ b/pydrive2/auth.py
@@ -1,21 +1,10 @@
import httplib2
import json
-import oauth2client.clientsecrets as clientsecrets
import google.oauth2.credentials
import google.oauth2.service_account
from googleapiclient.discovery import build
-
-from functools import wraps
-from oauth2client.service_account import ServiceAccountCredentials
-from oauth2client.client import FlowExchangeError
-from oauth2client.client import AccessTokenRefreshError
-from oauth2client.client import OAuth2WebServerFlow
-from oauth2client.client import OOB_CALLBACK_URN
-from oauth2client.file import Storage
-from oauth2client.tools import ClientRedirectHandler
-from oauth2client.tools import ClientRedirectServer
-from oauth2client._helpers import scopes_to_string
+from pydrive2.storage import FileBackend
from .apiattr import ApiAttribute
from .apiattr import ApiAttributeMixin
from .settings import LoadSettingsFile
@@ -257,6 +246,9 @@ def LocalWebserverAuth(
print("using configured ports. Default ports are 8080 and 8090.")
raise AuthenticationError()
+ if self.storage:
+ self.SaveCredentials()
+
def CommandLineAuth(self):
"""Authenticate and authorize from user by printing authentication url
retrieving authentication code from command-line.
@@ -274,7 +266,6 @@ def CommandLineAuth(self):
self.LocalWebserverAuth(host_name="127.0.0.1")
-
def ServiceAuth(self):
"""Authenticate and authorize using P12 private key, client id
and client email for a Service account.
@@ -526,7 +517,6 @@ def GetFlow(self):
:raises: InvalidConfigError
"""
-
additional_config = {}
scopes = self.settings.get("oauth_scope")
diff --git a/setup.py b/setup.py
index 2b2d952..74e6d24 100644
--- a/setup.py
+++ b/setup.py
@@ -32,7 +32,6 @@
install_requires=[
"google-api-python-client >= 1.12.5",
"six >= 1.13.0",
- "oauth2client >= 4.0.0",
"google-auth >= 2.6.6",
"google-auth-httplib2 >= 0.1.0",
"google-auth-oauthlib >= 0.5.1",
From 012f9edfc8f7da06ff74542fbf8938b772e2483f Mon Sep 17 00:00:00 2001
From: junpeng-jp <junpeng.ong@gmail.com>
Date: Wed, 1 Jun 2022 23:09:53 +0800
Subject: [PATCH 9/9] Changed mocker.spy to track calls to FileBackend's
__init__
---
pydrive2/test/test_oauth.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pydrive2/test/test_oauth.py b/pydrive2/test/test_oauth.py
index 40963c1..7e6df5d 100644
--- a/pydrive2/test/test_oauth.py
+++ b/pydrive2/test/test_oauth.py
@@ -8,7 +8,7 @@
delete_file,
settings_file_path,
)
-from oauth2client.file import Storage
+from ..storage import FileBackend
def setup_module(module):
@@ -121,7 +121,7 @@ def test_09_SaveLoadCredentialsUsesDefaultStorage(mocker):
# Delete old credentials file
delete_file(credentials_file)
assert not os.path.exists(credentials_file)
- spy = mocker.spy(Storage, "__init__")
+ spy = mocker.spy(FileBackend, "__init__")
ga.ServiceAuth()
assert spy.call_count == 0