diff --git a/PyDrive2-1.10.1.tar.gz b/PyDrive2-1.10.1.tar.gz deleted file mode 100644 index da4a644..0000000 --- a/PyDrive2-1.10.1.tar.gz +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ac29d6da1eff3e5f14d3da0af10ff0bf3d12448427ee4e99d2f8336663455483 -size 48501 diff --git a/PyDrive2-1.16.2.tar.gz b/PyDrive2-1.16.2.tar.gz new file mode 100644 index 0000000..36ef292 --- /dev/null +++ b/PyDrive2-1.16.2.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d205233d650dea4b9f6d5c4ab50ff7de0c856e6243235690f6732a2f65fa6a79 +size 60931 diff --git a/migrate-to-google-auth.patch b/migrate-to-google-auth.patch index 5d72acc..78b660b 100644 --- a/migrate-to-google-auth.patch +++ b/migrate-to-google-auth.patch @@ -1,363 +1,84 @@ -From 0236bf917d3103e95eafdf5c839d652e2a75c790 Mon Sep 17 00:00:00 2001 +From 2789fda4c4ee7aac002f561819f2cec68e838239 Mon Sep 17 00:00:00 2001 From: junpeng-jp -Date: Sun, 29 May 2022 00:12:34 +0800 -Subject: [PATCH 1/9] Update setup.py with new google-auth dependencies +Date: Sat, 6 Aug 2022 21:25:26 +0800 +Subject: [PATCH 01/11] Migrate to Google Auth --- - 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 -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 -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(-) + pydrive2/auth.py | 606 +++++++++--------- + pydrive2/auth_helpers.py | 58 ++ + pydrive2/storage.py | 146 +++++ + pydrive2/test/settings/default_user.yaml | 11 + + .../settings/default_user_no_refresh.yaml | 9 + + pydrive2/test/test_oauth.py | 28 +- + pydrive2/test/test_token_expiry.py | 77 +++ + 7 files changed, 616 insertions(+), 319 deletions(-) create mode 100644 pydrive2/auth_helpers.py + create mode 100644 pydrive2/storage.py + create mode 100644 pydrive2/test/settings/default_user.yaml + create mode 100644 pydrive2/test/settings/default_user_no_refresh.yaml + create mode 100644 pydrive2/test/test_token_expiry.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 @@ +Index: PyDrive2-1.16.2/pydrive2/auth.py +=================================================================== +--- PyDrive2-1.16.2.orig/pydrive2/auth.py ++++ PyDrive2-1.16.2/pydrive2/auth.py +@@ -1,28 +1,27 @@ + import json -import webbrowser import httplib2 -+import json - import oauth2client.clientsecrets as clientsecrets --import threading -+import google.oauth2.credentials -+import google.oauth2.service_account +-import oauth2client.clientsecrets as clientsecrets + import threading 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 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.contrib.dictionary_storage import DictionaryStorage +-from oauth2client.file import Storage +-from oauth2client.tools import ClientRedirectHandler +-from oauth2client.tools import ClientRedirectServer +-from oauth2client._helpers import scopes_to_string +-from .apiattr import ApiAttribute +-from .apiattr import ApiAttributeMixin ++import google.oauth2.credentials ++import google.oauth2.service_account ++from .storage import FileBackend, DictionaryBackend ++ + from .settings import LoadSettingsFile + from .settings import ValidateSettings 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 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.""" +@@ -45,23 +44,16 @@ class RefreshError(AuthError): --def LoadAuth(decoratee): + 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() ++ """ ++ Decorator to check the self.auth & self.http object in a decorated API call. ++ Loads a new GoogleAuth or Http object if needed. ++ """ + + @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": @@ -368,14 +89,31 @@ index 08625e8..d70841e 100644 - # Initialise service if not built yet. - if self.auth.service is None: - self.auth.Authorize() -- -- -- -- return decoratee(self, *args, **kwargs) -- -- return _decorated -- -- + + # Ensure that a thread-safe HTTP object is provided. + if ( +@@ -71,77 +63,20 @@ def LoadAuth(decoratee): + and "http" in kwargs["param"] + and kwargs["param"]["http"] is not None + ): ++ # overwrites the HTTP objects used by the Gdrive API object + 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 ++ # If HTTP object not specified, resuse HTTP from self.auth.thread_local ++ self.http = self.auth.authorized_http + + return decoratee(self, *args, **kwargs) + + return _decorated + + -def CheckServiceAuth(decoratee): - """Decorator to authorize service account.""" - @@ -431,19 +169,15 @@ index 08625e8..d70841e 100644 - return _decorated - - - class GoogleAuth(ApiAttributeMixin): +-class GoogleAuth(ApiAttributeMixin): ++class GoogleAuth: """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"], -- } + Loads all settings and credentials from one 'settings.yaml' file +@@ -155,37 +90,24 @@ class GoogleAuth(ApiAttributeMixin): + "save_credentials": False, + "oauth_scope": ["https://www.googleapis.com/auth/drive"], + } - CLIENT_CONFIGS_LIST = [ - "client_id", - "client_secret", @@ -452,43 +186,57 @@ index 08625e8..d70841e 100644 - "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): +- settings = ApiAttribute("settings") +- client_config = ApiAttribute("client_config") +- flow = ApiAttribute("flow") +- credentials = ApiAttribute("credentials") +- http = ApiAttribute("http") +- service = ApiAttribute("service") +- auth_method = ApiAttribute("auth_method") + + def __init__( + self, settings_file="settings.yaml", http_timeout=None, settings=None + ): + """Create an instance of GoogleAuth. + ++ This constructor parses just the yaml settings file. ++ All other config & auth related objects are lazily loaded (see properties section) ++ + :param settings_file: path of settings file. 'settings.yaml' by default. + :type settings_file: str. + :param settings: settings dict. + :type settings: dict. """ self.http_timeout = http_timeout - ApiAttributeMixin.__init__(self) +- ApiAttributeMixin.__init__(self) + self.thread_local = threading.local() - 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 + if settings is None and settings_file: + try: +@@ -196,21 +118,92 @@ class GoogleAuth(ApiAttributeMixin): + self.settings = settings or self.DEFAULT_SETTINGS + ValidateSettings(self.settings) + +- storages, default = self._InitializeStoragesFromSettings() +- self._storages = storages +- self._default_storage = default ++ self._service = 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 - ++ self._storage_registry = {} ++ self._default_storage = None ++ self._credentials = None ++ ++ @property ++ def service(self): ++ if not self._service: ++ self._service = build("drive", "v2", cache_discovery=False) ++ return self._service ++ + @property + def client_config(self): + if not self._client_config: @@ -508,9 +256,27 @@ index 08625e8..d70841e 100644 + return self._flow + + @property ++ def default_storage(self): ++ if not self.settings.get("save_credentials"): ++ return None ++ ++ if not self._default_storage: ++ self._InitializeStoragesFromSettings() ++ return self._default_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.default_storage: ++ try: ++ self.LoadCredentials() ++ return self._credentials ++ except FileNotFoundError: ++ pass ++ + self.LocalWebserverAuth() + + elif self.oauth_type == "service": @@ -522,8 +288,18 @@ index 08625e8..d70841e 100644 + + return self._credentials + ++ @property ++ def authorized_http(self): ++ # returns a thread-safe, local, cached HTTP object ++ if not getattr(self.thread_local, "http", None): ++ # If HTTP object not available in thread_local, ++ # create and store Authorized Http object in thread_local storage ++ self.thread_local.http = self.Get_Http_Object() ++ ++ return self.thread_local.http ++ + # Other properties - @property ++ @property def access_token_expired(self): """Checks if access token doesn't exist or is expired. @@ -533,14 +309,13 @@ index 08625e8..d70841e 100644 + if not self.credentials: return True - return self.credentials.access_token_expired ++ return not self.credentials.valid - @CheckAuth -+ return not self.credentials.valid -+ def LocalWebserverAuth( - self, host_name="localhost", port_numbers=None, launch_browser=True - ): -@@ -219,27 +185,53 @@ def LocalWebserverAuth( + self, + host_name="localhost", +@@ -238,119 +231,100 @@ class GoogleAuth(ApiAttributeMixin): :raises: AuthenticationRejected, AuthenticationError """ if port_numbers is None: @@ -550,12 +325,6 @@ index 08625e8..d70841e 100644 - ] # 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 = {} @@ -565,58 +334,39 @@ index 08625e8..d70841e 100644 + additional_config["access_type"] = "offline" + additional_config["prompt"] = "select_account" + -+ try: -+ for port in port_numbers: + port_number = 0 + for port in port_numbers: + port_number = port + try: +- httpd = ClientRedirectServer( +- (bind_addr or host_name, port), ClientRedirectHandler + self._credentials = self.flow.run_local_server( -+ host=host_name, -+ port=port, ++ host=bind_addr or host_name, ++ port=port_number, + authorization_prompt_message=_CLIENT_AUTH_PROMPT_MESSAGE, + open_browser=launch_browser, + **additional_config, ++ ) ++ except OSError as e: ++ print( ++ "Port {} is in use. Trying a different port".format(port) ) - except OSError: - pass - else: - success = True -+ # if any port results in successful auth, we're done - break +- 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() +- print( +- "Failed to start a local web server. Please check your firewall" +- ) +- print( +- "settings and locally running programs that may be blocking or" +- ) +- 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: @@ -641,12 +391,49 @@ index 08625e8..d70841e 100644 - raise AuthenticationError("No code found in redirect") - @CheckAuth ++ 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 uri") ++ ++ except OAuth2Error as e: ++ # catch all other oauth 2 errors ++ print("Authentication request was rejected") ++ raise AuthenticationRejected("User rejected authentication") ++ ++ # if any port results in successful auth, we're done ++ if self._credentials: ++ if self.default_storage: ++ self.SaveCredentials() ++ ++ return ++ ++ # If we have tried all ports and could not find a port ++ print("Failed to start a local web server. Please check your firewall") ++ print("settings and locally running programs that may be blocking or") ++ print("using configured ports. Default ports are 8080 and 8090.") ++ raise AuthenticationError("None of the specified ports are available") ++ 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() + + :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() ++ raise DeprecationWarning( ++ "The command line auth has been deprecated. " ++ "The recommended alternative is to use local webserver auth with a loopback address." ++ ) - @CheckServiceAuth def ServiceAuth(self): @@ -657,45 +444,207 @@ index 08625e8..d70841e 100644 - 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") + keyfile_name = self.client_config.get("client_json_file_path") + keyfile_dict = self.client_config.get("client_json_dict") + keyfile_json = self.client_config.get("client_json") + ++ # setting the subject for domain-wide delegation ++ additional_config = {} ++ additional_config["subject"] = self.client_config.get( ++ "client_user_email" ++ ) ++ additional_config["scopes"] = self.settings["oauth_scope"] + - if client_service_json: + if not keyfile_dict and keyfile_json: + # Compensating for missing ServiceAccountCredentials.from_json_keyfile + keyfile_dict = json.loads(keyfile_json) + + if keyfile_dict: +- self.credentials = ( +- ServiceAccountCredentials.from_json_keyfile_dict( +- keyfile_dict=keyfile_dict, scopes=scopes +- ) ++ self._credentials = google.oauth2.service_account.Credentials.from_service_account_info( ++ keyfile_dict, **additional_config + ) + elif keyfile_name: - self.credentials = ( - ServiceAccountCredentials.from_json_keyfile_name( -- filename=client_service_json, scopes=scopes +- filename=keyfile_name, scopes=scopes - ) -+ additional_config = {} -+ additional_config["subject"] = self.client_config.get( -+ "client_user_email" ++ self._credentials = google.oauth2.service_account.Credentials.from_service_account_file( ++ keyfile_name, **additional_config ) -- else: + 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 +- ) ++ raise AuthenticationError("Invalid service credentials") def _InitializeStoragesFromSettings(self): - result = {"file": None} -@@ -463,144 +427,102 @@ def LoadClientConfigFile(self, client_config_file=None): +- result = {"file": None, "dictionary": None} + backend = self.settings.get("save_credentials_backend") + save_credentials = self.settings.get("save_credentials") + if backend == "file": +@@ -359,7 +333,9 @@ class GoogleAuth(ApiAttributeMixin): + raise InvalidConfigError( + "Please specify credentials file to read" + ) +- result[backend] = Storage(credentials_file) ++ ++ self._storage_registry[backend] = FileBackend(credentials_file) ++ + elif backend == "dictionary": + creds_dict = self.settings.get("save_credentials_dict") + if creds_dict is None: +@@ -369,12 +345,14 @@ class GoogleAuth(ApiAttributeMixin): + if creds_key is None: + raise InvalidConfigError("Please specify credentials key") + +- result[backend] = DictionaryStorage(creds_dict, creds_key) ++ self._storage_registry[backend] = DictionaryBackend(creds_dict) ++ + elif save_credentials: + raise InvalidConfigError( + "Unknown save_credentials_backend: %s" % backend + ) +- return result, result.get(backend) ++ ++ self._default_storage = self._storage_registry.get(backend) + + def LoadCredentials(self, backend=None): + """Loads credentials or create empty credentials if it doesn't exist. +@@ -404,7 +382,8 @@ class GoogleAuth(ApiAttributeMixin): + :raises: InvalidConfigError, InvalidCredentialsError + """ + if credentials_file is None: +- self._default_storage = self._storages["file"] ++ self._default_storage = self._storage_registry["file"] ++ credentials_file = self.settings.get("save_credentials_file") + if self._default_storage is None: + raise InvalidConfigError( + "Backend `file` is not configured, specify " +@@ -412,30 +391,49 @@ class GoogleAuth(ApiAttributeMixin): + "file or pass an explicit value" + ) + else: +- self._default_storage = Storage(credentials_file) ++ self._default_storage = FileBackend(credentials_file) + + try: +- self.credentials = self._default_storage.get() ++ auth_info = self.default_storage.read_credentials() ++ except FileNotFoundError: ++ # if credential 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: ++ 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"), ++ ) + + def _LoadCredentialsDictionary(self): +- self._default_storage = self._storages["dictionary"] ++ self._default_storage = self._storage_registry["dictionary"] + if self._default_storage is None: + raise InvalidConfigError( + "Backend `dictionary` is not configured, specify " + "credentials dict and key to read in the settings file" + ) + +- self.credentials = self._default_storage.get() ++ creds_key = self.settings.get("save_credentials_key") + +- if self.credentials: +- self.credentials.set_store(self._default_storage) ++ self._credentials = self.default_storage.read_credentials(creds_key) + + def SaveCredentials(self, backend=None): + """Saves credentials according to specified backend. +@@ -465,39 +463,39 @@ class GoogleAuth(ApiAttributeMixin): + :type credentials_file: str. + :raises: InvalidConfigError, InvalidCredentialsError + """ +- if self.credentials is None: ++ if self._credentials is None: + 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) ++ ++ storage = self._storage_registry["file"] + + try: +- storage.put(self.credentials) ++ storage.store_credentials(self._credentials) ++ + except OSError: + raise InvalidCredentialsError( + "Credentials file cannot be symbolic link" + ) + + def _SaveCredentialsDictionary(self): +- if self.credentials is None: ++ if self._credentials is None: + raise InvalidCredentialsError("No credentials to save") + +- storage = self._storages["dictionary"] ++ storage = self._storage_registry["dictionary"] + if storage is None: + raise InvalidConfigError( + "Backend `dictionary` is not configured, specify " + "credentials dict and key to write in the settings file" + ) + +- storage.put(self.credentials) ++ creds_key = self.settings.get("save_credentials_key") ++ storage.store_credentials(self._credentials, creds_key) + + def LoadClientConfig(self, backend=None): + """Loads client configuration according to specified backend. +@@ -535,45 +533,18 @@ class GoogleAuth(ApiAttributeMixin): """ if client_config_file is None: client_config_file = self.settings["client_config_file"] @@ -730,12 +679,11 @@ index 08625e8..d70841e 100644 - ] - 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) -+ + +- # Service auth related fields. +- service_auth_config = ["client_email"] try: - for config in service_auth_config: - self.client_config[config] = client_info[config] @@ -743,51 +691,55 @@ index 08625e8..d70841e 100644 - 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) ++ except ValueError as error: ++ raise InvalidConfigError("Invalid client secrets file: %s" % error) + + 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: + """Loads client configuration from settings. +@@ -585,11 +556,12 @@ class GoogleAuth(ApiAttributeMixin): + "client_json", + "client_pkcs12_file_path", + ] ++ service_config = {} + + for config in configs: + 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: ++ service_config[config] = value + break + else: + raise InvalidConfigError( +@@ -597,91 +569,91 @@ class GoogleAuth(ApiAttributeMixin): + ) + + if config == "client_pkcs12_file_path": +- self.SERVICE_CONFIGS_LIST.append("client_service_email") ++ # see https://github.com/googleapis/google-auth-library-python/issues/288 + 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: + for config in self.SERVICE_CONFIGS_LIST: + try: - self.client_config[config] = self.settings["service_config"][ -- config ++ service_config[config] = self.settings["service_config"].get( + config - ] -- except KeyError: -- err = "Insufficient service config in settings" -- err += f"\n\nMissing: {config} key." -- raise InvalidConfigError(err) ++ ) + 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. @@ -802,7 +754,6 @@ index 08625e8..d70841e 100644 - raise InvalidConfigError( - "Insufficient client config in settings" - ) -+ + try: + client_config = self.settings["client_config"] + except KeyError as e: @@ -816,12 +767,13 @@ index 08625e8..d70841e 100644 + ) + except ValueError as e: + raise InvalidConfigError("Invalid client secrets file: %s" % e) -+ + +- def GetFlow(self): + # 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): ++ ++ def GetFlow(self, scopes=None, **kwargs): """Gets Flow object from client configuration. :raises: InvalidConfigError @@ -847,15 +799,14 @@ index 08625e8..d70841e 100644 - 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 not scopes: ++ 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, ++ scopes=scopes, ++ **kwargs, ) + if self.oauth_type == "service": @@ -865,7 +816,7 @@ index 08625e8..d70841e 100644 + def Refresh(self): """Refreshes the access_token. - +- :raises: RefreshError """ - if self.credentials is None: @@ -878,14 +829,19 @@ index 08625e8..d70841e 100644 - "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) + raise DeprecationWarning( -+ "Refresh is now handled automatically within the new google.auth Credential objects. " -+ "There's no need to manually refresh your credentials now." ++ "Manual refresh is deprecated as the" ++ "new google auth library handles refresh automatically" + ) - def GetAuthUrl(self): +- def GetAuthUrl(self): ++ def GetAuthUrl(self, redirect_uri="http://localhost:8080/"): """Creates authentication url where user visits to grant access. :returns: str -- Authentication url. @@ -898,16 +854,28 @@ index 08625e8..d70841e 100644 + "Authentication is not required for service client type." + ) + ++ self.flow.redirect_uri = redirect_uri ++ + return self.flow.authorization_url() def Auth(self, code): """Authenticate, authorize, and build service. -@@ -619,13 +541,26 @@ def Authenticate(self, code): +@@ -691,7 +663,6 @@ class GoogleAuth(ApiAttributeMixin): + :raises: AuthenticationError + """ + self.Authenticate(code) +- self.Authorize() + + def Authenticate(self, code): + """Authenticates given authentication code back from user. +@@ -700,12 +671,35 @@ class GoogleAuth(ApiAttributeMixin): :type code: str. :raises: AuthenticationError """ - if self.flow is None: - self.GetFlow() ++ from urllib.parse import unquote ++ + if self.oauth_type == "service": + raise AuthenticationError( + "Authentication is not required for service client type." @@ -917,8 +885,7 @@ index 08625e8..d70841e 100644 - 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) ++ self.flow.fetch_token(code=unquote(code)) + + except MissingCodeError as e: + # if code is not found in the redirect uri's query parameters @@ -931,15 +898,53 @@ index 08625e8..d70841e 100644 + except OAuth2Error as e: + # catch oauth 2 errors + print("Authentication request was rejected") -+ raise AuthenticationRejected("User rejected authentication") ++ raise AuthenticationRejected("User rejected authentication") from e ++ ++ self._credentials = self.flow.credentials ++ ++ # save credentials if there's a default storage ++ if self.default_storage: ++ self.SaveCredentials() ++ + print("Authentication successful.") 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 +@@ -728,24 +722,16 @@ class GoogleAuth(ApiAttributeMixin): + + :raises: AuthenticationError + """ +- if self.access_token_expired: +- raise AuthenticationError( +- "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 ++ raise DeprecationWarning( ++ "Manual authorization of HTTP will be deprecated as the" ++ "new google auth library handles the adding to relevant oauth headers automatically" + ) + + def Get_Http_Object(self): +- """Create and authorize an httplib2.Http object. Necessary for +- thread-safety. ++ """ ++ Helper function to get a new Authorized Http object. + :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()) +Index: PyDrive2-1.16.2/pydrive2/auth_helpers.py +=================================================================== --- /dev/null -+++ b/pydrive2/auth_helpers.py ++++ PyDrive2-1.16.2/pydrive2/auth_helpers.py @@ -0,0 +1,58 @@ +_OLD_CLIENT_CONFIG_KEYS = frozenset( + ( @@ -999,355 +1004,15 @@ index 0000000..04e5f67 + 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['']['']. -@@ -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 -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 +Index: PyDrive2-1.16.2/pydrive2/storage.py +=================================================================== --- /dev/null -+++ b/pydrive2/storage.py -@@ -0,0 +1,117 @@ ++++ PyDrive2-1.16.2/pydrive2/storage.py +@@ -0,0 +1,116 @@ +import os +import json +import warnings -+from filelock import FileLock ++import threading + + +_SYM_LINK_MESSAGE = "File: {0}: Is a symbolic link." @@ -1365,170 +1030,146 @@ index 0000000..ad9f772 + + +class CredentialBackend(object): -+ """Adapter that provides a consistent interface to read and write credential files""" ++ """Adapter that provides a consistent interface to read and write credentials""" + -+ def _read_credentials(self, rpath): -+ """Specific implementation of how the storage object should retrieve a file.""" ++ def _read_credentials(self, **kwargs): ++ """Specific implementation of how credentials are retrieved from backend""" + return NotImplementedError + -+ def _store_credentials(self, credential, rpath): -+ """Specific implementation of how the storage object should write a file""" ++ def _store_credentials(self, credential, **kwargs): ++ """Specific implementation of how credentials are written to backend""" + return NotImplementedError + -+ def _delete_credentials(self, rpath): -+ """Specific implementation of how the storage object should delete a file.""" ++ def _delete_credentials(self, **kwargs): ++ """Specific implementation of how credentials are deleted from backend""" + 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 ++ def read_credentials(self, **kwargs): ++ """Reads a credential config from the backend and ++ returns the config as a dictionary ++ :return: A dictionary of the credentials + """ -+ return self._read_credentials(rpath) ++ return self._read_credentials(**kwargs) + -+ 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 store_credentials(self, credential, **kwargs): ++ """Write a credential to the backend""" ++ self._store_credentials(credential, **kwargs) + -+ def delete_credentials(self, rpath): ++ def delete_credentials(self, **kwargs): + """Delete credential. -+ Frees any resources associated with storing the credential. -+ The Storage lock must *not* be held when this is called. -+ -+ Returns: -+ None ++ Frees any resources associated with storing the credential + """ -+ self._delete_credentials(rpath) ++ self._delete_credentials(**kwargs) + + +class FileBackend(CredentialBackend): -+ # https://stackoverflow.com/questions/37084682/is-oauth-thread-safe -+ """Read and write credentials to a file backend with File Locking""" ++ """Read and write credential to a specific file backend with Thread-locking""" + -+ def __init__(self): -+ self._locks = {} ++ def __init__(self, filename): ++ self._filename = filename ++ self._thread_lock = threading.Lock() + -+ 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): ++ def _create_file_if_needed(self, filename): + """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): ++ if not os.path.exists(filename): + old_umask = os.umask(0o177) + try: -+ open(rpath, "a+b").close() ++ open(filename, "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: ++ def _read_credentials(self, **kwargs): ++ """Reads a local json file and parses the information into a info dictionary.""" ++ with self._thread_lock: ++ validate_file(self._filename) ++ with open(self._filename, "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) ++ def _store_credentials(self, credentials, **kwargs): ++ """Writes current credentials to a local json file.""" ++ with self._thread_lock: ++ # write new credentials to the temp file ++ dirname, filename = os.path.split(self._filename) ++ temp_path = os.path.join(dirname, "temp_{}".format(filename)) ++ self._create_file_if_needed(temp_path) + -+ with open(rpath, "w") as json_file: ++ with open(temp_path, "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 -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 ++ # replace the existing credential file ++ os.replace(temp_path, self._filename) ++ ++ def _delete_credentials(self, **kwargs): ++ """Delete credentials file.""" ++ with self._thread_lock: ++ os.unlink(self._filename) ++ ++ ++class DictionaryBackend(CredentialBackend): ++ """Read and write credentials to a dictionary backend""" ++ ++ def __init__(self, dictionary): ++ self._dictionary = dictionary ++ ++ def _read_credentials(self, key): ++ """Reads a local json file and parses the information into a info dictionary.""" ++ return self._dictionary.get(key) ++ ++ def _store_credentials(self, credentials, key): ++ """Writes current credentials to a local json file.""" ++ self._dictionary[key] = credentials.to_json() ++ ++ def _delete_credentials(self, key): ++ """Delete Credentials file.""" ++ self._dictionary.pop(key, None) +Index: PyDrive2-1.16.2/pydrive2/test/settings/default_user.yaml +=================================================================== +--- /dev/null ++++ PyDrive2-1.16.2/pydrive2/test/settings/default_user.yaml +@@ -0,0 +1,11 @@ ++client_config_backend: file ++client_config_file: /tmp/pydrive2/user.json ++ ++save_credentials: True ++save_credentials_backend: file ++save_credentials_file: credentials/default_user.dat ++ ++oauth_scope: ++ - https://www.googleapis.com/auth/drive ++ ++get_refresh_token: True +\ No newline at end of file +Index: PyDrive2-1.16.2/pydrive2/test/settings/default_user_no_refresh.yaml +=================================================================== +--- /dev/null ++++ PyDrive2-1.16.2/pydrive2/test/settings/default_user_no_refresh.yaml +@@ -0,0 +1,9 @@ ++client_config_backend: file ++client_config_file: /tmp/pydrive2/user.json ++ ++save_credentials: True ++save_credentials_backend: file ++save_credentials_file: credentials/default_user_no_refresh.dat ++ ++oauth_scope: ++ - https://www.googleapis.com/auth/drive +Index: PyDrive2-1.16.2/pydrive2/test/test_oauth.py +=================================================================== +--- PyDrive2-1.16.2.orig/pydrive2/test/test_oauth.py ++++ PyDrive2-1.16.2/pydrive2/test/test_oauth.py +@@ -10,7 +10,7 @@ from pydrive2.test.test_util import ( + settings_file_path, + GDRIVE_USER_CREDENTIALS_DATA, + ) +-from oauth2client.file import Storage ++from ..storage import FileBackend - 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(): + def setup_module(module): +@@ -24,6 +24,7 @@ def test_01_LocalWebserverAuthWithClient # Test if authentication works with config read from file ga = GoogleAuth(settings_file_path("test_oauth_test_01.yaml")) ga.LocalWebserverAuth() @@ -1536,7 +1177,7 @@ index d876661..b57b2bb 100644 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(): +@@ -37,6 +38,7 @@ def test_02_LocalWebserverAuthWithClient # Test if authentication works with config read from settings ga = GoogleAuth(settings_file_path("test_oauth_test_02.yaml")) ga.LocalWebserverAuth() @@ -1544,7 +1185,7 @@ index d876661..b57b2bb 100644 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(): +@@ -50,6 +52,7 @@ def test_03_LocalWebServerAuthWithNoCred ga = GoogleAuth(settings_file_path("test_oauth_test_03.yaml")) assert not ga.settings["save_credentials"] ga.LocalWebserverAuth() @@ -1552,7 +1193,7 @@ index d876661..b57b2bb 100644 assert not ga.access_token_expired time.sleep(1) -@@ -59,6 +62,7 @@ def test_04_CommandLineAuthWithClientConfigFromFile(): +@@ -61,6 +64,7 @@ def test_04_CommandLineAuthWithClientCon # Test if authentication works with config read from file ga = GoogleAuth(settings_file_path("test_oauth_test_04.yaml")) ga.CommandLineAuth() @@ -1560,7 +1201,7 @@ index d876661..b57b2bb 100644 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(): +@@ -72,6 +76,7 @@ def test_05_ConfigFromSettingsWithoutOau # Test if authentication works without oauth_scope ga = GoogleAuth(settings_file_path("test_oauth_test_05.yaml")) ga.LocalWebserverAuth() @@ -1568,21 +1209,27 @@ index d876661..b57b2bb 100644 assert not ga.access_token_expired time.sleep(1) -@@ -79,6 +84,7 @@ def test_06_ServiceAuthFromSavedCredentialsP12File(): +@@ -81,7 +86,7 @@ def test_06_ServiceAuthFromSavedCredenti setup_credentials("credentials/6.dat") ga = GoogleAuth(settings_file_path("test_oauth_test_06.yaml")) ga.ServiceAuth() +- assert not ga.access_token_expired + assert ga.credentials - assert not ga.access_token_expired time.sleep(1) -@@ -91,12 +97,9 @@ def test_07_ServiceAuthFromSavedCredentialsJsonFile(): + +@@ -92,13 +97,14 @@ def test_07_ServiceAuthFromSavedCredenti + # Delete old credentials file delete_file(credentials_file) assert not os.path.exists(credentials_file) ++ # For Service Auth, credentials are created with no token (treated as expired) ++ # JWT token is not populated until the first request where auto-refresh happens ++ # assert not ga.access_token_expired ga.ServiceAuth() - assert os.path.exists(credentials_file) - # Secondary auth should be made only using the previously saved - # login info ++ assert ga.credentials ga = GoogleAuth(settings_file_path("test_oauth_test_07.yaml")) ga.ServiceAuth() - assert not ga.access_token_expired @@ -1590,204 +1237,184 @@ index d876661..b57b2bb 100644 time.sleep(1) -@@ -120,8 +123,6 @@ def test_09_SaveLoadCredentialsUsesDefaultStorage(mocker): - assert not os.path.exists(credentials_file) - spy = mocker.spy(Storage, "__init__") +@@ -108,6 +114,7 @@ def test_08_ServiceAuthFromJsonFileNoCre + ga = GoogleAuth(settings_file_path("test_oauth_test_08.yaml")) + assert not ga.settings["save_credentials"] ga.ServiceAuth() -- ga.LoadCredentials() -- ga.SaveCredentials() - assert spy.call_count == 0 - - - -From 2a688e28b15cb310ea39faac4375029c62a574ef Mon Sep 17 00:00:00 2001 -From: junpeng-jp -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 -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 -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 -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 + time.sleep(1) - def setup_module(module): -@@ -121,7 +121,7 @@ def test_09_SaveLoadCredentialsUsesDefaultStorage(mocker): +@@ -120,10 +127,9 @@ def test_09_SaveLoadCredentialsUsesDefau # 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() +- ga.LoadCredentials() +- ga.SaveCredentials() ++ assert ga.credentials assert spy.call_count == 0 + +@@ -142,14 +148,14 @@ def test_10_ServiceAuthFromSavedCredenti + } + ga = GoogleAuth(settings=settings) + ga.ServiceAuth() +- assert not ga.access_token_expired ++ assert ga.credentials + assert creds_dict + first_creds_dict = creds_dict.copy() + # Secondary auth should be made only using the previously saved + # login info + ga = GoogleAuth(settings=settings) + ga.ServiceAuth() +- assert not ga.access_token_expired ++ assert ga.credentials + assert creds_dict == first_creds_dict + time.sleep(1) + +Index: PyDrive2-1.16.2/pydrive2/test/test_token_expiry.py +=================================================================== +--- /dev/null ++++ PyDrive2-1.16.2/pydrive2/test/test_token_expiry.py +@@ -0,0 +1,77 @@ ++import pytest ++from pydrive2.auth import GoogleAuth ++from pydrive2.drive import GoogleDrive ++from pydrive2.test.test_util import ( ++ settings_file_path, ++ setup_credentials, ++ pydrive_retry, ++ delete_file, ++) ++from google.auth.exceptions import RefreshError ++ ++ ++@pytest.fixture ++def googleauth_refresh(): ++ setup_credentials() ++ # Delete old credentials file ++ delete_file("credentials/default_user.dat") ++ ga = GoogleAuth(settings_file_path("default_user.yaml")) ++ ga.LocalWebserverAuth() ++ ++ return ga ++ ++ ++@pytest.fixture ++def googleauth_no_refresh(): ++ setup_credentials() ++ # Delete old credentials file ++ delete_file("credentials/default_user_no_refresh.dat") ++ ga = GoogleAuth(settings_file_path("default_user_no_refresh.yaml")) ++ ga.LocalWebserverAuth() ++ ++ return ga ++ ++ ++@pytest.mark.manual ++def test_01_TokenExpiryWithRefreshToken(googleauth_refresh): ++ gdrive = GoogleDrive(googleauth_refresh) ++ ++ about_object = pydrive_retry(gdrive.GetAbout) ++ assert about_object is not None ++ ++ # save the first access token for comparison ++ token1 = gdrive.auth.credentials.token ++ ++ # simulate token expiry by deleting the underlying token ++ gdrive.auth.credentials.token = None ++ ++ # credential object should still exist but access token expired ++ assert gdrive.auth.credentials ++ assert gdrive.auth.access_token_expired ++ ++ about_object = pydrive_retry(gdrive.GetAbout) ++ assert about_object is not None ++ ++ # save the second access token for comparison ++ token2 = gdrive.auth.credentials.token ++ ++ assert token1 != token2 ++ ++ ++@pytest.mark.manual ++def test_02_TokenExpiryWithoutRefreshToken(googleauth_no_refresh): ++ gdrive = GoogleDrive(googleauth_no_refresh) ++ ++ about_object = pydrive_retry(gdrive.GetAbout) ++ assert about_object is not None ++ ++ # simulate token expiry by deleting the underlying token ++ gdrive.auth.credentials.token = None ++ ++ # credential object should still exist but access token expired ++ assert gdrive.auth.credentials ++ assert gdrive.auth.access_token_expired ++ ++ # as credentials have no refresh token, this would fail ++ with pytest.raises(RefreshError) as e_info: ++ about_object = pydrive_retry(gdrive.GetAbout) +Index: PyDrive2-1.16.2/setup.py +=================================================================== +--- PyDrive2-1.16.2.orig/setup.py ++++ PyDrive2-1.16.2/setup.py +@@ -37,7 +37,8 @@ setup( + long_description_content_type="text/x-rst", + install_requires=[ + "google-api-python-client >= 1.12.5", +- "oauth2client >= 4.0.0", ++ "google-auth", ++ "google-auth-oauthlib", + "PyYAML >= 3.0", + "pyOpenSSL >= 19.1.0", + ], +Index: PyDrive2-1.16.2/pydrive2/test/test_oauth_custom.py +=================================================================== +--- /dev/null ++++ PyDrive2-1.16.2/pydrive2/test/test_oauth_custom.py +@@ -0,0 +1,42 @@ ++import pytest ++import os ++from pydrive2.auth import GoogleAuth ++from pydrive2.drive import GoogleDrive ++from pydrive2.test.test_util import ( ++ settings_file_path, ++ setup_credentials, ++ delete_file, ++) ++ ++ ++@pytest.fixture ++def googleauth_preauth(): ++ setup_credentials() ++ # Delete old credentials file ++ delete_file("credentials/default_user.dat") ++ ga = GoogleAuth(settings_file_path("default_user.yaml")) ++ ++ return ga ++ ++ ++@pytest.mark.manual ++def test_01_CustomAuthWithSavingOfCredentials(googleauth_preauth): ++ ++ credentials_file = googleauth_preauth.settings["save_credentials_file"] ++ ++ assert not os.path.exists(credentials_file) ++ ++ auth_url, state = googleauth_preauth.GetAuthUrl() ++ print("please visit this url: {}".format(auth_url)) ++ ++ googleauth_preauth.Authenticate(input("Please enter the auth code: ")) ++ ++ # credentials have been loaded ++ assert googleauth_preauth.credentials ++ # check that credentials file has been saved ++ assert os.path.exists(credentials_file) ++ ++ gdrive = GoogleDrive(googleauth_preauth) ++ ++ about_object = gdrive.GetAbout() ++ assert about_object is not None diff --git a/modernize.patch b/modernize.patch deleted file mode 100644 index 35c83a2..0000000 --- a/modernize.patch +++ /dev/null @@ -1,353 +0,0 @@ -From 2e43e4561d965ce78dc158a02fbdb75ba6c38105 Mon Sep 17 00:00:00 2001 -From: Ruslan Kuprieiev -Date: Wed, 27 Apr 2022 15:31:53 +0300 -Subject: [PATCH] pydrive2: pyupgrade --py37-plus - ---- - pydrive2/apiattr.py | 15 ++++++--------- - pydrive2/auth.py | 14 ++++++-------- - pydrive2/drive.py | 2 +- - pydrive2/files.py | 10 ++++------ - pydrive2/settings.py | 8 +++----- - pydrive2/test/test_drive.py | 1 - - pydrive2/test/test_file.py | 18 ++++++++---------- - pydrive2/test/test_filelist.py | 7 +++---- - setup.py | 4 +--- - 9 files changed, 32 insertions(+), 47 deletions(-) - -diff --git a/pydrive2/apiattr.py b/pydrive2/apiattr.py -index 46845b1..6b66b42 100644 ---- a/pydrive2/apiattr.py -+++ b/pydrive2/apiattr.py -@@ -1,7 +1,4 @@ --from six import Iterator, iteritems -- -- --class ApiAttribute(object): -+class ApiAttribute: - """A data descriptor that sets and returns values.""" - - def __init__(self, name): -@@ -32,7 +29,7 @@ def __del__(self, obj=None): - del obj.dirty[self.name] - - --class ApiAttributeMixin(object): -+class ApiAttributeMixin: - """Mixin to initialize required global variables to use ApiAttribute.""" - - def __init__(self): -@@ -54,7 +51,7 @@ class ApiResource(dict): - - def __init__(self, *args, **kwargs): - """Create an instance of ApiResource.""" -- super(ApiResource, self).__init__() -+ super().__init__() - self.update(*args, **kwargs) - self.metadata = dict(self) - -@@ -79,11 +76,11 @@ def __setitem__(self, key, val): - def __repr__(self): - """Overwritten method of dictionary.""" - dict_representation = dict.__repr__(self) -- return "%s(%s)" % (type(self).__name__, dict_representation) -+ return f"{type(self).__name__}({dict_representation})" - - def update(self, *args, **kwargs): - """Overwritten method of dictionary.""" -- for k, v in iteritems(dict(*args, **kwargs)): -+ for k, v in dict(*args, **kwargs).items(): - self[k] = v - - def UpdateMetadata(self, metadata=None): -@@ -106,7 +103,7 @@ def GetChanges(self): - return dirty - - --class ApiResourceList(ApiAttributeMixin, ApiResource, Iterator): -+class ApiResourceList(ApiAttributeMixin, ApiResource): - """Abstract class of all api list resources. - - Inherits ApiResource and builds iterator to list any API resource. -diff --git a/pydrive2/auth.py b/pydrive2/auth.py -index 644befb..a1b8b2f 100644 ---- a/pydrive2/auth.py -+++ b/pydrive2/auth.py -@@ -1,8 +1,6 @@ --import socket - import webbrowser - import httplib2 - import oauth2client.clientsecrets as clientsecrets --from six.moves import input - import threading - - from googleapiclient.discovery import build -@@ -141,7 +139,7 @@ def _decorated(self, *args, **kwargs): - return _decorated - - --class GoogleAuth(ApiAttributeMixin, object): -+class GoogleAuth(ApiAttributeMixin): - """Wrapper class for oauth2client library in google-api-python-client. - - Loads all settings and credentials from one 'settings.yaml' file -@@ -241,13 +239,13 @@ def LocalWebserverAuth( - httpd = ClientRedirectServer( - (host_name, port), ClientRedirectHandler - ) -- except socket.error: -+ except OSError: - pass - else: - success = True - break - if success: -- oauth_callback = "http://%s:%s/" % (host_name, port_number) -+ oauth_callback = f"http://{host_name}:{port_number}/" - else: - print( - "Failed to start a local web server. Please check your firewall" -@@ -381,7 +379,7 @@ def LoadCredentialsFile(self, credentials_file=None): - - try: - self.credentials = self._default_storage.get() -- except IOError: -+ except OSError: - raise InvalidCredentialsError( - "Credentials file cannot be symbolic link" - ) -@@ -431,7 +429,7 @@ def SaveCredentialsFile(self, credentials_file=None): - - try: - storage.put(self.credentials) -- except IOError: -+ except OSError: - raise InvalidCredentialsError( - "Credentials file cannot be symbolic link" - ) -@@ -538,7 +536,7 @@ def LoadServiceConfigSettings(self): - ] - except KeyError: - err = "Insufficient service config in settings" -- err += "\n\nMissing: {} key.".format(config) -+ err += f"\n\nMissing: {config} key." - raise InvalidConfigError(err) - - def LoadClientConfigSettings(self): -diff --git a/pydrive2/drive.py b/pydrive2/drive.py -index d8b26aa..8189ab1 100644 ---- a/pydrive2/drive.py -+++ b/pydrive2/drive.py -@@ -4,7 +4,7 @@ - from .auth import LoadAuth - - --class GoogleDrive(ApiAttributeMixin, object): -+class GoogleDrive(ApiAttributeMixin): - """Main Google Drive class.""" - - def __init__(self, auth=None): -diff --git a/pydrive2/files.py b/pydrive2/files.py -index 096f9bb..4716c7d 100644 ---- a/pydrive2/files.py -+++ b/pydrive2/files.py -@@ -17,9 +17,7 @@ - BLOCK_SIZE = 1024 - # Usage: MIME_TYPE_TO_BOM['']['']. - MIME_TYPE_TO_BOM = { -- "application/vnd.google-apps.document": { -- "text/plain": "\ufeff".encode("utf8") -- } -+ "application/vnd.google-apps.document": {"text/plain": "\ufeff".encode()} - } - - -@@ -68,7 +66,7 @@ class GoogleDriveFileList(ApiResourceList): - - def __init__(self, auth=None, param=None): - """Create an instance of GoogleDriveFileList.""" -- super(GoogleDriveFileList, self).__init__(auth=auth, metadata=param) -+ super().__init__(auth=auth, metadata=param) - - @LoadAuth - def _GetList(self): -@@ -98,7 +96,7 @@ def _GetList(self): - return result - - --class IoBuffer(object): -+class IoBuffer: - """Lightweight retention of one chunk.""" - - def __init__(self, encoding): -@@ -116,7 +114,7 @@ def read(self): - ) - - --class MediaIoReadable(object): -+class MediaIoReadable: - def __init__( - self, - request, -diff --git a/pydrive2/settings.py b/pydrive2/settings.py -index 8e79881..f5e84ab 100644 ---- a/pydrive2/settings.py -+++ b/pydrive2/settings.py -@@ -103,9 +103,9 @@ def LoadSettingsFile(filename=SETTINGS_FILE): - :raises: SettingsError - """ - try: -- with open(filename, "r") as stream: -+ with open(filename) as stream: - data = load(stream, Loader=Loader) -- except (YAMLError, IOError) as e: -+ except (YAMLError, OSError) as e: - raise SettingsError(e) - return data - -@@ -158,9 +158,7 @@ def _ValidateSettingsElement(data, struct, key): - data[key] = default - # If data exists, Check type of the data - elif type(value) is not data_type: -- raise InvalidConfigError( -- "Setting %s should be type %s" % (key, data_type) -- ) -+ raise InvalidConfigError(f"Setting {key} should be type {data_type}") - # If type of this data is dict, check if structure of the data is valid. - if data_type is dict: - _ValidateSettingsStruct(data[key], struct[key]["struct"]) -diff --git a/pydrive2/test/test_drive.py b/pydrive2/test/test_drive.py -index 23b58a6..81498a8 100644 ---- a/pydrive2/test/test_drive.py -+++ b/pydrive2/test/test_drive.py -@@ -1,4 +1,3 @@ --# -*- coding: utf-8 -*- - import unittest - - from pydrive2.auth import GoogleAuth -diff --git a/pydrive2/test/test_file.py b/pydrive2/test/test_file.py -index e83898c..bf3021a 100644 ---- a/pydrive2/test/test_file.py -+++ b/pydrive2/test/test_file.py -@@ -1,4 +1,3 @@ --# -*- coding: utf-8 -*- - import filecmp - import os - import unittest -@@ -8,7 +7,6 @@ - from tempfile import mkdtemp - from time import time - --from six.moves import range - import timeout_decorator - from concurrent.futures import ThreadPoolExecutor, as_completed - from googleapiclient import errors -@@ -699,22 +697,22 @@ def test_Gfile_Conversion_Add_Remove_BOM(self): - - def test_InsertPrefix(self): - # Create BytesIO. -- file_obj = BytesIO("abc".encode("utf8")) -+ file_obj = BytesIO(b"abc") - original_length = len(file_obj.getvalue()) -- char_to_insert = "\ufeff".encode("utf8") -+ char_to_insert = "\ufeff".encode() - - # Insert the prefix. - GoogleDriveFile._InsertPrefix(file_obj, char_to_insert) - modified_length = len(file_obj.getvalue()) - self.assertGreater(modified_length, original_length) -- self.assertEqual(file_obj.getvalue(), "\ufeffabc".encode("utf8")) -+ self.assertEqual(file_obj.getvalue(), "\ufeffabc".encode()) - - def test_InsertPrefixLarge(self): - # Create BytesIO. - test_content = "abc" * 800 - file_obj = BytesIO(test_content.encode("utf-8")) - original_length = len(file_obj.getvalue()) -- char_to_insert = "\ufeff".encode("utf8") -+ char_to_insert = "\ufeff".encode() - - # Insert the prefix. - GoogleDriveFile._InsertPrefix(file_obj, char_to_insert) -@@ -725,22 +723,22 @@ def test_InsertPrefixLarge(self): - - def test_RemovePrefix(self): - # Create BytesIO. -- file_obj = BytesIO("\ufeffabc".encode("utf8")) -+ file_obj = BytesIO("\ufeffabc".encode()) - original_length = len(file_obj.getvalue()) -- char_to_remove = "\ufeff".encode("utf8") -+ char_to_remove = "\ufeff".encode() - - # Insert the prefix. - GoogleDriveFile._RemovePrefix(file_obj, char_to_remove) - modified_length = len(file_obj.getvalue()) - self.assertLess(modified_length, original_length) -- self.assertEqual(file_obj.getvalue(), "abc".encode("utf8")) -+ self.assertEqual(file_obj.getvalue(), b"abc") - - def test_RemovePrefixLarge(self): - # Create BytesIO. - test_content = "\ufeff" + "abc" * 800 - file_obj = BytesIO(test_content.encode("utf8")) - original_length = len(file_obj.getvalue()) -- char_to_remove = "\ufeff".encode("utf8") -+ char_to_remove = "\ufeff".encode() - - # Insert the prefix. - GoogleDriveFile._RemovePrefix(file_obj, char_to_remove) -diff --git a/pydrive2/test/test_filelist.py b/pydrive2/test/test_filelist.py -index 644c13f..15918cd 100644 ---- a/pydrive2/test/test_filelist.py -+++ b/pydrive2/test/test_filelist.py -@@ -1,4 +1,3 @@ --# -*- coding: utf-8 -*- - import os - import unittest - -@@ -28,7 +27,7 @@ def setup_class(cls): - - def test_01_Files_List_GetList(self): - drive = GoogleDrive(self.ga) -- query = "title = '{}' and trashed = false".format(self.title) -+ query = f"title = '{self.title}' and trashed = false" - for file1 in pydrive_list_item(drive, query): - found = False - for file2 in pydrive_list_item(drive, query): -@@ -38,7 +37,7 @@ def test_01_Files_List_GetList(self): - - def test_02_Files_List_ForLoop(self): - drive = GoogleDrive(self.ga) -- query = "title = '{}' and trashed = false".format(self.title) -+ query = f"title = '{self.title}' and trashed = false" - files = [] - for x in pydrive_list_item( - drive, query, 2 -@@ -84,7 +83,7 @@ def test_File_List_Folders(self): - ) - pydrive_retry(folder1.Upload) - self.file_list.append(folder1) -- query = "title = '{}' and trashed = false".format(self.title) -+ query = f"title = '{self.title}' and trashed = false" - count = 0 - for file1 in pydrive_list_item(drive, query): - self.assertFileInFileList(file1) -diff --git a/setup.py b/setup.py -index 32c9364..06982d0 100644 ---- a/setup.py -+++ b/setup.py -@@ -1,4 +1,3 @@ --import sys - from setuptools import setup - - # Extra dependecies to run tests -@@ -11,8 +10,7 @@ - "pytest-mock", - ] - --if sys.version_info >= (3, 6): -- tests_requirements.append("black==22.3.0") -+tests_requirements.append("black==22.3.0") - - setup( - name="PyDrive2", diff --git a/python-pydrive2.changes b/python-pydrive2.changes index ff83cc2..408bfd3 100644 --- a/python-pydrive2.changes +++ b/python-pydrive2.changes @@ -1,3 +1,22 @@ +------------------------------------------------------------------- +Thu Dec 7 09:17:01 UTC 2023 - Markéta Machová + +- Update to 1.16.2 (bsc#1217858, CVE-2023-49297) + * auth: add dictionary storage + * auth: rename client_creds_dict -> client_json_dict + * fs: simplify auth + * fs: hide gdrive_* methods + * fs: add acknowledge_abuse parameter + * remove six + * Implement mv method in GDriveFileSystem + * fs: use itertools.chain.from_iterable instead of funcy.py3.cat + * add bind_addr parameter to LocalWebserverAuth + * drop Python 3.7 support + * Merge pull request from GHSA-v5f6-hjmf-9mc5 +- Drop merged modernize.patch +- Rebase migrate-to-google-auth.patch + * pr#180 was closed in favor of pr#221, which was closed as stale + ------------------------------------------------------------------- Fri Jun 3 11:11:45 UTC 2022 - Markéta Machová diff --git a/python-pydrive2.spec b/python-pydrive2.spec index b39f7b2..8fab9bc 100644 --- a/python-pydrive2.spec +++ b/python-pydrive2.spec @@ -1,7 +1,7 @@ # # spec file for package python-pydrive2 # -# Copyright (c) 2022 SUSE LLC +# Copyright (c) 2023 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -16,17 +16,14 @@ # -%{?!python_module:%define python_module() python-%{**} python3-%{**}} Name: python-pydrive2 -Version: 1.10.1 +Version: 1.16.2 Release: 0 Summary: A wrapper library for google-api-python-client License: Apache-2.0 URL: https://github.com/iterative/PyDrive2 Source: https://files.pythonhosted.org/packages/source/P/PyDrive2/PyDrive2-%{version}.tar.gz -# PATCH-FIX-UPSTREAM https://github.com/iterative/PyDrive2/commit/2e43e4561d965ce78dc158a02fbdb75ba6c38105 pydrive2: modernise to python 3.7+ -Patch0: modernize.patch -# PATCH-FIX-UPSTREAM https://github.com/iterative/PyDrive2/pull/180 Migrating to Google Auth Library +# PATCH-FIX-UPSTREAM https://github.com/iterative/PyDrive2/pull/221 Migrating to Google Auth Library Patch1: migrate-to-google-auth.patch BuildRequires: %{python_module setuptools} BuildRequires: python-rpm-macros @@ -35,30 +32,25 @@ BuildRequires: python-rpm-macros #BuildRequires: %{python_module google-api-python-client >= 1.12.5} #BuildRequires: %{python_module PyYAML >= 3.0} #BuildRequires: %{python_module black} -BuildRequires: %{python_module filelock >= 3.7.0} #BuildRequires: %{python_module flake8-docstrings} #BuildRequires: %{python_module flake8} #BuildRequires: %{python_module fsspec} -#BuildRequires: %{python_module funcy >= 1.14} BuildRequires: %{python_module google-auth-oauthlib >= 0.5.1} BuildRequires: %{python_module google-auth >= 2.6.6} BuildRequires: %{python_module google-auth-httplib2 >= 0.1.0} #BuildRequires: %{python_module pyOpenSSL >= 19.1.0} #BuildRequires: %{python_module pytest-mock} #BuildRequires: %{python_module pytest} -#BuildRequires: %{python_module six >= 1.13.0} #BuildRequires: %{python_module timeout-decorator} #BuildRequires: %{python_module tqdm} # /SECTION BuildRequires: fdupes Requires: python-PyYAML >= 3.0 -Requires: python-filelock >= 3.7.0 Requires: python-google-api-python-client >= 1.12.5 Requires: python-google-auth >= 2.6.6 Requires: python-google-auth-httplib2 >= 0.1.0 Requires: python-google-auth-oauthlib >= 0.5.1 Requires: python-pyOpenSSL >= 19.1.0 -Requires: python-six >= 1.13.0 BuildArch: noarch %python_subpackages @@ -84,6 +76,7 @@ By the authors and maintainers of the Git for Data - DVC project. %files %{python_files} %doc CHANGES README.rst %license LICENSE -%{python_sitelib}/* +%{python_sitelib}/PyDrive2*-info +%{python_sitelib}/pydrive2 %changelog