Merge lp:~adrien-delhorme/duplicity/hubic into lp:~duplicity-team/duplicity/0.7-series
- hubic
- Merge into 0.7-series
Status: | Merged |
---|---|
Merged at revision: | 1025 |
Proposed branch: | lp:~adrien-delhorme/duplicity/hubic |
Merge into: | lp:~duplicity-team/duplicity/0.7-series |
Diff against target: |
421 lines (+368/-1) 5 files modified
bin/duplicity.1 (+29/-1) duplicity/backends/hubicbackend.py (+62/-0) duplicity/backends/pyrax_identity/__init__.py (+20/-0) duplicity/backends/pyrax_identity/hubic.py (+256/-0) setup.py (+1/-0) |
To merge this branch: | bzr merge lp:~adrien-delhorme/duplicity/hubic |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
edso | Approve | ||
Review via email: mp+240539@code.launchpad.net |
Commit message
Description of the change
Add Hubic support through pyrax and a custom pyrax_identity module.
- 1015. By Adrien Delhorme
-
Update man page
Add hubic identity module
Adrien Delhorme (adrien-delhorme) wrote : | # |
Hi,
I've pushed some fixes yesterday. Let me know if I can improve something.
edso (ed.so) wrote : | # |
please recheck.. at least in manpage you have things like
<<<<<<< TREE
and
======
which are leftovers from a conflict.
also you can join hubic & cloudfiles under REQUIREMENTS, as they both need pyrax.
is "hubiC" a typo?
thx ..ede
edso (ed.so) wrote : | # |
ok, sorry.. no leftovers. actually your manpage file conflicts with the branch. there were changes in between.
update your local branch and readd/merge your changes to the manpage again. then repush your branch to this location.
..ede
- 1016. By Adrien Delhorme
-
Update man page
- 1017. By Adrien Delhorme
-
Merge lp:duplicity onto lp:~adrien-delhorme/duplicity/hubic
Adrien Delhorme (adrien-delhorme) wrote : | # |
I did the merge.
hubiC, with a capital C is the real name: it stands for "hub in the Cloud".
Preview Diff
1 | === modified file 'bin/duplicity.1' |
2 | --- bin/duplicity.1 2014-10-30 17:22:39 +0000 |
3 | +++ bin/duplicity.1 2014-11-28 06:24:34 +0000 |
4 | @@ -64,7 +64,7 @@ |
5 | .B Cloud Files Python API (deprecated) |
6 | - http://www.rackspace.com/knowledge_center/article/python-api-installation-for-cloud-files |
7 | .TP |
8 | -.BR "cfpyrax backend" " (Rackspace Cloud)" |
9 | +.BR "cfpyrax backend" " (Rackspace Cloud) and " "hubic backend" " (hubic.com)" |
10 | .B Rackspace CloudFiles Pyrax API |
11 | - http://docs.rackspace.com/sdks/guide/content/python.html |
12 | .TP |
13 | @@ -1143,6 +1143,15 @@ |
14 | hsi://user[:password]@other.host/some_dir |
15 | .RE |
16 | .PP |
17 | +.B "hubiC" |
18 | +.PP |
19 | +.RS |
20 | +cf+hubic://container_name |
21 | +.PP |
22 | +See also |
23 | +.B "A NOTE ON HUBIC" |
24 | +.RE |
25 | +.PP |
26 | .B "IMAP email storage" |
27 | .PP |
28 | .RS |
29 | @@ -1635,6 +1644,25 @@ |
30 | Create Access Keys: |
31 | https://code.google.com/apis/console#:storage:legacy |
32 | |
33 | +.SH A NOTE ON HUBIC |
34 | +The hubic backend requires the pyrax library to be installed on the system. See REQUIREMENTS above. |
35 | +You will need to set your credentials for hubiC in a file called ~/.hubic_credentials, following this |
36 | +pattern: |
37 | +.PP |
38 | +.RS |
39 | +[hubic] |
40 | +.br |
41 | +email = your_email |
42 | +.br |
43 | +password = your_password |
44 | +.br |
45 | +client_id = api_client_id |
46 | +.br |
47 | +client_secret = api_secret_key |
48 | +.br |
49 | +redirect_uri = http://localhost/ |
50 | +.RE |
51 | + |
52 | .SH A NOTE ON IMAP |
53 | An IMAP account can be used as a target for the upload. The userid may |
54 | be specified and the password will be requested. |
55 | |
56 | === added file 'duplicity/backends/hubicbackend.py' |
57 | --- duplicity/backends/hubicbackend.py 1970-01-01 00:00:00 +0000 |
58 | +++ duplicity/backends/hubicbackend.py 2014-11-28 06:24:34 +0000 |
59 | @@ -0,0 +1,62 @@ |
60 | +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- |
61 | +# |
62 | +# Copyright 2013 J.P. Krauss <jkrauss@asymworks.com> |
63 | +# |
64 | +# This file is part of duplicity. |
65 | +# |
66 | +# Duplicity is free software; you can redistribute it and/or modify it |
67 | +# under the terms of the GNU General Public License as published by the |
68 | +# Free Software Foundation; either version 2 of the License, or (at your |
69 | +# option) any later version. |
70 | +# |
71 | +# Duplicity is distributed in the hope that it will be useful, but |
72 | +# WITHOUT ANY WARRANTY; without even the implied warranty of |
73 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
74 | +# General Public License for more details. |
75 | +# |
76 | +# You should have received a copy of the GNU General Public License |
77 | +# along with duplicity; if not, write to the Free Software Foundation, |
78 | +# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
79 | + |
80 | +import os |
81 | + |
82 | +import duplicity.backend |
83 | +from duplicity import log |
84 | +from duplicity import util |
85 | +from duplicity.errors import BackendException |
86 | +from ._cf_pyrax import PyraxBackend |
87 | + |
88 | + |
89 | +class HubicBackend(PyraxBackend): |
90 | + """ |
91 | + Backend for Hubic using Pyrax |
92 | + """ |
93 | + def __init__(self, parsed_url): |
94 | + try: |
95 | + import pyrax |
96 | + except ImportError: |
97 | + raise BackendException("This backend requires the pyrax " |
98 | + "library available from Rackspace.") |
99 | + |
100 | + # Inform Pyrax that we're talking to Hubic |
101 | + pyrax.set_setting("identity_type", "duplicity.backends.pyrax_identity.hubic.HubicIdentity") |
102 | + |
103 | + CREDENTIALS_FILE = os.path.expanduser("~/.hubic_credentials") |
104 | + if os.path.exists(CREDENTIALS_FILE): |
105 | + try: |
106 | + pyrax.set_credential_file(CREDENTIALS_FILE) |
107 | + except Exception as e: |
108 | + log.FatalError("Connection failed, please check your credentials: %s %s" |
109 | + % (e.__class__.__name__, util.uexc(e)), |
110 | + log.ErrorCode.connection_failed) |
111 | + |
112 | + else: |
113 | + raise BackendException("No ~/.hubic_credentials file found.") |
114 | + |
115 | + container = parsed_url.path.lstrip('/') |
116 | + |
117 | + self.client_exc = pyrax.exceptions.ClientException |
118 | + self.nso_exc = pyrax.exceptions.NoSuchObject |
119 | + self.container = pyrax.cloudfiles.create_container(container) |
120 | + |
121 | +duplicity.backend.register_backend("cf+hubic", HubicBackend) |
122 | |
123 | === added directory 'duplicity/backends/pyrax_identity' |
124 | === added file 'duplicity/backends/pyrax_identity/__init__.py' |
125 | --- duplicity/backends/pyrax_identity/__init__.py 1970-01-01 00:00:00 +0000 |
126 | +++ duplicity/backends/pyrax_identity/__init__.py 2014-11-28 06:24:34 +0000 |
127 | @@ -0,0 +1,20 @@ |
128 | +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- |
129 | +# |
130 | +# Copyright 2002 Ben Escoto <ben@emerose.org> |
131 | +# Copyright 2007 Kenneth Loafman <kenneth@loafman.com> |
132 | +# |
133 | +# This file is part of duplicity. |
134 | +# |
135 | +# Duplicity is free software; you can redistribute it and/or modify it |
136 | +# under the terms of the GNU General Public License as published by the |
137 | +# Free Software Foundation; either version 2 of the License, or (at your |
138 | +# option) any later version. |
139 | +# |
140 | +# Duplicity is distributed in the hope that it will be useful, but |
141 | +# WITHOUT ANY WARRANTY; without even the implied warranty of |
142 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
143 | +# General Public License for more details. |
144 | +# |
145 | +# You should have received a copy of the GNU General Public License |
146 | +# along with duplicity; if not, write to the Free Software Foundation, |
147 | +# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
148 | \ No newline at end of file |
149 | |
150 | === added file 'duplicity/backends/pyrax_identity/hubic.py' |
151 | --- duplicity/backends/pyrax_identity/hubic.py 1970-01-01 00:00:00 +0000 |
152 | +++ duplicity/backends/pyrax_identity/hubic.py 2014-11-28 06:24:34 +0000 |
153 | @@ -0,0 +1,256 @@ |
154 | +#!/usr/bin/env python |
155 | +# -*- coding: utf-8 -*- |
156 | +# Copyright (c) 2014 Gu1 |
157 | +# Licensed under the MIT license |
158 | + |
159 | +import os |
160 | +import pyrax |
161 | +import pyrax.exceptions as exc |
162 | +import requests |
163 | +import re |
164 | +import urlparse |
165 | +import ConfigParser |
166 | +import time |
167 | +from pyrax.base_identity import BaseIdentity, Service |
168 | +from requests.compat import quote, quote_plus |
169 | + |
170 | + |
171 | +OAUTH_ENDPOINT = "https://api.hubic.com/oauth/" |
172 | +API_ENDPOINT = "https://api.hubic.com/1.0/" |
173 | +TOKENS_FILE = os.path.expanduser("~/.hubic_tokens") |
174 | + |
175 | + |
176 | +class BearerTokenAuth(requests.auth.AuthBase): |
177 | + def __init__(self, token): |
178 | + self.token = token |
179 | + |
180 | + def __call__(self, req): |
181 | + req.headers['Authorization'] = 'Bearer '+self.token |
182 | + return req |
183 | + |
184 | + |
185 | +class HubicIdentity(BaseIdentity): |
186 | + |
187 | + def _get_auth_endpoint(self): |
188 | + return "" |
189 | + |
190 | + def set_credentials(self, email, password, client_id, |
191 | + client_secret, redirect_uri, |
192 | + authenticate=False): |
193 | + """Sets the username and password directly.""" |
194 | + self._email = email |
195 | + self._password = password |
196 | + self._client_id = client_id |
197 | + self.tenant_id = client_id |
198 | + self._client_secret = client_secret |
199 | + self._redirect_uri = redirect_uri |
200 | + if authenticate: |
201 | + self.authenticate() |
202 | + |
203 | + def _read_credential_file(self, cfg): |
204 | + """ |
205 | + Parses the credential file with Rackspace-specific labels. |
206 | + """ |
207 | + self._email = cfg.get("hubic", "email") |
208 | + self._password = cfg.get("hubic", "password") |
209 | + self._client_id = cfg.get("hubic", "client_id") |
210 | + self.tenant_id = self._client_id |
211 | + self._client_secret = cfg.get("hubic", "client_secret") |
212 | + self._redirect_uri = cfg.get("hubic", "redirect_uri") |
213 | + |
214 | + def _parse_error(self, resp): |
215 | + if not 'location' in resp.headers: |
216 | + return None |
217 | + query = urlparse.urlsplit(resp.headers['location']).query |
218 | + qs = dict(urlparse.parse_qsl(query)) |
219 | + return {'error': qs['error'], 'error_description': qs['error_description']} |
220 | + |
221 | + def _get_access_token(self, code): |
222 | + r = requests.post( |
223 | + OAUTH_ENDPOINT+'token/', |
224 | + data={ |
225 | + 'code': code, |
226 | + 'redirect_uri': self._redirect_uri, |
227 | + 'grant_type': 'authorization_code', |
228 | + }, |
229 | + auth=(self._client_id, self._client_secret) |
230 | + ) |
231 | + if r.status_code != 200: |
232 | + try: |
233 | + err = r.json() |
234 | + err['code'] = r.status_code |
235 | + except: |
236 | + err = {} |
237 | + |
238 | + raise exc.AuthenticationFailed("Unable to get oauth access token, " |
239 | + "wrong client_id or client_secret ? (%s)" % str(err)) |
240 | + |
241 | + oauth_token = r.json() |
242 | + |
243 | + config = ConfigParser.ConfigParser() |
244 | + config.read(TOKENS_FILE) |
245 | + |
246 | + if not config.has_section("hubic"): |
247 | + config.add_section("hubic") |
248 | + |
249 | + if oauth_token['access_token'] is not None: |
250 | + config.set("hubic", "access_token", oauth_token['access_token']) |
251 | + with open(TOKENS_FILE, 'wb') as configfile: |
252 | + config.write(configfile) |
253 | + else: |
254 | + raise exc.AuthenticationFailed("Unable to get oauth access token, wrong client_id or client_secret ? (%s)" % str(err)) |
255 | + |
256 | + if oauth_token['refresh_token'] is not None: |
257 | + config.set("hubic", "refresh_token", oauth_token['refresh_token']) |
258 | + with open(TOKENS_FILE, 'wb') as configfile: |
259 | + config.write(configfile) |
260 | + else: |
261 | + raise exc.AuthenticationFailed("Unable to get the refresh token.") |
262 | + |
263 | + # removing username and password from .hubic_tokens |
264 | + if config.has_option("hubic", "email"): |
265 | + config.remove_option("hubic", "email") |
266 | + with open(TOKENS_FILE, 'wb') as configfile: |
267 | + config.write(configfile) |
268 | + print "username has been removed from the .hubic_tokens file sent to the CE." |
269 | + if config.has_option("hubic", "password"): |
270 | + config.remove_option("hubic", "password") |
271 | + with open(TOKENS_FILE, 'wb') as configfile: |
272 | + config.write(configfile) |
273 | + print "password has been removed from the .hubic_tokens file sent to the CE." |
274 | + |
275 | + return oauth_token |
276 | + |
277 | + def _refresh_access_token(self): |
278 | + |
279 | + config = ConfigParser.ConfigParser() |
280 | + config.read(TOKENS_FILE) |
281 | + refresh_token = config.get("hubic", "refresh_token") |
282 | + |
283 | + if refresh_token is None: |
284 | + raise exc.AuthenticationFailed("refresh_token is null. Not acquiered before ?") |
285 | + |
286 | + success = False |
287 | + max_retries = 20 |
288 | + retries = 0 |
289 | + sleep_time = 30 |
290 | + max_sleep_time = 3600 |
291 | + |
292 | + while retries < max_retries and not success: |
293 | + r = requests.post( |
294 | + OAUTH_ENDPOINT+'token/', |
295 | + data={ |
296 | + 'refresh_token': refresh_token, |
297 | + 'grant_type': 'refresh_token', |
298 | + }, |
299 | + auth=(self._client_id, self._client_secret) |
300 | + ) |
301 | + if r.status_code != 200: |
302 | + if r.status_code == 509: |
303 | + print "status_code 509: attempt #", retries, " failed" |
304 | + retries += 1 |
305 | + time.sleep(sleep_time) |
306 | + sleep_time = sleep_time * 2 |
307 | + if sleep_time > max_sleep_time: |
308 | + sleep_time = max_sleep_time |
309 | + else: |
310 | + try: |
311 | + err = r.json() |
312 | + err['code'] = r.status_code |
313 | + except: |
314 | + err = {} |
315 | + |
316 | + raise exc.AuthenticationFailed("Unable to get oauth access token, wrong client_id or client_secret ? (%s)" % str(err)) |
317 | + else: |
318 | + success = True |
319 | + |
320 | + if not success: |
321 | + raise exc.AuthenticationFailed("All the attempts failed to get the refresh token: status_code = 509: Bandwidth Limit Exceeded") |
322 | + |
323 | + oauth_token = r.json() |
324 | + |
325 | + if oauth_token['access_token'] is not None: |
326 | + return oauth_token |
327 | + else: |
328 | + raise exc.AuthenticationFailed("Unable to get oauth access token from json") |
329 | + |
330 | + def authenticate(self): |
331 | + config = ConfigParser.ConfigParser() |
332 | + config.read(TOKENS_FILE) |
333 | + |
334 | + if config.has_option("hubic", "refresh_token"): |
335 | + oauth_token = self._refresh_access_token() |
336 | + else: |
337 | + r = requests.get( |
338 | + OAUTH_ENDPOINT+'auth/?client_id={0}&redirect_uri={1}' |
339 | + '&scope=credentials.r,account.r&response_type=code&state={2}'.format( |
340 | + quote(self._client_id), |
341 | + quote_plus(self._redirect_uri), |
342 | + pyrax.utils.random_ascii() # csrf ? wut ?.. |
343 | + ), |
344 | + allow_redirects=False |
345 | + ) |
346 | + if r.status_code != 200: |
347 | + raise exc.AuthenticationFailed("Incorrect/unauthorized " |
348 | + "client_id (%s)" % str(self._parse_error(r))) |
349 | + |
350 | + try: |
351 | + from lxml import html as lxml_html |
352 | + except ImportError: |
353 | + lxml_html = None |
354 | + |
355 | + if lxml_html: |
356 | + oauth = lxml_html.document_fromstring(r.content).xpath('//input[@name="oauth"]') |
357 | + oauth = oauth[0].value if oauth else None |
358 | + else: |
359 | + oauth = re.search(r'<input\s+[^>]*name=[\'"]?oauth[\'"]?\s+[^>]*value=[\'"]?(\d+)[\'"]?>', r.content) |
360 | + oauth = oauth.group(1) if oauth else None |
361 | + |
362 | + if not oauth: |
363 | + raise exc.AuthenticationFailed("Unable to get oauth_id from authorization page") |
364 | + |
365 | + if self._email is None or self._password is None: |
366 | + raise exc.AuthenticationFailed("Cannot retrieve email and/or password. Please run expresslane-hubic-setup.sh") |
367 | + |
368 | + r = requests.post( |
369 | + OAUTH_ENDPOINT+'auth/', |
370 | + data={ |
371 | + 'action': 'accepted', |
372 | + 'oauth': oauth, |
373 | + 'login': self._email, |
374 | + 'user_pwd': self._password, |
375 | + 'account': 'r', |
376 | + 'credentials': 'r', |
377 | + |
378 | + }, |
379 | + allow_redirects=False |
380 | + ) |
381 | + |
382 | + try: |
383 | + query = urlparse.urlsplit(r.headers['location']).query |
384 | + code = dict(urlparse.parse_qsl(query))['code'] |
385 | + except: |
386 | + raise exc.AuthenticationFailed("Unable to authorize client_id, invalid login/password ?") |
387 | + |
388 | + oauth_token = self._get_access_token(code) |
389 | + |
390 | + if oauth_token['token_type'].lower() != 'bearer': |
391 | + raise exc.AuthenticationFailed("Unsupported access token type") |
392 | + |
393 | + r = requests.get( |
394 | + API_ENDPOINT+'account/credentials', |
395 | + auth=BearerTokenAuth(oauth_token['access_token']), |
396 | + ) |
397 | + |
398 | + swift_token = r.json() |
399 | + self.authenticated = True |
400 | + self.token = swift_token['token'] |
401 | + self.expires = swift_token['expires'] |
402 | + self.services['object_store'] = Service(self, { |
403 | + 'name': 'HubiC', |
404 | + 'type': 'cloudfiles', |
405 | + 'endpoints': [ |
406 | + {'public_url': swift_token['endpoint']} |
407 | + ] |
408 | + }) |
409 | + self.username = self.password = None |
410 | |
411 | === modified file 'setup.py' |
412 | --- setup.py 2014-10-27 02:27:36 +0000 |
413 | +++ setup.py 2014-11-28 06:24:34 +0000 |
414 | @@ -132,6 +132,7 @@ |
415 | url="http://duplicity.nongnu.org/index.html", |
416 | packages = ['duplicity', |
417 | 'duplicity.backends', |
418 | + 'duplicity.backends.pyrax_identity', |
419 | 'testing', |
420 | 'testing.functional', |
421 | 'testing.overrides', |
hi,
1. please update the manpage bin/duplicity.1, sections Requirements, URL Format as needed
2. please use the prefix cf+hubic to make clear that it depends on cf support, pyrax will become cf soon as the latter is deprecated
.e.de/duply.net