Merge lp:~leonardr/launchpadlib/uses-restfulclient into lp:~launchpad-pqm/launchpadlib/devel
- uses-restfulclient
- Merge into devel
Proposed by
Leonard Richardson
Status: | Merged |
---|---|
Merge reported by: | Gavin Panella |
Merged at revision: | not available |
Proposed branch: | lp:~leonardr/launchpadlib/uses-restfulclient |
Merge into: | lp:~launchpad-pqm/launchpadlib/devel |
Diff against target: | None lines |
To merge this branch: | bzr merge lp:~leonardr/launchpadlib/uses-restfulclient |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
launchpadlib developers | Pending | ||
Review via email: mp+5348@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote : | # |
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'setup.py' |
2 | --- setup.py 2009-03-23 22:48:16 +0000 |
3 | +++ setup.py 2009-03-30 17:17:08 +0000 |
4 | @@ -60,6 +60,7 @@ |
5 | license='LGPL v3', |
6 | install_requires=[ |
7 | 'httplib2', |
8 | + 'lazr.restfulclient', |
9 | 'lazr.uri', |
10 | 'oauth', |
11 | 'setuptools', |
12 | |
13 | === removed file 'src/launchpadlib/_browser.py' |
14 | --- src/launchpadlib/_browser.py 2009-03-20 20:46:06 +0000 |
15 | +++ src/launchpadlib/_browser.py 1970-01-01 00:00:00 +0000 |
16 | @@ -1,265 +0,0 @@ |
17 | -# Copyright 2008 Canonical Ltd. |
18 | - |
19 | -# This file is part of launchpadlib. |
20 | -# |
21 | -# launchpadlib is free software: you can redistribute it and/or modify it |
22 | -# under the terms of the GNU Lesser General Public License as published by the |
23 | -# Free Software Foundation, version 3 of the License. |
24 | -# |
25 | -# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
26 | -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
27 | -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
28 | -# for more details. |
29 | -# |
30 | -# You should have received a copy of the GNU Lesser General Public License |
31 | -# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
32 | - |
33 | -"""Browser object to make requests of Launchpad web service. |
34 | - |
35 | -The `Browser` class implements OAuth authenticated communications with |
36 | -Launchpad. It is not part of the public launchpadlib API. |
37 | -""" |
38 | - |
39 | -__metaclass__ = type |
40 | -__all__ = [ |
41 | - 'Browser', |
42 | - ] |
43 | - |
44 | - |
45 | -import atexit |
46 | -from cStringIO import StringIO |
47 | -import gzip |
48 | -from httplib2 import ( |
49 | - FailedToDecompressContent, FileCache, Http, safename, urlnorm) |
50 | -from lazr.uri import URI |
51 | -from oauth.oauth import ( |
52 | - OAuthRequest, OAuthSignatureMethod_PLAINTEXT) |
53 | -import shutil |
54 | -import simplejson |
55 | -import tempfile |
56 | -from urllib import urlencode |
57 | -from wadllib.application import Application |
58 | -import zlib |
59 | - |
60 | -from launchpadlib.errors import HTTPError |
61 | -from launchpadlib._json import DatetimeJSONEncoder |
62 | - |
63 | - |
64 | -OAUTH_REALM = 'https://api.launchpad.net' |
65 | - |
66 | -# A drop-in replacement for httplib2's _decompressContent, which looks |
67 | -# in the Transfer-Encoding header instead of in Content-Encoding. |
68 | -def _decompressContent(response, new_content): |
69 | - content = new_content |
70 | - try: |
71 | - encoding = response.get('transfer-encoding', None) |
72 | - if encoding in ['gzip', 'deflate']: |
73 | - if encoding == 'gzip': |
74 | - content = gzip.GzipFile( |
75 | - fileobj=StringIO.StringIO(new_content)).read() |
76 | - if encoding == 'deflate': |
77 | - content = zlib.decompress(content) |
78 | - response['content-length'] = str(len(content)) |
79 | - del response['transfer-encoding'] |
80 | - except IOError: |
81 | - content = "" |
82 | - raise FailedToDecompressContent( |
83 | - ("Content purported to be compressed with %s but failed " |
84 | - "to decompress." % response.get('transfer-encoding')), |
85 | - response, content) |
86 | - return content |
87 | - |
88 | - |
89 | -class OAuthSigningHttp(Http): |
90 | - """A client that signs every outgoing request with OAuth credentials.""" |
91 | - |
92 | - def __init__(self, oauth_credentials, cache=None, timeout=None, |
93 | - proxy_info=None): |
94 | - self.oauth_credentials = oauth_credentials |
95 | - Http.__init__(self, cache, timeout, proxy_info) |
96 | - |
97 | - def _request(self, conn, host, absolute_uri, request_uri, method, body, |
98 | - headers, redirections, cachekey): |
99 | - """Sign a request with OAuth credentials before sending it.""" |
100 | - oauth_request = OAuthRequest.from_consumer_and_token( |
101 | - self.oauth_credentials.consumer, |
102 | - self.oauth_credentials.access_token, |
103 | - http_url=absolute_uri) |
104 | - oauth_request.sign_request( |
105 | - OAuthSignatureMethod_PLAINTEXT(), |
106 | - self.oauth_credentials.consumer, |
107 | - self.oauth_credentials.access_token) |
108 | - if headers.has_key('authorization'): |
109 | - # There's an authorization header left over from a |
110 | - # previous request that resulted in a redirect. Remove it |
111 | - # and start again. |
112 | - del headers['authorization'] |
113 | - |
114 | - # httplib2 asks for compressed representations in |
115 | - # Accept-Encoding. But a different content-encoding means a |
116 | - # different ETag, which can cause problems later when we make |
117 | - # a conditional request. We don't want to treat a |
118 | - # representation differently based on whether or not we asked |
119 | - # for a compressed version of it. |
120 | - # |
121 | - # So we move the compression request from Accept-Encoding to |
122 | - # TE. Transfer-encoding compression can be handled transparently. |
123 | - if 'accept-encoding' in headers: |
124 | - headers['te'] = 'deflate, gzip' |
125 | - del headers['accept-encoding'] |
126 | - headers.update(oauth_request.to_header(OAUTH_REALM)) |
127 | - return super(OAuthSigningHttp, self)._request( |
128 | - conn, host, absolute_uri, request_uri, method, body, headers, |
129 | - redirections, cachekey) |
130 | - |
131 | - def _conn_request(self, conn, request_uri, method, body, headers): |
132 | - """Decompress content using our version of _decompressContent.""" |
133 | - response, content = super(OAuthSigningHttp, self)._conn_request( |
134 | - conn, request_uri, method, body, headers) |
135 | - # Decompress the response, if it was compressed. |
136 | - if method != "HEAD": |
137 | - content = _decompressContent(response, content) |
138 | - return (response, content) |
139 | - |
140 | - def _getCachedHeader(self, uri, header): |
141 | - """Retrieve a cached value for an HTTP header.""" |
142 | - if isinstance(self.cache, MultipleRepresentationCache): |
143 | - return self.cache._getCachedHeader(uri, header) |
144 | - return None |
145 | - |
146 | - |
147 | -class MultipleRepresentationCache(FileCache): |
148 | - """A cache that can hold different representations of the same resource. |
149 | - |
150 | - If a resource has two representations with two media types, |
151 | - FileCache will only store the most recently fetched |
152 | - representation. This cache can keep track of multiple |
153 | - representations of the same resource. |
154 | - |
155 | - This class works on the assumption that outside calling code sets |
156 | - an instance's request_media_type attribute to the value of the |
157 | - 'Accept' header before initiating the request. |
158 | - |
159 | - This class is very much not thread-safe, but FileCache isn't |
160 | - thread-safe anyway. |
161 | - """ |
162 | - def __init__(self, cache): |
163 | - """Tell FileCache to call append_media_type when generating keys.""" |
164 | - super(MultipleRepresentationCache, self).__init__( |
165 | - cache, self.append_media_type) |
166 | - self.request_media_type = None |
167 | - |
168 | - def append_media_type(self, key): |
169 | - """Append the request media type to the cache key. |
170 | - |
171 | - This ensures that representations of the same resource will be |
172 | - cached separately, so long as they're served as different |
173 | - media types. |
174 | - """ |
175 | - if self.request_media_type is not None: |
176 | - key = key + '-' + self.request_media_type |
177 | - return safename(key) |
178 | - |
179 | - |
180 | - def _getCachedHeader(self, uri, header): |
181 | - """Retrieve a cached value for an HTTP header.""" |
182 | - (scheme, authority, request_uri, cachekey) = urlnorm(uri) |
183 | - cached_value = self.get(cachekey) |
184 | - header_start = header + ':' |
185 | - if cached_value is not None: |
186 | - for line in StringIO(cached_value): |
187 | - if line.startswith(header_start): |
188 | - return line[len(header_start):].strip() |
189 | - return None |
190 | - |
191 | - |
192 | -class Browser: |
193 | - """A class for making calls to Launchpad web services.""" |
194 | - |
195 | - def __init__(self, credentials, cache=None, timeout=None, |
196 | - proxy_info=None): |
197 | - """Initialize, possibly creating a cache. |
198 | - |
199 | - If no cache is provided, a temporary directory will be used as |
200 | - a cache. The temporary directory will be automatically removed |
201 | - when the Python process exits. |
202 | - """ |
203 | - if cache is None: |
204 | - cache = tempfile.mkdtemp() |
205 | - atexit.register(shutil.rmtree, cache) |
206 | - if isinstance(cache, str): |
207 | - cache = MultipleRepresentationCache(cache) |
208 | - self._connection = OAuthSigningHttp( |
209 | - credentials, cache, timeout, proxy_info) |
210 | - |
211 | - def _request(self, url, data=None, method='GET', |
212 | - media_type='application/json', extra_headers=None): |
213 | - """Create an authenticated request object.""" |
214 | - # Add extra headers for the request. |
215 | - headers = {'Accept' : media_type} |
216 | - if isinstance(self._connection.cache, MultipleRepresentationCache): |
217 | - self._connection.cache.request_media_type = media_type |
218 | - if extra_headers is not None: |
219 | - headers.update(extra_headers) |
220 | - # Make the request. It will be signed automatically when |
221 | - # _request is called. |
222 | - response, content = self._connection.request( |
223 | - str(url), method=method, body=data, headers=headers) |
224 | - # Turn non-2xx responses into exceptions. |
225 | - if response.status // 100 != 2: |
226 | - raise HTTPError(response, content) |
227 | - return response, content |
228 | - |
229 | - def get(self, resource_or_uri, headers=None, return_response=False): |
230 | - """GET a representation of the given resource or URI.""" |
231 | - if isinstance(resource_or_uri, (basestring, URI)): |
232 | - url = resource_or_uri |
233 | - else: |
234 | - method = resource_or_uri.get_method('get') |
235 | - url = method.build_request_url() |
236 | - response, content = self._request(url, extra_headers=headers) |
237 | - if return_response: |
238 | - return (response, content) |
239 | - return content |
240 | - |
241 | - def get_wadl_application(self, url): |
242 | - """GET a WADL representation of the resource at the requested url.""" |
243 | - response, content = self._request( |
244 | - url, media_type='application/vd.sun.wadl+xml') |
245 | - return Application(str(url), content) |
246 | - |
247 | - def post(self, url, method_name, **kws): |
248 | - """POST a request to the web service.""" |
249 | - kws['ws.op'] = method_name |
250 | - data = urlencode(kws) |
251 | - return self._request(url, data, 'POST') |
252 | - |
253 | - def put(self, url, representation, media_type, headers=None): |
254 | - """PUT the given representation to the URL.""" |
255 | - extra_headers = {'Content-Type': media_type} |
256 | - if headers is not None: |
257 | - extra_headers.update(headers) |
258 | - return self._request( |
259 | - url, representation, 'PUT', extra_headers=extra_headers) |
260 | - |
261 | - def delete(self, url): |
262 | - """DELETE the resource at the given URL.""" |
263 | - self._request(url, method='DELETE') |
264 | - |
265 | - def patch(self, url, representation, headers=None): |
266 | - """PATCH the object at url with the updated representation.""" |
267 | - extra_headers = {'Content-Type': 'application/json'} |
268 | - if headers is not None: |
269 | - extra_headers.update(headers) |
270 | - # httplib2 doesn't know about the PATCH method, so we need to |
271 | - # do some work ourselves. Pull any cached value of "ETag" out |
272 | - # and use it as the value for "If-Match". |
273 | - cached_etag = self._connection._getCachedHeader(str(url), 'etag') |
274 | - if cached_etag is not None and not self._connection.ignore_etag: |
275 | - # http://www.w3.org/1999/04/Editing/ |
276 | - headers['If-Match'] = cached_etag |
277 | - |
278 | - return self._request( |
279 | - url, simplejson.dumps(representation, |
280 | - cls=DatetimeJSONEncoder), |
281 | - 'PATCH', extra_headers=extra_headers) |
282 | |
283 | === removed file 'src/launchpadlib/_json.py' |
284 | --- src/launchpadlib/_json.py 2009-03-20 20:46:06 +0000 |
285 | +++ src/launchpadlib/_json.py 1970-01-01 00:00:00 +0000 |
286 | @@ -1,33 +0,0 @@ |
287 | -# Copyright 2009 Canonical Ltd. |
288 | - |
289 | -# This file is part of launchpadlib. |
290 | -# |
291 | -# launchpadlib is free software: you can redistribute it and/or modify it |
292 | -# under the terms of the GNU Lesser General Public License as published by the |
293 | -# Free Software Foundation, version 3 of the License. |
294 | -# |
295 | -# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
296 | -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
297 | -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
298 | -# for more details. |
299 | -# |
300 | -# You should have received a copy of the GNU Lesser General Public License |
301 | -# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
302 | - |
303 | -"""Classes for working with JSON.""" |
304 | - |
305 | -__metaclass__ = type |
306 | -__all__ = ['DatetimeJSONEncoder'] |
307 | - |
308 | -import datetime |
309 | -import simplejson |
310 | - |
311 | -class DatetimeJSONEncoder(simplejson.JSONEncoder): |
312 | - """A JSON encoder that understands datetime objects. |
313 | - |
314 | - Datetime objects are formatted according to ISO 1601. |
315 | - """ |
316 | - def default(self, obj): |
317 | - if isinstance(obj, datetime.datetime): |
318 | - return obj.isoformat() |
319 | - return simplejson.JSONEncoder.default(self, obj) |
320 | |
321 | === modified file 'src/launchpadlib/credentials.py' |
322 | --- src/launchpadlib/credentials.py 2009-03-23 21:50:35 +0000 |
323 | +++ src/launchpadlib/credentials.py 2009-03-26 21:07:35 +0000 |
324 | @@ -29,7 +29,7 @@ |
325 | from oauth.oauth import OAuthConsumer, OAuthToken |
326 | from urllib import urlencode |
327 | |
328 | -from launchpadlib.errors import CredentialsFileError, HTTPError |
329 | +from lazr.restfulclient.errors import CredentialsFileError, HTTPError |
330 | |
331 | |
332 | CREDENTIALS_FILE_VERSION = '1' |
333 | |
334 | === modified file 'src/launchpadlib/errors.py' |
335 | --- src/launchpadlib/errors.py 2009-03-20 20:46:06 +0000 |
336 | +++ src/launchpadlib/errors.py 2009-03-26 21:07:35 +0000 |
337 | @@ -14,50 +14,7 @@ |
338 | # You should have received a copy of the GNU Lesser General Public License |
339 | # along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
340 | |
341 | -"""launchpadlib errors.""" |
342 | - |
343 | -__metaclass__ = type |
344 | -__all__ = [ |
345 | - 'CredentialsError', |
346 | - 'CredentialsFileError', |
347 | - 'HTTPError', |
348 | - 'LaunchpadError', |
349 | - 'ResponseError', |
350 | - 'UnexpectedResponseError', |
351 | - ] |
352 | - |
353 | - |
354 | -class LaunchpadError(Exception): |
355 | - """Base error for the Launchpad API library.""" |
356 | - |
357 | - |
358 | -class CredentialsError(LaunchpadError): |
359 | - """Base credentials/authentication error.""" |
360 | - |
361 | - |
362 | -class CredentialsFileError(CredentialsError): |
363 | - """Error in credentials file.""" |
364 | - |
365 | - |
366 | -class ResponseError(LaunchpadError): |
367 | - """Error in response.""" |
368 | - |
369 | - def __init__(self, response, content): |
370 | - LaunchpadError.__init__(self) |
371 | - self.response = response |
372 | - self.content = content |
373 | - |
374 | - |
375 | -class UnexpectedResponseError(ResponseError): |
376 | - """An unexpected response was received.""" |
377 | - |
378 | - def __str__(self): |
379 | - return '%s: %s' % (self.response.status, self.response.reason) |
380 | - |
381 | - |
382 | -class HTTPError(ResponseError): |
383 | - """An HTTP non-2xx response code was received.""" |
384 | - |
385 | - def __str__(self): |
386 | - return 'HTTP Error %s: %s' % ( |
387 | - self.response.status, self.response.reason) |
388 | + |
389 | +"""Reimport errors from restfulclient for convenience's sake.""" |
390 | + |
391 | +from lazr.restfulclient.errors import * |
392 | |
393 | === modified file 'src/launchpadlib/launchpad.py' |
394 | --- src/launchpadlib/launchpad.py 2009-03-23 21:50:35 +0000 |
395 | +++ src/launchpadlib/launchpad.py 2009-03-26 21:07:35 +0000 |
396 | @@ -21,32 +21,64 @@ |
397 | 'Launchpad', |
398 | ] |
399 | |
400 | -import os |
401 | -import shutil |
402 | -import simplejson |
403 | -import stat |
404 | import sys |
405 | -import tempfile |
406 | -import urlparse |
407 | import webbrowser |
408 | |
409 | -from wadllib.application import Resource as WadlResource |
410 | from lazr.uri import URI |
411 | - |
412 | -from launchpadlib._browser import Browser |
413 | -from launchpadlib.resource import Resource |
414 | +from lazr.restfulclient._browser import RestfulHttp |
415 | +from lazr.restfulclient.resource import ( |
416 | + CollectionWithKeyBasedLookup, HostedFile, ServiceRoot) |
417 | from launchpadlib.credentials import AccessToken, Credentials |
418 | +from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT |
419 | |
420 | +OAUTH_REALM = 'https://api.launchpad.net' |
421 | STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/beta/' |
422 | EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/beta/' |
423 | |
424 | -class Launchpad(Resource): |
425 | + |
426 | +class PersonSet(CollectionWithKeyBasedLookup): |
427 | + """A custom subclass capable of person lookup by username.""" |
428 | + |
429 | + def _get_url_from_id(self, key): |
430 | + """Transform a username into the URL to a person resource.""" |
431 | + return str(self._root._root_uri.ensureSlash()) + '~' + str(key) |
432 | + |
433 | + |
434 | +class BugSet(CollectionWithKeyBasedLookup): |
435 | + """A custom subclass capable of bug lookup by bug ID.""" |
436 | + |
437 | + def _get_url_from_id(self, key): |
438 | + """Transform a bug ID into the URL to a bug resource.""" |
439 | + return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key) |
440 | + |
441 | + |
442 | +class PillarSet(CollectionWithKeyBasedLookup): |
443 | + """A custom subclass capable of lookup by pillar name. |
444 | + |
445 | + Projects, project groups, and distributions are all pillars. |
446 | + """ |
447 | + |
448 | + def _get_url_from_id(self, key): |
449 | + """Transform a project name into the URL to a project resource.""" |
450 | + return str(self._root._root_uri.ensureSlash()) + str(key) |
451 | + |
452 | + |
453 | +class Launchpad(ServiceRoot): |
454 | """Root Launchpad API class. |
455 | |
456 | :ivar credentials: The credentials instance used to access Launchpad. |
457 | :type credentials: `Credentials` |
458 | """ |
459 | |
460 | + RESOURCE_TYPE_CLASSES = { |
461 | + 'bugs': BugSet, |
462 | + 'distributions': PillarSet, |
463 | + 'HostedFile': HostedFile, |
464 | + 'people': PersonSet, |
465 | + 'project_groups': PillarSet, |
466 | + 'projects': PillarSet, |
467 | + } |
468 | + |
469 | def __init__(self, credentials, service_root=STAGING_SERVICE_ROOT, |
470 | cache=None, timeout=None, proxy_info=None): |
471 | """Root access to the Launchpad API. |
472 | @@ -56,34 +88,11 @@ |
473 | :param service_root: The URL to the root of the web service. |
474 | :type service_root: string |
475 | """ |
476 | - self._root_uri = URI(service_root) |
477 | - self.credentials = credentials |
478 | - # Get the WADL definition. |
479 | - self._browser = Browser(self.credentials, cache, timeout, proxy_info) |
480 | - self._wadl = self._browser.get_wadl_application(self._root_uri) |
481 | - |
482 | - # Get the root resource. |
483 | - root_resource = self._wadl.get_resource_by_path('') |
484 | - bound_root = root_resource.bind( |
485 | - self._browser.get(root_resource), 'application/json') |
486 | - super(Launchpad, self).__init__(None, bound_root) |
487 | - |
488 | - def load(self, url): |
489 | - """Load a resource given its URL.""" |
490 | - document = self._browser.get(url) |
491 | - try: |
492 | - representation = simplejson.loads(document) |
493 | - except ValueError: |
494 | - raise ValueError("%s doesn't serve a JSON document." % url) |
495 | - type_link = representation.get("resource_type_link") |
496 | - if type_link is None: |
497 | - raise ValueError("Couldn't determine the resource type of %s." |
498 | - % url) |
499 | - resource_type = self._root._wadl.get_resource_type(type_link) |
500 | - wadl_resource = WadlResource(self._root._wadl, url, resource_type.tag) |
501 | - return self._create_bound_resource( |
502 | - self._root, wadl_resource, representation, 'application/json', |
503 | - representation_needs_processing=False) |
504 | + super(Launchpad, self).__init__( |
505 | + credentials, service_root, cache, timeout, proxy_info) |
506 | + |
507 | + def httpFactory(self, credentials, cache, timeout, proxy_info): |
508 | + return OAuthSigningHttp(credentials, cache, timeout, proxy_info) |
509 | |
510 | @classmethod |
511 | def login(cls, consumer_name, token_string, access_secret, |
512 | @@ -157,69 +166,27 @@ |
513 | credentials.exchange_request_token_for_access_token(web_root) |
514 | return cls(credentials, service_root, cache, timeout, proxy_info) |
515 | |
516 | - @classmethod |
517 | - def login_with(cls, consumer_name, |
518 | - service_root=STAGING_SERVICE_ROOT, |
519 | - launchpadlib_dir=None, timeout=None, proxy_info=None): |
520 | - """Log in to Launchpad with possibly cached credentials. |
521 | - |
522 | - This is a convenience method for either setting up new login |
523 | - credentials, or re-using existing ones. When a login token is |
524 | - generated using this method, the resulting credentials will be |
525 | - saved in the `launchpadlib_dir` directory. If the same |
526 | - `launchpadlib_dir` is passed in a second time, the credentials |
527 | - in `launchpadlib_dir` for the consumer will be used |
528 | - automatically. |
529 | - |
530 | - Each consumer has their own credentials per service root in |
531 | - `launchpadlib_dir`. `launchpadlib_dir` is also used for caching |
532 | - fetched objects. The cache is per service root, and shared by |
533 | - all consumers. |
534 | - |
535 | - See `Launchpad.get_token_and_login()` for more information about |
536 | - how new tokens are generated. |
537 | - |
538 | - :param consumer_name: The consumer name, as appropriate for the |
539 | - `Consumer` constructor |
540 | - :type consumer_name: string |
541 | - :param service_root: The URL to the root of the web service. |
542 | - :type service_root: string |
543 | - :param launchpadlib_dir: The directory where the cache and |
544 | - credentials are stored. |
545 | - :type launchpadlib_dir: string |
546 | - :return: The web service root |
547 | - :rtype: `Launchpad` |
548 | - |
549 | - """ |
550 | - if launchpadlib_dir is None: |
551 | - home_dir = os.environ['HOME'] |
552 | - launchpadlib_dir = os.path.join(home_dir, '.launchpadlib') |
553 | - launchpadlib_dir = os.path.expanduser(launchpadlib_dir) |
554 | - # Each service root has its own cache and credential dirs. |
555 | - scheme, host_name, path, query, fragment = urlparse.urlsplit( |
556 | - service_root) |
557 | - service_root_dir = os.path.join(launchpadlib_dir, host_name) |
558 | - cache_path = os.path.join(service_root_dir, 'cache') |
559 | - if not os.path.exists(cache_path): |
560 | - os.makedirs(cache_path) |
561 | - credentials_path = os.path.join(service_root_dir, 'credentials') |
562 | - if not os.path.exists(credentials_path): |
563 | - os.makedirs(credentials_path) |
564 | - consumer_credentials_path = os.path.join( |
565 | - credentials_path, consumer_name) |
566 | - if os.path.exists(consumer_credentials_path): |
567 | - credentials = Credentials.load_from_path( |
568 | - consumer_credentials_path) |
569 | - launchpad = cls( |
570 | - credentials, service_root=service_root, cache=cache_path, |
571 | - timeout=timeout, proxy_info=proxy_info) |
572 | - else: |
573 | - launchpad = cls.get_token_and_login( |
574 | - consumer_name, service_root=service_root, cache=cache_path, |
575 | - timeout=timeout, proxy_info=proxy_info) |
576 | - launchpad.credentials.save_to_path( |
577 | - os.path.join(credentials_path, consumer_name)) |
578 | - os.chmod( |
579 | - os.path.join(credentials_path, consumer_name), |
580 | - stat.S_IREAD | stat.S_IWRITE) |
581 | - return launchpad |
582 | + |
583 | +class OAuthSigningHttp(RestfulHttp): |
584 | + """A client that signs every outgoing request with OAuth credentials.""" |
585 | + |
586 | + def _request(self, conn, host, absolute_uri, request_uri, method, body, |
587 | + headers, redirections, cachekey): |
588 | + """Sign a request with OAuth credentials before sending it.""" |
589 | + oauth_request = OAuthRequest.from_consumer_and_token( |
590 | + self.restful_credentials.consumer, |
591 | + self.restful_credentials.access_token, |
592 | + http_url=absolute_uri) |
593 | + oauth_request.sign_request( |
594 | + OAuthSignatureMethod_PLAINTEXT(), |
595 | + self.restful_credentials.consumer, |
596 | + self.restful_credentials.access_token) |
597 | + if headers.has_key('authorization'): |
598 | + # There's an authorization header left over from a |
599 | + # previous request that resulted in a redirect. Remove it |
600 | + # and start again. |
601 | + del headers['authorization'] |
602 | + headers.update(oauth_request.to_header(OAUTH_REALM)) |
603 | + return super(OAuthSigningHttp, self)._request( |
604 | + conn, host, absolute_uri, request_uri, method, body, headers, |
605 | + redirections, cachekey) |
606 | |
607 | === removed file 'src/launchpadlib/resource.py' |
608 | --- src/launchpadlib/resource.py 2009-03-20 20:46:06 +0000 |
609 | +++ src/launchpadlib/resource.py 1970-01-01 00:00:00 +0000 |
610 | @@ -1,830 +0,0 @@ |
611 | -# Copyright 2008 Canonical Ltd. |
612 | - |
613 | -# This file is part of launchpadlib. |
614 | -# |
615 | -# launchpadlib is free software: you can redistribute it and/or modify it |
616 | -# under the terms of the GNU Lesser General Public License as published by the |
617 | -# Free Software Foundation, version 3 of the License. |
618 | -# |
619 | -# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
620 | -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
621 | -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
622 | -# for more details. |
623 | -# |
624 | -# You should have received a copy of the GNU Lesser General Public License |
625 | -# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
626 | - |
627 | -"""Common support for web service resources.""" |
628 | - |
629 | -__metaclass__ = type |
630 | -__all__ = [ |
631 | - 'Collection', |
632 | - 'Entry', |
633 | - 'NamedOperation', |
634 | - 'Resource', |
635 | - ] |
636 | - |
637 | - |
638 | -import cgi |
639 | -import simplejson |
640 | -from StringIO import StringIO |
641 | -import urllib |
642 | -from urlparse import urlparse |
643 | -from lazr.uri import URI |
644 | - |
645 | -from launchpadlib._json import DatetimeJSONEncoder |
646 | -from launchpadlib.errors import HTTPError |
647 | -from wadllib.application import Resource as WadlResource |
648 | - |
649 | - |
650 | -class HeaderDictionary: |
651 | - """A dictionary that bridges httplib2's and wadllib's expectations. |
652 | - |
653 | - httplib2 expects all header dictionary access to give lowercase |
654 | - header names. wadllib expects to access the header exactly as it's |
655 | - specified in the WADL file, which means the official HTTP header name. |
656 | - |
657 | - This class transforms keys to lowercase before doing a lookup on |
658 | - the underlying dictionary. That way wadllib can pass in the |
659 | - official header name and httplib2 will get the lowercased name. |
660 | - """ |
661 | - def __init__(self, wrapped_dictionary): |
662 | - self.wrapped_dictionary = wrapped_dictionary |
663 | - |
664 | - def get(self, key, default=None): |
665 | - """Retrieve a value, converting the key to lowercase.""" |
666 | - return self.wrapped_dictionary.get(key.lower()) |
667 | - |
668 | - def __getitem__(self, key): |
669 | - """Retrieve a value, converting the key to lowercase.""" |
670 | - missing = object() |
671 | - value = self.get(key, missing) |
672 | - if value is missing: |
673 | - raise KeyError(key) |
674 | - return value |
675 | - |
676 | - |
677 | -class LaunchpadBase: |
678 | - """Base class for classes that know about Launchpad.""" |
679 | - |
680 | - JSON_MEDIA_TYPE = 'application/json' |
681 | - |
682 | - def _transform_resources_to_links(self, dictionary): |
683 | - new_dictionary = {} |
684 | - for key, value in dictionary.items(): |
685 | - if isinstance(value, Resource): |
686 | - value = value.self_link |
687 | - new_dictionary[self._get_external_param_name(key)] = value |
688 | - return new_dictionary |
689 | - |
690 | - def _get_external_param_name(self, param_name): |
691 | - """Turn a launchpadlib name into something to be sent over HTTP. |
692 | - |
693 | - For resources this may involve sticking '_link' or |
694 | - '_collection_link' on the end of the parameter name. For |
695 | - arguments to named operations, the parameter name is returned |
696 | - as is. |
697 | - """ |
698 | - return param_name |
699 | - |
700 | - |
701 | -class Resource(LaunchpadBase): |
702 | - """Base class for Launchpad's HTTP resources.""" |
703 | - |
704 | - def __init__(self, root, wadl_resource): |
705 | - """Initialize with respect to a wadllib Resource object.""" |
706 | - if root is None: |
707 | - # This _is_ the root. |
708 | - root = self |
709 | - # These values need to be put directly into __dict__ to avoid |
710 | - # calling __setattr__, which would cause an infinite recursion. |
711 | - self.__dict__['_root'] = root |
712 | - self.__dict__['_wadl_resource'] = wadl_resource |
713 | - |
714 | - FIND_COLLECTIONS = object() |
715 | - FIND_ENTRIES = object() |
716 | - FIND_ATTRIBUTES = object() |
717 | - |
718 | - @property |
719 | - def lp_collections(self): |
720 | - """Name the collections this resource links to.""" |
721 | - return self._get_parameter_names(self.FIND_COLLECTIONS) |
722 | - |
723 | - @property |
724 | - def lp_entries(self): |
725 | - """Name the entries this resource links to.""" |
726 | - return self._get_parameter_names(self.FIND_ENTRIES) |
727 | - |
728 | - @property |
729 | - def lp_attributes(self): |
730 | - """Name this resource's scalar attributes.""" |
731 | - return self._get_parameter_names(self.FIND_ATTRIBUTES) |
732 | - |
733 | - @property |
734 | - def lp_operations(self): |
735 | - """Name all of this resource's custom operations.""" |
736 | - # This library distinguishes between named operations by the |
737 | - # value they give for ws.op, not by their WADL names or IDs. |
738 | - names = [] |
739 | - form_encoded_type = 'application/x-www-form-urlencoded' |
740 | - for method in self._wadl_resource.method_iter: |
741 | - name = method.name.lower() |
742 | - if name == 'get': |
743 | - params = method.request.params(['query', 'plain']) |
744 | - elif name == 'post': |
745 | - definition = method.request.representation_definition( |
746 | - form_encoded_type).resolve_definition() |
747 | - params = definition.params(self._wadl_resource) |
748 | - for param in params: |
749 | - if param.name == 'ws.op': |
750 | - names.append(param.fixed_value) |
751 | - break |
752 | - return names |
753 | - |
754 | - @property |
755 | - def __members__(self): |
756 | - """A hook into dir() that returns web service-derived members.""" |
757 | - return self._get_parameter_names( |
758 | - self.FIND_COLLECTIONS, self.FIND_ENTRIES, self.FIND_ATTRIBUTES) |
759 | - |
760 | - __methods__ = lp_operations |
761 | - |
762 | - def _get_parameter_names(self, *kinds): |
763 | - """Retrieve some subset of the resource's parameters.""" |
764 | - names = [] |
765 | - for name in self._wadl_resource.parameter_names( |
766 | - self.JSON_MEDIA_TYPE): |
767 | - if name.endswith('_collection_link'): |
768 | - if self.FIND_COLLECTIONS in kinds: |
769 | - names.append(name[:-16]) |
770 | - elif (name.endswith('_link') |
771 | - and name not in ('self_link', 'resource_type_link')): |
772 | - # launchpadlib_obj.self will work, but is never |
773 | - # necessary. launchpadlib_obj.resource_type is also |
774 | - # unneccessary, and won't work anyway because |
775 | - # resource_type_link points to a WADL description, |
776 | - # not a normal Launchpad resource. |
777 | - if self.FIND_ENTRIES in kinds: |
778 | - names.append(name[:-5]) |
779 | - elif self.FIND_ATTRIBUTES in kinds: |
780 | - names.append(name) |
781 | - return names |
782 | - |
783 | - def lp_has_parameter(self, param_name): |
784 | - """Does this resource have a parameter with the given name?""" |
785 | - return self._get_external_param_name(param_name) is not None |
786 | - |
787 | - def lp_get_parameter(self, param_name): |
788 | - """Get the value of one of the resource's parameters. |
789 | - |
790 | - :return: A scalar value if the parameter is not a link. A new |
791 | - Resource object, whose resource is bound to a |
792 | - representation, if the parameter is a link. |
793 | - """ |
794 | - self._ensure_representation() |
795 | - for suffix in ['_link', '_collection_link']: |
796 | - param = self._wadl_resource.get_parameter( |
797 | - param_name + suffix, self.JSON_MEDIA_TYPE) |
798 | - if param is not None: |
799 | - if param.get_value() is None: |
800 | - # This parameter is a link to another object, but |
801 | - # there's no other object. Return None rather than |
802 | - # chasing down the nonexistent other object. |
803 | - return None |
804 | - linked_resource = param.linked_resource |
805 | - return self._create_bound_resource( |
806 | - self._root, linked_resource, param_name=param.name) |
807 | - param = self._wadl_resource.get_parameter(param_name) |
808 | - if param is None: |
809 | - raise KeyError("No such parameter: %s" % param_name) |
810 | - return param.get_value() |
811 | - |
812 | - def lp_get_named_operation(self, operation_name): |
813 | - """Get a custom operation with the given name. |
814 | - |
815 | - :return: A NamedOperation instance that can be called with |
816 | - appropriate arguments to invoke the operation. |
817 | - """ |
818 | - params = { 'ws.op' : operation_name } |
819 | - method = self._wadl_resource.get_method('get', query_params=params) |
820 | - if method is None: |
821 | - method = self._wadl_resource.get_method( |
822 | - 'post', representation_params=params) |
823 | - if method is None: |
824 | - raise KeyError("No operation with name: %s" % operation_name) |
825 | - return NamedOperation(self._root, self, method) |
826 | - |
827 | - @classmethod |
828 | - def _create_bound_resource( |
829 | - cls, root, resource, representation=None, |
830 | - representation_media_type='application/json', |
831 | - representation_needs_processing=True, representation_definition=None, |
832 | - param_name=None): |
833 | - """Create a launchpadlib Resource subclass from a wadllib Resource. |
834 | - |
835 | - :param resource: The wadllib Resource to wrap. |
836 | - :param representation: A previously fetched representation of |
837 | - this resource, to be reused. If not provided, this method |
838 | - will act just like the Resource constructor. |
839 | - :param representation_media_type: The media type of any previously |
840 | - fetched representation. |
841 | - :param representation_needs_processing: Set to False if the |
842 | - 'representation' parameter should be used as |
843 | - is. |
844 | - :param representation_definition: A wadllib |
845 | - RepresentationDefinition object describing the structure |
846 | - of this representation. Used in cases when the representation |
847 | - isn't the result of sending a standard GET to the resource. |
848 | - :param param_name: The name of the link that was followed to get |
849 | - to this resource. |
850 | - :return: An instance of the appropriate launchpadlib Resource |
851 | - subclass. |
852 | - """ |
853 | - # We happen to know that all Launchpad resource types are |
854 | - # defined in a single document. Turn the resource's type_url |
855 | - # into an anchor into that document: this is its resource |
856 | - # type. Then look up a client-side class that corresponds to |
857 | - # the resource type. |
858 | - type_url = resource.type_url |
859 | - resource_type = urlparse(type_url)[-1] |
860 | - default = Entry |
861 | - if (type_url.endswith('-page') |
862 | - or (param_name is not None |
863 | - and param_name.endswith('_collection_link'))): |
864 | - default = Collection |
865 | - r_class = RESOURCE_TYPE_CLASSES.get(resource_type, default) |
866 | - if representation is not None: |
867 | - # We've been given a representation. Bind the resource |
868 | - # immediately. |
869 | - resource = resource.bind( |
870 | - representation, representation_media_type, |
871 | - representation_needs_processing, |
872 | - representation_definition=representation_definition) |
873 | - else: |
874 | - # We'll fetch a representation and bind the resource when |
875 | - # necessary. |
876 | - pass |
877 | - return r_class(root, resource) |
878 | - |
879 | - def lp_refresh(self, new_url=None, etag=None): |
880 | - """Update this resource's representation.""" |
881 | - if new_url is not None: |
882 | - self._wadl_resource._url = new_url |
883 | - headers = {} |
884 | - if etag is not None: |
885 | - headers['If-None-Match'] = etag |
886 | - try: |
887 | - representation = self._root._browser.get( |
888 | - self._wadl_resource, headers=headers) |
889 | - except HTTPError, e: |
890 | - if e.response['status'] == '304': |
891 | - # The entry wasn't modified. No need to do anything. |
892 | - return |
893 | - else: |
894 | - raise e |
895 | - # __setattr__ assumes we're setting an attribute of the resource, |
896 | - # so we manipulate __dict__ directly. |
897 | - self.__dict__['_wadl_resource'] = self._wadl_resource.bind( |
898 | - representation, self.JSON_MEDIA_TYPE) |
899 | - |
900 | - def __getattr__(self, attr): |
901 | - """Try to retrive a named operation or parameter of the given name.""" |
902 | - try: |
903 | - return self.lp_get_parameter(attr) |
904 | - except KeyError: |
905 | - pass |
906 | - try: |
907 | - return self.lp_get_named_operation(attr) |
908 | - except KeyError: |
909 | - raise AttributeError("'%s' object has no attribute '%s'" |
910 | - % (self.__class__.__name__, attr)) |
911 | - |
912 | - def _get_external_param_name(self, param_name): |
913 | - """What's this parameter's name in the underlying representation?""" |
914 | - for suffix in ['_link', '_collection_link', '']: |
915 | - name = param_name + suffix |
916 | - if self._wadl_resource.get_parameter(name): |
917 | - return name |
918 | - return None |
919 | - |
920 | - def _ensure_representation(self): |
921 | - """Make sure this resource has a representation fetched.""" |
922 | - if self._wadl_resource.representation is None: |
923 | - # Get a representation of the linked resource. |
924 | - representation = self._root._browser.get(self._wadl_resource) |
925 | - self.__dict__['_wadl_resource'] = self._wadl_resource.bind( |
926 | - representation, self.JSON_MEDIA_TYPE) |
927 | - |
928 | - |
929 | -class NamedOperation(LaunchpadBase): |
930 | - """A class for a named operation to be invoked with GET or POST.""" |
931 | - |
932 | - def __init__(self, root, resource, wadl_method): |
933 | - """Initialize with respect to a WADL Method object""" |
934 | - self.root = root |
935 | - self.resource = resource |
936 | - self.wadl_method = wadl_method |
937 | - |
938 | - def __call__(self, *args, **kwargs): |
939 | - """Invoke the method and process the result.""" |
940 | - if len(args) > 0: |
941 | - raise TypeError('Method must be called with keyword args.') |
942 | - http_method = self.wadl_method.name |
943 | - args = self._transform_resources_to_links(kwargs) |
944 | - for key, value in args.items(): |
945 | - args[key] = simplejson.dumps(value, cls=DatetimeJSONEncoder) |
946 | - if http_method in ('get', 'head', 'delete'): |
947 | - url = self.wadl_method.build_request_url(**args) |
948 | - in_representation = '' |
949 | - extra_headers = {} |
950 | - else: |
951 | - url = self.wadl_method.build_request_url() |
952 | - (media_type, |
953 | - in_representation) = self.wadl_method.build_representation( |
954 | - **args) |
955 | - extra_headers = { 'Content-type' : media_type } |
956 | - response, content = self.root._browser._request( |
957 | - url, in_representation, http_method, extra_headers=extra_headers) |
958 | - |
959 | - if response.status == 201: |
960 | - return self._handle_201_response(url, response, content) |
961 | - else: |
962 | - if http_method == 'post': |
963 | - # The method call probably modified this resource in |
964 | - # an unknown way. Refresh its representation. |
965 | - self.resource.lp_refresh() |
966 | - return self._handle_200_response(url, response, content) |
967 | - |
968 | - def _handle_201_response(self, url, response, content): |
969 | - """Handle the creation of a new resource by fetching it.""" |
970 | - wadl_response = self.wadl_method.response.bind( |
971 | - HeaderDictionary(response)) |
972 | - wadl_parameter = wadl_response.get_parameter('Location') |
973 | - wadl_resource = wadl_parameter.linked_resource |
974 | - # Fetch a representation of the new resource. |
975 | - response, content = self.root._browser._request( |
976 | - wadl_resource.url) |
977 | - # Return an instance of the appropriate launchpadlib |
978 | - # Resource subclass. |
979 | - return Resource._create_bound_resource( |
980 | - self.root, wadl_resource, content, response['content-type']) |
981 | - |
982 | - def _handle_200_response(self, url, response, content): |
983 | - """Process the return value of an operation.""" |
984 | - content_type = response['content-type'] |
985 | - # Process the returned content, assuming we know how. |
986 | - response_definition = self.wadl_method.response |
987 | - representation_definition = ( |
988 | - response_definition.get_representation_definition( |
989 | - content_type)) |
990 | - |
991 | - if representation_definition is None: |
992 | - # The operation returned a document with nothing |
993 | - # special about it. |
994 | - if content_type == self.JSON_MEDIA_TYPE: |
995 | - return simplejson.loads(content) |
996 | - # We don't know how to process the content. |
997 | - return content |
998 | - |
999 | - # The operation returned a representation of some |
1000 | - # resource. Instantiate a Resource object for it. |
1001 | - document = simplejson.loads(content) |
1002 | - if "self_link" in document and "resource_type_link" in document: |
1003 | - # The operation returned an entry. Use the self_link and |
1004 | - # resource_type_link of the entry representation to build |
1005 | - # a Resource object of the appropriate type. That way this |
1006 | - # object will support all of the right named operations. |
1007 | - url = document["self_link"] |
1008 | - resource_type = self.root._wadl.get_resource_type( |
1009 | - document["resource_type_link"]) |
1010 | - wadl_resource = WadlResource(self.root._wadl, url, |
1011 | - resource_type.tag) |
1012 | - else: |
1013 | - # The operation returned a collection. It's probably an ad |
1014 | - # hoc collection that doesn't correspond to any resource |
1015 | - # type. Instantiate it as a resource backed by the |
1016 | - # representation type defined in the return value, instead |
1017 | - # of a resource type tag. |
1018 | - representation_definition = ( |
1019 | - representation_definition.resolve_definition()) |
1020 | - wadl_resource = WadlResource( |
1021 | - self.root._wadl, url, representation_definition.tag) |
1022 | - |
1023 | - return Resource._create_bound_resource( |
1024 | - self.root, wadl_resource, document, content_type, |
1025 | - representation_needs_processing=False, |
1026 | - representation_definition=representation_definition) |
1027 | - |
1028 | - def _get_external_param_name(self, param_name): |
1029 | - """Named operation parameter names are sent as is.""" |
1030 | - return param_name |
1031 | - |
1032 | - |
1033 | -class Entry(Resource): |
1034 | - """A class for an entry-type resource that can be updated with PATCH.""" |
1035 | - |
1036 | - def __init__(self, root, wadl_resource): |
1037 | - super(Entry, self).__init__(root, wadl_resource) |
1038 | - # Initialize this here in a semi-magical way so as to stop a |
1039 | - # particular infinite loop that would follow. Setting |
1040 | - # self._dirty_attributes would call __setattr__(), which would |
1041 | - # turn around immediately and get self._dirty_attributes. If |
1042 | - # this latter was not in the instance dictionary, that would |
1043 | - # end up calling __getattr__(), which would again reference |
1044 | - # self._dirty_attributes. This is where the infloop would |
1045 | - # occur. Poking this directly into self.__dict__ means that |
1046 | - # the check for self._dirty_attributes won't call __getattr__(), |
1047 | - # breaking the cycle. |
1048 | - self.__dict__['_dirty_attributes'] = {} |
1049 | - super(Entry, self).__init__(root, wadl_resource) |
1050 | - |
1051 | - def __repr__(self): |
1052 | - """Return the WADL resource type and the URL to the resource.""" |
1053 | - return '<%s at %s>' % ( |
1054 | - URI(self.resource_type_link).fragment, self.self_link) |
1055 | - |
1056 | - def __str__(self): |
1057 | - """Return the URL to the resource.""" |
1058 | - return self.self_link |
1059 | - |
1060 | - def __getattr__(self, name): |
1061 | - """Try to retrive a parameter of the given name.""" |
1062 | - if name != '_dirty_attributes': |
1063 | - if name in self._dirty_attributes: |
1064 | - return self._dirty_attributes[name] |
1065 | - return super(Entry, self).__getattr__(name) |
1066 | - |
1067 | - def __setattr__(self, name, value): |
1068 | - """Set the parameter of the given name.""" |
1069 | - if not self.lp_has_parameter(name): |
1070 | - raise AttributeError("'%s' object has no attribute '%s'" % |
1071 | - (self.__class__.__name__, name)) |
1072 | - self._dirty_attributes[name] = value |
1073 | - |
1074 | - def lp_refresh(self, new_url=None): |
1075 | - """Update this resource's representation.""" |
1076 | - etag = getattr(self, 'http_etag', None) |
1077 | - super(Entry, self).lp_refresh(new_url, etag) |
1078 | - self._dirty_attributes.clear() |
1079 | - |
1080 | - def lp_save(self): |
1081 | - """Save changes to the entry.""" |
1082 | - representation = self._transform_resources_to_links( |
1083 | - self._dirty_attributes) |
1084 | - |
1085 | - # If the entry contains an ETag, set the If-Match header |
1086 | - # to that value. |
1087 | - headers = {} |
1088 | - etag = getattr(self, 'http_etag', None) |
1089 | - if etag is not None: |
1090 | - headers['If-Match'] = etag |
1091 | - |
1092 | - # PATCH the new representation to the 'self' link. It's possible that |
1093 | - # this will cause the object to be permanently moved. Catch that |
1094 | - # exception and refresh our representation. |
1095 | - try: |
1096 | - response, content = self._root._browser.patch( |
1097 | - URI(self.self_link), representation, headers) |
1098 | - except HTTPError, error: |
1099 | - if error.response.status == 301: |
1100 | - response = error.response |
1101 | - self.lp_refresh(error.response['location']) |
1102 | - else: |
1103 | - raise |
1104 | - self._dirty_attributes.clear() |
1105 | - |
1106 | - content_type = response['content-type'] |
1107 | - if response.status == 209 and content_type == self.JSON_MEDIA_TYPE: |
1108 | - # The server sent back a new representation of the object. |
1109 | - # Use it in preference to the existing representation. |
1110 | - new_representation = simplejson.loads(content) |
1111 | - self._wadl_resource.representation = new_representation |
1112 | - self._wadl_resource.media_type = content_type |
1113 | - |
1114 | - |
1115 | -class Collection(Resource): |
1116 | - """A collection-type resource that supports pagination.""" |
1117 | - |
1118 | - def __init__(self, root, wadl_resource): |
1119 | - """Create a collection object.""" |
1120 | - super(Collection, self).__init__(root, wadl_resource) |
1121 | - |
1122 | - def __len__(self): |
1123 | - """The number of items in the collection. |
1124 | - |
1125 | - :return: length of the collection |
1126 | - :rtype: int |
1127 | - """ |
1128 | - try: |
1129 | - return int(self.total_size) |
1130 | - except AttributeError: |
1131 | - raise TypeError('collection size is not available') |
1132 | - |
1133 | - def __iter__(self): |
1134 | - """Iterate over the items in the collection. |
1135 | - |
1136 | - :return: iterator |
1137 | - :rtype: sequence of `Entry` |
1138 | - """ |
1139 | - self._ensure_representation() |
1140 | - current_page = self._wadl_resource.representation |
1141 | - while True: |
1142 | - for resource in self._convert_dicts_to_entries( |
1143 | - current_page.get('entries', {})): |
1144 | - yield resource |
1145 | - next_link = current_page.get('next_collection_link') |
1146 | - if next_link is None: |
1147 | - break |
1148 | - current_page = simplejson.loads( |
1149 | - self._root._browser.get(URI(next_link))) |
1150 | - |
1151 | - def __getitem__(self, key): |
1152 | - """Look up a slice, or a subordinate resource by index. |
1153 | - |
1154 | - To discourage situations where a launchpadlib client fetches |
1155 | - all of an enormous list, all collection slices must have a |
1156 | - definitive end point. For performance reasons, all collection |
1157 | - slices must be indexed from the start of the list rather than |
1158 | - the end. |
1159 | - """ |
1160 | - if isinstance(key, slice): |
1161 | - return self._get_slice(key) |
1162 | - else: |
1163 | - # Look up a single item by its position in the list. |
1164 | - found_slice = self._get_slice(slice(key, key+1)) |
1165 | - if len(found_slice) != 1: |
1166 | - raise IndexError("list index out of range") |
1167 | - return found_slice[0] |
1168 | - |
1169 | - def _get_slice(self, slice): |
1170 | - """Retrieve a slice of a collection.""" |
1171 | - start = slice.start or 0 |
1172 | - stop = slice.stop |
1173 | - |
1174 | - if start < 0: |
1175 | - raise ValueError("Collection slices must have a nonnegative " |
1176 | - "start point.") |
1177 | - if stop < 0: |
1178 | - raise ValueError("Collection slices must have a definite, " |
1179 | - "nonnegative end point.") |
1180 | - |
1181 | - existing_representation = self._wadl_resource.representation |
1182 | - if (existing_representation is not None |
1183 | - and start < len(existing_representation['entries'])): |
1184 | - # An optimization: the first page of entries has already |
1185 | - # been loaded. This can happen if this collection is the |
1186 | - # return value of a named operation, or if the client did |
1187 | - # something like check the length of the collection. |
1188 | - # |
1189 | - # Either way, we've already made an HTTP request and |
1190 | - # gotten some entries back. The client has requested a |
1191 | - # slice that includes some of the entries we already have. |
1192 | - # In the best case, we can fulfil the slice immediately, |
1193 | - # without making another HTTP request. |
1194 | - # |
1195 | - # Even if we can't fulfil the entire slice, we can get one |
1196 | - # or more objects from the first page and then have fewer |
1197 | - # objects to retrieve from the server later. This saves us |
1198 | - # time and bandwidth, and it might let us save a whole |
1199 | - # HTTP request. |
1200 | - entry_page = existing_representation['entries'] |
1201 | - |
1202 | - first_page_size = len(entry_page) |
1203 | - entry_dicts = entry_page[start:stop] |
1204 | - page_url = existing_representation.get('next_collection_link') |
1205 | - else: |
1206 | - # No part of this collection has been loaded yet, or the |
1207 | - # slice starts beyond the part that has been loaded. We'll |
1208 | - # use our secret knowledge of Launchpad to set a value for |
1209 | - # the ws.start variable. That way we start reading entries |
1210 | - # from the first one we want. |
1211 | - first_page_size = None |
1212 | - entry_dicts = [] |
1213 | - page_url = self._with_url_query_variable_set( |
1214 | - self._wadl_resource.url, 'ws.start', start) |
1215 | - |
1216 | - desired_size = stop-start |
1217 | - more_needed = desired_size - len(entry_dicts) |
1218 | - |
1219 | - # Iterate over pages until we have the correct number of entries. |
1220 | - while more_needed > 0 and page_url is not None: |
1221 | - representation = simplejson.loads( |
1222 | - self._root._browser.get(page_url)) |
1223 | - current_page_entries = representation['entries'] |
1224 | - entry_dicts += current_page_entries[:more_needed] |
1225 | - more_needed = desired_size - len(entry_dicts) |
1226 | - |
1227 | - page_url = representation.get('next_collection_link') |
1228 | - if page_url is None: |
1229 | - # We've gotten the entire collection; there are no |
1230 | - # more entries. |
1231 | - break |
1232 | - if first_page_size is None: |
1233 | - first_page_size = len(current_page_entries) |
1234 | - if more_needed > 0 and more_needed < first_page_size: |
1235 | - # An optimization: it's likely that we need less than |
1236 | - # a full page of entries, because the number we need |
1237 | - # is less than the size of the first page we got. |
1238 | - # Instead of requesting a full-sized page, we'll |
1239 | - # request only the number of entries we think we'll |
1240 | - # need. If we're wrong, there's no problem; we'll just |
1241 | - # keep looping. |
1242 | - page_url = self._with_url_query_variable_set( |
1243 | - page_url, 'ws.size', more_needed) |
1244 | - |
1245 | - if slice.step is not None: |
1246 | - entry_dicts = entry_dicts[::slice.step] |
1247 | - |
1248 | - # Convert entry_dicts into a list of Entry objects. |
1249 | - return [resource for resource |
1250 | - in self._convert_dicts_to_entries(entry_dicts)] |
1251 | - |
1252 | - def _convert_dicts_to_entries(self, entries): |
1253 | - """Convert dictionaries describing entries to Entry objects. |
1254 | - |
1255 | - The dictionaries come from the 'entries' field of the JSON |
1256 | - dictionary you get when you GET a page of a collection. Each |
1257 | - dictionary is the same as you'd get if you sent a GET request |
1258 | - to the corresponding entry resource. So each of these |
1259 | - dictionaries can be treated as a preprocessed representation |
1260 | - of an entry resource, and turned into an Entry instance. |
1261 | - |
1262 | - :yield: A sequence of Entry instances. |
1263 | - """ |
1264 | - for entry_dict in entries: |
1265 | - resource_url = entry_dict['self_link'] |
1266 | - resource_type_link = entry_dict['resource_type_link'] |
1267 | - wadl_application = self._wadl_resource.application |
1268 | - resource_type = wadl_application.get_resource_type( |
1269 | - resource_type_link) |
1270 | - resource = WadlResource( |
1271 | - self._wadl_resource.application, resource_url, |
1272 | - resource_type.tag) |
1273 | - yield Resource._create_bound_resource( |
1274 | - self._root, resource, entry_dict, self.JSON_MEDIA_TYPE, |
1275 | - False) |
1276 | - |
1277 | - def _with_url_query_variable_set(self, url, variable, new_value): |
1278 | - """A helper method to set a query variable in a URL.""" |
1279 | - uri = URI(url) |
1280 | - if uri.query is None: |
1281 | - params = {} |
1282 | - else: |
1283 | - params = cgi.parse_qs(uri.query) |
1284 | - params[variable] = str(new_value) |
1285 | - uri.query = urllib.urlencode(params, True) |
1286 | - return str(uri) |
1287 | - |
1288 | - |
1289 | -class CollectionWithKeyBasedLookup(Collection): |
1290 | - """A collection-type resource that supports key-based lookup. |
1291 | - |
1292 | - This collection can be sliced, but any single index passed into |
1293 | - __getitem__ will be treated as a custom lookup key. |
1294 | - """ |
1295 | - |
1296 | - def __getitem__(self, key): |
1297 | - """Look up a slice, or a subordinate resource by unique ID.""" |
1298 | - if isinstance(key, slice): |
1299 | - return super(CollectionWithKeyBasedLookup, self).__getitem__(key) |
1300 | - try: |
1301 | - url = self._get_url_from_id(key) |
1302 | - except NotImplementedError: |
1303 | - raise TypeError("unsubscriptable object") |
1304 | - if url is None: |
1305 | - raise KeyError(key) |
1306 | - |
1307 | - # We don't know what kind of resource this is. Even the |
1308 | - # subclass doesn't necessarily know, because some resources |
1309 | - # (the person list) are gateways to more than one kind of |
1310 | - # resource (people, and teams). The only way to know for sure |
1311 | - # is to retrieve a representation of the resource and see how |
1312 | - # the resource describes itself. |
1313 | - try: |
1314 | - representation = simplejson.loads(self._root._browser.get(url)) |
1315 | - except HTTPError, error: |
1316 | - # There's no resource corresponding to the given ID. |
1317 | - if error.response.status == 404: |
1318 | - raise KeyError(key) |
1319 | - raise |
1320 | - # We know that every Launchpad resource has a 'resource_type_link' |
1321 | - # in its representation. |
1322 | - resource_type_link = representation['resource_type_link'] |
1323 | - resource = WadlResource(self._root._wadl, url, resource_type_link) |
1324 | - return self._create_bound_resource( |
1325 | - self._root, resource, representation=representation, |
1326 | - representation_needs_processing=False) |
1327 | - |
1328 | - |
1329 | - def _get_url_from_id(self, key): |
1330 | - """Transform the unique ID of an object into its URL.""" |
1331 | - raise NotImplementedError() |
1332 | - |
1333 | - |
1334 | -class HostedFile(Resource): |
1335 | - """A resource represnting a file hosted on Launchpad's server.""" |
1336 | - |
1337 | - def open(self, mode='r', content_type=None, filename=None): |
1338 | - """Open the file on the server for read or write access.""" |
1339 | - if mode in ('r', 'w'): |
1340 | - return HostedFileBuffer(self, mode, content_type, filename) |
1341 | - else: |
1342 | - raise ValueError("Invalid mode. Supported modes are: r, w") |
1343 | - |
1344 | - def delete(self): |
1345 | - """Delete the file from the server.""" |
1346 | - self._root._browser.delete(self._wadl_resource.url) |
1347 | - |
1348 | - def _get_parameter_names(self, *kinds): |
1349 | - """HostedFile objects define no web service parameters.""" |
1350 | - return [] |
1351 | - |
1352 | - |
1353 | -class HostedFileBuffer(StringIO): |
1354 | - """The contents of a file hosted on Launchpad's server.""" |
1355 | - def __init__(self, hosted_file, mode, content_type=None, filename=None): |
1356 | - self.url = hosted_file._wadl_resource.url |
1357 | - if mode == 'r': |
1358 | - if content_type is not None: |
1359 | - raise ValueError("Files opened for read access can't " |
1360 | - "specify content_type.") |
1361 | - if filename is not None: |
1362 | - raise ValueError("Files opened for read access can't " |
1363 | - "specify filename.") |
1364 | - response, value = hosted_file._root._browser.get( |
1365 | - self.url, return_response=True) |
1366 | - content_type = response['content-type'] |
1367 | - last_modified = response['last-modified'] |
1368 | - |
1369 | - # The Content-Location header contains the URL of the file |
1370 | - # in the Launchpad library. We happen to know that the |
1371 | - # final component of the URL is the name of the uploaded |
1372 | - # file. |
1373 | - content_location = response['content-location'] |
1374 | - path = urlparse(content_location)[2] |
1375 | - filename = urllib.unquote(path.split("/")[-1]) |
1376 | - elif mode == 'w': |
1377 | - value = '' |
1378 | - if content_type is None: |
1379 | - raise ValueError("Files opened for write access must " |
1380 | - "specify content_type.") |
1381 | - if filename is None: |
1382 | - raise ValueError("Files opened for write access must " |
1383 | - "specify filename.") |
1384 | - last_modified = None |
1385 | - else: |
1386 | - raise ValueError("Invalid mode. Supported modes are: r, w") |
1387 | - |
1388 | - self.hosted_file = hosted_file |
1389 | - self.mode = mode |
1390 | - self.content_type = content_type |
1391 | - self.filename = filename |
1392 | - self.last_modified = last_modified |
1393 | - StringIO.__init__(self, value) |
1394 | - |
1395 | - def close(self): |
1396 | - if self.mode == 'w': |
1397 | - disposition = 'attachment; filename="%s"' % self.filename |
1398 | - self.hosted_file._root._browser.put( |
1399 | - self.url, self.getvalue(), |
1400 | - self.content_type, {'Content-Disposition' : disposition}) |
1401 | - StringIO.close(self) |
1402 | - |
1403 | - |
1404 | -class PersonSet(CollectionWithKeyBasedLookup): |
1405 | - """A custom subclass capable of person lookup by username.""" |
1406 | - |
1407 | - def _get_url_from_id(self, key): |
1408 | - """Transform a username into the URL to a person resource.""" |
1409 | - return str(self._root._root_uri.ensureSlash()) + '~' + str(key) |
1410 | - |
1411 | - |
1412 | -class BugSet(CollectionWithKeyBasedLookup): |
1413 | - """A custom subclass capable of bug lookup by bug ID.""" |
1414 | - |
1415 | - def _get_url_from_id(self, key): |
1416 | - """Transform a bug ID into the URL to a bug resource.""" |
1417 | - return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key) |
1418 | - |
1419 | - |
1420 | -class PillarSet(CollectionWithKeyBasedLookup): |
1421 | - """A custom subclass capable of lookup by pillar name. |
1422 | - |
1423 | - Projects, project groups, and distributions are all pillars. |
1424 | - """ |
1425 | - |
1426 | - def _get_url_from_id(self, key): |
1427 | - """Transform a project name into the URL to a project resource.""" |
1428 | - return str(self._root._root_uri.ensureSlash()) + str(key) |
1429 | - |
1430 | - |
1431 | -# A mapping of resource type IDs to the client-side classes that handle |
1432 | -# those resource types. |
1433 | -RESOURCE_TYPE_CLASSES = { |
1434 | - 'bugs': BugSet, |
1435 | - 'distributions': PillarSet, |
1436 | - 'HostedFile': HostedFile, |
1437 | - 'people': PersonSet, |
1438 | - 'project_groups': PillarSet, |
1439 | - 'projects': PillarSet, |
1440 | - } |
Diff: https:/ /pastebin. canonical. com/16096/
Test: ./test.py -vvt launchpadlib (within a Launchpad checkout with a hacked launchpadlib link)
This branch removes almost all the code from launchpadlib, in favor of
the equivalent code from lazr.restfulclient.
The vast majority of the diff is code removal. Here are the more
interesting parts:
PersonSet, BugSet, etc. have been moved to TYPE_CLASSES, which maps WADL resource types to
launchpad.py. RESOURCE_
these classes, has been made a member of the Launchpad class.
The way launchpadlib intercepts outgoing HTTP requests to sign them ent's
with OAuth has been changed to fit into lazr.restfulcli
factory-based system.