From 0df7364bea8ed4a66da1656ed2bb48320b5507db Mon Sep 17 00:00:00 2001 From: Rob Weber Date: Thu, 9 Oct 2014 11:31:38 -0500 Subject: [PATCH] added pydrive files --- resources/lib/pydrive/__init__.py | 0 resources/lib/pydrive/apiattr.py | 173 +++++++++++ resources/lib/pydrive/auth.py | 432 ++++++++++++++++++++++++++++ resources/lib/pydrive/drive.py | 38 +++ resources/lib/pydrive/files.py | 322 +++++++++++++++++++++ resources/lib/pydrive/settings.py | 190 ++++++++++++ resources/lib/pydrive/settings.yaml | 7 + 7 files changed, 1162 insertions(+) create mode 100644 resources/lib/pydrive/__init__.py create mode 100644 resources/lib/pydrive/apiattr.py create mode 100644 resources/lib/pydrive/auth.py create mode 100644 resources/lib/pydrive/drive.py create mode 100644 resources/lib/pydrive/files.py create mode 100644 resources/lib/pydrive/settings.py create mode 100644 resources/lib/pydrive/settings.yaml 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..fbfe4cf --- /dev/null +++ b/resources/lib/pydrive/settings.py @@ -0,0 +1,190 @@ +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 + }, + 'client_secret': { + 'type': str, + 'required': True + }, + '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: + 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