Merge lp:~leonardr/launchpadlib/restfulclient into lp:~launchpad-pqm/launchpadlib/devel
- restfulclient
- Merge into devel
Proposed by
Leonard Richardson
Status: | Rejected |
---|---|
Rejected by: | Leonard Richardson |
Proposed branch: | lp:~leonardr/launchpadlib/restfulclient |
Merge into: | lp:~launchpad-pqm/launchpadlib/devel |
Diff against target: | None lines |
To merge this branch: | bzr merge lp:~leonardr/launchpadlib/restfulclient |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Celso Providelo (community) | Approve | ||
Review via email: mp+4946@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 : | # |
Revision history for this message
Celso Providelo (cprov) wrote : | # |
Hi Leonard,
Tests pass and the code looks good.
The transition with the restfulclient library within launchpadlib was a very good idea, it makes the migration much easier. Thanks for thinking about it.
r=me, nothing to complain about ;)
review:
Approve
- 45. By Leonard Richardson
-
Removed xsl file that was moved to restfulclient.
Unmerged revisions
- 45. By Leonard Richardson
-
Removed xsl file that was moved to restfulclient.
- 44. By Leonard Richardson
-
Changed imports to reduce the size of the diff.
- 43. By Leonard Richardson
-
Moved wadl-to-refhtml.xsl back so I don't have to change launchpadlib and launchpad simultaneously more than once.
- 42. By Leonard Richardson
-
Renamed LaunchpadError.
- 41. By Leonard Richardson
-
Removed now-useless file.
- 40. By Leonard Richardson
-
Fixed copyright notices.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === removed file 'src/launchpadlib/_browser.py' |
2 | --- src/launchpadlib/_browser.py 2009-03-20 20:46:06 +0000 |
3 | +++ src/launchpadlib/_browser.py 1970-01-01 00:00:00 +0000 |
4 | @@ -1,265 +0,0 @@ |
5 | -# Copyright 2008 Canonical Ltd. |
6 | - |
7 | -# This file is part of launchpadlib. |
8 | -# |
9 | -# launchpadlib is free software: you can redistribute it and/or modify it |
10 | -# under the terms of the GNU Lesser General Public License as published by the |
11 | -# Free Software Foundation, version 3 of the License. |
12 | -# |
13 | -# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
14 | -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
15 | -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
16 | -# for more details. |
17 | -# |
18 | -# You should have received a copy of the GNU Lesser General Public License |
19 | -# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
20 | - |
21 | -"""Browser object to make requests of Launchpad web service. |
22 | - |
23 | -The `Browser` class implements OAuth authenticated communications with |
24 | -Launchpad. It is not part of the public launchpadlib API. |
25 | -""" |
26 | - |
27 | -__metaclass__ = type |
28 | -__all__ = [ |
29 | - 'Browser', |
30 | - ] |
31 | - |
32 | - |
33 | -import atexit |
34 | -from cStringIO import StringIO |
35 | -import gzip |
36 | -from httplib2 import ( |
37 | - FailedToDecompressContent, FileCache, Http, safename, urlnorm) |
38 | -from lazr.uri import URI |
39 | -from oauth.oauth import ( |
40 | - OAuthRequest, OAuthSignatureMethod_PLAINTEXT) |
41 | -import shutil |
42 | -import simplejson |
43 | -import tempfile |
44 | -from urllib import urlencode |
45 | -from wadllib.application import Application |
46 | -import zlib |
47 | - |
48 | -from launchpadlib.errors import HTTPError |
49 | -from launchpadlib._json import DatetimeJSONEncoder |
50 | - |
51 | - |
52 | -OAUTH_REALM = 'https://api.launchpad.net' |
53 | - |
54 | -# A drop-in replacement for httplib2's _decompressContent, which looks |
55 | -# in the Transfer-Encoding header instead of in Content-Encoding. |
56 | -def _decompressContent(response, new_content): |
57 | - content = new_content |
58 | - try: |
59 | - encoding = response.get('transfer-encoding', None) |
60 | - if encoding in ['gzip', 'deflate']: |
61 | - if encoding == 'gzip': |
62 | - content = gzip.GzipFile( |
63 | - fileobj=StringIO.StringIO(new_content)).read() |
64 | - if encoding == 'deflate': |
65 | - content = zlib.decompress(content) |
66 | - response['content-length'] = str(len(content)) |
67 | - del response['transfer-encoding'] |
68 | - except IOError: |
69 | - content = "" |
70 | - raise FailedToDecompressContent( |
71 | - ("Content purported to be compressed with %s but failed " |
72 | - "to decompress." % response.get('transfer-encoding')), |
73 | - response, content) |
74 | - return content |
75 | - |
76 | - |
77 | -class OAuthSigningHttp(Http): |
78 | - """A client that signs every outgoing request with OAuth credentials.""" |
79 | - |
80 | - def __init__(self, oauth_credentials, cache=None, timeout=None, |
81 | - proxy_info=None): |
82 | - self.oauth_credentials = oauth_credentials |
83 | - Http.__init__(self, cache, timeout, proxy_info) |
84 | - |
85 | - def _request(self, conn, host, absolute_uri, request_uri, method, body, |
86 | - headers, redirections, cachekey): |
87 | - """Sign a request with OAuth credentials before sending it.""" |
88 | - oauth_request = OAuthRequest.from_consumer_and_token( |
89 | - self.oauth_credentials.consumer, |
90 | - self.oauth_credentials.access_token, |
91 | - http_url=absolute_uri) |
92 | - oauth_request.sign_request( |
93 | - OAuthSignatureMethod_PLAINTEXT(), |
94 | - self.oauth_credentials.consumer, |
95 | - self.oauth_credentials.access_token) |
96 | - if headers.has_key('authorization'): |
97 | - # There's an authorization header left over from a |
98 | - # previous request that resulted in a redirect. Remove it |
99 | - # and start again. |
100 | - del headers['authorization'] |
101 | - |
102 | - # httplib2 asks for compressed representations in |
103 | - # Accept-Encoding. But a different content-encoding means a |
104 | - # different ETag, which can cause problems later when we make |
105 | - # a conditional request. We don't want to treat a |
106 | - # representation differently based on whether or not we asked |
107 | - # for a compressed version of it. |
108 | - # |
109 | - # So we move the compression request from Accept-Encoding to |
110 | - # TE. Transfer-encoding compression can be handled transparently. |
111 | - if 'accept-encoding' in headers: |
112 | - headers['te'] = 'deflate, gzip' |
113 | - del headers['accept-encoding'] |
114 | - headers.update(oauth_request.to_header(OAUTH_REALM)) |
115 | - return super(OAuthSigningHttp, self)._request( |
116 | - conn, host, absolute_uri, request_uri, method, body, headers, |
117 | - redirections, cachekey) |
118 | - |
119 | - def _conn_request(self, conn, request_uri, method, body, headers): |
120 | - """Decompress content using our version of _decompressContent.""" |
121 | - response, content = super(OAuthSigningHttp, self)._conn_request( |
122 | - conn, request_uri, method, body, headers) |
123 | - # Decompress the response, if it was compressed. |
124 | - if method != "HEAD": |
125 | - content = _decompressContent(response, content) |
126 | - return (response, content) |
127 | - |
128 | - def _getCachedHeader(self, uri, header): |
129 | - """Retrieve a cached value for an HTTP header.""" |
130 | - if isinstance(self.cache, MultipleRepresentationCache): |
131 | - return self.cache._getCachedHeader(uri, header) |
132 | - return None |
133 | - |
134 | - |
135 | -class MultipleRepresentationCache(FileCache): |
136 | - """A cache that can hold different representations of the same resource. |
137 | - |
138 | - If a resource has two representations with two media types, |
139 | - FileCache will only store the most recently fetched |
140 | - representation. This cache can keep track of multiple |
141 | - representations of the same resource. |
142 | - |
143 | - This class works on the assumption that outside calling code sets |
144 | - an instance's request_media_type attribute to the value of the |
145 | - 'Accept' header before initiating the request. |
146 | - |
147 | - This class is very much not thread-safe, but FileCache isn't |
148 | - thread-safe anyway. |
149 | - """ |
150 | - def __init__(self, cache): |
151 | - """Tell FileCache to call append_media_type when generating keys.""" |
152 | - super(MultipleRepresentationCache, self).__init__( |
153 | - cache, self.append_media_type) |
154 | - self.request_media_type = None |
155 | - |
156 | - def append_media_type(self, key): |
157 | - """Append the request media type to the cache key. |
158 | - |
159 | - This ensures that representations of the same resource will be |
160 | - cached separately, so long as they're served as different |
161 | - media types. |
162 | - """ |
163 | - if self.request_media_type is not None: |
164 | - key = key + '-' + self.request_media_type |
165 | - return safename(key) |
166 | - |
167 | - |
168 | - def _getCachedHeader(self, uri, header): |
169 | - """Retrieve a cached value for an HTTP header.""" |
170 | - (scheme, authority, request_uri, cachekey) = urlnorm(uri) |
171 | - cached_value = self.get(cachekey) |
172 | - header_start = header + ':' |
173 | - if cached_value is not None: |
174 | - for line in StringIO(cached_value): |
175 | - if line.startswith(header_start): |
176 | - return line[len(header_start):].strip() |
177 | - return None |
178 | - |
179 | - |
180 | -class Browser: |
181 | - """A class for making calls to Launchpad web services.""" |
182 | - |
183 | - def __init__(self, credentials, cache=None, timeout=None, |
184 | - proxy_info=None): |
185 | - """Initialize, possibly creating a cache. |
186 | - |
187 | - If no cache is provided, a temporary directory will be used as |
188 | - a cache. The temporary directory will be automatically removed |
189 | - when the Python process exits. |
190 | - """ |
191 | - if cache is None: |
192 | - cache = tempfile.mkdtemp() |
193 | - atexit.register(shutil.rmtree, cache) |
194 | - if isinstance(cache, str): |
195 | - cache = MultipleRepresentationCache(cache) |
196 | - self._connection = OAuthSigningHttp( |
197 | - credentials, cache, timeout, proxy_info) |
198 | - |
199 | - def _request(self, url, data=None, method='GET', |
200 | - media_type='application/json', extra_headers=None): |
201 | - """Create an authenticated request object.""" |
202 | - # Add extra headers for the request. |
203 | - headers = {'Accept' : media_type} |
204 | - if isinstance(self._connection.cache, MultipleRepresentationCache): |
205 | - self._connection.cache.request_media_type = media_type |
206 | - if extra_headers is not None: |
207 | - headers.update(extra_headers) |
208 | - # Make the request. It will be signed automatically when |
209 | - # _request is called. |
210 | - response, content = self._connection.request( |
211 | - str(url), method=method, body=data, headers=headers) |
212 | - # Turn non-2xx responses into exceptions. |
213 | - if response.status // 100 != 2: |
214 | - raise HTTPError(response, content) |
215 | - return response, content |
216 | - |
217 | - def get(self, resource_or_uri, headers=None, return_response=False): |
218 | - """GET a representation of the given resource or URI.""" |
219 | - if isinstance(resource_or_uri, (basestring, URI)): |
220 | - url = resource_or_uri |
221 | - else: |
222 | - method = resource_or_uri.get_method('get') |
223 | - url = method.build_request_url() |
224 | - response, content = self._request(url, extra_headers=headers) |
225 | - if return_response: |
226 | - return (response, content) |
227 | - return content |
228 | - |
229 | - def get_wadl_application(self, url): |
230 | - """GET a WADL representation of the resource at the requested url.""" |
231 | - response, content = self._request( |
232 | - url, media_type='application/vd.sun.wadl+xml') |
233 | - return Application(str(url), content) |
234 | - |
235 | - def post(self, url, method_name, **kws): |
236 | - """POST a request to the web service.""" |
237 | - kws['ws.op'] = method_name |
238 | - data = urlencode(kws) |
239 | - return self._request(url, data, 'POST') |
240 | - |
241 | - def put(self, url, representation, media_type, headers=None): |
242 | - """PUT the given representation to the URL.""" |
243 | - extra_headers = {'Content-Type': media_type} |
244 | - if headers is not None: |
245 | - extra_headers.update(headers) |
246 | - return self._request( |
247 | - url, representation, 'PUT', extra_headers=extra_headers) |
248 | - |
249 | - def delete(self, url): |
250 | - """DELETE the resource at the given URL.""" |
251 | - self._request(url, method='DELETE') |
252 | - |
253 | - def patch(self, url, representation, headers=None): |
254 | - """PATCH the object at url with the updated representation.""" |
255 | - extra_headers = {'Content-Type': 'application/json'} |
256 | - if headers is not None: |
257 | - extra_headers.update(headers) |
258 | - # httplib2 doesn't know about the PATCH method, so we need to |
259 | - # do some work ourselves. Pull any cached value of "ETag" out |
260 | - # and use it as the value for "If-Match". |
261 | - cached_etag = self._connection._getCachedHeader(str(url), 'etag') |
262 | - if cached_etag is not None and not self._connection.ignore_etag: |
263 | - # http://www.w3.org/1999/04/Editing/ |
264 | - headers['If-Match'] = cached_etag |
265 | - |
266 | - return self._request( |
267 | - url, simplejson.dumps(representation, |
268 | - cls=DatetimeJSONEncoder), |
269 | - 'PATCH', extra_headers=extra_headers) |
270 | |
271 | === modified file 'src/launchpadlib/credentials.py' |
272 | --- src/launchpadlib/credentials.py 2009-03-23 21:50:35 +0000 |
273 | +++ src/launchpadlib/credentials.py 2009-03-26 19:26:13 +0000 |
274 | @@ -29,7 +29,7 @@ |
275 | from oauth.oauth import OAuthConsumer, OAuthToken |
276 | from urllib import urlencode |
277 | |
278 | -from launchpadlib.errors import CredentialsFileError, HTTPError |
279 | +from launchpadlib.restfulclient.errors import CredentialsFileError, HTTPError |
280 | |
281 | |
282 | CREDENTIALS_FILE_VERSION = '1' |
283 | |
284 | === modified file 'src/launchpadlib/errors.py' |
285 | --- src/launchpadlib/errors.py 2009-03-20 20:46:06 +0000 |
286 | +++ src/launchpadlib/errors.py 2009-03-26 19:08:48 +0000 |
287 | @@ -14,50 +14,7 @@ |
288 | # You should have received a copy of the GNU Lesser General Public License |
289 | # along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
290 | |
291 | -"""launchpadlib errors.""" |
292 | - |
293 | -__metaclass__ = type |
294 | -__all__ = [ |
295 | - 'CredentialsError', |
296 | - 'CredentialsFileError', |
297 | - 'HTTPError', |
298 | - 'LaunchpadError', |
299 | - 'ResponseError', |
300 | - 'UnexpectedResponseError', |
301 | - ] |
302 | - |
303 | - |
304 | -class LaunchpadError(Exception): |
305 | - """Base error for the Launchpad API library.""" |
306 | - |
307 | - |
308 | -class CredentialsError(LaunchpadError): |
309 | - """Base credentials/authentication error.""" |
310 | - |
311 | - |
312 | -class CredentialsFileError(CredentialsError): |
313 | - """Error in credentials file.""" |
314 | - |
315 | - |
316 | -class ResponseError(LaunchpadError): |
317 | - """Error in response.""" |
318 | - |
319 | - def __init__(self, response, content): |
320 | - LaunchpadError.__init__(self) |
321 | - self.response = response |
322 | - self.content = content |
323 | - |
324 | - |
325 | -class UnexpectedResponseError(ResponseError): |
326 | - """An unexpected response was received.""" |
327 | - |
328 | - def __str__(self): |
329 | - return '%s: %s' % (self.response.status, self.response.reason) |
330 | - |
331 | - |
332 | -class HTTPError(ResponseError): |
333 | - """An HTTP non-2xx response code was received.""" |
334 | - |
335 | - def __str__(self): |
336 | - return 'HTTP Error %s: %s' % ( |
337 | - self.response.status, self.response.reason) |
338 | + |
339 | +"""Reimport errors from restfulclient for convenience's sake.""" |
340 | + |
341 | +from launchpadlib.restfulclient.errors import * |
342 | |
343 | === modified file 'src/launchpadlib/launchpad.py' |
344 | --- src/launchpadlib/launchpad.py 2009-03-23 21:50:35 +0000 |
345 | +++ src/launchpadlib/launchpad.py 2009-03-26 19:26:13 +0000 |
346 | @@ -21,32 +21,64 @@ |
347 | 'Launchpad', |
348 | ] |
349 | |
350 | -import os |
351 | -import shutil |
352 | -import simplejson |
353 | -import stat |
354 | import sys |
355 | -import tempfile |
356 | -import urlparse |
357 | import webbrowser |
358 | |
359 | -from wadllib.application import Resource as WadlResource |
360 | from lazr.uri import URI |
361 | - |
362 | -from launchpadlib._browser import Browser |
363 | -from launchpadlib.resource import Resource |
364 | +from launchpadlib.restfulclient.resource import ( |
365 | + CollectionWithKeyBasedLookup, HostedFile, ServiceRoot) |
366 | from launchpadlib.credentials import AccessToken, Credentials |
367 | +from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT |
368 | +from launchpadlib.restfulclient._browser import RestfulHttp |
369 | |
370 | +OAUTH_REALM = 'https://api.launchpad.net' |
371 | STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/beta/' |
372 | EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/beta/' |
373 | |
374 | -class Launchpad(Resource): |
375 | + |
376 | +class PersonSet(CollectionWithKeyBasedLookup): |
377 | + """A custom subclass capable of person lookup by username.""" |
378 | + |
379 | + def _get_url_from_id(self, key): |
380 | + """Transform a username into the URL to a person resource.""" |
381 | + return str(self._root._root_uri.ensureSlash()) + '~' + str(key) |
382 | + |
383 | + |
384 | +class BugSet(CollectionWithKeyBasedLookup): |
385 | + """A custom subclass capable of bug lookup by bug ID.""" |
386 | + |
387 | + def _get_url_from_id(self, key): |
388 | + """Transform a bug ID into the URL to a bug resource.""" |
389 | + return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key) |
390 | + |
391 | + |
392 | +class PillarSet(CollectionWithKeyBasedLookup): |
393 | + """A custom subclass capable of lookup by pillar name. |
394 | + |
395 | + Projects, project groups, and distributions are all pillars. |
396 | + """ |
397 | + |
398 | + def _get_url_from_id(self, key): |
399 | + """Transform a project name into the URL to a project resource.""" |
400 | + return str(self._root._root_uri.ensureSlash()) + str(key) |
401 | + |
402 | + |
403 | +class Launchpad(ServiceRoot): |
404 | """Root Launchpad API class. |
405 | |
406 | :ivar credentials: The credentials instance used to access Launchpad. |
407 | :type credentials: `Credentials` |
408 | """ |
409 | |
410 | + RESOURCE_TYPE_CLASSES = { |
411 | + 'bugs': BugSet, |
412 | + 'distributions': PillarSet, |
413 | + 'HostedFile': HostedFile, |
414 | + 'people': PersonSet, |
415 | + 'project_groups': PillarSet, |
416 | + 'projects': PillarSet, |
417 | + } |
418 | + |
419 | def __init__(self, credentials, service_root=STAGING_SERVICE_ROOT, |
420 | cache=None, timeout=None, proxy_info=None): |
421 | """Root access to the Launchpad API. |
422 | @@ -56,34 +88,11 @@ |
423 | :param service_root: The URL to the root of the web service. |
424 | :type service_root: string |
425 | """ |
426 | - self._root_uri = URI(service_root) |
427 | - self.credentials = credentials |
428 | - # Get the WADL definition. |
429 | - self._browser = Browser(self.credentials, cache, timeout, proxy_info) |
430 | - self._wadl = self._browser.get_wadl_application(self._root_uri) |
431 | - |
432 | - # Get the root resource. |
433 | - root_resource = self._wadl.get_resource_by_path('') |
434 | - bound_root = root_resource.bind( |
435 | - self._browser.get(root_resource), 'application/json') |
436 | - super(Launchpad, self).__init__(None, bound_root) |
437 | - |
438 | - def load(self, url): |
439 | - """Load a resource given its URL.""" |
440 | - document = self._browser.get(url) |
441 | - try: |
442 | - representation = simplejson.loads(document) |
443 | - except ValueError: |
444 | - raise ValueError("%s doesn't serve a JSON document." % url) |
445 | - type_link = representation.get("resource_type_link") |
446 | - if type_link is None: |
447 | - raise ValueError("Couldn't determine the resource type of %s." |
448 | - % url) |
449 | - resource_type = self._root._wadl.get_resource_type(type_link) |
450 | - wadl_resource = WadlResource(self._root._wadl, url, resource_type.tag) |
451 | - return self._create_bound_resource( |
452 | - self._root, wadl_resource, representation, 'application/json', |
453 | - representation_needs_processing=False) |
454 | + super(Launchpad, self).__init__( |
455 | + credentials, service_root, cache, timeout, proxy_info) |
456 | + |
457 | + def httpFactory(self, credentials, cache, timeout, proxy_info): |
458 | + return OAuthSigningHttp(credentials, cache, timeout, proxy_info) |
459 | |
460 | @classmethod |
461 | def login(cls, consumer_name, token_string, access_secret, |
462 | @@ -157,69 +166,27 @@ |
463 | credentials.exchange_request_token_for_access_token(web_root) |
464 | return cls(credentials, service_root, cache, timeout, proxy_info) |
465 | |
466 | - @classmethod |
467 | - def login_with(cls, consumer_name, |
468 | - service_root=STAGING_SERVICE_ROOT, |
469 | - launchpadlib_dir=None, timeout=None, proxy_info=None): |
470 | - """Log in to Launchpad with possibly cached credentials. |
471 | - |
472 | - This is a convenience method for either setting up new login |
473 | - credentials, or re-using existing ones. When a login token is |
474 | - generated using this method, the resulting credentials will be |
475 | - saved in the `launchpadlib_dir` directory. If the same |
476 | - `launchpadlib_dir` is passed in a second time, the credentials |
477 | - in `launchpadlib_dir` for the consumer will be used |
478 | - automatically. |
479 | - |
480 | - Each consumer has their own credentials per service root in |
481 | - `launchpadlib_dir`. `launchpadlib_dir` is also used for caching |
482 | - fetched objects. The cache is per service root, and shared by |
483 | - all consumers. |
484 | - |
485 | - See `Launchpad.get_token_and_login()` for more information about |
486 | - how new tokens are generated. |
487 | - |
488 | - :param consumer_name: The consumer name, as appropriate for the |
489 | - `Consumer` constructor |
490 | - :type consumer_name: string |
491 | - :param service_root: The URL to the root of the web service. |
492 | - :type service_root: string |
493 | - :param launchpadlib_dir: The directory where the cache and |
494 | - credentials are stored. |
495 | - :type launchpadlib_dir: string |
496 | - :return: The web service root |
497 | - :rtype: `Launchpad` |
498 | - |
499 | - """ |
500 | - if launchpadlib_dir is None: |
501 | - home_dir = os.environ['HOME'] |
502 | - launchpadlib_dir = os.path.join(home_dir, '.launchpadlib') |
503 | - launchpadlib_dir = os.path.expanduser(launchpadlib_dir) |
504 | - # Each service root has its own cache and credential dirs. |
505 | - scheme, host_name, path, query, fragment = urlparse.urlsplit( |
506 | - service_root) |
507 | - service_root_dir = os.path.join(launchpadlib_dir, host_name) |
508 | - cache_path = os.path.join(service_root_dir, 'cache') |
509 | - if not os.path.exists(cache_path): |
510 | - os.makedirs(cache_path) |
511 | - credentials_path = os.path.join(service_root_dir, 'credentials') |
512 | - if not os.path.exists(credentials_path): |
513 | - os.makedirs(credentials_path) |
514 | - consumer_credentials_path = os.path.join( |
515 | - credentials_path, consumer_name) |
516 | - if os.path.exists(consumer_credentials_path): |
517 | - credentials = Credentials.load_from_path( |
518 | - consumer_credentials_path) |
519 | - launchpad = cls( |
520 | - credentials, service_root=service_root, cache=cache_path, |
521 | - timeout=timeout, proxy_info=proxy_info) |
522 | - else: |
523 | - launchpad = cls.get_token_and_login( |
524 | - consumer_name, service_root=service_root, cache=cache_path, |
525 | - timeout=timeout, proxy_info=proxy_info) |
526 | - launchpad.credentials.save_to_path( |
527 | - os.path.join(credentials_path, consumer_name)) |
528 | - os.chmod( |
529 | - os.path.join(credentials_path, consumer_name), |
530 | - stat.S_IREAD | stat.S_IWRITE) |
531 | - return launchpad |
532 | + |
533 | +class OAuthSigningHttp(RestfulHttp): |
534 | + """A client that signs every outgoing request with OAuth credentials.""" |
535 | + |
536 | + def _request(self, conn, host, absolute_uri, request_uri, method, body, |
537 | + headers, redirections, cachekey): |
538 | + """Sign a request with OAuth credentials before sending it.""" |
539 | + oauth_request = OAuthRequest.from_consumer_and_token( |
540 | + self.restful_credentials.consumer, |
541 | + self.restful_credentials.access_token, |
542 | + http_url=absolute_uri) |
543 | + oauth_request.sign_request( |
544 | + OAuthSignatureMethod_PLAINTEXT(), |
545 | + self.restful_credentials.consumer, |
546 | + self.restful_credentials.access_token) |
547 | + if headers.has_key('authorization'): |
548 | + # There's an authorization header left over from a |
549 | + # previous request that resulted in a redirect. Remove it |
550 | + # and start again. |
551 | + del headers['authorization'] |
552 | + headers.update(oauth_request.to_header(OAUTH_REALM)) |
553 | + return super(OAuthSigningHttp, self)._request( |
554 | + conn, host, absolute_uri, request_uri, method, body, headers, |
555 | + redirections, cachekey) |
556 | |
557 | === removed file 'src/launchpadlib/resource.py' |
558 | --- src/launchpadlib/resource.py 2009-03-20 20:46:06 +0000 |
559 | +++ src/launchpadlib/resource.py 1970-01-01 00:00:00 +0000 |
560 | @@ -1,830 +0,0 @@ |
561 | -# Copyright 2008 Canonical Ltd. |
562 | - |
563 | -# This file is part of launchpadlib. |
564 | -# |
565 | -# launchpadlib is free software: you can redistribute it and/or modify it |
566 | -# under the terms of the GNU Lesser General Public License as published by the |
567 | -# Free Software Foundation, version 3 of the License. |
568 | -# |
569 | -# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
570 | -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
571 | -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
572 | -# for more details. |
573 | -# |
574 | -# You should have received a copy of the GNU Lesser General Public License |
575 | -# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
576 | - |
577 | -"""Common support for web service resources.""" |
578 | - |
579 | -__metaclass__ = type |
580 | -__all__ = [ |
581 | - 'Collection', |
582 | - 'Entry', |
583 | - 'NamedOperation', |
584 | - 'Resource', |
585 | - ] |
586 | - |
587 | - |
588 | -import cgi |
589 | -import simplejson |
590 | -from StringIO import StringIO |
591 | -import urllib |
592 | -from urlparse import urlparse |
593 | -from lazr.uri import URI |
594 | - |
595 | -from launchpadlib._json import DatetimeJSONEncoder |
596 | -from launchpadlib.errors import HTTPError |
597 | -from wadllib.application import Resource as WadlResource |
598 | - |
599 | - |
600 | -class HeaderDictionary: |
601 | - """A dictionary that bridges httplib2's and wadllib's expectations. |
602 | - |
603 | - httplib2 expects all header dictionary access to give lowercase |
604 | - header names. wadllib expects to access the header exactly as it's |
605 | - specified in the WADL file, which means the official HTTP header name. |
606 | - |
607 | - This class transforms keys to lowercase before doing a lookup on |
608 | - the underlying dictionary. That way wadllib can pass in the |
609 | - official header name and httplib2 will get the lowercased name. |
610 | - """ |
611 | - def __init__(self, wrapped_dictionary): |
612 | - self.wrapped_dictionary = wrapped_dictionary |
613 | - |
614 | - def get(self, key, default=None): |
615 | - """Retrieve a value, converting the key to lowercase.""" |
616 | - return self.wrapped_dictionary.get(key.lower()) |
617 | - |
618 | - def __getitem__(self, key): |
619 | - """Retrieve a value, converting the key to lowercase.""" |
620 | - missing = object() |
621 | - value = self.get(key, missing) |
622 | - if value is missing: |
623 | - raise KeyError(key) |
624 | - return value |
625 | - |
626 | - |
627 | -class LaunchpadBase: |
628 | - """Base class for classes that know about Launchpad.""" |
629 | - |
630 | - JSON_MEDIA_TYPE = 'application/json' |
631 | - |
632 | - def _transform_resources_to_links(self, dictionary): |
633 | - new_dictionary = {} |
634 | - for key, value in dictionary.items(): |
635 | - if isinstance(value, Resource): |
636 | - value = value.self_link |
637 | - new_dictionary[self._get_external_param_name(key)] = value |
638 | - return new_dictionary |
639 | - |
640 | - def _get_external_param_name(self, param_name): |
641 | - """Turn a launchpadlib name into something to be sent over HTTP. |
642 | - |
643 | - For resources this may involve sticking '_link' or |
644 | - '_collection_link' on the end of the parameter name. For |
645 | - arguments to named operations, the parameter name is returned |
646 | - as is. |
647 | - """ |
648 | - return param_name |
649 | - |
650 | - |
651 | -class Resource(LaunchpadBase): |
652 | - """Base class for Launchpad's HTTP resources.""" |
653 | - |
654 | - def __init__(self, root, wadl_resource): |
655 | - """Initialize with respect to a wadllib Resource object.""" |
656 | - if root is None: |
657 | - # This _is_ the root. |
658 | - root = self |
659 | - # These values need to be put directly into __dict__ to avoid |
660 | - # calling __setattr__, which would cause an infinite recursion. |
661 | - self.__dict__['_root'] = root |
662 | - self.__dict__['_wadl_resource'] = wadl_resource |
663 | - |
664 | - FIND_COLLECTIONS = object() |
665 | - FIND_ENTRIES = object() |
666 | - FIND_ATTRIBUTES = object() |
667 | - |
668 | - @property |
669 | - def lp_collections(self): |
670 | - """Name the collections this resource links to.""" |
671 | - return self._get_parameter_names(self.FIND_COLLECTIONS) |
672 | - |
673 | - @property |
674 | - def lp_entries(self): |
675 | - """Name the entries this resource links to.""" |
676 | - return self._get_parameter_names(self.FIND_ENTRIES) |
677 | - |
678 | - @property |
679 | - def lp_attributes(self): |
680 | - """Name this resource's scalar attributes.""" |
681 | - return self._get_parameter_names(self.FIND_ATTRIBUTES) |
682 | - |
683 | - @property |
684 | - def lp_operations(self): |
685 | - """Name all of this resource's custom operations.""" |
686 | - # This library distinguishes between named operations by the |
687 | - # value they give for ws.op, not by their WADL names or IDs. |
688 | - names = [] |
689 | - form_encoded_type = 'application/x-www-form-urlencoded' |
690 | - for method in self._wadl_resource.method_iter: |
691 | - name = method.name.lower() |
692 | - if name == 'get': |
693 | - params = method.request.params(['query', 'plain']) |
694 | - elif name == 'post': |
695 | - definition = method.request.representation_definition( |
696 | - form_encoded_type).resolve_definition() |
697 | - params = definition.params(self._wadl_resource) |
698 | - for param in params: |
699 | - if param.name == 'ws.op': |
700 | - names.append(param.fixed_value) |
701 | - break |
702 | - return names |
703 | - |
704 | - @property |
705 | - def __members__(self): |
706 | - """A hook into dir() that returns web service-derived members.""" |
707 | - return self._get_parameter_names( |
708 | - self.FIND_COLLECTIONS, self.FIND_ENTRIES, self.FIND_ATTRIBUTES) |
709 | - |
710 | - __methods__ = lp_operations |
711 | - |
712 | - def _get_parameter_names(self, *kinds): |
713 | - """Retrieve some subset of the resource's parameters.""" |
714 | - names = [] |
715 | - for name in self._wadl_resource.parameter_names( |
716 | - self.JSON_MEDIA_TYPE): |
717 | - if name.endswith('_collection_link'): |
718 | - if self.FIND_COLLECTIONS in kinds: |
719 | - names.append(name[:-16]) |
720 | - elif (name.endswith('_link') |
721 | - and name not in ('self_link', 'resource_type_link')): |
722 | - # launchpadlib_obj.self will work, but is never |
723 | - # necessary. launchpadlib_obj.resource_type is also |
724 | - # unneccessary, and won't work anyway because |
725 | - # resource_type_link points to a WADL description, |
726 | - # not a normal Launchpad resource. |
727 | - if self.FIND_ENTRIES in kinds: |
728 | - names.append(name[:-5]) |
729 | - elif self.FIND_ATTRIBUTES in kinds: |
730 | - names.append(name) |
731 | - return names |
732 | - |
733 | - def lp_has_parameter(self, param_name): |
734 | - """Does this resource have a parameter with the given name?""" |
735 | - return self._get_external_param_name(param_name) is not None |
736 | - |
737 | - def lp_get_parameter(self, param_name): |
738 | - """Get the value of one of the resource's parameters. |
739 | - |
740 | - :return: A scalar value if the parameter is not a link. A new |
741 | - Resource object, whose resource is bound to a |
742 | - representation, if the parameter is a link. |
743 | - """ |
744 | - self._ensure_representation() |
745 | - for suffix in ['_link', '_collection_link']: |
746 | - param = self._wadl_resource.get_parameter( |
747 | - param_name + suffix, self.JSON_MEDIA_TYPE) |
748 | - if param is not None: |
749 | - if param.get_value() is None: |
750 | - # This parameter is a link to another object, but |
751 | - # there's no other object. Return None rather than |
752 | - # chasing down the nonexistent other object. |
753 | - return None |
754 | - linked_resource = param.linked_resource |
755 | - return self._create_bound_resource( |
756 | - self._root, linked_resource, param_name=param.name) |
757 | - param = self._wadl_resource.get_parameter(param_name) |
758 | - if param is None: |
759 | - raise KeyError("No such parameter: %s" % param_name) |
760 | - return param.get_value() |
761 | - |
762 | - def lp_get_named_operation(self, operation_name): |
763 | - """Get a custom operation with the given name. |
764 | - |
765 | - :return: A NamedOperation instance that can be called with |
766 | - appropriate arguments to invoke the operation. |
767 | - """ |
768 | - params = { 'ws.op' : operation_name } |
769 | - method = self._wadl_resource.get_method('get', query_params=params) |
770 | - if method is None: |
771 | - method = self._wadl_resource.get_method( |
772 | - 'post', representation_params=params) |
773 | - if method is None: |
774 | - raise KeyError("No operation with name: %s" % operation_name) |
775 | - return NamedOperation(self._root, self, method) |
776 | - |
777 | - @classmethod |
778 | - def _create_bound_resource( |
779 | - cls, root, resource, representation=None, |
780 | - representation_media_type='application/json', |
781 | - representation_needs_processing=True, representation_definition=None, |
782 | - param_name=None): |
783 | - """Create a launchpadlib Resource subclass from a wadllib Resource. |
784 | - |
785 | - :param resource: The wadllib Resource to wrap. |
786 | - :param representation: A previously fetched representation of |
787 | - this resource, to be reused. If not provided, this method |
788 | - will act just like the Resource constructor. |
789 | - :param representation_media_type: The media type of any previously |
790 | - fetched representation. |
791 | - :param representation_needs_processing: Set to False if the |
792 | - 'representation' parameter should be used as |
793 | - is. |
794 | - :param representation_definition: A wadllib |
795 | - RepresentationDefinition object describing the structure |
796 | - of this representation. Used in cases when the representation |
797 | - isn't the result of sending a standard GET to the resource. |
798 | - :param param_name: The name of the link that was followed to get |
799 | - to this resource. |
800 | - :return: An instance of the appropriate launchpadlib Resource |
801 | - subclass. |
802 | - """ |
803 | - # We happen to know that all Launchpad resource types are |
804 | - # defined in a single document. Turn the resource's type_url |
805 | - # into an anchor into that document: this is its resource |
806 | - # type. Then look up a client-side class that corresponds to |
807 | - # the resource type. |
808 | - type_url = resource.type_url |
809 | - resource_type = urlparse(type_url)[-1] |
810 | - default = Entry |
811 | - if (type_url.endswith('-page') |
812 | - or (param_name is not None |
813 | - and param_name.endswith('_collection_link'))): |
814 | - default = Collection |
815 | - r_class = RESOURCE_TYPE_CLASSES.get(resource_type, default) |
816 | - if representation is not None: |
817 | - # We've been given a representation. Bind the resource |
818 | - # immediately. |
819 | - resource = resource.bind( |
820 | - representation, representation_media_type, |
821 | - representation_needs_processing, |
822 | - representation_definition=representation_definition) |
823 | - else: |
824 | - # We'll fetch a representation and bind the resource when |
825 | - # necessary. |
826 | - pass |
827 | - return r_class(root, resource) |
828 | - |
829 | - def lp_refresh(self, new_url=None, etag=None): |
830 | - """Update this resource's representation.""" |
831 | - if new_url is not None: |
832 | - self._wadl_resource._url = new_url |
833 | - headers = {} |
834 | - if etag is not None: |
835 | - headers['If-None-Match'] = etag |
836 | - try: |
837 | - representation = self._root._browser.get( |
838 | - self._wadl_resource, headers=headers) |
839 | - except HTTPError, e: |
840 | - if e.response['status'] == '304': |
841 | - # The entry wasn't modified. No need to do anything. |
842 | - return |
843 | - else: |
844 | - raise e |
845 | - # __setattr__ assumes we're setting an attribute of the resource, |
846 | - # so we manipulate __dict__ directly. |
847 | - self.__dict__['_wadl_resource'] = self._wadl_resource.bind( |
848 | - representation, self.JSON_MEDIA_TYPE) |
849 | - |
850 | - def __getattr__(self, attr): |
851 | - """Try to retrive a named operation or parameter of the given name.""" |
852 | - try: |
853 | - return self.lp_get_parameter(attr) |
854 | - except KeyError: |
855 | - pass |
856 | - try: |
857 | - return self.lp_get_named_operation(attr) |
858 | - except KeyError: |
859 | - raise AttributeError("'%s' object has no attribute '%s'" |
860 | - % (self.__class__.__name__, attr)) |
861 | - |
862 | - def _get_external_param_name(self, param_name): |
863 | - """What's this parameter's name in the underlying representation?""" |
864 | - for suffix in ['_link', '_collection_link', '']: |
865 | - name = param_name + suffix |
866 | - if self._wadl_resource.get_parameter(name): |
867 | - return name |
868 | - return None |
869 | - |
870 | - def _ensure_representation(self): |
871 | - """Make sure this resource has a representation fetched.""" |
872 | - if self._wadl_resource.representation is None: |
873 | - # Get a representation of the linked resource. |
874 | - representation = self._root._browser.get(self._wadl_resource) |
875 | - self.__dict__['_wadl_resource'] = self._wadl_resource.bind( |
876 | - representation, self.JSON_MEDIA_TYPE) |
877 | - |
878 | - |
879 | -class NamedOperation(LaunchpadBase): |
880 | - """A class for a named operation to be invoked with GET or POST.""" |
881 | - |
882 | - def __init__(self, root, resource, wadl_method): |
883 | - """Initialize with respect to a WADL Method object""" |
884 | - self.root = root |
885 | - self.resource = resource |
886 | - self.wadl_method = wadl_method |
887 | - |
888 | - def __call__(self, *args, **kwargs): |
889 | - """Invoke the method and process the result.""" |
890 | - if len(args) > 0: |
891 | - raise TypeError('Method must be called with keyword args.') |
892 | - http_method = self.wadl_method.name |
893 | - args = self._transform_resources_to_links(kwargs) |
894 | - for key, value in args.items(): |
895 | - args[key] = simplejson.dumps(value, cls=DatetimeJSONEncoder) |
896 | - if http_method in ('get', 'head', 'delete'): |
897 | - url = self.wadl_method.build_request_url(**args) |
898 | - in_representation = '' |
899 | - extra_headers = {} |
900 | - else: |
901 | - url = self.wadl_method.build_request_url() |
902 | - (media_type, |
903 | - in_representation) = self.wadl_method.build_representation( |
904 | - **args) |
905 | - extra_headers = { 'Content-type' : media_type } |
906 | - response, content = self.root._browser._request( |
907 | - url, in_representation, http_method, extra_headers=extra_headers) |
908 | - |
909 | - if response.status == 201: |
910 | - return self._handle_201_response(url, response, content) |
911 | - else: |
912 | - if http_method == 'post': |
913 | - # The method call probably modified this resource in |
914 | - # an unknown way. Refresh its representation. |
915 | - self.resource.lp_refresh() |
916 | - return self._handle_200_response(url, response, content) |
917 | - |
918 | - def _handle_201_response(self, url, response, content): |
919 | - """Handle the creation of a new resource by fetching it.""" |
920 | - wadl_response = self.wadl_method.response.bind( |
921 | - HeaderDictionary(response)) |
922 | - wadl_parameter = wadl_response.get_parameter('Location') |
923 | - wadl_resource = wadl_parameter.linked_resource |
924 | - # Fetch a representation of the new resource. |
925 | - response, content = self.root._browser._request( |
926 | - wadl_resource.url) |
927 | - # Return an instance of the appropriate launchpadlib |
928 | - # Resource subclass. |
929 | - return Resource._create_bound_resource( |
930 | - self.root, wadl_resource, content, response['content-type']) |
931 | - |
932 | - def _handle_200_response(self, url, response, content): |
933 | - """Process the return value of an operation.""" |
934 | - content_type = response['content-type'] |
935 | - # Process the returned content, assuming we know how. |
936 | - response_definition = self.wadl_method.response |
937 | - representation_definition = ( |
938 | - response_definition.get_representation_definition( |
939 | - content_type)) |
940 | - |
941 | - if representation_definition is None: |
942 | - # The operation returned a document with nothing |
943 | - # special about it. |
944 | - if content_type == self.JSON_MEDIA_TYPE: |
945 | - return simplejson.loads(content) |
946 | - # We don't know how to process the content. |
947 | - return content |
948 | - |
949 | - # The operation returned a representation of some |
950 | - # resource. Instantiate a Resource object for it. |
951 | - document = simplejson.loads(content) |
952 | - if "self_link" in document and "resource_type_link" in document: |
953 | - # The operation returned an entry. Use the self_link and |
954 | - # resource_type_link of the entry representation to build |
955 | - # a Resource object of the appropriate type. That way this |
956 | - # object will support all of the right named operations. |
957 | - url = document["self_link"] |
958 | - resource_type = self.root._wadl.get_resource_type( |
959 | - document["resource_type_link"]) |
960 | - wadl_resource = WadlResource(self.root._wadl, url, |
961 | - resource_type.tag) |
962 | - else: |
963 | - # The operation returned a collection. It's probably an ad |
964 | - # hoc collection that doesn't correspond to any resource |
965 | - # type. Instantiate it as a resource backed by the |
966 | - # representation type defined in the return value, instead |
967 | - # of a resource type tag. |
968 | - representation_definition = ( |
969 | - representation_definition.resolve_definition()) |
970 | - wadl_resource = WadlResource( |
971 | - self.root._wadl, url, representation_definition.tag) |
972 | - |
973 | - return Resource._create_bound_resource( |
974 | - self.root, wadl_resource, document, content_type, |
975 | - representation_needs_processing=False, |
976 | - representation_definition=representation_definition) |
977 | - |
978 | - def _get_external_param_name(self, param_name): |
979 | - """Named operation parameter names are sent as is.""" |
980 | - return param_name |
981 | - |
982 | - |
983 | -class Entry(Resource): |
984 | - """A class for an entry-type resource that can be updated with PATCH.""" |
985 | - |
986 | - def __init__(self, root, wadl_resource): |
987 | - super(Entry, self).__init__(root, wadl_resource) |
988 | - # Initialize this here in a semi-magical way so as to stop a |
989 | - # particular infinite loop that would follow. Setting |
990 | - # self._dirty_attributes would call __setattr__(), which would |
991 | - # turn around immediately and get self._dirty_attributes. If |
992 | - # this latter was not in the instance dictionary, that would |
993 | - # end up calling __getattr__(), which would again reference |
994 | - # self._dirty_attributes. This is where the infloop would |
995 | - # occur. Poking this directly into self.__dict__ means that |
996 | - # the check for self._dirty_attributes won't call __getattr__(), |
997 | - # breaking the cycle. |
998 | - self.__dict__['_dirty_attributes'] = {} |
999 | - super(Entry, self).__init__(root, wadl_resource) |
1000 | - |
1001 | - def __repr__(self): |
1002 | - """Return the WADL resource type and the URL to the resource.""" |
1003 | - return '<%s at %s>' % ( |
1004 | - URI(self.resource_type_link).fragment, self.self_link) |
1005 | - |
1006 | - def __str__(self): |
1007 | - """Return the URL to the resource.""" |
1008 | - return self.self_link |
1009 | - |
1010 | - def __getattr__(self, name): |
1011 | - """Try to retrive a parameter of the given name.""" |
1012 | - if name != '_dirty_attributes': |
1013 | - if name in self._dirty_attributes: |
1014 | - return self._dirty_attributes[name] |
1015 | - return super(Entry, self).__getattr__(name) |
1016 | - |
1017 | - def __setattr__(self, name, value): |
1018 | - """Set the parameter of the given name.""" |
1019 | - if not self.lp_has_parameter(name): |
1020 | - raise AttributeError("'%s' object has no attribute '%s'" % |
1021 | - (self.__class__.__name__, name)) |
1022 | - self._dirty_attributes[name] = value |
1023 | - |
1024 | - def lp_refresh(self, new_url=None): |
1025 | - """Update this resource's representation.""" |
1026 | - etag = getattr(self, 'http_etag', None) |
1027 | - super(Entry, self).lp_refresh(new_url, etag) |
1028 | - self._dirty_attributes.clear() |
1029 | - |
1030 | - def lp_save(self): |
1031 | - """Save changes to the entry.""" |
1032 | - representation = self._transform_resources_to_links( |
1033 | - self._dirty_attributes) |
1034 | - |
1035 | - # If the entry contains an ETag, set the If-Match header |
1036 | - # to that value. |
1037 | - headers = {} |
1038 | - etag = getattr(self, 'http_etag', None) |
1039 | - if etag is not None: |
1040 | - headers['If-Match'] = etag |
1041 | - |
1042 | - # PATCH the new representation to the 'self' link. It's possible that |
1043 | - # this will cause the object to be permanently moved. Catch that |
1044 | - # exception and refresh our representation. |
1045 | - try: |
1046 | - response, content = self._root._browser.patch( |
1047 | - URI(self.self_link), representation, headers) |
1048 | - except HTTPError, error: |
1049 | - if error.response.status == 301: |
1050 | - response = error.response |
1051 | - self.lp_refresh(error.response['location']) |
1052 | - else: |
1053 | - raise |
1054 | - self._dirty_attributes.clear() |
1055 | - |
1056 | - content_type = response['content-type'] |
1057 | - if response.status == 209 and content_type == self.JSON_MEDIA_TYPE: |
1058 | - # The server sent back a new representation of the object. |
1059 | - # Use it in preference to the existing representation. |
1060 | - new_representation = simplejson.loads(content) |
1061 | - self._wadl_resource.representation = new_representation |
1062 | - self._wadl_resource.media_type = content_type |
1063 | - |
1064 | - |
1065 | -class Collection(Resource): |
1066 | - """A collection-type resource that supports pagination.""" |
1067 | - |
1068 | - def __init__(self, root, wadl_resource): |
1069 | - """Create a collection object.""" |
1070 | - super(Collection, self).__init__(root, wadl_resource) |
1071 | - |
1072 | - def __len__(self): |
1073 | - """The number of items in the collection. |
1074 | - |
1075 | - :return: length of the collection |
1076 | - :rtype: int |
1077 | - """ |
1078 | - try: |
1079 | - return int(self.total_size) |
1080 | - except AttributeError: |
1081 | - raise TypeError('collection size is not available') |
1082 | - |
1083 | - def __iter__(self): |
1084 | - """Iterate over the items in the collection. |
1085 | - |
1086 | - :return: iterator |
1087 | - :rtype: sequence of `Entry` |
1088 | - """ |
1089 | - self._ensure_representation() |
1090 | - current_page = self._wadl_resource.representation |
1091 | - while True: |
1092 | - for resource in self._convert_dicts_to_entries( |
1093 | - current_page.get('entries', {})): |
1094 | - yield resource |
1095 | - next_link = current_page.get('next_collection_link') |
1096 | - if next_link is None: |
1097 | - break |
1098 | - current_page = simplejson.loads( |
1099 | - self._root._browser.get(URI(next_link))) |
1100 | - |
1101 | - def __getitem__(self, key): |
1102 | - """Look up a slice, or a subordinate resource by index. |
1103 | - |
1104 | - To discourage situations where a launchpadlib client fetches |
1105 | - all of an enormous list, all collection slices must have a |
1106 | - definitive end point. For performance reasons, all collection |
1107 | - slices must be indexed from the start of the list rather than |
1108 | - the end. |
1109 | - """ |
1110 | - if isinstance(key, slice): |
1111 | - return self._get_slice(key) |
1112 | - else: |
1113 | - # Look up a single item by its position in the list. |
1114 | - found_slice = self._get_slice(slice(key, key+1)) |
1115 | - if len(found_slice) != 1: |
1116 | - raise IndexError("list index out of range") |
1117 | - return found_slice[0] |
1118 | - |
1119 | - def _get_slice(self, slice): |
1120 | - """Retrieve a slice of a collection.""" |
1121 | - start = slice.start or 0 |
1122 | - stop = slice.stop |
1123 | - |
1124 | - if start < 0: |
1125 | - raise ValueError("Collection slices must have a nonnegative " |
1126 | - "start point.") |
1127 | - if stop < 0: |
1128 | - raise ValueError("Collection slices must have a definite, " |
1129 | - "nonnegative end point.") |
1130 | - |
1131 | - existing_representation = self._wadl_resource.representation |
1132 | - if (existing_representation is not None |
1133 | - and start < len(existing_representation['entries'])): |
1134 | - # An optimization: the first page of entries has already |
1135 | - # been loaded. This can happen if this collection is the |
1136 | - # return value of a named operation, or if the client did |
1137 | - # something like check the length of the collection. |
1138 | - # |
1139 | - # Either way, we've already made an HTTP request and |
1140 | - # gotten some entries back. The client has requested a |
1141 | - # slice that includes some of the entries we already have. |
1142 | - # In the best case, we can fulfil the slice immediately, |
1143 | - # without making another HTTP request. |
1144 | - # |
1145 | - # Even if we can't fulfil the entire slice, we can get one |
1146 | - # or more objects from the first page and then have fewer |
1147 | - # objects to retrieve from the server later. This saves us |
1148 | - # time and bandwidth, and it might let us save a whole |
1149 | - # HTTP request. |
1150 | - entry_page = existing_representation['entries'] |
1151 | - |
1152 | - first_page_size = len(entry_page) |
1153 | - entry_dicts = entry_page[start:stop] |
1154 | - page_url = existing_representation.get('next_collection_link') |
1155 | - else: |
1156 | - # No part of this collection has been loaded yet, or the |
1157 | - # slice starts beyond the part that has been loaded. We'll |
1158 | - # use our secret knowledge of Launchpad to set a value for |
1159 | - # the ws.start variable. That way we start reading entries |
1160 | - # from the first one we want. |
1161 | - first_page_size = None |
1162 | - entry_dicts = [] |
1163 | - page_url = self._with_url_query_variable_set( |
1164 | - self._wadl_resource.url, 'ws.start', start) |
1165 | - |
1166 | - desired_size = stop-start |
1167 | - more_needed = desired_size - len(entry_dicts) |
1168 | - |
1169 | - # Iterate over pages until we have the correct number of entries. |
1170 | - while more_needed > 0 and page_url is not None: |
1171 | - representation = simplejson.loads( |
1172 | - self._root._browser.get(page_url)) |
1173 | - current_page_entries = representation['entries'] |
1174 | - entry_dicts += current_page_entries[:more_needed] |
1175 | - more_needed = desired_size - len(entry_dicts) |
1176 | - |
1177 | - page_url = representation.get('next_collection_link') |
1178 | - if page_url is None: |
1179 | - # We've gotten the entire collection; there are no |
1180 | - # more entries. |
1181 | - break |
1182 | - if first_page_size is None: |
1183 | - first_page_size = len(current_page_entries) |
1184 | - if more_needed > 0 and more_needed < first_page_size: |
1185 | - # An optimization: it's likely that we need less than |
1186 | - # a full page of entries, because the number we need |
1187 | - # is less than the size of the first page we got. |
1188 | - # Instead of requesting a full-sized page, we'll |
1189 | - # request only the number of entries we think we'll |
1190 | - # need. If we're wrong, there's no problem; we'll just |
1191 | - # keep looping. |
1192 | - page_url = self._with_url_query_variable_set( |
1193 | - page_url, 'ws.size', more_needed) |
1194 | - |
1195 | - if slice.step is not None: |
1196 | - entry_dicts = entry_dicts[::slice.step] |
1197 | - |
1198 | - # Convert entry_dicts into a list of Entry objects. |
1199 | - return [resource for resource |
1200 | - in self._convert_dicts_to_entries(entry_dicts)] |
1201 | - |
1202 | - def _convert_dicts_to_entries(self, entries): |
1203 | - """Convert dictionaries describing entries to Entry objects. |
1204 | - |
1205 | - The dictionaries come from the 'entries' field of the JSON |
1206 | - dictionary you get when you GET a page of a collection. Each |
1207 | - dictionary is the same as you'd get if you sent a GET request |
1208 | - to the corresponding entry resource. So each of these |
1209 | - dictionaries can be treated as a preprocessed representation |
1210 | - of an entry resource, and turned into an Entry instance. |
1211 | - |
1212 | - :yield: A sequence of Entry instances. |
1213 | - """ |
1214 | - for entry_dict in entries: |
1215 | - resource_url = entry_dict['self_link'] |
1216 | - resource_type_link = entry_dict['resource_type_link'] |
1217 | - wadl_application = self._wadl_resource.application |
1218 | - resource_type = wadl_application.get_resource_type( |
1219 | - resource_type_link) |
1220 | - resource = WadlResource( |
1221 | - self._wadl_resource.application, resource_url, |
1222 | - resource_type.tag) |
1223 | - yield Resource._create_bound_resource( |
1224 | - self._root, resource, entry_dict, self.JSON_MEDIA_TYPE, |
1225 | - False) |
1226 | - |
1227 | - def _with_url_query_variable_set(self, url, variable, new_value): |
1228 | - """A helper method to set a query variable in a URL.""" |
1229 | - uri = URI(url) |
1230 | - if uri.query is None: |
1231 | - params = {} |
1232 | - else: |
1233 | - params = cgi.parse_qs(uri.query) |
1234 | - params[variable] = str(new_value) |
1235 | - uri.query = urllib.urlencode(params, True) |
1236 | - return str(uri) |
1237 | - |
1238 | - |
1239 | -class CollectionWithKeyBasedLookup(Collection): |
1240 | - """A collection-type resource that supports key-based lookup. |
1241 | - |
1242 | - This collection can be sliced, but any single index passed into |
1243 | - __getitem__ will be treated as a custom lookup key. |
1244 | - """ |
1245 | - |
1246 | - def __getitem__(self, key): |
1247 | - """Look up a slice, or a subordinate resource by unique ID.""" |
1248 | - if isinstance(key, slice): |
1249 | - return super(CollectionWithKeyBasedLookup, self).__getitem__(key) |
1250 | - try: |
1251 | - url = self._get_url_from_id(key) |
1252 | - except NotImplementedError: |
1253 | - raise TypeError("unsubscriptable object") |
1254 | - if url is None: |
1255 | - raise KeyError(key) |
1256 | - |
1257 | - # We don't know what kind of resource this is. Even the |
1258 | - # subclass doesn't necessarily know, because some resources |
1259 | - # (the person list) are gateways to more than one kind of |
1260 | - # resource (people, and teams). The only way to know for sure |
1261 | - # is to retrieve a representation of the resource and see how |
1262 | - # the resource describes itself. |
1263 | - try: |
1264 | - representation = simplejson.loads(self._root._browser.get(url)) |
1265 | - except HTTPError, error: |
1266 | - # There's no resource corresponding to the given ID. |
1267 | - if error.response.status == 404: |
1268 | - raise KeyError(key) |
1269 | - raise |
1270 | - # We know that every Launchpad resource has a 'resource_type_link' |
1271 | - # in its representation. |
1272 | - resource_type_link = representation['resource_type_link'] |
1273 | - resource = WadlResource(self._root._wadl, url, resource_type_link) |
1274 | - return self._create_bound_resource( |
1275 | - self._root, resource, representation=representation, |
1276 | - representation_needs_processing=False) |
1277 | - |
1278 | - |
1279 | - def _get_url_from_id(self, key): |
1280 | - """Transform the unique ID of an object into its URL.""" |
1281 | - raise NotImplementedError() |
1282 | - |
1283 | - |
1284 | -class HostedFile(Resource): |
1285 | - """A resource represnting a file hosted on Launchpad's server.""" |
1286 | - |
1287 | - def open(self, mode='r', content_type=None, filename=None): |
1288 | - """Open the file on the server for read or write access.""" |
1289 | - if mode in ('r', 'w'): |
1290 | - return HostedFileBuffer(self, mode, content_type, filename) |
1291 | - else: |
1292 | - raise ValueError("Invalid mode. Supported modes are: r, w") |
1293 | - |
1294 | - def delete(self): |
1295 | - """Delete the file from the server.""" |
1296 | - self._root._browser.delete(self._wadl_resource.url) |
1297 | - |
1298 | - def _get_parameter_names(self, *kinds): |
1299 | - """HostedFile objects define no web service parameters.""" |
1300 | - return [] |
1301 | - |
1302 | - |
1303 | -class HostedFileBuffer(StringIO): |
1304 | - """The contents of a file hosted on Launchpad's server.""" |
1305 | - def __init__(self, hosted_file, mode, content_type=None, filename=None): |
1306 | - self.url = hosted_file._wadl_resource.url |
1307 | - if mode == 'r': |
1308 | - if content_type is not None: |
1309 | - raise ValueError("Files opened for read access can't " |
1310 | - "specify content_type.") |
1311 | - if filename is not None: |
1312 | - raise ValueError("Files opened for read access can't " |
1313 | - "specify filename.") |
1314 | - response, value = hosted_file._root._browser.get( |
1315 | - self.url, return_response=True) |
1316 | - content_type = response['content-type'] |
1317 | - last_modified = response['last-modified'] |
1318 | - |
1319 | - # The Content-Location header contains the URL of the file |
1320 | - # in the Launchpad library. We happen to know that the |
1321 | - # final component of the URL is the name of the uploaded |
1322 | - # file. |
1323 | - content_location = response['content-location'] |
1324 | - path = urlparse(content_location)[2] |
1325 | - filename = urllib.unquote(path.split("/")[-1]) |
1326 | - elif mode == 'w': |
1327 | - value = '' |
1328 | - if content_type is None: |
1329 | - raise ValueError("Files opened for write access must " |
1330 | - "specify content_type.") |
1331 | - if filename is None: |
1332 | - raise ValueError("Files opened for write access must " |
1333 | - "specify filename.") |
1334 | - last_modified = None |
1335 | - else: |
1336 | - raise ValueError("Invalid mode. Supported modes are: r, w") |
1337 | - |
1338 | - self.hosted_file = hosted_file |
1339 | - self.mode = mode |
1340 | - self.content_type = content_type |
1341 | - self.filename = filename |
1342 | - self.last_modified = last_modified |
1343 | - StringIO.__init__(self, value) |
1344 | - |
1345 | - def close(self): |
1346 | - if self.mode == 'w': |
1347 | - disposition = 'attachment; filename="%s"' % self.filename |
1348 | - self.hosted_file._root._browser.put( |
1349 | - self.url, self.getvalue(), |
1350 | - self.content_type, {'Content-Disposition' : disposition}) |
1351 | - StringIO.close(self) |
1352 | - |
1353 | - |
1354 | -class PersonSet(CollectionWithKeyBasedLookup): |
1355 | - """A custom subclass capable of person lookup by username.""" |
1356 | - |
1357 | - def _get_url_from_id(self, key): |
1358 | - """Transform a username into the URL to a person resource.""" |
1359 | - return str(self._root._root_uri.ensureSlash()) + '~' + str(key) |
1360 | - |
1361 | - |
1362 | -class BugSet(CollectionWithKeyBasedLookup): |
1363 | - """A custom subclass capable of bug lookup by bug ID.""" |
1364 | - |
1365 | - def _get_url_from_id(self, key): |
1366 | - """Transform a bug ID into the URL to a bug resource.""" |
1367 | - return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key) |
1368 | - |
1369 | - |
1370 | -class PillarSet(CollectionWithKeyBasedLookup): |
1371 | - """A custom subclass capable of lookup by pillar name. |
1372 | - |
1373 | - Projects, project groups, and distributions are all pillars. |
1374 | - """ |
1375 | - |
1376 | - def _get_url_from_id(self, key): |
1377 | - """Transform a project name into the URL to a project resource.""" |
1378 | - return str(self._root._root_uri.ensureSlash()) + str(key) |
1379 | - |
1380 | - |
1381 | -# A mapping of resource type IDs to the client-side classes that handle |
1382 | -# those resource types. |
1383 | -RESOURCE_TYPE_CLASSES = { |
1384 | - 'bugs': BugSet, |
1385 | - 'distributions': PillarSet, |
1386 | - 'HostedFile': HostedFile, |
1387 | - 'people': PersonSet, |
1388 | - 'project_groups': PillarSet, |
1389 | - 'projects': PillarSet, |
1390 | - } |
1391 | |
1392 | === added directory 'src/launchpadlib/restfulclient' |
1393 | === added file 'src/launchpadlib/restfulclient/__init__.py' |
1394 | --- src/launchpadlib/restfulclient/__init__.py 1970-01-01 00:00:00 +0000 |
1395 | +++ src/launchpadlib/restfulclient/__init__.py 2009-03-26 18:40:36 +0000 |
1396 | @@ -0,0 +1,19 @@ |
1397 | +# Copyright 2008 Canonical Ltd. |
1398 | + |
1399 | +# This file is part of lazr.restfulclient. |
1400 | +# |
1401 | +# lazr.restfulclient is free software: you can redistribute it and/or modify |
1402 | +# it under the terms of the GNU Lesser General Public License as |
1403 | +# published by the Free Software Foundation, either version 3 of the |
1404 | +# License, or (at your option) any later version. |
1405 | +# |
1406 | +# lazr.restfulclient is distributed in the hope that it will be useful, but |
1407 | +# WITHOUT ANY WARRANTY; without even the implied warranty of |
1408 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
1409 | +# Lesser General Public License for more details. |
1410 | +# |
1411 | +# You should have received a copy of the GNU Lesser General Public |
1412 | +# License along with lazr.restfulclient. If not, see |
1413 | +# <http://www.gnu.org/licenses/>. |
1414 | + |
1415 | +__version__ = '1.0' |
1416 | |
1417 | === added file 'src/launchpadlib/restfulclient/_browser.py' |
1418 | --- src/launchpadlib/restfulclient/_browser.py 1970-01-01 00:00:00 +0000 |
1419 | +++ src/launchpadlib/restfulclient/_browser.py 2009-03-26 19:26:13 +0000 |
1420 | @@ -0,0 +1,258 @@ |
1421 | +# Copyright 2008 Canonical Ltd. |
1422 | + |
1423 | +# This file is part of lazr.restfulclient. |
1424 | +# |
1425 | +# lazr.restfulclient is free software: you can redistribute it and/or modify |
1426 | +# it under the terms of the GNU Lesser General Public License as |
1427 | +# published by the Free Software Foundation, either version 3 of the |
1428 | +# License, or (at your option) any later version. |
1429 | +# |
1430 | +# lazr.restfulclient is distributed in the hope that it will be useful, but |
1431 | +# WITHOUT ANY WARRANTY; without even the implied warranty of |
1432 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
1433 | +# Lesser General Public License for more details. |
1434 | +# |
1435 | +# You should have received a copy of the GNU Lesser General Public |
1436 | +# License along with lazr.restfulclient. If not, see |
1437 | +# <http://www.gnu.org/licenses/>. |
1438 | + |
1439 | +"""Browser object to make requests of lazr.restful web services. |
1440 | + |
1441 | +The `Browser` class does some massage of HTTP requests and responses, |
1442 | +and handles custom caches. It is not part of the public |
1443 | +lazr.restfulclient API. (But maybe it should be?) |
1444 | +""" |
1445 | + |
1446 | +__metaclass__ = type |
1447 | +__all__ = [ |
1448 | + 'Browser', |
1449 | + 'RestfulHttp', |
1450 | + ] |
1451 | + |
1452 | + |
1453 | +import atexit |
1454 | +import gzip |
1455 | +import shutil |
1456 | +import tempfile |
1457 | +from httplib2 import ( |
1458 | + FailedToDecompressContent, FileCache, Http, safename, urlnorm) |
1459 | +import simplejson |
1460 | +from cStringIO import StringIO |
1461 | +import zlib |
1462 | + |
1463 | +from urllib import urlencode |
1464 | +from wadllib.application import Application |
1465 | +from lazr.uri import URI |
1466 | +from errors import HTTPError |
1467 | +from _json import DatetimeJSONEncoder |
1468 | + |
1469 | +# A drop-in replacement for httplib2's _decompressContent, which looks |
1470 | +# in the Transfer-Encoding header instead of in Content-Encoding. |
1471 | +def _decompressContent(response, new_content): |
1472 | + content = new_content |
1473 | + try: |
1474 | + encoding = response.get('transfer-encoding', None) |
1475 | + if encoding in ['gzip', 'deflate']: |
1476 | + if encoding == 'gzip': |
1477 | + content = gzip.GzipFile( |
1478 | + fileobj=StringIO.StringIO(new_content)).read() |
1479 | + if encoding == 'deflate': |
1480 | + content = zlib.decompress(content) |
1481 | + response['content-length'] = str(len(content)) |
1482 | + del response['transfer-encoding'] |
1483 | + except IOError: |
1484 | + content = "" |
1485 | + raise FailedToDecompressContent( |
1486 | + ("Content purported to be compressed with %s but failed " |
1487 | + "to decompress." % response.get('transfer-encoding')), |
1488 | + response, content) |
1489 | + return content |
1490 | + |
1491 | + |
1492 | +class RestfulHttp(Http): |
1493 | + """An Http subclass with some custom behavior. |
1494 | + |
1495 | + This Http client uses the TE header instead of the Accept-Encoding |
1496 | + header to ask for compressed representations. It also knows how to |
1497 | + react when its cache is a MultipleRepresentationCache. |
1498 | + """ |
1499 | + |
1500 | + def __init__(self, credentials, cache=None, timeout=None, |
1501 | + proxy_info=None): |
1502 | + super(RestfulHttp, self).__init__(cache, timeout, proxy_info) |
1503 | + # The credentials are not used in this class, but you can |
1504 | + # use them in a subclass. |
1505 | + self.restful_credentials = credentials |
1506 | + |
1507 | + |
1508 | + def _request(self, conn, host, absolute_uri, request_uri, method, body, |
1509 | + headers, redirections, cachekey): |
1510 | + """Manipulate Transfer-Encoding header before sending the request. |
1511 | + |
1512 | + httplib2 asks for compressed representations in |
1513 | + Accept-Encoding. But a different content-encoding means a |
1514 | + different ETag, which can cause problems later when we make |
1515 | + a conditional request. We don't want to treat a |
1516 | + representation differently based on whether or not we asked |
1517 | + for a compressed version of it. |
1518 | + |
1519 | + So we move the compression request from Accept-Encoding to |
1520 | + TE. Transfer-encoding compression can be handled |
1521 | + transparently, without affecting the ETag. |
1522 | + """ |
1523 | + if 'accept-encoding' in headers: |
1524 | + headers['te'] = 'deflate, gzip' |
1525 | + del headers['accept-encoding'] |
1526 | + return super(RestfulHttp, self)._request( |
1527 | + conn, host, absolute_uri, request_uri, method, body, headers, |
1528 | + redirections, cachekey) |
1529 | + |
1530 | + def _conn_request(self, conn, request_uri, method, body, headers): |
1531 | + """Decompress content using our version of _decompressContent.""" |
1532 | + response, content = super(RestfulHttp, self)._conn_request( |
1533 | + conn, request_uri, method, body, headers) |
1534 | + # Decompress the response, if it was compressed. |
1535 | + if method != "HEAD": |
1536 | + content = _decompressContent(response, content) |
1537 | + return (response, content) |
1538 | + |
1539 | + def _getCachedHeader(self, uri, header): |
1540 | + """Retrieve a cached value for an HTTP header.""" |
1541 | + if isinstance(self.cache, MultipleRepresentationCache): |
1542 | + return self.cache._getCachedHeader(uri, header) |
1543 | + return None |
1544 | + |
1545 | + |
1546 | +class MultipleRepresentationCache(FileCache): |
1547 | + """A cache that can hold different representations of the same resource. |
1548 | + |
1549 | + If a resource has two representations with two media types, |
1550 | + FileCache will only store the most recently fetched |
1551 | + representation. This cache can keep track of multiple |
1552 | + representations of the same resource. |
1553 | + |
1554 | + This class works on the assumption that outside calling code sets |
1555 | + an instance's request_media_type attribute to the value of the |
1556 | + 'Accept' header before initiating the request. |
1557 | + |
1558 | + This class is very much not thread-safe, but FileCache isn't |
1559 | + thread-safe anyway. |
1560 | + """ |
1561 | + def __init__(self, cache): |
1562 | + """Tell FileCache to call append_media_type when generating keys.""" |
1563 | + super(MultipleRepresentationCache, self).__init__( |
1564 | + cache, self.append_media_type) |
1565 | + self.request_media_type = None |
1566 | + |
1567 | + def append_media_type(self, key): |
1568 | + """Append the request media type to the cache key. |
1569 | + |
1570 | + This ensures that representations of the same resource will be |
1571 | + cached separately, so long as they're served as different |
1572 | + media types. |
1573 | + """ |
1574 | + if self.request_media_type is not None: |
1575 | + key = key + '-' + self.request_media_type |
1576 | + return safename(key) |
1577 | + |
1578 | + |
1579 | + def _getCachedHeader(self, uri, header): |
1580 | + """Retrieve a cached value for an HTTP header.""" |
1581 | + (scheme, authority, request_uri, cachekey) = urlnorm(uri) |
1582 | + cached_value = self.get(cachekey) |
1583 | + header_start = header + ':' |
1584 | + if cached_value is not None: |
1585 | + for line in StringIO(cached_value): |
1586 | + if line.startswith(header_start): |
1587 | + return line[len(header_start):].strip() |
1588 | + return None |
1589 | + |
1590 | + |
1591 | +class Browser: |
1592 | + """A class for making calls to lazr.restful web services.""" |
1593 | + |
1594 | + def __init__(self, service_root, credentials, cache=None, timeout=None, |
1595 | + proxy_info=None): |
1596 | + """Initialize, possibly creating a cache. |
1597 | + |
1598 | + If no cache is provided, a temporary directory will be used as |
1599 | + a cache. The temporary directory will be automatically removed |
1600 | + when the Python process exits. |
1601 | + """ |
1602 | + if cache is None: |
1603 | + cache = tempfile.mkdtemp() |
1604 | + atexit.register(shutil.rmtree, cache) |
1605 | + if isinstance(cache, str): |
1606 | + cache = MultipleRepresentationCache(cache) |
1607 | + self._connection = service_root.httpFactory( |
1608 | + credentials, cache, timeout, proxy_info) |
1609 | + |
1610 | + def _request(self, url, data=None, method='GET', |
1611 | + media_type='application/json', extra_headers=None): |
1612 | + """Create an authenticated request object.""" |
1613 | + # Add extra headers for the request. |
1614 | + headers = {'Accept' : media_type} |
1615 | + if isinstance(self._connection.cache, MultipleRepresentationCache): |
1616 | + self._connection.cache.request_media_type = media_type |
1617 | + if extra_headers is not None: |
1618 | + headers.update(extra_headers) |
1619 | + # Make the request. |
1620 | + response, content = self._connection.request( |
1621 | + str(url), method=method, body=data, headers=headers) |
1622 | + # Turn non-2xx responses into exceptions. |
1623 | + if response.status // 100 != 2: |
1624 | + raise HTTPError(response, content) |
1625 | + return response, content |
1626 | + |
1627 | + def get(self, resource_or_uri, headers=None, return_response=False): |
1628 | + """GET a representation of the given resource or URI.""" |
1629 | + if isinstance(resource_or_uri, (basestring, URI)): |
1630 | + url = resource_or_uri |
1631 | + else: |
1632 | + method = resource_or_uri.get_method('get') |
1633 | + url = method.build_request_url() |
1634 | + response, content = self._request(url, extra_headers=headers) |
1635 | + if return_response: |
1636 | + return (response, content) |
1637 | + return content |
1638 | + |
1639 | + def get_wadl_application(self, url): |
1640 | + """GET a WADL representation of the resource at the requested url.""" |
1641 | + response, content = self._request( |
1642 | + url, media_type='application/vd.sun.wadl+xml') |
1643 | + return Application(str(url), content) |
1644 | + |
1645 | + def post(self, url, method_name, **kws): |
1646 | + """POST a request to the web service.""" |
1647 | + kws['ws.op'] = method_name |
1648 | + data = urlencode(kws) |
1649 | + return self._request(url, data, 'POST') |
1650 | + |
1651 | + def put(self, url, representation, media_type, headers=None): |
1652 | + """PUT the given representation to the URL.""" |
1653 | + extra_headers = {'Content-Type': media_type} |
1654 | + if headers is not None: |
1655 | + extra_headers.update(headers) |
1656 | + return self._request( |
1657 | + url, representation, 'PUT', extra_headers=extra_headers) |
1658 | + |
1659 | + def delete(self, url): |
1660 | + """DELETE the resource at the given URL.""" |
1661 | + self._request(url, method='DELETE') |
1662 | + |
1663 | + def patch(self, url, representation, headers=None): |
1664 | + """PATCH the object at url with the updated representation.""" |
1665 | + extra_headers = {'Content-Type': 'application/json'} |
1666 | + if headers is not None: |
1667 | + extra_headers.update(headers) |
1668 | + # httplib2 doesn't know about the PATCH method, so we need to |
1669 | + # do some work ourselves. Pull any cached value of "ETag" out |
1670 | + # and use it as the value for "If-Match". |
1671 | + cached_etag = self._connection._getCachedHeader(str(url), 'etag') |
1672 | + if cached_etag is not None and not self._connection.ignore_etag: |
1673 | + # http://www.w3.org/1999/04/Editing/ |
1674 | + headers['If-Match'] = cached_etag |
1675 | + |
1676 | + return self._request( |
1677 | + url, simplejson.dumps(representation, cls=DatetimeJSONEncoder), |
1678 | + 'PATCH', extra_headers=extra_headers) |
1679 | |
1680 | === renamed file 'src/launchpadlib/_json.py' => 'src/launchpadlib/restfulclient/_json.py' |
1681 | --- src/launchpadlib/_json.py 2009-03-20 20:46:06 +0000 |
1682 | +++ src/launchpadlib/restfulclient/_json.py 2009-03-26 18:40:36 +0000 |
1683 | @@ -1,18 +1,20 @@ |
1684 | # Copyright 2009 Canonical Ltd. |
1685 | |
1686 | -# This file is part of launchpadlib. |
1687 | -# |
1688 | -# launchpadlib is free software: you can redistribute it and/or modify it |
1689 | -# under the terms of the GNU Lesser General Public License as published by the |
1690 | -# Free Software Foundation, version 3 of the License. |
1691 | -# |
1692 | -# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
1693 | -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
1694 | -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
1695 | -# for more details. |
1696 | -# |
1697 | -# You should have received a copy of the GNU Lesser General Public License |
1698 | -# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
1699 | +# This file is part of lazr.restfulclient. |
1700 | +# |
1701 | +# lazr.restfulclient is free software: you can redistribute it and/or |
1702 | +# modify it under the terms of the GNU Lesser General Public License |
1703 | +# as published by the Free Software Foundation, version 3 of the |
1704 | +# License. |
1705 | +# |
1706 | +# lazr.restfulclient is distributed in the hope that it will be |
1707 | +# useful, but WITHOUT ANY WARRANTY; without even the implied warranty |
1708 | +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
1709 | +# Lesser General Public License for more details. |
1710 | +# |
1711 | +# You should have received a copy of the GNU Lesser General Public |
1712 | +# License along with lazr.restfulclient. If not, see |
1713 | +# <http://www.gnu.org/licenses/>. |
1714 | |
1715 | """Classes for working with JSON.""" |
1716 | |
1717 | @@ -22,6 +24,7 @@ |
1718 | import datetime |
1719 | import simplejson |
1720 | |
1721 | + |
1722 | class DatetimeJSONEncoder(simplejson.JSONEncoder): |
1723 | """A JSON encoder that understands datetime objects. |
1724 | |
1725 | |
1726 | === added file 'src/launchpadlib/restfulclient/errors.py' |
1727 | --- src/launchpadlib/restfulclient/errors.py 1970-01-01 00:00:00 +0000 |
1728 | +++ src/launchpadlib/restfulclient/errors.py 2009-03-26 19:08:48 +0000 |
1729 | @@ -0,0 +1,65 @@ |
1730 | +# Copyright 2008 Canonical Ltd. |
1731 | + |
1732 | +# This file is part of lazr.restfulclient. |
1733 | +# |
1734 | +# lazr.restfulclient is free software: you can redistribute it and/or modify |
1735 | +# it under the terms of the GNU Lesser General Public License as |
1736 | +# published by the Free Software Foundation, either version 3 of the |
1737 | +# License, or (at your option) any later version. |
1738 | +# |
1739 | +# lazr.restfulclient is distributed in the hope that it will be useful, but |
1740 | +# WITHOUT ANY WARRANTY; without even the implied warranty of |
1741 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
1742 | +# Lesser General Public License for more details. |
1743 | +# |
1744 | +# You should have received a copy of the GNU Lesser General Public |
1745 | +# License along with lazr.restfulclient. If not, see |
1746 | +# <http://www.gnu.org/licenses/>. |
1747 | + |
1748 | +"""lazr.restfulclient errors.""" |
1749 | + |
1750 | +__metaclass__ = type |
1751 | +__all__ = [ |
1752 | + 'CredentialsError', |
1753 | + 'CredentialsFileError', |
1754 | + 'HTTPError', |
1755 | + 'RestfulError', |
1756 | + 'ResponseError', |
1757 | + 'UnexpectedResponseError', |
1758 | + ] |
1759 | + |
1760 | + |
1761 | +class RestfulError(Exception): |
1762 | + """Base error for the lazr.restfulclient API library.""" |
1763 | + |
1764 | + |
1765 | +class CredentialsError(RestfulError): |
1766 | + """Base credentials/authentication error.""" |
1767 | + |
1768 | + |
1769 | +class CredentialsFileError(CredentialsError): |
1770 | + """Error in credentials file.""" |
1771 | + |
1772 | + |
1773 | +class ResponseError(RestfulError): |
1774 | + """Error in response.""" |
1775 | + |
1776 | + def __init__(self, response, content): |
1777 | + RestfulError.__init__(self) |
1778 | + self.response = response |
1779 | + self.content = content |
1780 | + |
1781 | + |
1782 | +class UnexpectedResponseError(ResponseError): |
1783 | + """An unexpected response was received.""" |
1784 | + |
1785 | + def __str__(self): |
1786 | + return '%s: %s' % (self.response.status, self.response.reason) |
1787 | + |
1788 | + |
1789 | +class HTTPError(ResponseError): |
1790 | + """An HTTP non-2xx response code was received.""" |
1791 | + |
1792 | + def __str__(self): |
1793 | + return 'HTTP Error %s: %s' % ( |
1794 | + self.response.status, self.response.reason) |
1795 | |
1796 | === added file 'src/launchpadlib/restfulclient/resource.py' |
1797 | --- src/launchpadlib/restfulclient/resource.py 1970-01-01 00:00:00 +0000 |
1798 | +++ src/launchpadlib/restfulclient/resource.py 2009-03-26 18:40:36 +0000 |
1799 | @@ -0,0 +1,846 @@ |
1800 | +# Copyright 2008 Canonical Ltd. |
1801 | + |
1802 | +# This file is part of lazr.restfulclient. |
1803 | +# |
1804 | +# lazr.restfulclient is free software: you can redistribute it and/or |
1805 | +# modify it under the terms of the GNU Lesser General Public License |
1806 | +# as published by the Free Software Foundation, either version 3 of |
1807 | +# the License, or (at your option) any later version. |
1808 | +# |
1809 | +# lazr.restfulclient is distributed in the hope that it will be |
1810 | +# useful, but WITHOUT ANY WARRANTY; without even the implied warranty |
1811 | +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
1812 | +# Lesser General Public License for more details. |
1813 | +# |
1814 | +# You should have received a copy of the GNU Lesser General Public |
1815 | +# License along with lazr.restfulclient. If not, see |
1816 | +# <http://www.gnu.org/licenses/>. |
1817 | + |
1818 | +"""Common support for web service resources.""" |
1819 | + |
1820 | +__metaclass__ = type |
1821 | +__all__ = [ |
1822 | + 'Collection', |
1823 | + 'Entry', |
1824 | + 'NamedOperation', |
1825 | + 'Resource', |
1826 | + ] |
1827 | + |
1828 | + |
1829 | +import cgi |
1830 | +import simplejson |
1831 | +from StringIO import StringIO |
1832 | +import urllib |
1833 | +from urlparse import urlparse |
1834 | + |
1835 | +from lazr.uri import URI |
1836 | +from wadllib.application import Resource as WadlResource |
1837 | +from _browser import Browser, RestfulHttp |
1838 | +from _json import DatetimeJSONEncoder |
1839 | +from errors import HTTPError |
1840 | + |
1841 | + |
1842 | +class HeaderDictionary: |
1843 | + """A dictionary that bridges httplib2's and wadllib's expectations. |
1844 | + |
1845 | + httplib2 expects all header dictionary access to give lowercase |
1846 | + header names. wadllib expects to access the header exactly as it's |
1847 | + specified in the WADL file, which means the official HTTP header name. |
1848 | + |
1849 | + This class transforms keys to lowercase before doing a lookup on |
1850 | + the underlying dictionary. That way wadllib can pass in the |
1851 | + official header name and httplib2 will get the lowercased name. |
1852 | + """ |
1853 | + def __init__(self, wrapped_dictionary): |
1854 | + self.wrapped_dictionary = wrapped_dictionary |
1855 | + |
1856 | + def get(self, key, default=None): |
1857 | + """Retrieve a value, converting the key to lowercase.""" |
1858 | + return self.wrapped_dictionary.get(key.lower()) |
1859 | + |
1860 | + def __getitem__(self, key): |
1861 | + """Retrieve a value, converting the key to lowercase.""" |
1862 | + missing = object() |
1863 | + value = self.get(key, missing) |
1864 | + if value is missing: |
1865 | + raise KeyError(key) |
1866 | + return value |
1867 | + |
1868 | + |
1869 | +class RestfulBase: |
1870 | + """Base class for classes that know about lazr.restful services.""" |
1871 | + |
1872 | + JSON_MEDIA_TYPE = 'application/json' |
1873 | + |
1874 | + def _transform_resources_to_links(self, dictionary): |
1875 | + new_dictionary = {} |
1876 | + for key, value in dictionary.items(): |
1877 | + if isinstance(value, Resource): |
1878 | + value = value.self_link |
1879 | + new_dictionary[self._get_external_param_name(key)] = value |
1880 | + return new_dictionary |
1881 | + |
1882 | + def _get_external_param_name(self, param_name): |
1883 | + """Turn a lazr.restful name into something to be sent over HTTP. |
1884 | + |
1885 | + For resources this may involve sticking '_link' or |
1886 | + '_collection_link' on the end of the parameter name. For |
1887 | + arguments to named operations, the parameter name is returned |
1888 | + as is. |
1889 | + """ |
1890 | + return param_name |
1891 | + |
1892 | + |
1893 | +class Resource(RestfulBase): |
1894 | + """Base class for lazr.restful HTTP resources.""" |
1895 | + |
1896 | + def __init__(self, root, wadl_resource): |
1897 | + """Initialize with respect to a wadllib Resource object.""" |
1898 | + if root is None: |
1899 | + # This _is_ the root. |
1900 | + root = self |
1901 | + # These values need to be put directly into __dict__ to avoid |
1902 | + # calling __setattr__, which would cause an infinite recursion. |
1903 | + self.__dict__['_root'] = root |
1904 | + self.__dict__['_wadl_resource'] = wadl_resource |
1905 | + |
1906 | + FIND_COLLECTIONS = object() |
1907 | + FIND_ENTRIES = object() |
1908 | + FIND_ATTRIBUTES = object() |
1909 | + |
1910 | + @property |
1911 | + def lp_collections(self): |
1912 | + """Name the collections this resource links to.""" |
1913 | + return self._get_parameter_names(self.FIND_COLLECTIONS) |
1914 | + |
1915 | + @property |
1916 | + def lp_entries(self): |
1917 | + """Name the entries this resource links to.""" |
1918 | + return self._get_parameter_names(self.FIND_ENTRIES) |
1919 | + |
1920 | + @property |
1921 | + def lp_attributes(self): |
1922 | + """Name this resource's scalar attributes.""" |
1923 | + return self._get_parameter_names(self.FIND_ATTRIBUTES) |
1924 | + |
1925 | + @property |
1926 | + def lp_operations(self): |
1927 | + """Name all of this resource's custom operations.""" |
1928 | + # This library distinguishes between named operations by the |
1929 | + # value they give for ws.op, not by their WADL names or IDs. |
1930 | + names = [] |
1931 | + form_encoded_type = 'application/x-www-form-urlencoded' |
1932 | + for method in self._wadl_resource.method_iter: |
1933 | + name = method.name.lower() |
1934 | + if name == 'get': |
1935 | + params = method.request.params(['query', 'plain']) |
1936 | + elif name == 'post': |
1937 | + definition = method.request.representation_definition( |
1938 | + form_encoded_type).resolve_definition() |
1939 | + params = definition.params(self._wadl_resource) |
1940 | + for param in params: |
1941 | + if param.name == 'ws.op': |
1942 | + names.append(param.fixed_value) |
1943 | + break |
1944 | + return names |
1945 | + |
1946 | + @property |
1947 | + def __members__(self): |
1948 | + """A hook into dir() that returns web service-derived members.""" |
1949 | + return self._get_parameter_names( |
1950 | + self.FIND_COLLECTIONS, self.FIND_ENTRIES, self.FIND_ATTRIBUTES) |
1951 | + |
1952 | + __methods__ = lp_operations |
1953 | + |
1954 | + def _get_parameter_names(self, *kinds): |
1955 | + """Retrieve some subset of the resource's parameters.""" |
1956 | + names = [] |
1957 | + for name in self._wadl_resource.parameter_names( |
1958 | + self.JSON_MEDIA_TYPE): |
1959 | + if name.endswith('_collection_link'): |
1960 | + if self.FIND_COLLECTIONS in kinds: |
1961 | + names.append(name[:-16]) |
1962 | + elif (name.endswith('_link') |
1963 | + and name not in ('self_link', 'resource_type_link')): |
1964 | + # lazr.restful_obj.self will work, but is never |
1965 | + # necessary. lazr.restful_obj.resource_type is also |
1966 | + # unneccessary, and won't work anyway because |
1967 | + # resource_type_link points to a WADL description, |
1968 | + # not a normal lazr.restful resource. |
1969 | + if self.FIND_ENTRIES in kinds: |
1970 | + names.append(name[:-5]) |
1971 | + elif self.FIND_ATTRIBUTES in kinds: |
1972 | + names.append(name) |
1973 | + return names |
1974 | + |
1975 | + def lp_has_parameter(self, param_name): |
1976 | + """Does this resource have a parameter with the given name?""" |
1977 | + return self._get_external_param_name(param_name) is not None |
1978 | + |
1979 | + def lp_get_parameter(self, param_name): |
1980 | + """Get the value of one of the resource's parameters. |
1981 | + |
1982 | + :return: A scalar value if the parameter is not a link. A new |
1983 | + Resource object, whose resource is bound to a |
1984 | + representation, if the parameter is a link. |
1985 | + """ |
1986 | + self._ensure_representation() |
1987 | + for suffix in ['_link', '_collection_link']: |
1988 | + param = self._wadl_resource.get_parameter( |
1989 | + param_name + suffix, self.JSON_MEDIA_TYPE) |
1990 | + if param is not None: |
1991 | + if param.get_value() is None: |
1992 | + # This parameter is a link to another object, but |
1993 | + # there's no other object. Return None rather than |
1994 | + # chasing down the nonexistent other object. |
1995 | + return None |
1996 | + linked_resource = param.linked_resource |
1997 | + return self._create_bound_resource( |
1998 | + self._root, linked_resource, param_name=param.name) |
1999 | + param = self._wadl_resource.get_parameter(param_name) |
2000 | + if param is None: |
2001 | + raise KeyError("No such parameter: %s" % param_name) |
2002 | + return param.get_value() |
2003 | + |
2004 | + def lp_get_named_operation(self, operation_name): |
2005 | + """Get a custom operation with the given name. |
2006 | + |
2007 | + :return: A NamedOperation instance that can be called with |
2008 | + appropriate arguments to invoke the operation. |
2009 | + """ |
2010 | + params = { 'ws.op' : operation_name } |
2011 | + method = self._wadl_resource.get_method('get', query_params=params) |
2012 | + if method is None: |
2013 | + method = self._wadl_resource.get_method( |
2014 | + 'post', representation_params=params) |
2015 | + if method is None: |
2016 | + raise KeyError("No operation with name: %s" % operation_name) |
2017 | + return NamedOperation(self._root, self, method) |
2018 | + |
2019 | + @classmethod |
2020 | + def _create_bound_resource( |
2021 | + cls, root, resource, representation=None, |
2022 | + representation_media_type='application/json', |
2023 | + representation_needs_processing=True, representation_definition=None, |
2024 | + param_name=None): |
2025 | + """Create a lazr.restful Resource subclass from a wadllib Resource. |
2026 | + |
2027 | + :param resource: The wadllib Resource to wrap. |
2028 | + :param representation: A previously fetched representation of |
2029 | + this resource, to be reused. If not provided, this method |
2030 | + will act just like the Resource constructor. |
2031 | + :param representation_media_type: The media type of any previously |
2032 | + fetched representation. |
2033 | + :param representation_needs_processing: Set to False if the |
2034 | + 'representation' parameter should be used as |
2035 | + is. |
2036 | + :param representation_definition: A wadllib |
2037 | + RepresentationDefinition object describing the structure |
2038 | + of this representation. Used in cases when the representation |
2039 | + isn't the result of sending a standard GET to the resource. |
2040 | + :param param_name: The name of the link that was followed to get |
2041 | + to this resource. |
2042 | + :return: An instance of the appropriate lazr.restful Resource |
2043 | + subclass. |
2044 | + """ |
2045 | + # We happen to know that all lazr.restful resource types are |
2046 | + # defined in a single document. Turn the resource's type_url |
2047 | + # into an anchor into that document: this is its resource |
2048 | + # type. Then look up a client-side class that corresponds to |
2049 | + # the resource type. |
2050 | + type_url = resource.type_url |
2051 | + resource_type = urlparse(type_url)[-1] |
2052 | + default = Entry |
2053 | + if (type_url.endswith('-page') |
2054 | + or (param_name is not None |
2055 | + and param_name.endswith('_collection_link'))): |
2056 | + default = Collection |
2057 | + r_class = root.RESOURCE_TYPE_CLASSES.get(resource_type, default) |
2058 | + if representation is not None: |
2059 | + # We've been given a representation. Bind the resource |
2060 | + # immediately. |
2061 | + resource = resource.bind( |
2062 | + representation, representation_media_type, |
2063 | + representation_needs_processing, |
2064 | + representation_definition=representation_definition) |
2065 | + else: |
2066 | + # We'll fetch a representation and bind the resource when |
2067 | + # necessary. |
2068 | + pass |
2069 | + return r_class(root, resource) |
2070 | + |
2071 | + def lp_refresh(self, new_url=None, etag=None): |
2072 | + """Update this resource's representation.""" |
2073 | + if new_url is not None: |
2074 | + self._wadl_resource._url = new_url |
2075 | + headers = {} |
2076 | + if etag is not None: |
2077 | + headers['If-None-Match'] = etag |
2078 | + try: |
2079 | + representation = self._root._browser.get( |
2080 | + self._wadl_resource, headers=headers) |
2081 | + except HTTPError, e: |
2082 | + if e.response['status'] == '304': |
2083 | + # The entry wasn't modified. No need to do anything. |
2084 | + return |
2085 | + else: |
2086 | + raise e |
2087 | + # __setattr__ assumes we're setting an attribute of the resource, |
2088 | + # so we manipulate __dict__ directly. |
2089 | + self.__dict__['_wadl_resource'] = self._wadl_resource.bind( |
2090 | + representation, self.JSON_MEDIA_TYPE) |
2091 | + |
2092 | + def __getattr__(self, attr): |
2093 | + """Try to retrive a named operation or parameter of the given name.""" |
2094 | + try: |
2095 | + return self.lp_get_parameter(attr) |
2096 | + except KeyError: |
2097 | + pass |
2098 | + try: |
2099 | + return self.lp_get_named_operation(attr) |
2100 | + except KeyError: |
2101 | + raise AttributeError("'%s' object has no attribute '%s'" |
2102 | + % (self.__class__.__name__, attr)) |
2103 | + |
2104 | + def _get_external_param_name(self, param_name): |
2105 | + """What's this parameter's name in the underlying representation?""" |
2106 | + for suffix in ['_link', '_collection_link', '']: |
2107 | + name = param_name + suffix |
2108 | + if self._wadl_resource.get_parameter(name): |
2109 | + return name |
2110 | + return None |
2111 | + |
2112 | + def _ensure_representation(self): |
2113 | + """Make sure this resource has a representation fetched.""" |
2114 | + if self._wadl_resource.representation is None: |
2115 | + # Get a representation of the linked resource. |
2116 | + representation = self._root._browser.get(self._wadl_resource) |
2117 | + self.__dict__['_wadl_resource'] = self._wadl_resource.bind( |
2118 | + representation, self.JSON_MEDIA_TYPE) |
2119 | + |
2120 | + |
2121 | +class HostedFile(Resource): |
2122 | + """A resource representing a file managed by a lazr.restful service.""" |
2123 | + |
2124 | + def open(self, mode='r', content_type=None, filename=None): |
2125 | + """Open the file on the server for read or write access.""" |
2126 | + if mode in ('r', 'w'): |
2127 | + return HostedFileBuffer(self, mode, content_type, filename) |
2128 | + else: |
2129 | + raise ValueError("Invalid mode. Supported modes are: r, w") |
2130 | + |
2131 | + def delete(self): |
2132 | + """Delete the file from the server.""" |
2133 | + self._root._browser.delete(self._wadl_resource.url) |
2134 | + |
2135 | + def _get_parameter_names(self, *kinds): |
2136 | + """HostedFile objects define no web service parameters.""" |
2137 | + return [] |
2138 | + |
2139 | + |
2140 | +class ServiceRoot(Resource): |
2141 | + """Entry point to the service. Subclass this for a service-specific client. |
2142 | + |
2143 | + :ivar credentials: The credentials instance used to access Launchpad. |
2144 | + """ |
2145 | + |
2146 | + # Custom subclasses of Entry or Collection to use when |
2147 | + # instantiating resources of a certain WADL type. |
2148 | + RESOURCE_TYPE_CLASSES = {'HostedFile': HostedFile} |
2149 | + |
2150 | + def __init__(self, credentials, service_root, cache=None, |
2151 | + timeout=None, proxy_info=None): |
2152 | + """Root access to a lazr.restful API. |
2153 | + |
2154 | + :param credentials: The credentials used to access the service. |
2155 | + :param service_root: The URL to the root of the web service. |
2156 | + :type service_root: string |
2157 | + """ |
2158 | + self._root_uri = URI(service_root) |
2159 | + self.credentials = credentials |
2160 | + # Get the WADL definition. |
2161 | + self._browser = Browser( |
2162 | + self, self.credentials, cache, timeout, proxy_info) |
2163 | + self._wadl = self._browser.get_wadl_application(self._root_uri) |
2164 | + |
2165 | + # Get the root resource. |
2166 | + root_resource = self._wadl.get_resource_by_path('') |
2167 | + bound_root = root_resource.bind( |
2168 | + self._browser.get(root_resource), 'application/json') |
2169 | + super(ServiceRoot, self).__init__(None, bound_root) |
2170 | + |
2171 | + def httpFactory(self, credentials, cache, timeout, proxy_info): |
2172 | + return RestfulHttp(credentials, cache, timeout, proxy_info) |
2173 | + |
2174 | + def load(self, url): |
2175 | + """Load a resource given its URL.""" |
2176 | + document = self._browser.get(url) |
2177 | + try: |
2178 | + representation = simplejson.loads(document) |
2179 | + except ValueError: |
2180 | + raise ValueError("%s doesn't serve a JSON document." % url) |
2181 | + type_link = representation.get("resource_type_link") |
2182 | + if type_link is None: |
2183 | + raise ValueError("Couldn't determine the resource type of %s." |
2184 | + % url) |
2185 | + resource_type = self._root._wadl.get_resource_type(type_link) |
2186 | + wadl_resource = WadlResource(self._root._wadl, url, resource_type.tag) |
2187 | + return self._create_bound_resource( |
2188 | + self._root, wadl_resource, representation, 'application/json', |
2189 | + representation_needs_processing=False) |
2190 | + |
2191 | + |
2192 | +class NamedOperation(RestfulBase): |
2193 | + """A class for a named operation to be invoked with GET or POST.""" |
2194 | + |
2195 | + def __init__(self, root, resource, wadl_method): |
2196 | + """Initialize with respect to a WADL Method object""" |
2197 | + self.root = root |
2198 | + self.resource = resource |
2199 | + self.wadl_method = wadl_method |
2200 | + |
2201 | + def __call__(self, *args, **kwargs): |
2202 | + """Invoke the method and process the result.""" |
2203 | + if len(args) > 0: |
2204 | + raise TypeError('Method must be called with keyword args.') |
2205 | + http_method = self.wadl_method.name |
2206 | + args = self._transform_resources_to_links(kwargs) |
2207 | + for key, value in args.items(): |
2208 | + args[key] = simplejson.dumps(value, cls=DatetimeJSONEncoder) |
2209 | + if http_method in ('get', 'head', 'delete'): |
2210 | + url = self.wadl_method.build_request_url(**args) |
2211 | + in_representation = '' |
2212 | + extra_headers = {} |
2213 | + else: |
2214 | + url = self.wadl_method.build_request_url() |
2215 | + (media_type, |
2216 | + in_representation) = self.wadl_method.build_representation( |
2217 | + **args) |
2218 | + extra_headers = { 'Content-type' : media_type } |
2219 | + response, content = self.root._browser._request( |
2220 | + url, in_representation, http_method, extra_headers=extra_headers) |
2221 | + |
2222 | + if response.status == 201: |
2223 | + return self._handle_201_response(url, response, content) |
2224 | + else: |
2225 | + if http_method == 'post': |
2226 | + # The method call probably modified this resource in |
2227 | + # an unknown way. Refresh its representation. |
2228 | + self.resource.lp_refresh() |
2229 | + return self._handle_200_response(url, response, content) |
2230 | + |
2231 | + def _handle_201_response(self, url, response, content): |
2232 | + """Handle the creation of a new resource by fetching it.""" |
2233 | + wadl_response = self.wadl_method.response.bind( |
2234 | + HeaderDictionary(response)) |
2235 | + wadl_parameter = wadl_response.get_parameter('Location') |
2236 | + wadl_resource = wadl_parameter.linked_resource |
2237 | + # Fetch a representation of the new resource. |
2238 | + response, content = self.root._browser._request( |
2239 | + wadl_resource.url) |
2240 | + # Return an instance of the appropriate lazr.restful |
2241 | + # Resource subclass. |
2242 | + return Resource._create_bound_resource( |
2243 | + self.root, wadl_resource, content, response['content-type']) |
2244 | + |
2245 | + def _handle_200_response(self, url, response, content): |
2246 | + """Process the return value of an operation.""" |
2247 | + content_type = response['content-type'] |
2248 | + # Process the returned content, assuming we know how. |
2249 | + response_definition = self.wadl_method.response |
2250 | + representation_definition = ( |
2251 | + response_definition.get_representation_definition( |
2252 | + content_type)) |
2253 | + |
2254 | + if representation_definition is None: |
2255 | + # The operation returned a document with nothing |
2256 | + # special about it. |
2257 | + if content_type == self.JSON_MEDIA_TYPE: |
2258 | + return simplejson.loads(content) |
2259 | + # We don't know how to process the content. |
2260 | + return content |
2261 | + |
2262 | + # The operation returned a representation of some |
2263 | + # resource. Instantiate a Resource object for it. |
2264 | + document = simplejson.loads(content) |
2265 | + if "self_link" in document and "resource_type_link" in document: |
2266 | + # The operation returned an entry. Use the self_link and |
2267 | + # resource_type_link of the entry representation to build |
2268 | + # a Resource object of the appropriate type. That way this |
2269 | + # object will support all of the right named operations. |
2270 | + url = document["self_link"] |
2271 | + resource_type = self.root._wadl.get_resource_type( |
2272 | + document["resource_type_link"]) |
2273 | + wadl_resource = WadlResource(self.root._wadl, url, |
2274 | + resource_type.tag) |
2275 | + else: |
2276 | + # The operation returned a collection. It's probably an ad |
2277 | + # hoc collection that doesn't correspond to any resource |
2278 | + # type. Instantiate it as a resource backed by the |
2279 | + # representation type defined in the return value, instead |
2280 | + # of a resource type tag. |
2281 | + representation_definition = ( |
2282 | + representation_definition.resolve_definition()) |
2283 | + wadl_resource = WadlResource( |
2284 | + self.root._wadl, url, representation_definition.tag) |
2285 | + |
2286 | + return Resource._create_bound_resource( |
2287 | + self.root, wadl_resource, document, content_type, |
2288 | + representation_needs_processing=False, |
2289 | + representation_definition=representation_definition) |
2290 | + |
2291 | + def _get_external_param_name(self, param_name): |
2292 | + """Named operation parameter names are sent as is.""" |
2293 | + return param_name |
2294 | + |
2295 | + |
2296 | +class Entry(Resource): |
2297 | + """A class for an entry-type resource that can be updated with PATCH.""" |
2298 | + |
2299 | + def __init__(self, root, wadl_resource): |
2300 | + super(Entry, self).__init__(root, wadl_resource) |
2301 | + # Initialize this here in a semi-magical way so as to stop a |
2302 | + # particular infinite loop that would follow. Setting |
2303 | + # self._dirty_attributes would call __setattr__(), which would |
2304 | + # turn around immediately and get self._dirty_attributes. If |
2305 | + # this latter was not in the instance dictionary, that would |
2306 | + # end up calling __getattr__(), which would again reference |
2307 | + # self._dirty_attributes. This is where the infloop would |
2308 | + # occur. Poking this directly into self.__dict__ means that |
2309 | + # the check for self._dirty_attributes won't call __getattr__(), |
2310 | + # breaking the cycle. |
2311 | + self.__dict__['_dirty_attributes'] = {} |
2312 | + super(Entry, self).__init__(root, wadl_resource) |
2313 | + |
2314 | + def __repr__(self): |
2315 | + """Return the WADL resource type and the URL to the resource.""" |
2316 | + return '<%s at %s>' % ( |
2317 | + URI(self.resource_type_link).fragment, self.self_link) |
2318 | + |
2319 | + def __str__(self): |
2320 | + """Return the URL to the resource.""" |
2321 | + return self.self_link |
2322 | + |
2323 | + def __getattr__(self, name): |
2324 | + """Try to retrive a parameter of the given name.""" |
2325 | + if name != '_dirty_attributes': |
2326 | + if name in self._dirty_attributes: |
2327 | + return self._dirty_attributes[name] |
2328 | + return super(Entry, self).__getattr__(name) |
2329 | + |
2330 | + def __setattr__(self, name, value): |
2331 | + """Set the parameter of the given name.""" |
2332 | + if not self.lp_has_parameter(name): |
2333 | + raise AttributeError("'%s' object has no attribute '%s'" % |
2334 | + (self.__class__.__name__, name)) |
2335 | + self._dirty_attributes[name] = value |
2336 | + |
2337 | + def lp_refresh(self, new_url=None): |
2338 | + """Update this resource's representation.""" |
2339 | + etag = getattr(self, 'http_etag', None) |
2340 | + super(Entry, self).lp_refresh(new_url, etag) |
2341 | + self._dirty_attributes.clear() |
2342 | + |
2343 | + def lp_save(self): |
2344 | + """Save changes to the entry.""" |
2345 | + representation = self._transform_resources_to_links( |
2346 | + self._dirty_attributes) |
2347 | + |
2348 | + # If the entry contains an ETag, set the If-Match header |
2349 | + # to that value. |
2350 | + headers = {} |
2351 | + etag = getattr(self, 'http_etag', None) |
2352 | + if etag is not None: |
2353 | + headers['If-Match'] = etag |
2354 | + |
2355 | + # PATCH the new representation to the 'self' link. It's possible that |
2356 | + # this will cause the object to be permanently moved. Catch that |
2357 | + # exception and refresh our representation. |
2358 | + try: |
2359 | + response, content = self._root._browser.patch( |
2360 | + URI(self.self_link), representation, headers) |
2361 | + except HTTPError, error: |
2362 | + if error.response.status == 301: |
2363 | + response = error.response |
2364 | + self.lp_refresh(error.response['location']) |
2365 | + else: |
2366 | + raise |
2367 | + self._dirty_attributes.clear() |
2368 | + |
2369 | + content_type = response['content-type'] |
2370 | + if response.status == 209 and content_type == self.JSON_MEDIA_TYPE: |
2371 | + # The server sent back a new representation of the object. |
2372 | + # Use it in preference to the existing representation. |
2373 | + new_representation = simplejson.loads(content) |
2374 | + self._wadl_resource.representation = new_representation |
2375 | + self._wadl_resource.media_type = content_type |
2376 | + |
2377 | + |
2378 | +class Collection(Resource): |
2379 | + """A collection-type resource that supports pagination.""" |
2380 | + |
2381 | + def __init__(self, root, wadl_resource): |
2382 | + """Create a collection object.""" |
2383 | + super(Collection, self).__init__(root, wadl_resource) |
2384 | + |
2385 | + def __len__(self): |
2386 | + """The number of items in the collection. |
2387 | + |
2388 | + :return: length of the collection |
2389 | + :rtype: int |
2390 | + """ |
2391 | + try: |
2392 | + return int(self.total_size) |
2393 | + except AttributeError: |
2394 | + raise TypeError('collection size is not available') |
2395 | + |
2396 | + def __iter__(self): |
2397 | + """Iterate over the items in the collection. |
2398 | + |
2399 | + :return: iterator |
2400 | + :rtype: sequence of `Entry` |
2401 | + """ |
2402 | + self._ensure_representation() |
2403 | + current_page = self._wadl_resource.representation |
2404 | + while True: |
2405 | + for resource in self._convert_dicts_to_entries( |
2406 | + current_page.get('entries', {})): |
2407 | + yield resource |
2408 | + next_link = current_page.get('next_collection_link') |
2409 | + if next_link is None: |
2410 | + break |
2411 | + current_page = simplejson.loads( |
2412 | + self._root._browser.get(URI(next_link))) |
2413 | + |
2414 | + def __getitem__(self, key): |
2415 | + """Look up a slice, or a subordinate resource by index. |
2416 | + |
2417 | + To discourage situations where a lazr.restful client fetches |
2418 | + all of an enormous list, all collection slices must have a |
2419 | + definitive end point. For performance reasons, all collection |
2420 | + slices must be indexed from the start of the list rather than |
2421 | + the end. |
2422 | + """ |
2423 | + if isinstance(key, slice): |
2424 | + return self._get_slice(key) |
2425 | + else: |
2426 | + # Look up a single item by its position in the list. |
2427 | + found_slice = self._get_slice(slice(key, key+1)) |
2428 | + if len(found_slice) != 1: |
2429 | + raise IndexError("list index out of range") |
2430 | + return found_slice[0] |
2431 | + |
2432 | + def _get_slice(self, slice): |
2433 | + """Retrieve a slice of a collection.""" |
2434 | + start = slice.start or 0 |
2435 | + stop = slice.stop |
2436 | + |
2437 | + if start < 0: |
2438 | + raise ValueError("Collection slices must have a nonnegative " |
2439 | + "start point.") |
2440 | + if stop < 0: |
2441 | + raise ValueError("Collection slices must have a definite, " |
2442 | + "nonnegative end point.") |
2443 | + |
2444 | + existing_representation = self._wadl_resource.representation |
2445 | + if (existing_representation is not None |
2446 | + and start < len(existing_representation['entries'])): |
2447 | + # An optimization: the first page of entries has already |
2448 | + # been loaded. This can happen if this collection is the |
2449 | + # return value of a named operation, or if the client did |
2450 | + # something like check the length of the collection. |
2451 | + # |
2452 | + # Either way, we've already made an HTTP request and |
2453 | + # gotten some entries back. The client has requested a |
2454 | + # slice that includes some of the entries we already have. |
2455 | + # In the best case, we can fulfil the slice immediately, |
2456 | + # without making another HTTP request. |
2457 | + # |
2458 | + # Even if we can't fulfil the entire slice, we can get one |
2459 | + # or more objects from the first page and then have fewer |
2460 | + # objects to retrieve from the server later. This saves us |
2461 | + # time and bandwidth, and it might let us save a whole |
2462 | + # HTTP request. |
2463 | + entry_page = existing_representation['entries'] |
2464 | + |
2465 | + first_page_size = len(entry_page) |
2466 | + entry_dicts = entry_page[start:stop] |
2467 | + page_url = existing_representation.get('next_collection_link') |
2468 | + else: |
2469 | + # No part of this collection has been loaded yet, or the |
2470 | + # slice starts beyond the part that has been loaded. We'll |
2471 | + # use our secret knowledge of lazr.restful to set a value for |
2472 | + # the ws.start variable. That way we start reading entries |
2473 | + # from the first one we want. |
2474 | + first_page_size = None |
2475 | + entry_dicts = [] |
2476 | + page_url = self._with_url_query_variable_set( |
2477 | + self._wadl_resource.url, 'ws.start', start) |
2478 | + |
2479 | + desired_size = stop-start |
2480 | + more_needed = desired_size - len(entry_dicts) |
2481 | + |
2482 | + # Iterate over pages until we have the correct number of entries. |
2483 | + while more_needed > 0 and page_url is not None: |
2484 | + representation = simplejson.loads( |
2485 | + self._root._browser.get(page_url)) |
2486 | + current_page_entries = representation['entries'] |
2487 | + entry_dicts += current_page_entries[:more_needed] |
2488 | + more_needed = desired_size - len(entry_dicts) |
2489 | + |
2490 | + page_url = representation.get('next_collection_link') |
2491 | + if page_url is None: |
2492 | + # We've gotten the entire collection; there are no |
2493 | + # more entries. |
2494 | + break |
2495 | + if first_page_size is None: |
2496 | + first_page_size = len(current_page_entries) |
2497 | + if more_needed > 0 and more_needed < first_page_size: |
2498 | + # An optimization: it's likely that we need less than |
2499 | + # a full page of entries, because the number we need |
2500 | + # is less than the size of the first page we got. |
2501 | + # Instead of requesting a full-sized page, we'll |
2502 | + # request only the number of entries we think we'll |
2503 | + # need. If we're wrong, there's no problem; we'll just |
2504 | + # keep looping. |
2505 | + page_url = self._with_url_query_variable_set( |
2506 | + page_url, 'ws.size', more_needed) |
2507 | + |
2508 | + if slice.step is not None: |
2509 | + entry_dicts = entry_dicts[::slice.step] |
2510 | + |
2511 | + # Convert entry_dicts into a list of Entry objects. |
2512 | + return [resource for resource |
2513 | + in self._convert_dicts_to_entries(entry_dicts)] |
2514 | + |
2515 | + def _convert_dicts_to_entries(self, entries): |
2516 | + """Convert dictionaries describing entries to Entry objects. |
2517 | + |
2518 | + The dictionaries come from the 'entries' field of the JSON |
2519 | + dictionary you get when you GET a page of a collection. Each |
2520 | + dictionary is the same as you'd get if you sent a GET request |
2521 | + to the corresponding entry resource. So each of these |
2522 | + dictionaries can be treated as a preprocessed representation |
2523 | + of an entry resource, and turned into an Entry instance. |
2524 | + |
2525 | + :yield: A sequence of Entry instances. |
2526 | + """ |
2527 | + for entry_dict in entries: |
2528 | + resource_url = entry_dict['self_link'] |
2529 | + resource_type_link = entry_dict['resource_type_link'] |
2530 | + wadl_application = self._wadl_resource.application |
2531 | + resource_type = wadl_application.get_resource_type( |
2532 | + resource_type_link) |
2533 | + resource = WadlResource( |
2534 | + self._wadl_resource.application, resource_url, |
2535 | + resource_type.tag) |
2536 | + yield Resource._create_bound_resource( |
2537 | + self._root, resource, entry_dict, self.JSON_MEDIA_TYPE, |
2538 | + False) |
2539 | + |
2540 | + def _with_url_query_variable_set(self, url, variable, new_value): |
2541 | + """A helper method to set a query variable in a URL.""" |
2542 | + uri = URI(url) |
2543 | + if uri.query is None: |
2544 | + params = {} |
2545 | + else: |
2546 | + params = cgi.parse_qs(uri.query) |
2547 | + params[variable] = str(new_value) |
2548 | + uri.query = urllib.urlencode(params, True) |
2549 | + return str(uri) |
2550 | + |
2551 | + |
2552 | +class CollectionWithKeyBasedLookup(Collection): |
2553 | + """A collection-type resource that supports key-based lookup. |
2554 | + |
2555 | + This collection can be sliced, but any single index passed into |
2556 | + __getitem__ will be treated as a custom lookup key. |
2557 | + """ |
2558 | + |
2559 | + def __getitem__(self, key): |
2560 | + """Look up a slice, or a subordinate resource by unique ID.""" |
2561 | + if isinstance(key, slice): |
2562 | + return super(CollectionWithKeyBasedLookup, self).__getitem__(key) |
2563 | + try: |
2564 | + url = self._get_url_from_id(key) |
2565 | + except NotImplementedError: |
2566 | + raise TypeError("unsubscriptable object") |
2567 | + if url is None: |
2568 | + raise KeyError(key) |
2569 | + |
2570 | + # We don't know what kind of resource this is. Even the |
2571 | + # subclass doesn't necessarily know, because some resources |
2572 | + # (the person list) are gateways to more than one kind of |
2573 | + # resource (people, and teams). The only way to know for sure |
2574 | + # is to retrieve a representation of the resource and see how |
2575 | + # the resource describes itself. |
2576 | + try: |
2577 | + representation = simplejson.loads(self._root._browser.get(url)) |
2578 | + except HTTPError, error: |
2579 | + # There's no resource corresponding to the given ID. |
2580 | + if error.response.status == 404: |
2581 | + raise KeyError(key) |
2582 | + raise |
2583 | + # We know that every lazr.restful resource has a |
2584 | + # 'resource_type_link' in its representation. |
2585 | + resource_type_link = representation['resource_type_link'] |
2586 | + resource = WadlResource(self._root._wadl, url, resource_type_link) |
2587 | + return self._create_bound_resource( |
2588 | + self._root, resource, representation=representation, |
2589 | + representation_needs_processing=False) |
2590 | + |
2591 | + |
2592 | + def _get_url_from_id(self, key): |
2593 | + """Transform the unique ID of an object into its URL.""" |
2594 | + raise NotImplementedError() |
2595 | + |
2596 | + |
2597 | +class HostedFileBuffer(StringIO): |
2598 | + """The contents of a file hosted by a lazr.restful service.""" |
2599 | + def __init__(self, hosted_file, mode, content_type=None, filename=None): |
2600 | + self.url = hosted_file._wadl_resource.url |
2601 | + if mode == 'r': |
2602 | + if content_type is not None: |
2603 | + raise ValueError("Files opened for read access can't " |
2604 | + "specify content_type.") |
2605 | + if filename is not None: |
2606 | + raise ValueError("Files opened for read access can't " |
2607 | + "specify filename.") |
2608 | + response, value = hosted_file._root._browser.get( |
2609 | + self.url, return_response=True) |
2610 | + content_type = response['content-type'] |
2611 | + last_modified = response['last-modified'] |
2612 | + |
2613 | + # The Content-Location header contains the URL of the file |
2614 | + # hosted by the web service. We happen to know that the |
2615 | + # final component of the URL is the name of the uploaded |
2616 | + # file. |
2617 | + content_location = response['content-location'] |
2618 | + path = urlparse(content_location)[2] |
2619 | + filename = urllib.unquote(path.split("/")[-1]) |
2620 | + elif mode == 'w': |
2621 | + value = '' |
2622 | + if content_type is None: |
2623 | + raise ValueError("Files opened for write access must " |
2624 | + "specify content_type.") |
2625 | + if filename is None: |
2626 | + raise ValueError("Files opened for write access must " |
2627 | + "specify filename.") |
2628 | + last_modified = None |
2629 | + else: |
2630 | + raise ValueError("Invalid mode. Supported modes are: r, w") |
2631 | + |
2632 | + self.hosted_file = hosted_file |
2633 | + self.mode = mode |
2634 | + self.content_type = content_type |
2635 | + self.filename = filename |
2636 | + self.last_modified = last_modified |
2637 | + StringIO.__init__(self, value) |
2638 | + |
2639 | + def close(self): |
2640 | + if self.mode == 'w': |
2641 | + disposition = 'attachment; filename="%s"' % self.filename |
2642 | + self.hosted_file._root._browser.put( |
2643 | + self.url, self.getvalue(), |
2644 | + self.content_type, {'Content-Disposition' : disposition}) |
2645 | + StringIO.close(self) |
Pre-imple: flacoste /pastebin. canonical. com/15558/
Tests: ./test.py -vvt launchpadlib.
Diff: https:/
This branch refactors almost all of the launchpadlib code into a
generic library called "restfulclient". In a future branch this code
will be separated from launchpadlib and put in the lazr.restfulclient
project.
launchpadlib is now a very small specialization. The
Launchpad-specific code is as follows:
* PersonSet, BugSet, PillarSet: Launchpad-specific data object classes.
* Launchpad: Now a specialization of restfulclient's ServiceRoot, the
Launchpad class includes code for tracking OAuth credentials.
* OAuthSigningHttp: A specialization of restfulclient's RestfulHttp
which signs an outgoing HTTP request with OAuth credentials.
* credentials.py is pretty much unchanged.
One thing I haven't done is move wadl-to-refhtml.xsl into
restfulclient. This would mean changing the Launchpad Makefile, which
would mean landing Launchpad and launchpadlib changes
simultaneously. Then I'd have to do it all again when I separate the
restfulclient directory out into the lazr.restfulclient project. I'll
move wadl-to-refhtml.xsl directly into lazr.restfulclient in my next
branch, and save myself the trouble