1
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
3
# Copyright 2015 Yigal Asnis
5
# This file is free software; you can redistribute it and/or modify it
6
# under the terms of the GNU General Public License as published by the
7
# Free Software Foundation; either version 2 of the License, or (at your
8
# option) any later version.
10
# It is distributed in the hope that it will be useful, but
11
# WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13
# General Public License for more details.
15
# You should have received a copy of the GNU General Public License
16
# along with duplicity; if not, write to the Free Software Foundation,
17
# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
22
import duplicity.backend
23
from duplicity import log
24
from duplicity.errors import BackendException
27
class PyDriveBackend(duplicity.backend.Backend):
28
"""Connect to remote store using PyDrive API"""
30
def __init__(self, parsed_url):
31
duplicity.backend.Backend.__init__(self, parsed_url)
35
from apiclient.discovery import build
36
from pydrive.auth import GoogleAuth
37
from pydrive.drive import GoogleDrive
38
from pydrive.files import FileNotUploadedError
39
except ImportError as e:
40
raise BackendException("""\
41
PyDrive backend requires PyDrive installation. Please read the manpage for setup details.
42
Exception: %s""" % str(e))
44
# let user get by with old client while he can
46
from oauth2client.client import SignedJwtAssertionCredentials
49
from oauth2client.service_account import ServiceAccountCredentials
50
from oauth2client import crypt
51
self.oldClient = False
53
if 'GOOGLE_DRIVE_ACCOUNT_KEY' in os.environ:
54
account_key = os.environ['GOOGLE_DRIVE_ACCOUNT_KEY']
56
credentials = SignedJwtAssertionCredentials(parsed_url.username +
57
'@' + parsed_url.hostname,
59
scopes='https://www.googleapis.com/auth/drive')
61
signer = crypt.Signer.from_string(account_key)
62
credentials = ServiceAccountCredentials(parsed_url.username + '@' + parsed_url.hostname, signer,
63
scopes='https://www.googleapis.com/auth/drive')
64
credentials.authorize(httplib2.Http())
66
gauth.credentials = credentials
67
elif 'GOOGLE_DRIVE_SETTINGS' in os.environ:
68
gauth = GoogleAuth(settings_file=os.environ['GOOGLE_DRIVE_SETTINGS'])
69
gauth.CommandLineAuth()
70
elif ('GOOGLE_SECRETS_FILE' in os.environ and 'GOOGLE_CREDENTIALS_FILE' in os.environ):
72
gauth.LoadClientConfigFile(os.environ['GOOGLE_SECRETS_FILE'])
73
gauth.LoadCredentialsFile(os.environ['GOOGLE_CREDENTIALS_FILE'])
74
if gauth.credentials is None:
75
gauth.CommandLineAuth()
76
elif gauth.access_token_expired:
80
gauth.SaveCredentialsFile(os.environ['GOOGLE_CREDENTIALS_FILE'])
82
raise BackendException(
83
'GOOGLE_DRIVE_ACCOUNT_KEY or GOOGLE_DRIVE_SETTINGS environment '
84
'variable not set. Please read the manpage to fix.')
85
self.drive = GoogleDrive(gauth)
87
# Dirty way to find root folder id
88
file_list = self.drive.ListFile({'q': "'Root' in parents and trashed=false"}).GetList()
90
parent_folder_id = file_list[0]['parents'][0]['id']
92
file_in_root = self.drive.CreateFile({'title': 'i_am_in_root'})
94
parent_folder_id = file_in_root['parents'][0]['id']
97
# Fetch destination folder entry and create hierarchy if required.
98
folder_names = string.split(parsed_url.path, '/')
99
for folder_name in folder_names:
102
file_list = self.drive.ListFile({'q': "'" + parent_folder_id +
103
"' in parents and trashed=false"}).GetList()
104
folder = next((item for item in file_list if item['title'] == folder_name and
105
item['mimeType'] == 'application/vnd.google-apps.folder'), None)
107
folder = self.drive.CreateFile({'title': folder_name,
108
'mimeType': "application/vnd.google-apps.folder",
109
'parents': [{'id': parent_folder_id}]})
111
parent_folder_id = folder['id']
112
self.folder = parent_folder_id
115
def file_by_name(self, filename):
116
from pydrive.files import ApiRequestError
117
if filename in self.id_cache:
118
# It might since have been locally moved, renamed or deleted, so we
119
# need to validate the entry.
120
file_id = self.id_cache[filename]
121
drive_file = self.drive.CreateFile({'id': file_id})
123
if drive_file['title'] == filename and not drive_file['labels']['trashed']:
124
for parent in drive_file['parents']:
125
if parent['id'] == self.folder:
126
log.Info("PyDrive backend: found file '%s' with id %s in ID cache" %
129
except ApiRequestError as error:
130
# A 404 occurs if the ID is no longer valid
131
if error.args[0].resp.status != 404:
133
# If we get here, the cache entry is invalid
134
log.Info("PyDrive backend: invalidating '%s' (previously ID %s) from ID cache" %
136
del self.id_cache[filename]
138
# Not found in the cache, so use directory listing. This is less
139
# reliable because there is no strong consistency.
140
q = "title='%s' and '%s' in parents and trashed=false" % (filename, self.folder)
141
fields = 'items(title,id,fileSize,downloadUrl,exportLinks),nextPageToken'
142
flist = self.drive.ListFile({'q': q, 'fields': fields}).GetList()
144
log.FatalError(_("PyDrive backend: multiple files called '%s'.") % (filename,))
146
file_id = flist[0]['id']
147
self.id_cache[filename] = flist[0]['id']
148
log.Info("PyDrive backend: found file '%s' with id %s on server, "
149
"adding to cache" % (filename, file_id))
151
log.Info("PyDrive backend: file '%s' not found in cache or on server" %
155
def id_by_name(self, filename):
156
drive_file = self.file_by_name(filename)
157
if drive_file is None:
160
return drive_file['id']
162
def _put(self, source_path, remote_filename):
163
drive_file = self.file_by_name(remote_filename)
164
if drive_file is None:
165
# No existing file, make a new one
166
drive_file = self.drive.CreateFile({'title': remote_filename,
167
'parents': [{"kind": "drive#fileLink",
168
"id": self.folder}]})
169
log.Info("PyDrive backend: creating new file '%s'" % (remote_filename,))
171
log.Info("PyDrive backend: replacing existing file '%s' with id '%s'" % (
172
remote_filename, drive_file['id']))
173
drive_file.SetContentFile(source_path.name)
175
self.id_cache[remote_filename] = drive_file['id']
177
def _get(self, remote_filename, local_path):
178
drive_file = self.file_by_name(remote_filename)
179
drive_file.GetContentFile(local_path.name)
182
drive_files = self.drive.ListFile({
183
'q': "'" + self.folder + "' in parents and trashed=false",
184
'fields': 'items(title,id),nextPageToken'}).GetList()
185
filenames = set(item['title'] for item in drive_files)
186
# Check the cache as well. A file might have just been uploaded but
187
# not yet appear in the listing.
188
# Note: do not use iterkeys() here, because file_by_name will modify
189
# the cache if it finds invalid entries.
190
for filename in self.id_cache.keys():
191
if (filename not in filenames) and (self.file_by_name(filename) is not None):
192
filenames.add(filename)
193
return list(filenames)
195
def _delete(self, filename):
196
file_id = self.id_by_name(filename)
198
self.drive.auth.service.files().delete(fileId=file_id).execute()
200
log.Warn("File '%s' does not exist while trying to delete it" % (filename,))
202
def _query(self, filename):
203
drive_file = self.file_by_name(filename)
204
if drive_file is None:
207
size = int(drive_file['fileSize'])
208
return {'size': size}
210
def _error_code(self, operation, error):
211
from pydrive.files import ApiRequestError, FileNotUploadedError
212
if isinstance(error, FileNotUploadedError):
213
return log.ErrorCode.backend_not_found
214
elif isinstance(error, ApiRequestError):
215
return log.ErrorCode.backend_permission_denied
216
return log.ErrorCode.backend_error
218
duplicity.backend.register_backend('pydrive', PyDriveBackend)
219
""" pydrive is an alternate way to access gdocs """
220
duplicity.backend.register_backend('pydrive+gdocs', PyDriveBackend)
221
""" register pydrive as the default way to access gdocs """
222
duplicity.backend.register_backend('gdocs', PyDriveBackend)
224
duplicity.backend.uses_netloc.extend(['pydrive', 'pydrive+gdocs', 'gdocs'])