Merge lp:~matthew-t-bentley/duplicity/b2 into lp:~duplicity-team/duplicity/0.7-series

Proposed by Matthew Bentley
Status: Merged
Merged at revision: 1157
Proposed branch: lp:~matthew-t-bentley/duplicity/b2
Merge into: lp:~duplicity-team/duplicity/0.7-series
Diff against target: 402 lines (+357/-1)
4 files modified
bin/duplicity.1 (+6/-0)
duplicity/backends/b2backend.py (+340/-0)
duplicity/commandline.py (+9/-1)
duplicity/log.py (+2/-0)
To merge this branch: bzr merge lp:~matthew-t-bentley/duplicity/b2
Reviewer Review Type Date Requested Status
edso Approve
Review via email: mp+279453@code.launchpad.net

This proposal supersedes a proposal from 2015-12-03.

Description of the change

Adds a backed for BackBlaze's (currently beta) B2 backup service.

This adds backends/b2backend.py, modifies log.py to add an error code, modifies commandline.py to add the b2:// example to the help text, and updates the manpage to reflect these changes.

To post a comment you must log in.
Revision history for this message
edso (ed.so) wrote : Posted in a previous version of this proposal

please add backend documentation to the manpage (sections Requirements, Url Formats etc.)
 bin/duplicity.1

..ede/duply.net

Revision history for this message
edso (ed.so) wrote : Posted in a previous version of this proposal

manpage

review: Needs Fixing
Revision history for this message
Matthew Bentley (matthew-t-bentley) wrote : Posted in a previous version of this proposal

I added the necessary pieces to the manpage and updated en_GB.po for the additions to commandline.py.

Revision history for this message
edso (ed.so) wrote : Posted in a previous version of this proposal

isn't urllib2 a standard python library being distributed by default?

Requirements usually lists components needed in addition to python2.6/2.7.

..ede

Revision history for this message
Matthew Bentley (matthew-t-bentley) wrote : Posted in a previous version of this proposal

That is true. There are no other dependencies outside of python, so I removed B2 from the requirements section.

Revision history for this message
Kenneth Loafman (kenneth-loafman) wrote : Posted in a previous version of this proposal

One thing to note, po/duplicity.pot and po/en_GB.po are all handled
automatically with translation tools. Please remove these changes from the
merge. They will be generated automatically by the tools and Launchpad
Translation Services.

On Thu, Dec 3, 2015 at 8:33 AM, Matthew Bentley <email address hidden>
wrote:

> That is true. There are no other dependencies outside of python, so I
> removed B2 from the requirements section.
> --
> https://code.launchpad.net/~matthew-t-bentley/duplicity/b2/+merge/279445
> You are subscribed to branch lp:duplicity.
>

Revision history for this message
Matthew Bentley (matthew-t-bentley) wrote :

Fixed changes to po files.

Revision history for this message
Kenneth Loafman (kenneth-loafman) wrote :

@edso, is this ready to go?

Revision history for this message
edso (ed.so) :
review: Approve
Revision history for this message
edso (ed.so) wrote :

no objections from my side.
 https://www.youtube.com/watch?v=3jd1Ih8EUmw#t=8 :))

