diff --git a/.gitignore b/.gitignore
index efed6ed..4509144 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
*.pyo
+*.pyc
.project
.pydevproject
\ No newline at end of file
diff --git a/README.txt b/README.txt
index d8bbedf..2b7a62d 100644
--- a/README.txt
+++ b/README.txt
@@ -47,6 +47,11 @@ Using Dropbox as a storage target adds a few steps the first time you wish to ru
Once you have your app key and secret add them to the settings. Kodi Backup now needs to have permission to access your Dropbox account. When you see the prompt regarding the Dropbox URL Authorization DO NOT click OK. Check your Kodi log file for a line from "script.xbmcbackup" containing the authorization URL. Cut/paste this into a browser and click Allow. Once this is done you can click "OK" in Kodi and proceed as normal. Kodi Backup will cache the authorization code so you only have to do this once, or if you revoke the Dropbox permissions.
+Using Google Drive:
+
+Using the Google Drive target is very similar to the Dropbox one. You must create a Google API project and authenticate your account via the id and secret. Instructions for creating the Google API for Google Drive can be found here. You'll need the client id and client secret generated for the addon settings (https://developers.google.com/drive/web/quickstart/quickstart-python). You only need to follow Step 1.
+
+Once you have the client ID and Secret add them to the addon settings and run a backup. You'll get a notification that you need to enter your authorization code. Check your XBMC log file for a line from "script.xbmcbackup" containing the authorization URL. Cut/paste this into a browser and click Allow. Once this is done put the code from your browser into the pop-up dialog. The addon will cache these credentials so it should be a one-time authenication. From there the backup should start to run.
Scripting A Backup:
diff --git a/addon.xml b/addon.xml
index 56424b7..9fc63e2 100644
--- a/addon.xml
+++ b/addon.xml
@@ -3,6 +3,11 @@
name="Backup" version="1.0.0" provider-name="robweber">
+
+
+
+
+
executable
diff --git a/resources/language/English/strings.xml b/resources/language/English/strings.xml
index 90762aa..c839c1b 100644
--- a/resources/language/English/strings.xml
+++ b/resources/language/English/strings.xml
@@ -84,4 +84,5 @@
The destination may not be writeable
Zip archive could not be copied
Not all files were copied
+ Google Drive
diff --git a/resources/lib/backup.py b/resources/lib/backup.py
index 2093df2..b29b080 100644
--- a/resources/lib/backup.py
+++ b/resources/lib/backup.py
@@ -4,7 +4,7 @@ import xbmcvfs
import utils as utils
import time
import json
-from vfs import XBMCFileSystem,DropboxFileSystem,ZipFileSystem
+from vfs import XBMCFileSystem,DropboxFileSystem,ZipFileSystem,GoogleDriveFilesystem
from resources.lib.guisettings import GuiSettingsManager
def folderSort(aKey):
@@ -55,6 +55,9 @@ class XbmcBackup:
elif(utils.getSetting('remote_selection') == '2'):
self.remote_base_path = "/"
self.remote_vfs = DropboxFileSystem("/")
+ elif(utils.getSetting('remote_selection') == '3'):
+ self.remote_base_path = '/XBMC Backup/'
+ self.remote_vfs = GoogleDriveFilesystem('/XBMC Backup/')
def remoteConfigured(self):
result = True
@@ -69,7 +72,7 @@ class XbmcBackup:
#get all the folders in the current root path
dirs,files = self.remote_vfs.listdir(self.remote_base_path)
-
+
for aDir in dirs:
if(self.remote_vfs.exists(self.remote_base_path + aDir + "/xbmcbackup.val")):
@@ -453,8 +456,8 @@ class XbmcBackup:
else:
self._updateProgress()
wroteFile = True
- if(isinstance(source,DropboxFileSystem)):
- #if copying from dropbox we need the file handle, use get_file
+ if(isinstance(source,DropboxFileSystem) or isinstance(source,GoogleDriveFilesystem)):
+ #if copying from cloud storage we need the file handle, use get_file
wroteFile = source.get_file(aFile,dest.root_path + aFile[len(source.root_path):])
else:
#copy using normal method
@@ -495,6 +498,7 @@ class XbmcBackup:
dirs = self.listBackups()
if(len(dirs) > total_backups):
+
#remove backups to equal total wanted
remove_num = 0
self.filesTotal = self.filesTotal + remove_num + 1
diff --git a/resources/lib/pydrive/LICENSE.txt b/resources/lib/pydrive/LICENSE.txt
new file mode 100644
index 0000000..5c87881
--- /dev/null
+++ b/resources/lib/pydrive/LICENSE.txt
@@ -0,0 +1,185 @@
+Copyright 2013 Google Inc. All Rights Reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
\ No newline at end of file
diff --git a/resources/lib/pydrive/__init__.py b/resources/lib/pydrive/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/resources/lib/pydrive/apiattr.py b/resources/lib/pydrive/apiattr.py
new file mode 100644
index 0000000..891956d
--- /dev/null
+++ b/resources/lib/pydrive/apiattr.py
@@ -0,0 +1,173 @@
+class ApiAttribute(object):
+ """A data descriptor that sets and returns values."""
+
+ def __init__(self, name):
+ """Create an instance of ApiAttribute.
+
+ :param name: name of this attribute.
+ :type name: str.
+ """
+ self.name = name
+
+ def __get__(self, obj, type=None):
+ """Accesses value of this attribute."""
+ return obj.attr.get(self.name)
+
+ def __set__(self, obj, value):
+ """Write value of this attribute."""
+ obj.attr[self.name] = value
+ if obj.dirty.get(self.name) is not None:
+ obj.dirty[self.name] = True
+
+ def __del__(self, obj):
+ """Delete value of this attribute."""
+ del obj.attr[self.name]
+ if obj.dirty.get(self.name) is not None:
+ del obj.dirty[self.name]
+
+
+class ApiAttributeMixin(object):
+ """Mixin to initialize required global variables to use ApiAttribute."""
+
+ def __init__(self):
+ self.attr = {}
+ self.dirty = {}
+
+
+class ApiResource(dict):
+ """Super class of all api resources.
+
+ Inherits and behaves as a python dictionary to handle api resources.
+ Save clean copy of metadata in self.metadata as a dictionary.
+ Provides changed metadata elements to efficiently update api resources.
+ """
+ auth = ApiAttribute('auth')
+
+ def __init__(self, *args, **kwargs):
+ """Create an instance of ApiResource."""
+ self.update(*args, **kwargs)
+
+ def __getitem__(self, key):
+ """Overwritten method of dictionary.
+
+ :param key: key of the query.
+ :type key: str.
+ :returns: value of the query.
+ """
+ return dict.__getitem__(self, key)
+
+ def __setitem__(self, key, val):
+ """Overwritten method of dictionary.
+
+ :param key: key of the query.
+ :type key: str.
+ :param val: value of the query.
+ """
+ dict.__setitem__(self, key, val)
+
+ def __repr__(self):
+ """Overwritten method of dictionary."""
+ dictrepr = dict.__repr__(self)
+ return '%s(%s)' % (type(self).__name__, dictrepr)
+
+ def update(self, *args, **kwargs):
+ """Overwritten method of dictionary."""
+ for k, v in dict(*args, **kwargs).iteritems():
+ self[k] = v
+
+ def UpdateMetadata(self, metadata=None):
+ """Update metadata and mark all of them to be clean."""
+ if metadata:
+ self.update(metadata)
+ self.metadata = dict(self)
+
+ def GetChanges(self):
+ """Returns changed metadata elements to update api resources efficiently.
+
+ :returns: dict -- changed metadata elements.
+ """
+ dirty = {}
+ for key in self:
+ if self.metadata.get(key) is None:
+ dirty[key] = self[key]
+ elif self.metadata[key] != self[key]:
+ dirty[key] = self[key]
+ return dirty
+
+
+class ApiResourceList(ApiAttributeMixin, ApiResource):
+ """Abstract class of all api list resources.
+
+ Inherits ApiResource and builds iterator to list any API resource.
+ """
+ metadata = ApiAttribute('metadata')
+
+ def __init__(self, auth=None, metadata=None):
+ """Create an instance of ApiResourceList.
+
+ :param auth: authorized GoogleAuth instance.
+ :type auth: GoogleAuth.
+ :param metadata: parameter to send to list command.
+ :type metadata: dict.
+ """
+ ApiAttributeMixin.__init__(self)
+ ApiResource.__init__(self)
+ self.auth = auth
+ self.UpdateMetadata()
+ if metadata:
+ self.update(metadata)
+
+ def __iter__(self):
+ """Returns iterator object.
+
+ :returns: ApiResourceList -- self
+ """
+ return self
+
+ def next(self):
+ """Make API call to list resources and return them.
+
+ Auto updates 'pageToken' everytime it makes API call and
+ raises StopIteration when it reached the end of iteration.
+
+ :returns: list -- list of API resources.
+ :raises: StopIteration
+ """
+ if 'pageToken' in self and self['pageToken'] is None:
+ raise StopIteration
+ result = self._GetList()
+ self['pageToken'] = self.metadata.get('nextPageToken')
+ return result
+
+ def GetList(self):
+ """Get list of API resources.
+
+ If 'maxResults' is not specified, it will automatically iterate through
+ every resources available. Otherwise, it will make API call once and
+ update 'pageToken'.
+
+ :returns: list -- list of API resources.
+ """
+ if self.get('maxResults') is None:
+ self['maxResults'] = 1000
+ result = []
+ for x in self:
+ result.extend(x)
+ del self['maxResults']
+ return result
+ else:
+ return self.next()
+
+ def _GetList(self):
+ """Helper function which actually makes API call.
+
+ Should be overwritten.
+
+ :raises: NotImplementedError
+ """
+ raise NotImplementedError
+
+ def Reset(self):
+ """Resets current iteration"""
+ if 'pageToken' in self:
+ del self['pageToken']
diff --git a/resources/lib/pydrive/auth.py b/resources/lib/pydrive/auth.py
new file mode 100644
index 0000000..dbc0c51
--- /dev/null
+++ b/resources/lib/pydrive/auth.py
@@ -0,0 +1,432 @@
+import socket
+import webbrowser
+import httplib2
+import oauth2client.clientsecrets as clientsecrets
+
+from apiclient.discovery import build
+from functools import wraps
+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.file import CredentialsFileSymbolicLinkError
+from oauth2client.tools import ClientRedirectHandler
+from oauth2client.tools import ClientRedirectServer
+from oauth2client.util import scopes_to_string
+from .apiattr import ApiAttribute
+from .apiattr import ApiAttributeMixin
+from .settings import LoadSettingsFile
+from .settings import ValidateSettings
+from .settings import SettingsError
+from .settings import InvalidConfigError
+
+
+class AuthError(Exception):
+ """Base error for authentication/authorization errors."""
+
+
+class InvalidCredentialsError(IOError):
+ """Error trying to read credentials file."""
+
+
+class AuthenticationRejected(AuthError):
+ """User rejected authentication."""
+
+
+class AuthenticationError(AuthError):
+ """General authentication error."""
+
+
+class RefreshError(AuthError):
+ """Access token refresh error."""
+
+def LoadAuth(decoratee):
+ """Decorator to check if the auth is valid and loads auth if not."""
+ @wraps(decoratee)
+ def _decorated(self, *args, **kwargs):
+ if self.auth is None: # Initialize auth if needed.
+ self.auth = GoogleAuth()
+ if self.auth.access_token_expired:
+ self.auth.LocalWebserverAuth()
+ if self.auth.service is None: # Check if drive api is built.
+ self.auth.Authorize()
+ return decoratee(self, *args, **kwargs)
+ return _decorated
+
+def CheckAuth(decoratee):
+ """Decorator to check if it requires OAuth2 flow request."""
+ @wraps(decoratee)
+ def _decorated(self, *args, **kwargs):
+ dirty = False
+ code = None
+ save_credentials = self.settings.get('save_credentials')
+ if self.credentials is None and save_credentials:
+ self.LoadCredentials()
+ if self.flow is None:
+ self.GetFlow()
+ if self.credentials is None:
+ code = decoratee(self, *args, **kwargs)
+ dirty = True
+ else:
+ if self.access_token_expired:
+ if self.credentials.refresh_token is not None:
+ self.Refresh()
+ else:
+ code = decoratee(self, *args, **kwargs)
+ dirty = True
+ if code is not None:
+ self.Auth(code)
+ if dirty and save_credentials:
+ self.SaveCredentials()
+ return _decorated
+
+
+class GoogleAuth(ApiAttributeMixin, object):
+ """Wrapper class for oauth2client library in google-api-python-client.
+
+ Loads all settings and credentials from one 'settings.yaml' file
+ and performs common OAuth2.0 related functionality such as authentication
+ and authorization.
+ """
+ DEFAULT_SETTINGS = {
+ 'client_config_backend': 'file',
+ 'client_config_file': 'client_secrets.json',
+ 'save_credentials': False,
+ 'oauth_scope': ['https://www.googleapis.com/auth/drive']
+ }
+ CLIENT_CONFIGS_LIST = ['client_id', 'client_secret', 'auth_uri',
+ 'token_uri', 'revoke_uri', 'redirect_uri']
+ settings = ApiAttribute('settings')
+ client_config = ApiAttribute('client_config')
+ flow = ApiAttribute('flow')
+ credentials = ApiAttribute('credentials')
+ http = ApiAttribute('http')
+ service = ApiAttribute('service')
+
+ def __init__(self, settings_file='settings.yaml'):
+ """Create an instance of GoogleAuth.
+
+ This constructor just sets the path of settings file.
+ It does not actually read the file.
+
+ :param settings_file: path of settings file. 'settings.yaml' by default.
+ :type settings_file: str.
+ """
+ ApiAttributeMixin.__init__(self)
+ self.client_config = {}
+ try:
+ self.settings = LoadSettingsFile(settings_file)
+ except SettingsError:
+ self.settings = self.DEFAULT_SETTINGS
+ else:
+ if self.settings is None:
+ self.settings = self.DEFAULT_SETTINGS
+ else:
+ ValidateSettings(self.settings)
+
+ @property
+ def access_token_expired(self):
+ """Checks if access token doesn't exist or is expired.
+
+ :returns: bool -- True if access token doesn't exist or is expired.
+ """
+ if self.credentials is None:
+ return True
+ return self.credentials.access_token_expired
+
+ @CheckAuth
+ def LocalWebserverAuth(self, host_name='localhost',
+ port_numbers=[8080, 8090]):
+ """Authenticate and authorize from user by creating local webserver and
+ retrieving authentication code.
+
+ This function is not for webserver application. It creates local webserver
+ for user from standalone application.
+
+ :param host_name: host name of the local webserver.
+ :type host_name: str.
+ :param port_numbers: list of port numbers to be tried to used.
+ :type port_numbers: list.
+ :returns: str -- code returned from local webserver
+ :raises: AuthenticationRejected, AuthenticationError
+ """
+ success = False
+ port_number = 0
+ for port in port_numbers:
+ port_number = port
+ try:
+ httpd = ClientRedirectServer((host_name, port), ClientRedirectHandler)
+ except socket.error, e:
+ pass
+ else:
+ success = True
+ break
+ if success:
+ oauth_callback = 'http://%s:%s/' % (host_name, port_number)
+ else:
+ print 'Failed to start a local webserver. 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()
+ webbrowser.open(authorize_url, new=1, autoraise=True)
+ print 'Your browser has been opened to visit:'
+ print
+ print ' ' + authorize_url
+ print
+ httpd.handle_request()
+ if 'error' in httpd.query_params:
+ print 'Authentication request was rejected'
+ raise AuthenticationRejected('User rejected authentication')
+ if 'code' in httpd.query_params:
+ return httpd.query_params['code']
+ else:
+ print 'Failed to find "code" in the query parameters of the redirect.'
+ print 'Try command-line authentication'
+ raise AuthenticationError('No code found in redirect')
+
+ @CheckAuth
+ def CommandLineAuth(self):
+ """Authenticate and authorize from user by printing authentication url
+ retrieving authentication code from command-line.
+
+ :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 raw_input('Enter verification code: ').strip()
+
+ def LoadCredentials(self, backend=None):
+ """Loads credentials or create empty credentials if it doesn't exist.
+
+ :param backend: target backend to save credential to.
+ :type backend: str.
+ :raises: InvalidConfigError
+ """
+ if backend is None:
+ backend = self.settings.get('save_credentials_backend')
+ if backend is None:
+ raise InvalidConfigError('Please specify credential backend')
+ if backend == 'file':
+ self.LoadCredentialsFile()
+ else:
+ raise InvalidConfigError('Unknown save_credentials_backend')
+
+ def LoadCredentialsFile(self, credentials_file=None):
+ """Loads credentials or create empty credentials if it doesn't exist.
+
+ Loads credentials file from path in settings if not specified.
+
+ :param credentials_file: path of credentials file to read.
+ :type credentials_file: str.
+ :raises: InvalidConfigError, InvalidCredentialsError
+ """
+ if credentials_file is None:
+ credentials_file = self.settings.get('save_credentials_file')
+ if credentials_file is None:
+ raise InvalidConfigError('Please specify credentials file to read')
+ try:
+ storage = Storage(credentials_file)
+ self.credentials = storage.get()
+ except CredentialsFileSymbolicLinkError:
+ raise InvalidCredentialsError('Credentials file cannot be symbolic link')
+
+ def SaveCredentials(self, backend=None):
+ """Saves credentials according to specified backend.
+
+ If you have any specific credentials backend in mind, don't use this
+ function and use the corresponding function you want.
+
+ :param backend: backend to save credentials.
+ :type backend: str.
+ :raises: InvalidConfigError
+ """
+ if backend is None:
+ backend = self.settings.get('save_credentials_backend')
+ if backend is None:
+ raise InvalidConfigError('Please specify credential backend')
+ if backend == 'file':
+ self.SaveCredentialsFile()
+ else:
+ raise InvalidConfigError('Unknown save_credentials_backend')
+
+ def SaveCredentialsFile(self, credentials_file=None):
+ """Saves credentials to the file in JSON format.
+
+ :param credentials_file: destination to save file to.
+ :type credentials_file: str.
+ :raises: InvalidConfigError, InvalidCredentialsError
+ """
+ if self.credentials is None:
+ raise InvalidCredentialsError('No credentials to save')
+ if credentials_file is None:
+ credentials_file = self.settings.get('save_credentials_file')
+ if credentials_file is None:
+ raise InvalidConfigError('Please specify credentials file to read')
+ try:
+ storage = Storage(credentials_file)
+ storage.put(self.credentials)
+ self.credentials.set_store(storage)
+ except CredentialsFileSymbolicLinkError:
+ raise InvalidCredentialsError('Credentials file cannot be symbolic link')
+
+ def LoadClientConfig(self, backend=None):
+ """Loads client configuration according to specified backend.
+
+ If you have any specific backend to load client configuration from in mind,
+ don't use this function and use the corresponding function you want.
+
+ :param backend: backend to load client configuration from.
+ :type backend: str.
+ :raises: InvalidConfigError
+ """
+ if backend is None:
+ backend = self.settings.get('client_config_backend')
+ if backend is None:
+ raise InvalidConfigError('Please specify client config backend')
+ if backend == 'file':
+ self.LoadClientConfigFile()
+ elif backend == 'settings':
+ self.LoadClientConfigSettings()
+ else:
+ raise InvalidConfigError('Unknown client_config_backend')
+
+ def LoadClientConfigFile(self, client_config_file=None):
+ """Loads client configuration file downloaded from APIs console.
+
+ Loads client config file from path in settings if not specified.
+
+ :param client_config_file: path of client config file to read.
+ :type client_config_file: str.
+ :raises: InvalidConfigError
+ """
+ if client_config_file is None:
+ client_config_file = self.settings['client_config_file']
+ try:
+ client_type, client_info = clientsecrets.loadfile(client_config_file)
+ except clientsecrets.InvalidClientSecretsError, error:
+ raise InvalidConfigError('Invalid client secrets file %s' % error)
+ if not client_type in (clientsecrets.TYPE_WEB,
+ clientsecrets.TYPE_INSTALLED):
+ raise InvalidConfigError('Unknown client_type of client config file')
+ try:
+ config_index = ['client_id', 'client_secret', 'auth_uri', 'token_uri']
+ for config in config_index:
+ self.client_config[config] = client_info[config]
+ self.client_config['revoke_uri'] = client_info.get('revoke_uri')
+ self.client_config['redirect_uri'] = client_info['redirect_uris'][0]
+ except KeyError:
+ raise InvalidConfigError('Insufficient client config in file')
+
+ def LoadClientConfigSettings(self):
+ """Loads client configuration from settings file.
+
+ :raises: InvalidConfigError
+ """
+
+ for config in self.CLIENT_CONFIGS_LIST:
+ try:
+ self.client_config[config] = self.settings['client_config'][config]
+
+ except KeyError:
+ print config
+ raise InvalidConfigError('Insufficient client config in settings')
+
+ def GetFlow(self):
+ """Gets Flow object from client configuration.
+
+ :raises: InvalidConfigError
+ """
+ if not all(config in self.client_config \
+ for config in self.CLIENT_CONFIGS_LIST):
+ self.LoadClientConfig()
+ constructor_kwargs = {
+ 'redirect_uri': self.client_config['redirect_uri'],
+ 'auth_uri': self.client_config['auth_uri'],
+ 'token_uri': self.client_config['token_uri'],
+ }
+ if self.client_config['revoke_uri'] is not None:
+ constructor_kwargs['revoke_uri'] = self.client_config['revoke_uri']
+ self.flow = OAuth2WebServerFlow(
+ self.client_config['client_id'],
+ self.client_config['client_secret'],
+ scopes_to_string(self.settings['oauth_scope']),
+ **constructor_kwargs)
+ if self.settings.get('get_refresh_token'):
+ self.flow.params.update({'access_type': 'offline'})
+
+ def Refresh(self):
+ """Refreshes the access_token.
+
+ :raises: RefreshError
+ """
+ if self.credentials is None:
+ raise RefreshError('No credential to refresh.')
+ if self.credentials.refresh_token is None:
+ raise RefreshError('No refresh_token found.'
+ 'Please set access_type of OAuth to offline.')
+ if self.http is None:
+ self.http = httplib2.Http()
+ try:
+ self.credentials.refresh(self.http)
+ except AccessTokenRefreshError, error:
+ raise RefreshError('Access token refresh failed: %s' % error)
+
+ def GetAuthUrl(self, keys = None):
+ """Creates authentication url where user visits to grant access.
+
+ :returns: str -- Authentication url.
+ """
+
+ if(keys != None):
+ #update some of the settings in the client_config dict
+ self.client_config['client_id'] = keys['client_id']
+ self.client_config['client_secret'] = keys['client_secret']
+
+ if self.flow is None:
+ self.GetFlow()
+
+ return self.flow.step1_get_authorize_url()
+
+ def Auth(self, code):
+ """Authenticate, authorize, and build service.
+
+ :param code: Code for authentication.
+ :type code: str.
+ :raises: AuthenticationError
+ """
+ self.Authenticate(code)
+ self.Authorize()
+
+ def Authenticate(self, code):
+ """Authenticates given authentication code back from user.
+
+ :param code: Code for authentication.
+ :type code: str.
+ :raises: AuthenticationError
+ """
+ if self.flow is None:
+ self.GetFlow()
+ try:
+ self.credentials = self.flow.step2_exchange(code)
+ except FlowExchangeError, e:
+ raise AuthenticationError('OAuth2 code exchange failed: %s' % e)
+ print 'Authentication successful.'
+
+ def Authorize(self):
+ """Authorizes and builds service.
+
+ :raises: AuthenticationError
+ """
+ if self.http is None:
+ self.http = httplib2.Http()
+ if self.access_token_expired:
+ raise AuthenticationError('No valid credentials provided to authorize')
+ self.http = self.credentials.authorize(self.http)
+ self.service = build('drive', 'v2', http=self.http)
diff --git a/resources/lib/pydrive/drive.py b/resources/lib/pydrive/drive.py
new file mode 100644
index 0000000..94233e4
--- /dev/null
+++ b/resources/lib/pydrive/drive.py
@@ -0,0 +1,38 @@
+from .apiattr import ApiAttributeMixin
+from .files import GoogleDriveFile
+from .files import GoogleDriveFileList
+
+
+class GoogleDrive(ApiAttributeMixin, object):
+ """Main Google Drive class."""
+
+ def __init__(self, auth=None):
+ """Create an instance of GoogleDrive.
+
+ :param auth: authorized GoogleAuth instance.
+ :type auth: pydrive.auth.GoogleAuth.
+ """
+ ApiAttributeMixin.__init__(self)
+ self.auth = auth
+
+ def CreateFile(self, metadata=None):
+ """Create an instance of GoogleDriveFile with auth of this instance.
+
+ This method would not upload a file to GoogleDrive.
+
+ :param metadata: file resource to initialize GoogleDriveFile with.
+ :type metadata: dict.
+ :returns: pydrive.files.GoogleDriveFile -- initialized with auth of this instance.
+ """
+ return GoogleDriveFile(auth=self.auth, metadata=metadata)
+
+ def ListFile(self, param=None):
+ """Create an instance of GoogleDriveFileList with auth of this instance.
+
+ This method will not fetch from Files.List().
+
+ :param param: parameter to be sent to Files.List().
+ :type param: dict.
+ :returns: pydrive.files.GoogleDriveFileList -- initialized with auth of this instance.
+ """
+ return GoogleDriveFileList(auth=self.auth, param=param)
diff --git a/resources/lib/pydrive/files.py b/resources/lib/pydrive/files.py
new file mode 100644
index 0000000..e60d010
--- /dev/null
+++ b/resources/lib/pydrive/files.py
@@ -0,0 +1,322 @@
+import io
+import mimetypes
+
+from apiclient import errors
+from apiclient.http import MediaIoBaseUpload
+from functools import wraps
+
+from .apiattr import ApiAttribute
+from .apiattr import ApiAttributeMixin
+from .apiattr import ApiResource
+from .apiattr import ApiResourceList
+from .auth import LoadAuth
+
+
+class FileNotUploadedError(RuntimeError):
+ """Error trying to access metadata of file that is not uploaded."""
+
+
+class ApiRequestError(IOError):
+ """Error while making any API requests."""
+
+
+class FileNotDownloadableError(RuntimeError):
+ """Error trying to download file that is not downloadable."""
+
+
+def LoadMetadata(decoratee):
+ """Decorator to check if the file has metadata and fetches it if not.
+
+ :raises: ApiRequestError, FileNotUploadedError
+ """
+ @wraps(decoratee)
+ def _decorated(self, *args, **kwargs):
+ if not self.uploaded:
+ self.FetchMetadata()
+ return decoratee(self, *args, **kwargs)
+ return _decorated
+
+
+class GoogleDriveFileList(ApiResourceList):
+ """Google Drive FileList instance.
+
+ Equivalent to Files.list() in Drive APIs.
+ """
+
+ def __init__(self, auth=None, param=None):
+ """Create an instance of GoogleDriveFileList."""
+ super(GoogleDriveFileList, self).__init__(auth=auth, metadata=param)
+
+ @LoadAuth
+ def _GetList(self):
+ """Overwritten method which actually makes API call to list files.
+
+ :returns: list -- list of pydrive.files.GoogleDriveFile.
+ """
+ self.metadata = self.auth.service.files().list(**dict(self)).execute()
+ result = []
+ for file_metadata in self.metadata['items']:
+ tmp_file = GoogleDriveFile(
+ auth=self.auth,
+ metadata=file_metadata,
+ uploaded=True)
+ result.append(tmp_file)
+ return result
+
+
+class GoogleDriveFile(ApiAttributeMixin, ApiResource):
+ """Google Drive File instance.
+
+ Inherits ApiResource which inherits dict.
+ Can access and modify metadata like dictionary.
+ """
+ content = ApiAttribute('content')
+ uploaded = ApiAttribute('uploaded')
+ metadata = ApiAttribute('metadata')
+
+ def __init__(self, auth=None, metadata=None, uploaded=False):
+ """Create an instance of GoogleDriveFile.
+
+ :param auth: authorized GoogleAuth instance.
+ :type auth: pydrive.auth.GoogleAuth
+ :param metadata: file resource to initialize GoogleDirveFile with.
+ :type metadata: dict.
+ :param uploaded: True if this file is confirmed to be uploaded.
+ :type uploaded: bool.
+ """
+ ApiAttributeMixin.__init__(self)
+ ApiResource.__init__(self)
+ self.metadata = {}
+ self.dirty = {'content': False}
+ self.auth = auth
+ self.uploaded = uploaded
+ if uploaded:
+ self.UpdateMetadata(metadata)
+ elif metadata:
+ self.update(metadata)
+
+ def __getitem__(self, key):
+ """Overwrites manner of accessing Files resource.
+
+ If this file instance is not uploaded and id is specified,
+ it will try to look for metadata with Files.get().
+
+ :param key: key of dictionary query.
+ :type key: str.
+ :returns: value of Files resource
+ :raises: KeyError, FileNotUploadedError
+ """
+ try:
+ return dict.__getitem__(self, key)
+ except KeyError, e:
+ if self.uploaded:
+ raise KeyError(e)
+ if self.get('id'):
+ self.FetchMetadata()
+ return dict.__getitem__(self, key)
+ else:
+ raise FileNotUploadedError()
+
+ def SetContentString(self, content):
+ """Set content of this file to be a string.
+
+ Creates io.BytesIO instance of utf-8 encoded string.
+ Sets mimeType to be 'text/plain' if not specified.
+
+ :param content: content of the file in string.
+ :type content: str.
+ """
+ self.content = io.BytesIO(content.encode('utf-8'))
+ if self.get('mimeType') is None:
+ self['mimeType'] = 'text/plain'
+
+ def SetContentFile(self, filename):
+ """Set content of this file from a file.
+
+ Opens the file specified by this method.
+ Will be read, uploaded, and closed by Upload() method.
+ Sets metadata 'title' and 'mimeType' automatically if not specified.
+
+ :param filename: name of the file to be uploaded.
+ :type filename: str.
+ """
+ self.content = open(filename, 'rb')
+
+ if self.get('title') is None:
+ self['title'] = filename
+ if self.get('mimeType') is None:
+ self['mimeType'] = mimetypes.guess_type(filename)[0]
+
+ def GetContentString(self):
+ """Get content of this file as a string.
+
+ :returns: str -- utf-8 decoded content of the file
+ :raises: ApiRequestError, FileNotUploadedError, FileNotDownloadableError
+ """
+ if self.content is None or type(self.content) is not io.BytesIO:
+ self.FetchContent()
+ return self.content.getvalue().decode('utf-8')
+
+ def GetContentFile(self, filename, mimetype=None):
+ """Save content of this file as a local file.
+
+ :param filename: name of the file to write to.
+ :type filename: str.
+ :raises: ApiRequestError, FileNotUploadedError, FileNotDownloadableError
+ """
+ if self.content is None or type(self.content) is not io.BytesIO:
+ self.FetchContent(mimetype)
+ f = open(filename, 'wb')
+ f.write(self.content.getvalue())
+ f.close()
+
+ @LoadAuth
+ def FetchMetadata(self):
+ """Download file's metadata from id using Files.get().
+
+ :raises: ApiRequestError, FileNotUploadedError
+ """
+ file_id = self.metadata.get('id') or self.get('id')
+ if file_id:
+ try:
+ metadata = self.auth.service.files().get(fileId=file_id).execute()
+ except errors.HttpError, error:
+ raise ApiRequestError(error)
+ else:
+ self.uploaded = True
+ self.UpdateMetadata(metadata)
+ else:
+ raise FileNotUploadedError()
+
+ @LoadMetadata
+ def FetchContent(self, mimetype=None):
+ """Download file's content from download_url.
+
+ :raises: ApiRequestError, FileNotUploadedError, FileNotDownloadableError
+ """
+ download_url = self.metadata.get('downloadUrl')
+ if download_url:
+ self.content = io.BytesIO(self._DownloadFromUrl(download_url))
+ self.dirty['content'] = False
+ return
+
+ export_links = self.metadata.get('exportLinks')
+ if export_links and export_links.get(mimetype):
+ self.content = io.BytesIO(
+ self._DownloadFromUrl(export_links.get(mimetype)))
+ self.dirty['content'] = False
+ return
+
+ raise FileNotDownloadableError(
+ 'No downloadLink/exportLinks for mimetype found in metadata')
+
+ def Upload(self, param=None):
+ """Upload/update file by choosing the most efficient method.
+
+ :param param: additional parameter to upload file.
+ :type param: dict.
+ :raises: ApiRequestError
+ """
+ if self.uploaded or self.get('id') is not None:
+ if self.dirty['content']:
+ self._FilesUpdate(param=param)
+ else:
+ self._FilesPatch(param=param)
+ else:
+ self._FilesInsert(param=param)
+
+ def Delete(self):
+ if self.get('id') is not None:
+ self.auth.service.files().delete(fileId=self.get('id')).execute()
+
+ @LoadAuth
+ def _FilesInsert(self, param=None):
+ """Upload a new file using Files.insert().
+
+ :param param: additional parameter to upload file.
+ :type param: dict.
+ :raises: ApiRequestError
+ """
+ if param is None:
+ param = {}
+ param['body'] = self.GetChanges()
+ try:
+ if self.dirty['content']:
+ param['media_body'] = self._BuildMediaBody()
+ metadata = self.auth.service.files().insert(**param).execute()
+ except errors.HttpError, error:
+ raise ApiRequestError(error)
+ else:
+ self.uploaded = True
+ self.dirty['content'] = False
+ self.UpdateMetadata(metadata)
+
+ @LoadAuth
+ @LoadMetadata
+ def _FilesUpdate(self, param=None):
+ """Update metadata and/or content using Files.Update().
+
+ :param param: additional parameter to upload file.
+ :type param: dict.
+ :raises: ApiRequestError, FileNotUploadedError
+ """
+ if param is None:
+ param = {}
+ param['body'] = self.GetChanges()
+ param['fileId'] = self.metadata.get('id')
+ try:
+ if self.dirty['content']:
+ param['media_body'] = self._BuildMediaBody()
+ metadata = self.auth.service.files().update(**param).execute()
+ except errors.HttpError, error:
+ raise ApiRequestError(error)
+ else:
+ self.uploaded = True
+ self.dirty['content'] = False
+ self.UpdateMetadata(metadata)
+
+ @LoadAuth
+ @LoadMetadata
+ def _FilesPatch(self, param=None):
+ """Update metadata using Files.Patch().
+
+ :param param: additional parameter to upload file.
+ :type param: dict.
+ :raises: ApiRequestError, FileNotUploadedError
+ """
+ if param is None:
+ param = {}
+ param['body'] = self.GetChanges()
+ param['fileId'] = self.metadata.get('id')
+ try:
+ metadata = self.auth.service.files().patch(**param).execute()
+ except errors.HttpError, error:
+ raise ApiRequestError(error)
+ else:
+ self.UpdateMetadata(metadata)
+
+ def _BuildMediaBody(self):
+ """Build MediaIoBaseUpload to get prepared to upload content of the file.
+
+ Sets mimeType as 'application/octet-stream' if not specified.
+
+ :returns: MediaIoBaseUpload -- instance that will be used to upload content.
+ """
+ if self.get('mimeType') is None:
+ self['mimeType'] = 'application/octet-stream'
+
+ return MediaIoBaseUpload(self.content, self['mimeType'])
+
+ @LoadAuth
+ def _DownloadFromUrl(self, url):
+ """Download file from url using provided credential.
+
+ :param url: link of the file to download.
+ :type url: str.
+ :returns: str -- content of downloaded file in string.
+ :raises: ApiRequestError
+ """
+ resp, content = self.auth.service._http.request(url)
+ if resp.status != 200:
+ raise ApiRequestError('Cannot download file: %s' % resp)
+ return content
diff --git a/resources/lib/pydrive/settings.py b/resources/lib/pydrive/settings.py
new file mode 100644
index 0000000..468e3cc
--- /dev/null
+++ b/resources/lib/pydrive/settings.py
@@ -0,0 +1,193 @@
+from yaml import load
+from yaml import YAMLError
+try:
+ from yaml import CLoader as Loader
+except ImportError:
+ from yaml import Loader
+
+SETTINGS_FILE = 'settings.yaml'
+SETTINGS_STRUCT = {
+ 'client_config_backend': {
+ 'type': str,
+ 'required': True,
+ 'default': 'file',
+ 'dependency': [
+ {
+ 'value': 'file',
+ 'attribute': ['client_config_file']
+ },
+ {
+ 'value': 'settings',
+ 'attribute': ['client_config']
+ }
+ ]
+ },
+ 'save_credentials': {
+ 'type': bool,
+ 'required': True,
+ 'default': False,
+ 'dependency': [
+ {
+ 'value': True,
+ 'attribute': ['save_credentials_backend']
+ }
+ ]
+ },
+ 'get_refresh_token': {
+ 'type': bool,
+ 'required': False,
+ 'default': False
+ },
+ 'client_config_file': {
+ 'type': str,
+ 'required': False,
+ 'default': 'client_secrets.json'
+ },
+ 'save_credentials_backend': {
+ 'type': str,
+ 'required': False,
+ 'dependency': [
+ {
+ 'value': 'file',
+ 'attribute': ['save_credentials_file']
+ }
+ ]
+ },
+ 'client_config': {
+ 'type': dict,
+ 'required': False,
+ 'struct': {
+ 'client_id': {
+ 'type': str,
+ 'required': True,
+ 'default':'blank'
+ },
+ 'client_secret': {
+ 'type': str,
+ 'required': True,
+ 'default':'blank'
+ },
+ 'auth_uri': {
+ 'type': str,
+ 'required': True,
+ 'default': 'https://accounts.google.com/o/oauth2/auth'
+ },
+ 'token_uri': {
+ 'type': str,
+ 'required': True,
+ 'default': 'https://accounts.google.com/o/oauth2/token'
+ },
+ 'redirect_uri': {
+ 'type': str,
+ 'required': True,
+ 'default': 'urn:ietf:wg:oauth:2.0:oob'
+ },
+ 'revoke_uri': {
+ 'type': str,
+ 'required': True,
+ 'default': None
+ }
+ }
+ },
+ 'oauth_scope': {
+ 'type': list,
+ 'required': True,
+ 'struct': str,
+ 'default': ['https://www.googleapis.com/auth/drive']
+ },
+ 'save_credentials_file': {
+ 'type': str,
+ 'required': False,
+ }
+}
+
+
+class SettingsError(IOError):
+ """Error while loading/saving settings"""
+
+
+class InvalidConfigError(IOError):
+ """Error trying to read client configuration."""
+
+
+def LoadSettingsFile(filename=SETTINGS_FILE):
+ """Loads settings file in yaml format given file name.
+
+ :param filename: path for settings file. 'settings.yaml' by default.
+ :type filename: str.
+ :raises: SettingsError
+ """
+ try:
+ stream = file(filename, 'r')
+ data = load(stream, Loader=Loader)
+ except (YAMLError, IOError), e:
+ print e
+ raise SettingsError(e)
+ return data
+
+
+def ValidateSettings(data):
+ """Validates if current settings is valid.
+
+ :param data: dictionary containing all settings.
+ :type data: dict.
+ :raises: InvalidConfigError
+ """
+ _ValidateSettingsStruct(data, SETTINGS_STRUCT)
+
+
+def _ValidateSettingsStruct(data, struct):
+ """Validates if provided data fits provided structure.
+
+ :param data: dictionary containing settings.
+ :type data: dict.
+ :param struct: dictionary containing structure information of settings.
+ :type struct: dict.
+ :raises: InvalidConfigError
+ """
+ # Validate required elements of the setting.
+ for key in struct:
+ if struct[key]['required']:
+ _ValidateSettingsElement(data, struct, key)
+
+
+def _ValidateSettingsElement(data, struct, key):
+ """Validates if provided element of settings data fits provided structure.
+
+ :param data: dictionary containing settings.
+ :type data: dict.
+ :param struct: dictionary containing structure information of settings.
+ :type struct: dict.
+ :param key: key of the settings element to validate.
+ :type key: str.
+ :raises: InvalidConfigError
+ """
+ # Check if data exists. If not, check if default value exists.
+ value = data.get(key)
+ data_type = struct[key]['type']
+ if value is None:
+ try:
+ default = struct[key]['default']
+ except KeyError:
+ raise InvalidConfigError('Missing required setting %s' % key)
+ else:
+ 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))
+ # 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'])
+ # If type of this data is list, check if all values in the list is valid.
+ elif data_type is list:
+ for element in data[key]:
+ if type(element) is not struct[key]['struct']:
+ raise InvalidConfigError('Setting %s should be list of %s' %
+ (key, struct[key]['struct']))
+ # Check dependency of this attribute.
+ dependencies = struct[key].get('dependency')
+ if dependencies:
+ for dependency in dependencies:
+ if value == dependency['value']:
+ for reqkey in dependency['attribute']:
+ _ValidateSettingsElement(data, struct, reqkey)
diff --git a/resources/lib/pydrive/settings.yaml b/resources/lib/pydrive/settings.yaml
new file mode 100644
index 0000000..c28d08e
--- /dev/null
+++ b/resources/lib/pydrive/settings.yaml
@@ -0,0 +1,7 @@
+client_config_backend: 'settings'
+client_config:
+ client_id: "blank"
+ client_secret: "blank"
+get_refresh_token: True
+oauth_scope:
+ - "https://www.googleapis.com/auth/drive"
\ No newline at end of file
diff --git a/resources/lib/vfs.py b/resources/lib/vfs.py
index ce2a65d..505d6bc 100644
--- a/resources/lib/vfs.py
+++ b/resources/lib/vfs.py
@@ -4,10 +4,10 @@ import xbmcvfs
import xbmcgui
import zipfile
import zlib
+import os
from dropbox import client, rest, session
-
-APP_KEY = utils.getSetting('dropbox_key')
-APP_SECRET = utils.getSetting('dropbox_secret')
+from pydrive.auth import GoogleAuth
+from pydrive.drive import GoogleDrive
class Vfs:
root_path = None
@@ -38,9 +38,6 @@ class Vfs:
def put(self,source,dest):
return True
- def getFile(self,source):
- return True
-
def rmdir(self,directory):
return True
@@ -116,19 +113,25 @@ class ZipFileSystem(Vfs):
class DropboxFileSystem(Vfs):
client = None
+ APP_KEY = ''
+ APP_SECRET = ''
def __init__(self,rootString):
self.set_root(rootString)
+
+ self.APP_KEY = utils.getSetting('dropbox_key')
+ self.APP_SECRET = utils.getSetting('dropbox_secret')
+
self.setup()
def setup(self):
- if(APP_KEY == '' or APP_SECRET == ''):
+ if(self.APP_KEY == '' or self.APP_SECRET == ''):
xbmcgui.Dialog().ok(utils.getString(30010),utils.getString(30058),utils.getString(30059))
return
user_token_key,user_token_secret = self.getToken()
- sess = session.DropboxSession(APP_KEY,APP_SECRET,"app_folder")
+ sess = session.DropboxSession(self.APP_KEY,self.APP_SECRET,"app_folder")
if(user_token_key == '' and user_token_secret == ''):
token = sess.obtain_request_token()
@@ -161,9 +164,9 @@ class DropboxFileSystem(Vfs):
for aFile in metadata['contents']:
if(aFile['is_dir']):
- dirs.append(aFile['path'][len(directory):])
+ dirs.append(utils.encode(aFile['path'][len(directory):]))
else:
- files.append(aFile['path'][len(directory):])
+ files.append(utils.encode(aFile['path'][len(directory):]))
return [dirs,files]
else:
@@ -270,6 +273,212 @@ class DropboxFileSystem(Vfs):
xbmcvfs.delete(xbmc.translatePath(utils.data_dir() + "tokens.txt"))
+class GoogleDriveFilesystem(Vfs):
+ drive = None
+ history = {}
+ CLIENT_ID = ''
+ CLIENT_SECRET = ''
+ FOLDER_TYPE = 'application/vnd.google-apps.folder'
+
+ def __init__(self,rootString):
+ self.set_root(rootString)
+
+ self.CLIENT_ID = utils.getSetting('google_drive_id')
+ self.CLIENT_SECRET = utils.getSetting('google_drive_secret')
+ self.setup()
+
+ def setup(self):
+ #create authorization helper and load default settings
+ gauth = GoogleAuth(xbmc.validatePath(xbmc.translatePath(utils.addon_dir() + '/resources/lib/pydrive/settings.yaml')))
+ gauth.LoadClientConfigSettings()
+
+ #check if this user is already authorized
+ if(not xbmcvfs.exists(xbmc.translatePath(utils.data_dir() + "google_drive.dat"))):
+ settings = {"client_id":self.CLIENT_ID,'client_secret':self.CLIENT_SECRET}
+
+ drive_url = gauth.GetAuthUrl(settings)
+
+ utils.log("Google Drive Authorize URL: " + drive_url)
+ code = xbmcgui.Dialog().input('Google Drive Validation Code','Input the Validation code after authorizing this app')
+
+ gauth.Auth(code)
+ gauth.SaveCredentialsFile(xbmc.validatePath(xbmc.translatePath(utils.data_dir() + 'google_drive.dat')))
+ else:
+ gauth.LoadCredentialsFile(xbmc.validatePath(xbmc.translatePath(utils.data_dir() + 'google_drive.dat')))
+
+ #create the drive object
+ self.drive = GoogleDrive(gauth)
+
+ #make sure we have the folder we need
+ xbmc_folder = self._getGoogleFile(self.root_path)
+ print xbmc_folder
+ if(xbmc_folder == None):
+ self.mkdir(self.root_path)
+
+
+ def listdir(self,directory):
+ files = []
+ dirs = []
+
+ if(not directory.startswith('/')):
+ directory = '/' + directory
+
+ #get the id of this folder
+ parentFolder = self._getGoogleFile(directory)
+
+ #need to do this after
+ if(not directory.endswith('/')):
+ directory = directory + '/'
+
+ if(parentFolder != None):
+
+ fileList = self.drive.ListFile({'q':"'" + parentFolder['id'] + "' in parents and trashed = false"}).GetList()
+
+ for aFile in fileList:
+ if(aFile['mimeType'] == self.FOLDER_TYPE):
+ dirs.append(utils.encode(aFile['title']))
+ else:
+ files.append(utils.encode(aFile['title']))
+
+
+ return [dirs,files]
+
+ def mkdir(self,directory):
+ result = True
+
+ if(not directory.startswith('/')):
+ directory = '/' + directory
+
+ if(directory.endswith('/')):
+ directory = directory[:-1]
+
+ #split the string by the directory separator
+ pathList = os.path.split(directory)
+
+ if(pathList[0] == '/'):
+ #we're at the root, just make the folder
+ newFolder = self.drive.CreateFile({'title': pathList[1], 'parent':'root','mimeType':self.FOLDER_TYPE})
+ newFolder.Upload()
+ else:
+ #get the id of the parent folder
+ parentFolder = self._getGoogleFile(pathList[0])
+
+ if(parentFolder != None):
+ newFolder = self.drive.CreateFile({'title': pathList[1],"parents":[{'kind':'drive#fileLink','id':parentFolder['id']}],'mimeType':self.FOLDER_TYPE})
+ newFolder.Upload()
+ else:
+ result = False
+
+ return result
+
+ def put(self,source,dest):
+ result = True
+
+ #make the name separate from the path
+ if(not dest.startswith('/')):
+ dest = '/' + dest
+
+ pathList = os.path.split(dest)
+
+ #get the parent location
+ parentFolder = self._getGoogleFile(pathList[0])
+
+ if(parentFolder != None):
+ #create a new file in this folder
+ newFile = self.drive.CreateFile({"title":pathList[1],"parents":[{'kind':'drive#fileLink','id':parentFolder['id']}]})
+ newFile.SetContentFile(source)
+ newFile.Upload()
+ else:
+ result = False
+
+ return result
+
+ def get_file(self,source, dest):
+ result = True
+
+ #get the id of this file
+ file = self._getGoogleFile(source)
+
+ if(file != None):
+ file.GetContentFile(dest)
+ else:
+ result = False
+
+ return result
+
+ def rmdir(self,directory):
+ result = True
+
+ #check that the folder exists
+ folder = self._getGoogleFile(directory)
+
+ if(folder != None):
+ #delete the folder
+ folder.Delete()
+ else:
+ result = False
+
+ return result
+
+ def rmfile(self,aFile):
+ #really just the same as the remove directory function
+ return self.rmdir(aFile)
+
+ def exists(self,aFile):
+ #attempt to get this file
+ foundFile = self._getGoogleFile(aFile)
+
+ if(foundFile != None):
+ return True
+ else:
+ return False
+
+ def rename(self,aFile,newName):
+ return True
+
+ def _getGoogleFile(self,file):
+ result = None
+
+ #file must start with / and not end with one (even directory)
+ if(not file.startswith('/')):
+ file = '/' + file
+
+ if(file.endswith('/')):
+ file = file[:-1]
+
+ if(self.history.has_key(file)):
+
+ result = self.history[file]
+ else:
+ pathList = os.path.split(file)
+
+ #end of recurision, we got the root
+ if(pathList[0] == '/'):
+ #get the id of this file (if it exists)
+ file_list = self.drive.ListFile({'q':"title='" + pathList[1] + "' and 'root' in parents and trashed=false"}).GetList()
+
+ if(len(file_list) > 0):
+ result = file_list[0]
+ self.history[pathList[1]] = result
+ else:
+ #recurse down the tree
+ current_file = pathList[1]
+
+ parentId = self._getGoogleFile(pathList[0])
+
+ if(parentId != None):
+ self.history[pathList[0]] = parentId
+
+ #attempt to get the id of this file, with this parent
+ file_list = file_list = self.drive.ListFile({'q':"title='" + current_file + "' and '" + parentId['id'] + "' in parents and trashed=false"}).GetList()
+
+ if(len(file_list) > 0):
+ result = file_list[0]
+ self.history[file] = result
+
+
+ return result
+
diff --git a/resources/settings.xml b/resources/settings.xml
index f3aaa6b..4084a3f 100644
--- a/resources/settings.xml
+++ b/resources/settings.xml
@@ -1,11 +1,13 @@
-
+
+
+