Merge lp:~leonardr/launchpadlib/no-generic-tests into lp:~launchpad-pqm/launchpadlib/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
Reviewer Review Type Date Requested Status
Francis J. Lacoste (community) Approve
launchpadlib developers Pending
Review via email: mp+5632@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

Parent branch: lp:~leonardr/launchpadlib/uses-restfulclient
Diff: https://pastebin.canonical.com/16430/

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.

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-developers)
>
> Parent branch: lp:~leonardr/launchpadlib/uses-restfulclient
> Diff: https://pastebin.canonical.com/16430/
>

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- }

Subscribers

People subscribed via source and target branches