..ede

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 2015-12-02 20:16:00 +0000
+++ bin/duplicity.1 2015-12-03 15:07:58 +0000
@@ -1010,6 +1010,12 @@
1010.B "A NOTE ON AZURE ACCESS"1010.B "A NOTE ON AZURE ACCESS"
1011.RE1011.RE
1012.PP1012.PP
1013.BR "B2"
1014.PP
1015.RS
1016b2://account_id[:application_key]@bucket_name/[folder/]
1017.RE
1018.PP
1013.BR "Cloud Files" " (Rackspace)"1019.BR "Cloud Files" " (Rackspace)"
1014.PP1020.PP
1015.RS1021.RS
10161022
=== added file 'duplicity/backends/b2backend.py'
--- duplicity/backends/b2backend.py 1970-01-01 00:00:00 +0000
+++ duplicity/backends/b2backend.py 2015-12-03 15:07:58 +0000
@@ -0,0 +1,340 @@
1#
2# Copyright (c) 2015 Matthew Bentley
3#
4#
5# Permission is hereby granted, free of charge, to any person obtaining a copy
6# of this software and associated documentation files (the "Software"), to deal
7# in the Software without restriction, including without limitation the rights
8# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9# copies of the Software, and to permit persons to whom the Software is
10# furnished to do so, subject to the following conditions:
11#
12#
13# The above copyright notice and this permission notice shall be included in
14# all copies or substantial portions of the Software.
15#
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23# THE SOFTWARE.
24
25import os
26import hashlib
27
28import duplicity.backend
29from duplicity.errors import BackendException, FatalBackendException
30
31import json
32import urllib2
33import base64
34
35
36class B2Backend(duplicity.backend.Backend):
37 """
38 Backend for BackBlaze's B2 storage service
39 """
40
41 def __init__(self, parsed_url):
42 """
43 Authorize to B2 api and set up needed variables
44 """
45 duplicity.backend.Backend.__init__(self, parsed_url)
46
47 self.account_id = parsed_url.username
48 account_key = self.get_password()
49
50 self.url_parts = [
51 x for x in parsed_url.path.replace("@", "/").split('/') if x != ''
52 ]
53 if self.url_parts:
54 self.username = self.url_parts.pop(0)
55 self.bucket_name = self.url_parts.pop(0)
56 else:
57 raise BackendException("B2 requires a bucket name")
58 self.path = "/".join(self.url_parts)
59
60 id_and_key = self.account_id + ":" + account_key
61 basic_auth_string = 'Basic ' + base64.b64encode(id_and_key)
62 headers = {'Authorization': basic_auth_string}
63
64 request = urllib2.Request(
65 'https://api.backblaze.com/b2api/v1/b2_authorize_account',
66 headers=headers
67 )
68
69 response = urllib2.urlopen(request)
70 response_data = json.loads(response.read())
71 response.close()
72
73 self.auth_token = response_data['authorizationToken']
74 self.api_url = response_data['apiUrl']
75 self.download_url = response_data['downloadUrl']
76
77 try:
78 self.find_or_create_bucket(self.bucket_name)
79 except urllib2.HTTPError:
80 raise FatalBackendException("Bucket cannot be created")
81
82 def _get(self, remote_filename, local_path):
83 """
84 Download remote_filename to local_path
85 """
86 remote_filename = self.full_filename(remote_filename)
87 url = self.download_url + \
88 '/file/' + self.bucket_name + '/' + \
89 remote_filename
90 resp = self.get_or_post(url, None)
91
92 to_file = open(local_path.name, 'wb')
93 to_file.write(resp)
94 to_file.close()
95
96 def _put(self, source_path, remote_filename):
97 """
98 Copy source_path to remote_filename
99 """
100 self._delete(remote_filename)
101 digest = self.hex_sha1_of_file(source_path)
102 content_type = 'application/pgp-encrypted'
103 remote_filename = self.full_filename(remote_filename)
104
105 info = self.get_upload_info(self.bucket_id)
106 url = info['uploadUrl']
107
108 headers = {
109 'Authorization': info['authorizationToken'],
110 'X-Bz-File-Name': remote_filename,
111 'Content-Type': content_type,
112 'X-Bz-Content-Sha1': digest,
113 'Content-Length': str(os.path.getsize(source_path.name)),
114 }
115 data_file = source_path.open()
116 self.get_or_post(url, None, headers, data_file=data_file)
117
118 def _list(self):
119 """
120 List files on remote server
121 """
122 endpoint = 'b2_list_file_names'
123 url = self.formatted_url(endpoint)
124 params = {
125 'bucketId': self.bucket_id,
126 'maxFileCount': 1000,
127 }
128 try:
129 resp = self.get_or_post(url, params)
130 except urllib2.HTTPError:
131 return []
132
133 files = [x['fileName'].split('/')[-1] for x in resp['files']]
134
135 next_file = resp['nextFileName']
136 while next_file:
137 params['startFileName'] = next_file
138 try:
139 resp = self.get_or_post(url, params)
140 except urllib2.HTTPError:
141 return files
142
143 files += [x['fileName'].split('/')[-1] for x in resp['files']]
144 next_file = resp['nextFileName']
145
146 return files
147
148 def _delete(self, filename):
149 """
150 Delete filename from remote server
151 """
152 endpoint = 'b2_delete_file_version'
153 url = self.formatted_url(endpoint)
154 fileid = self.get_file_id(filename)
155 if fileid is None:
156 return
157 filename = self.full_filename(filename)
158 params = {'fileName': filename, 'fileId': fileid}
159 try:
160 self.get_or_post(url, params)
161 except urllib2.HTTPError as e:
162 if e.code == 400:
163 return
164 else:
165 raise e
166
167 def _query(self, filename):
168 """
169 Get size info of filename
170 """
171 info = self.get_file_info(filename)
172 if not info:
173 return {'size': -1}
174
175 return {'size': info['size']}
176
177 def _error_code(self, operation, e):
178 if isinstance(e, urllib2.HTTPError):
179 if e.code == 400:
180 return log.ErrorCode.bad_request
181 if e.code == 500:
182 return log.ErrorCode.backed_error
183 if e.code == 403:
184 return log.ErrorCode.backed_permission_denied
185
186 def find_or_create_bucket(self, bucket_name):
187 """
188 Find a bucket with name bucket_name and save its id.
189 If it doesn't exist, create it
190 """
191 endpoint = 'b2_list_buckets'
192 url = self.formatted_url(endpoint)
193
194 params = {'accountId': self.account_id}
195 resp = self.get_or_post(url, params)
196
197 bucket_names = [x['bucketName'] for x in resp['buckets']]
198
199 if bucket_name not in bucket_names:
200 self.create_bucket(bucket_name)
201 else:
202 self.bucket_id = {
203 x[
204 'bucketName'
205 ]: x['bucketId'] for x in resp['buckets']
206 }[bucket_name]
207
208 def create_bucket(self, bucket_name):
209 """
210 Create a bucket with name bucket_name and save its id
211 """
212 endpoint = 'b2_create_bucket'
213 url = self.formatted_url(endpoint)
214 params = {
215 'accountId': self.account_id,
216 'bucketName': bucket_name,
217 'bucketType': 'allPrivate'
218 }
219 resp = self.get_or_post(url, params)
220
221 self.bucket_id = resp['bucketId']
222
223 def formatted_url(self, endpoint):
224 """
225 Return the full api endpoint from just the last part
226 """
227 return '%s/b2api/v1/%s' % (self.api_url, endpoint)
228
229 def get_upload_info(self, bucket_id):
230 """
231 Get an upload url for a bucket
232 """
233 endpoint = 'b2_get_upload_url'
234 url = self.formatted_url(endpoint)
235 return self.get_or_post(url, {'bucketId': bucket_id})
236
237 def get_or_post(self, url, data, headers=None, data_file=None):
238 """
239 Sends the request, either get or post.
240 If data and data_file are None, send a get request.
241 data_file takes precedence over data.
242 If headers are not supplied, just send with an auth key
243 """
244 if headers is None:
245 headers = {'Authorization': self.auth_token}
246 if data_file is not None:
247 data = data_file
248 else:
249 data = json.dumps(data) if data else None
250
251 encoded_headers = dict(
252 (k, urllib2.quote(v.encode('utf-8')))
253 for (k, v) in headers.iteritems()
254 )
255
256 with OpenUrl(url, data, encoded_headers) as resp:
257 out = resp.read()
258 try:
259 return json.loads(out)
260 except ValueError:
261 return out
262
263 def get_file_info(self, filename):
264 """
265 Get a file info from filename
266 """
267 endpoint = 'b2_list_file_names'
268 url = self.formatted_url(endpoint)
269 filename = self.full_filename(filename)
270 params = {
271 'bucketId': self.bucket_id,
272 'maxFileCount': 1,
273 'startFileName': filename,
274 }
275 resp = self.get_or_post(url, params)
276
277 try:
278 return resp['files'][0]
279 except IndexError:
280 return None
281 except TypeError:
282 return None
283
284 def get_file_id(self, filename):
285 """
286 Get a file id form filename
287 """
288 try:
289 return self.get_file_info(filename)['fileId']
290 except IndexError:
291 return None
292 except TypeError:
293 return None
294
295 def full_filename(self, filename):
296 if self.path:
297 return self.path + '/' + filename
298 else:
299 return filename
300
301 @staticmethod
302 def hex_sha1_of_file(path):
303 """
304 Calculate the sha1 of a file to upload
305 """
306 f = path.open()
307 block_size = 1024 * 1024
308 digest = hashlib.sha1()
309 while True:
310 data = f.read(block_size)
311 if len(data) == 0:
312 break
313 digest.update(data)
314 f.close()
315 return digest.hexdigest()
316
317
318class OpenUrl(object):
319 """
320 Context manager that handles an open urllib2.Request, and provides
321 the file-like object that is the response.
322 """
323
324 def __init__(self, url, data, headers):
325 self.url = url
326 self.data = data
327 self.headers = headers
328 self.file = None
329
330 def __enter__(self):
331 request = urllib2.Request(self.url, self.data, self.headers)
332 self.file = urllib2.urlopen(request)
333 return self.file
334
335 def __exit__(self, exception_type, exception, traceback):
336 if self.file is not None:
337 self.file.close()
338
339
340duplicity.backend.register_backend("b2", B2Backend)
0341
=== modified file 'duplicity/commandline.py'
--- duplicity/commandline.py 2015-10-10 00:02:35 +0000
+++ duplicity/commandline.py 2015-12-03 15:07:58 +0000
@@ -867,7 +867,14 @@
867 # TRANSL: Used in usage help to represent a user name (i.e. login).867 # TRANSL: Used in usage help to represent a user name (i.e. login).
868 # Example:868 # Example:
869 # ftp://user[:password]@other.host[:port]/some_dir869 # ftp://user[:password]@other.host[:port]/some_dir
870 'user': _("user")870 'user': _("user"),
871
872 # TRANSL: account id for b2. Example: b2://account_id@bucket/
873 'account_id': _("account_id"),
874
875 # TRANSL: application_key for b2.
876 # Example: b2://account_id:application_key@bucket/
877 'application_key': _("application_key"),
871 }878 }
872879
873 # TRANSL: Header in usage help880 # TRANSL: Header in usage help
@@ -910,6 +917,7 @@
910 dpbx:///%(some_dir)s917 dpbx:///%(some_dir)s
911 onedrive://%(some_dir)s918 onedrive://%(some_dir)s
912 azure://%(container_name)s919 azure://%(container_name)s
920 b2://%(account_id)s[:%(application_key)s]@%(bucket_name)s/[%(some_dir)s/]
913921
914""" % dict922""" % dict
915923
916924
=== modified file 'duplicity/log.py'
--- duplicity/log.py 2015-05-08 12:28:47 +0000
+++ duplicity/log.py 2015-12-03 15:07:58 +0000
@@ -307,6 +307,8 @@
307307
308 dpbx_nologin = 47308 dpbx_nologin = 47
309309
310 bad_request = 48
311
310 # 50->69 reserved for backend errors312 # 50->69 reserved for backend errors
311 backend_error = 50313 backend_error = 50
312 backend_permission_denied = 51314 backend_permission_denied = 51

Subscribers

People subscribed via source and target branches