Merge lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend into lp:~duplicity-team/duplicity/0.7-series

Proposed by Michael Stapelberg
Status: Merged
Merged at revision: 1044
Proposed branch: lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend
Merge into: lp:~duplicity-team/duplicity/0.7-series
Diff against target: 376 lines (+344/-0)
3 files modified
bin/duplicity.1 (+13/-0)
duplicity/backends/onedrivebackend.py (+330/-0)
duplicity/commandline.py (+1/-0)
To merge this branch: bzr merge lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend
Reviewer Review Type Date Requested Status
duplicity-team Pending
Review via email: mp+245534@code.launchpad.net

Commit message

Add a Microsoft OneDrive backend

Description of the change

Add a Microsoft OneDrive backend

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'bin/duplicity.1'
--- bin/duplicity.1 2014-12-10 19:09:28 +0000
+++ bin/duplicity.1 2015-01-04 21:11:21 +0000
@@ -91,6 +91,13 @@
91.B Python library for mega API91.B Python library for mega API
92- https://github.com/ckornacker/mega.py, ubuntu ppa - ppa:ckornacker/backup92- https://github.com/ckornacker/mega.py, ubuntu ppa - ppa:ckornacker/backup
93.TP93.TP
94.BR "OneDrive backend" " (Microsoft OneDrive)"
95.B python-requests
96- http://python-requests.org
97.br
98.B python-requests-oauthlib
99- https://github.com/requests/requests-oauthlib
100.TP
94.BR "ncftp backend" " (ftp, select via ncftp+ftp://)"101.BR "ncftp backend" " (ftp, select via ncftp+ftp://)"
95.B NcFTP102.B NcFTP
96- http://www.ncftp.com/103- http://www.ncftp.com/
@@ -1178,6 +1185,12 @@
1178mega://user[:password]@mega.co.nz/some_dir1185mega://user[:password]@mega.co.nz/some_dir
1179.RE1186.RE
1180.PP1187.PP
1188.B "OneDrive Backend"
1189.PP
1190.RS
1191onedrive://some_dir
1192.RE
1193.PP
1181.B "Par2 Wrapper Backend"1194.B "Par2 Wrapper Backend"
1182.PP1195.PP
1183.RS1196.RS
11841197
=== added file 'duplicity/backends/onedrivebackend.py'
--- duplicity/backends/onedrivebackend.py 1970-01-01 00:00:00 +0000
+++ duplicity/backends/onedrivebackend.py 2015-01-04 21:11:21 +0000
@@ -0,0 +1,330 @@
1# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
2# vim:tabstop=4:shiftwidth=4:expandtab
3#
4# Copyright 2014 Google Inc.
5# Contact Michael Stapelberg <stapelberg+duplicity@google.com>
6# This is NOT a Google product.
7#
8# This file is part of duplicity.
9#
10# Duplicity is free software; you can redistribute it and/or modify it
11# under the terms of the GNU General Public License as published by the
12# Free Software Foundation; either version 2 of the License, or (at your
13# option) any later version.
14#
15# Duplicity is distributed in the hope that it will be useful, but
16# WITHOUT ANY WARRANTY; without even the implied warranty of
17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
18# General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License
21# along with duplicity; if not, write to the Free Software Foundation,
22# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
23
24import time
25import json
26import os
27import sys
28# On debian (and derivatives), get these dependencies using:
29# apt-get install python-requests python-requests-oauthlib
30# On fedora (and derivatives), get these dependencies using:
31# yum install python-requests python-requests-oauthlib
32import requests
33from requests_oauthlib import OAuth2Session
34
35import duplicity.backend
36from duplicity.errors import BackendException
37from duplicity import globals
38from duplicity import log
39
40# For documentation on the API, see
41# http://msdn.microsoft.com/en-us/library/dn659752.aspx
42# http://msdn.microsoft.com/en-us/library/dn631844.aspx
43# https://gist.github.com/rgregg/37ba8929768a62131e85
44class OneDriveBackend(duplicity.backend.Backend):
45 """Uses Microsoft OneDrive (formerly SkyDrive) for backups."""
46
47 OAUTH_TOKEN_PATH = os.path.expanduser(
48 '~/.duplicity_onedrive_oauthtoken.json')
49
50 API_URI = 'https://apis.live.net/v5.0/'
51 MAXIMUM_FRAGMENT_SIZE = 60 * 1024 * 1024
52 BITS_1_5_UPLOAD_PROTOCOL = '{7df0354d-249b-430f-820d-3d2a9bef4931}'
53 CLIENT_ID = '000000004C12E85D'
54 CLIENT_SECRET = 'k1oR0CbtbvTG9nK1PEDeVW2dzvAaiN4d'
55 OAUTH_TOKEN_URI = 'https://login.live.com/oauth20_token.srf'
56 OAUTH_AUTHORIZE_URI = 'https://login.live.com/oauth20_authorize.srf'
57 OAUTH_REDIRECT_URI = 'https://login.live.com/oauth20_desktop.srf'
58 # wl.skydrive is for reading files,
59 # wl.skydrive_update is for creating/writing files,
60 # wl.offline_access is necessary for duplicity to access onedrive without
61 # the user being logged in right now.
62 OAUTH_SCOPE = ['wl.skydrive', 'wl.skydrive_update', 'wl.offline_access']
63
64 def __init__(self, parsed_url):
65 duplicity.backend.Backend.__init__(self, parsed_url)
66 self.names_to_ids = None
67 self.user_id = None
68 self.directory = parsed_url.path.lstrip('/')
69 if self.directory == "":
70 raise BackendException((
71 'You did not specify a path. '
72 'Please specify a path, e.g. onedrive://duplicity_backups'))
73 if globals.volsize > (10 * 1024 * 1024 * 1024):
74 raise BackendException((
75 'Your --volsize is bigger than 10 GiB, which is the maximum '
76 'file size on OneDrive.'))
77 self.initialize_oauth2_session()
78 self.resolve_directory()
79
80 def initialize_oauth2_session(self):
81 def token_updater(token):
82 try:
83 with open(self.OAUTH_TOKEN_PATH, 'w') as f:
84 json.dump(token, f)
85 except Exception as e:
86 log.Error(('Could not save the OAuth2 token to %s. '
87 'This means you may need to do the OAuth2 '
88 'authorization process again soon. '
89 'Original error: %s' % (
90 self.OAUTH_TOKEN_PATH, e)))
91
92 token = None
93 try:
94 with open(self.OAUTH_TOKEN_PATH) as f:
95 token = json.load(f)
96 except IOError as e:
97 log.Error(('Could not load OAuth2 token. '
98 'Trying to create a new one. (original error: %s)' % e))
99
100 self.http_client = OAuth2Session(
101 self.CLIENT_ID,
102 scope=self.OAUTH_SCOPE,
103 redirect_uri=self.OAUTH_REDIRECT_URI,
104 token=token,
105 auto_refresh_kwargs={
106 'client_id': self.CLIENT_ID,
107 'client_secret': self.CLIENT_SECRET,
108 },
109 auto_refresh_url=self.OAUTH_TOKEN_URI,
110 token_updater=token_updater)
111
112 # Send a request to make sure the token is valid (or could at least be
113 # refreshed successfully, which will happen under the covers). In case
114 # this request fails, the provided token was too old (i.e. expired),
115 # and we need to get a new token.
116 user_info_response = self.http_client.get(self.API_URI + 'me')
117 if user_info_response.status_code != requests.codes.ok:
118 token = None
119
120 if token is None:
121 if not sys.stdout.isatty() or not sys.stdin.isatty():
122 log.FatalError(('The OAuth2 token could not be loaded from %s '
123 'and you are not running duplicity '
124 'interactively, so duplicity cannot possibly '
125 'access OneDrive.' % self.OAUTH_TOKEN_PATH))
126 authorization_url, state = self.http_client.authorization_url(
127 self.OAUTH_AUTHORIZE_URI, display='touch')
128
129 print ''
130 print ('In order to authorize duplicity to access your OneDrive, '
131 'please open %s in a browser and copy the URL of the blank '
132 'page the dialog leads to.' % authorization_url)
133 print ''
134
135 redirected_to = raw_input('URL of the blank page: ')
136
137 token = self.http_client.fetch_token(self.OAUTH_TOKEN_URI,
138 client_secret=self.CLIENT_SECRET,
139 authorization_response=redirected_to)
140
141 user_info_response = self.http_client.get(self.API_URI + 'me')
142 user_info_response.raise_for_status()
143
144 try:
145 with open(self.OAUTH_TOKEN_PATH, 'w') as f:
146 json.dump(token, f)
147 except Exception as e:
148 log.Error(('Could not save the OAuth2 token to %s. '
149 'This means you need to do the OAuth2 authorization '
150 'process on every start of duplicity. '
151 'Original error: %s' % (
152 self.OAUTH_TOKEN_PATH, e)))
153
154 if not 'id' in user_info_response.json():
155 log.Error('user info response lacks the "id" field.')
156
157 self.user_id = user_info_response.json()['id']
158
159 def resolve_directory(self):
160 """Ensures self.directory_id contains the folder id for the path.
161
162 There is no API call to resolve a logical path (e.g.
163 /backups/duplicity/notebook/), so we recursively list directories
164 until we get the object id of the configured directory, creating
165 directories as necessary.
166 """
167 object_id = 'me/skydrive'
168 for component in self.directory.split('/'):
169 tried_mkdir = False
170 while True:
171 files = self.get_files(object_id)
172 names_to_ids = {x['name']: x['id'] for x in files}
173 if component not in names_to_ids:
174 if not tried_mkdir:
175 self.mkdir(object_id, component)
176 tried_mkdir = True
177 continue
178 raise BackendException((
179 'Could not resolve/create directory "%s" on '
180 'OneDrive: %s not in %s (files of folder %s)' % (
181 self.directory, component,
182 names_to_ids.keys(), object_id)))
183 break
184 object_id = names_to_ids[component]
185 self.directory_id = object_id
186 log.Debug('OneDrive id for the configured directory "%s" is "%s"' % (
187 self.directory, self.directory_id))
188
189 def mkdir(self, object_id, folder_name):
190 data = {'name': folder_name, 'description': 'Created by duplicity'}
191 headers = {'Content-Type': 'application/json'}
192 response = self.http_client.post(
193 self.API_URI + object_id,
194 data=json.dumps(data),
195 headers=headers)
196 response.raise_for_status()
197
198 def get_files(self, path):
199 response = self.http_client.get(self.API_URI + path + '/files')
200 response.raise_for_status()
201 if 'data' not in response.json():
202 raise BackendException((
203 'Malformed JSON: expected "data" member in %s' % (
204 response.json())))
205 return response.json()['data']
206
207 def _list(self):
208 files = self.get_files(self.directory_id)
209 self.names_to_ids = {x['name']: x['id'] for x in files}
210 return [x['name'] for x in files]
211
212 def get_file_id(self, remote_filename):
213 """Returns the file id from cache, updating the cache if necessary."""
214 if (self.names_to_ids is None or
215 remote_filename not in self.names_to_ids):
216 self._list()
217 return self.names_to_ids.get(remote_filename)
218
219 def _get(self, remote_filename, local_path):
220 with local_path.open('wb') as f:
221 file_id = self.get_file_id(remote_filename)
222 if file_id is None:
223 raise BackendException((
224 'File "%s" cannot be downloaded: it does not exist' % (
225 remote_filename)))
226 response = self.http_client.get(
227 self.API_URI + file_id + '/content', stream=True)
228 response.raise_for_status()
229 for chunk in response.iter_content(chunk_size=4096):
230 if chunk:
231 f.write(chunk)
232 f.flush()
233
234 def _put(self, source_path, remote_filename):
235 # Check if the user has enough space available on OneDrive before even
236 # attempting to upload the file.
237 source_size = os.path.getsize(source_path.name)
238 start = time.time()
239 response = self.http_client.get(self.API_URI + 'me/skydrive/quota')
240 response.raise_for_status()
241 if ('available' in response.json() and
242 source_size > response.json()['available']):
243 raise BackendException((
244 'Out of space: trying to store "%s" (%d bytes), but only '
245 '%d bytes available on OneDrive.' % (
246 source_path.name, source_size,
247 response.json()['available'])))
248 log.Debug("Checked quota in %fs" % (time.time() - start))
249
250 with source_path.open() as source_file:
251 start = time.time()
252 # Create a BITS session, so that we can upload large files.
253 short_directory_id = self.directory_id.split('.')[-1]
254 url = 'https://cid-%s.users.storage.live.com/items/%s/%s' % (
255 self.user_id, short_directory_id, remote_filename)
256 headers = {
257 'X-Http-Method-Override': 'BITS_POST',
258 'BITS-Packet-Type': 'Create-Session',
259 'BITS-Supported-Protocols': self.BITS_1_5_UPLOAD_PROTOCOL,
260 }
261
262 response = self.http_client.post(
263 url,
264 headers=headers)
265 response.raise_for_status()
266 if (not 'bits-packet-type' in response.headers or
267 response.headers['bits-packet-type'].lower() != 'ack'):
268 raise BackendException((
269 'File "%s" cannot be uploaded: '
270 'Could not create BITS session: '
271 'Server response did not include BITS-Packet-Type: ACK' % (
272 remote_filename)))
273 bits_session_id = response.headers['bits-session-id']
274 log.Debug('BITS session id is "%s"' % bits_session_id)
275
276 # Send fragments (with a maximum size of 60 MB each).
277 offset = 0
278 while True:
279 chunk = source_file.read(self.MAXIMUM_FRAGMENT_SIZE)
280 if len(chunk) == 0:
281 break
282 headers = {
283 'X-Http-Method-Override': 'BITS_POST',
284 'BITS-Packet-Type': 'Fragment',
285 'BITS-Session-Id': bits_session_id,
286 'Content-Range': 'bytes %d-%d/%d' % (offset, offset + len(chunk) - 1, source_size),
287 }
288 response = self.http_client.post(
289 url,
290 headers=headers,
291 data=chunk)
292 response.raise_for_status()
293 offset += len(chunk)
294
295 # Close the BITS session to commit the file.
296 headers = {
297 'X-Http-Method-Override': 'BITS_POST',
298 'BITS-Packet-Type': 'Close-Session',
299 'BITS-Session-Id': bits_session_id,
300 }
301 response = self.http_client.post(url, headers=headers)
302 response.raise_for_status()
303
304 log.Debug("PUT file in %fs" % (time.time() - start))
305
306 def _delete(self, remote_filename):
307 file_id = self.get_file_id(remote_filename)
308 if file_id is None:
309 raise BackendException((
310 'File "%s" cannot be deleted: it does not exist' % (
311 remote_filename)))
312 response = self.http_client.delete(self.API_URI + file_id)
313 response.raise_for_status()
314
315 def _query(self, remote_filename):
316 file_id = self.get_file_id(remote_filename)
317 if file_id is None:
318 return {'size': -1}
319 response = self.http_client.get(self.API_URI + file_id)
320 response.raise_for_status()
321 if 'size' not in response.json():
322 raise BackendException((
323 'Malformed JSON: expected "size" member in %s' % (
324 response.json())))
325 return {'size': response.json()['size']}
326
327 def _retry_cleanup(self):
328 self.initialize_oauth2_session()
329
330duplicity.backend.register_backend('onedrive', OneDriveBackend)
0331
=== modified file 'duplicity/commandline.py'
--- duplicity/commandline.py 2014-12-12 14:54:56 +0000
+++ duplicity/commandline.py 2015-01-04 21:11:21 +0000
@@ -859,6 +859,7 @@
859 mega://%(user)s[:%(password)s]@%(other_host)s/%(some_dir)s859 mega://%(user)s[:%(password)s]@%(other_host)s/%(some_dir)s
860 copy://%(user)s[:%(password)s]@%(other_host)s/%(some_dir)s860 copy://%(user)s[:%(password)s]@%(other_host)s/%(some_dir)s
861 dpbx:///%(some_dir)s861 dpbx:///%(some_dir)s
862 onedrive://%(some_dir)s
862863
863""" % dict864""" % dict
864865

Subscribers

People subscribed via source and target branches