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