Merge lp:~leonardr/launchpadlib/trusted-client into lp:~launchpad-pqm/launchpadlib/devel

Proposed by Leonard Richardson
Status: Rejected
Rejected by: Leonard Richardson
Proposed branch: lp:~leonardr/launchpadlib/trusted-client
Merge into: lp:~launchpad-pqm/launchpadlib/devel
Diff against target: None lines
To merge this branch: bzr merge lp:~leonardr/launchpadlib/trusted-client
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Needs Fixing
Review via email: mp+8012@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

This branch introduces two new utility scripts to launchpadlib. The goal is to fix bug 387297.

1. launchpad-request-token: Acquires a request token for a given application. Prints the JSON response from Launchpad to STDOUT.

2. launchpad-credentials-console: A console-based trusted client for exchanging a request token for an access token.

Note that there are no tests for this branch. I can't run any launchpadlib tests on my machine until certain buildout problems are fixed, and we currently have no framework for testing console applications that include user input. (I know how to test this, but it's a whole nother task setting it up.) I did test each code path manually from the command line as I wrote it.

These scripts must be run against a Launchpad that uses my as-yet-unlanded branch: https://code.edge.launchpad.net/~leonardr/launchpad/oauth-json-description

Revision history for this message
Aaron Bentley (abentley) wrote :

As discussed in IRC, using this diff: https://pastebin.canonical.com/19095/

- please move lines 225-241 into launchpadlib itself.
- please ensure classes are new-style classes, i.e. set __metaclass__ = type
- please ensure most items at the top level are separated by two newlines, including 14, 255, 282, 315
- please ensure both added files have copyright statements
- please be consistent about using #!/usr/bin/python
- please make the consumer name an argument rather than an option in launchpad-request-token
- please separate standard library imports like sys from external imports like simplejson
- consider limiting the number of password prompts to 3.

review: Needs Fixing

Unmerged revisions

52. By Leonard Richardson

Got the entire workflow working.

51. By Leonard Richardson

Wrote one script and part of another.

50. By Leonard Richardson

Removed tests that duplicate tests found in lazr.restfulclient.

49. By Leonard Richardson

Reverted the XSLT change, rather than deleting it and re-adding it, so it won't show up in diffs.

48. By Leonard Richardson

Re-added XSLT file in response to feedback.

47. By Leonard Richardson

Added dependency on lazr.restfulclient.

46. By Leonard Richardson

Removed wadl-to-refhtml.

45. By Leonard Richardson

Removed code that's now in lazr.restfulclient.

44. By Leonard Richardson

Changed imports to reduce the size of the diff.

43. By Leonard Richardson

Moved wadl-to-refhtml.xsl back so I don't have to change launchpadlib and launchpad simultaneously more than once.

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=== added directory 'src/launchpadlib/bin'
322=== added file 'src/launchpadlib/bin/launchpad-credentials-console'
323--- src/launchpadlib/bin/launchpad-credentials-console 1970-01-01 00:00:00 +0000
324+++ src/launchpadlib/bin/launchpad-credentials-console 2009-06-24 19:52:16 +0000
325@@ -0,0 +1,257 @@
326+import base64
327+import getpass
328+import httplib2
329+from optparse import OptionParser, make_option
330+from urlparse import urljoin
331+import urllib
332+import simplejson
333+import webbrowser
334+
335+class OptionParser(OptionParser):
336+
337+ def check_required (self, opt):
338+ option = self.get_option(opt)
339+ if getattr(self.values, option.dest) is None:
340+ self.error("%s option not supplied" % option)
341+
342+parser = OptionParser()
343+parser.usage = "%prog -n CONSUMER -t [TOKEN] [-r URL] [-a LEVEL,LEVEL,...]"
344+parser.add_option("-r", "--root", dest="web_root",
345+ help="The root URL of the site to ask for a request token "
346+ "(default: %default)", metavar="URL",
347+ default="https://staging.launchpad.net/")
348+parser.add_option("-t", "--request_token", dest="token",
349+ help="The ID of an OAuth request token",
350+ metavar="TOKEN")
351+parser.add_option("-n", "--consumer-name", dest="consumer_name",
352+ help="Your application's consumer name", metavar="NAME")
353+parser.add_option("-a", "--access-level", dest="access_levels",
354+ help="Any restrictions on your clients' access levels",
355+ metavar="[LEVEL,LEVEL,...]", default="")
356+
357+
358+class App:
359+
360+ INTRO = """Launchpad credential client (console)
361+-------------------------------------
362+
363+An application identified as "%(app)s" wants to access
364+Launchpad on your behalf. I'm the Launchpad credential client and
365+I'm here to ask for your Launchpad username and password.
366+
367+I'll use your Launchpad password to give "%(app)s"
368+limited access to your Launchpad account. I will not
369+show your password to "%(app)s" itself.
370+
371+Let's get started!
372+"""
373+
374+ SIGN_UP = """OK, you'll need to get yourself a Launchpad account before you can integrate
375+Launchpad into %(app)s.
376+
377+I'm opening the Launchpad registration page in your web browser so you can
378+create an account. Once you've created an account, you can try this again."""
379+
380+ BAD_CREDENTIALS = """I can't log in with the credentials you gave me. Let's try again."""
381+
382+ SERVER_ERROR = """There seems to be something wrong on the Launchpad server side, and I
383+can't continue. Hopefully this is a temporary problem, but if it persists,
384+it's probably because of a bug in Lauchpad."""
385+
386+ CONSUMER_MISMATCH = """WARNING: The application you're using told me its name was "%(old_name)s",
387+but it told Launchpad its name was "%(real_name)s".
388+This is probably not a problem, but it's a little suspicious, so you might
389+want to look into this before continuing. I'll refer to the application as
390+"%(real_name)s" from this point on."""
391+
392+ CHOOSE_ACCESS_LEVEL = """Now it's time for you to decide how much power to give "%(app)s"
393+over your Launchpad account."""
394+
395+ CHOOSE_ACCESS_LEVEL_ONE = """
396+"%(app)s" says it needs the following level of access to your Launchpad
397+account: "%(level)s". It can't work with any other level of access,
398+so denying this level of access means prohibiting "%(app)s" from
399+using your Launchpad account at all."""
400+
401+ CHOOSE_ACCESS_LEVEL_MANY = """
402+The application "%(app)s" says it can work with any of these access levels.
403+
404+%(options)s
405+
406+If none of these choices appeal to you, you can deny "%(app)s"
407+all access to your Launchpad account, by typing in "Q"."""
408+
409+ CHOSE_UNAUTHORIZED = """Okay, I'm going to cancel the request that "%(app)s" made for
410+access to your account. You can always set this up again later."""
411+
412+ CHOSE_OTHER_THAN_UNAUTHORIZED = """Okay, I'm telling Launchpad to grant "%(app)s" access
413+to your account."""
414+
415+ ALREADY_APPROVED = """It looks like you already approved this request to grant "%(app)s"
416+access to your Launchpad account. You shouldn't need to do anything more."""
417+
418+ COMPLETE = """We're all done! You should now be able to use Launchpad integration
419+features of "%(app)s." """
420+
421+ UNAUTHORIZED = "UNAUTHORIZED"
422+
423+ def __init__(self, web_root, consumer_name, request_token,
424+ access_levels):
425+ self.consumer_name = consumer_name
426+ self.default_args = {'app' : self.consumer_name}
427+ self.web_root = web_root
428+ self.request_token = request_token
429+ self.access_levels = access_levels.split(',')
430+ self.http = httplib2.Http()
431+
432+ def getSingleCharInput(self, prompt, valid):
433+ valid = valid.upper()
434+ input = None
435+ while input is None:
436+ input = raw_input(prompt).upper()
437+ if len(input) != 1 or input not in valid:
438+ input = None
439+ return input
440+
441+ def pressEnterToExit(self):
442+ print
443+ print 'Press enter to go back to "%s".' % self.consumer_name
444+ sys.stdin.readline()
445+ sys.exit(-1)
446+
447+ def getCredentials(self, cached_username=None):
448+ if cached_username is not None:
449+ extra = "[%s]" % cached_username
450+ else:
451+ extra = "\n(No Launchpad account? Just hit enter.)"
452+ username = raw_input("What email address do you use on Launchpad? %s "
453+ % extra).strip()
454+ if username == '':
455+ if cached_username is not None:
456+ username = cached_username
457+ else:
458+ print
459+ print self.SIGN_UP % self.default_args
460+ webbrowser.open(urljoin(self.web_root, "+login"))
461+ self.pressEnterToExit()
462+ password = getpass.getpass("What's your Launchpad password? ")
463+ return username, password
464+
465+ def _checkConsumer(self, token_info):
466+ real_consumer = token_info['oauth_token_consumer']
467+ if real_consumer != self.consumer_name:
468+ print self.CONSUMER_MISMATCH % {'old_name' : self.consumer_name,
469+ 'real_name' : real_consumer}
470+ print
471+ self.consumer_name = real_consumer
472+
473+ def _authHeader(self, username, password):
474+ auth = base64.encodestring("%s:%s" % (username, password))[:-1]
475+ return "Basic " + auth
476+
477+ def chooseAccessLevel(self, token_info):
478+ access_levels = [level for level in token_info['access_levels']
479+ if level['value'] != 'UNAUTHORIZED']
480+ print self.CHOOSE_ACCESS_LEVEL % self.default_args
481+ if len(access_levels) == 1:
482+ print self.CHOOSE_ACCESS_LEVEL_ONE % {
483+ 'app' : self.consumer_name,
484+ 'level' : access_levels[0]['title']}
485+ print
486+ allow = self.getSingleCharInput(
487+ 'Do you want to give "%s" this level of access? [YN] '
488+ % self.consumer_name, "YN")
489+ if allow == "Y":
490+ return access_levels[0]['value']
491+ else:
492+ return self.UNAUTHORIZED
493+ else:
494+ options = []
495+ for i in range(0, len(access_levels)):
496+ options.append("%d: %s" % (i+1, access_levels[i]['title']))
497+ print self.CHOOSE_ACCESS_LEVEL_MANY % {
498+ 'app' : self.consumer_name,
499+ 'options' : "\n".join(options)}
500+ print
501+ allowed = ("".join(map(str, range(1, i+2)))) + "Q"
502+ allow = self.getSingleCharInput(
503+ 'What should "%s" be allowed to do using your\n'
504+ 'Launchpad account? [1-%d or Q] '
505+ % (self.consumer_name, i+1), allowed)
506+ if allow == "Q":
507+ return self.UNAUTHORIZED
508+ else:
509+ return access_levels[int(allow)-1]['value']
510+
511+ def grantAccess(self, access_level, username, password):
512+ headers = {'Content-type' : 'application/x-www-form-urlencoded'}
513+ headers['Authorization'] = self._authHeader(username, password)
514+ body = "oauth_token=%s&field.actions.%s=True" % (
515+ urllib.quote(self.request_token), urllib.quote(access_level))
516+ url = urljoin(self.web_root, "+authorize-token")
517+ response, content = self.http.request(
518+ url, method="POST", headers=headers, body=body)
519+ # This would be much less fragile if Launchpad gave us an
520+ # error code to work with.
521+ if 'Request already reviewed' in content:
522+ print
523+ print self.ALREADY_APPROVED % self.default_args
524+ self.pressEnterToExit()
525+ if not 'Almost finished' in content:
526+ print
527+ print self.COULD_NOT_APPROVE % self.default_args
528+ self.pressEnterToExit()
529+
530+ def run(self):
531+ print self.INTRO % self.default_args
532+
533+ token_info = None
534+ username = None
535+ while token_info is None:
536+ username, password = self.getCredentials(username)
537+ if self.access_levels != '':
538+ s = "&allow_permission="
539+ access_levels = s + s.join(self.access_levels)
540+ else:
541+ access_levels = ""
542+ page = "+authorize-token?oauth_token=%s%s" % (
543+ self.request_token, access_levels)
544+ url = urljoin(self.web_root, page)
545+ # We can't use add_credentials because Launchpad doesn't respond
546+ # to unauthorized access with a 401 response code.
547+ headers = {'Accept' : 'application/json'}
548+ headers['Authorization'] = self._authHeader(username, password)
549+ response, content = self.http.request(url, headers=headers)
550+ status = response.get('status')
551+ location = response.get('content-location')
552+ if (response.status == 401 or (
553+ response.status == 200 and '+login' in location)):
554+ print
555+ print self.BAD_CREDENTIALS
556+ continue
557+ if response.get('content-type') != 'application/json':
558+ print
559+ print self.SERVER_ERROR
560+ self.pressEnterToExit()
561+ token_info = simplejson.loads(content)
562+
563+ print
564+ self._checkConsumer(token_info)
565+ access_level = self.chooseAccessLevel(token_info)
566+ print
567+ if access_level == self.UNAUTHORIZED:
568+ print self.CHOSE_UNAUTHORIZED % self.default_args
569+ else:
570+ print self.CHOSE_OTHER_THAN_UNAUTHORIZED % self.default_args
571+ self.grantAccess(access_level, username, password)
572+ print
573+ print self.COMPLETE % self.default_args
574+ self.pressEnterToExit()
575+
576+if __name__ == '__main__':
577+ (options, args) = parser.parse_args()
578+ parser.check_required('-t')
579+ parser.check_required('-n')
580+ App(options.web_root, options.consumer_name, options.token,
581+ options.access_levels).run()
582+
583
584=== added file 'src/launchpadlib/bin/launchpad-request-token'
585--- src/launchpadlib/bin/launchpad-request-token 1970-01-01 00:00:00 +0000
586+++ src/launchpadlib/bin/launchpad-request-token 2009-06-23 21:19:33 +0000
587@@ -0,0 +1,53 @@
588+#!/usr/bin/python
589+
590+"""A script to retrieve a Launchpad request token.
591+
592+This script will create a Launchpad request token and print to STDOUT
593+some JSON data about the token and the available access levels.
594+"""
595+
596+import httplib2
597+import optparse
598+import simplejson
599+import sys
600+
601+from launchpadlib.credentials import Credentials
602+
603+class OptionParser(optparse.OptionParser):
604+
605+ def check_required (self, opt):
606+ option = self.get_option(opt)
607+ if getattr(self.values, option.dest) is None:
608+ self.error("%s option not supplied" % option)
609+
610+parser = OptionParser()
611+parser.usage = "%prog -n CONSUMER [-r URL] [-c CONTEXT]"
612+parser.add_option("-r", "--root", dest="web_root",
613+ help="The root URL of the site to ask for a request token "
614+ "(default: %default)", metavar="URL",
615+ default="https://staging.launchpad.net/")
616+parser.add_option("-c", "--context", dest="context",
617+ help="Restrict the token to a specific context "
618+ "(example: firefox)", metavar="CONTEXT", default="")
619+parser.add_option("-n", "--consumer-name", dest="consumer_name",
620+ help="Your application's consumer name", metavar="CONSUMER")
621+
622+
623+class App:
624+
625+ def __init__(self, web_root, consumer_name, context):
626+ self.credentials = Credentials(consumer_name)
627+ self.web_root = web_root
628+ self.context = context
629+
630+ def run(self):
631+ token = self.credentials.get_request_token(
632+ self.context, self.web_root,
633+ token_format=Credentials.JSON_TOKEN_FORMAT)
634+ print simplejson.dumps(token)
635+
636+if __name__ == '__main__':
637+ (options, args) = parser.parse_args()
638+ parser.check_required('-n')
639+ App(options.web_root, options.consumer_name, options.context).run()
640+
641
642=== modified file 'src/launchpadlib/credentials.py'
643--- src/launchpadlib/credentials.py 2009-03-23 21:50:35 +0000
644+++ src/launchpadlib/credentials.py 2009-06-23 21:19:33 +0000
645@@ -27,9 +27,10 @@
646 import cgi
647 import httplib2
648 from oauth.oauth import OAuthConsumer, OAuthToken
649+import simplejson
650 from urllib import urlencode
651
652-from launchpadlib.errors import CredentialsFileError, HTTPError
653+from lazr.restfulclient.errors import CredentialsFileError, HTTPError
654
655
656 CREDENTIALS_FILE_VERSION = '1'
657@@ -49,6 +50,9 @@
658 """
659 _request_token = None
660
661+ URL_TOKEN_FORMAT = "url"
662+ JSON_TOKEN_FORMAT = "json"
663+
664 def __init__(self, consumer_name=None, consumer_secret='',
665 access_token=None):
666 """The user's Launchpad API credentials.
667@@ -150,7 +154,8 @@
668 self.save(credentials_file)
669 credentials_file.close()
670
671- def get_request_token(self, context=None, web_root=STAGING_WEB_ROOT):
672+ def get_request_token(self, context=None, web_root=STAGING_WEB_ROOT,
673+ token_format="url"):
674 """Request an OAuth token to Launchpad.
675
676 Also store the token in self._request_token.
677@@ -162,6 +167,11 @@
678 validity within Launchpad.
679 :param web_root: The URL of the website on which the token
680 should be requested.
681+ :token_format: How the token should be
682+ presented. URL_TOKEN_FORMAT means just return the URL to
683+ the page that authorizes the token. JSON_TOKEN_FORMAT
684+ means return a JSON data structure describing the token
685+ and the site's authentication policy.
686 :return: The URL for the user to authorize the `OAuthToken` provided
687 by Launchpad.
688 """
689@@ -172,10 +182,17 @@
690 oauth_signature_method='PLAINTEXT',
691 oauth_signature='&')
692 url = web_root + request_token_page
693+ headers = {}
694+ if token_format == self.JSON_TOKEN_FORMAT:
695+ headers['Accept'] = 'application/json'
696 response, content = httplib2.Http().request(
697- url, method='POST', body=urlencode(params))
698+ url, method='POST', headers=headers, body=urlencode(params))
699 if response.status != 200:
700 raise HTTPError(response, content)
701+
702+ if token_format == self.JSON_TOKEN_FORMAT:
703+ return simplejson.loads(content)
704+
705 self._request_token = OAuthToken.from_string(content)
706 url = '%s%s?oauth_token=%s' % (web_root, authorize_token_page,
707 self._request_token.key)
708
709=== removed file 'src/launchpadlib/docs/caching.txt'
710--- src/launchpadlib/docs/caching.txt 2008-12-05 21:38:50 +0000
711+++ src/launchpadlib/docs/caching.txt 1970-01-01 00:00:00 +0000
712@@ -1,111 +0,0 @@
713-Launchpadlib automatically decompresses the documents it receives, and
714-caches the responses in a temporary directory.
715-
716- >>> import httplib2
717- >>> httplib2.debuglevel = 1
718-
719- >>> from launchpadlib.testing.helpers import salgado_with_full_permissions
720- >>> launchpad_with_cache = salgado_with_full_permissions.login()
721- connect: ...
722- send: 'GET /beta/ ...
723- reply: ...200...
724- ...
725- header: Transfer-Encoding: deflate
726- ...
727- send: 'GET /beta/ ...
728- ...
729- reply: ...200...
730- ...
731- header: Transfer-Encoding: deflate
732- ...
733-
734- >>> print launchpad_with_cache.projects['firefox'].name
735- send: 'GET /beta/firefox ...
736- reply: ...200...
737- ...
738- firefox
739-
740-The second and subsequent times you request some object, it's likely
741-that launchpadlib will make a conditional HTTP GET request instead of
742-a normal request. The HTTP response code will be 304 instead of 200,
743-and launchpadlib will use the cached representation of the object.
744-
745- >>> print launchpad_with_cache.projects['firefox'].name
746- send: 'GET /beta/firefox ...
747- reply: ...304...
748- ...
749- firefox
750-
751-This is true even if you initially got the object as part of a
752-collection.
753-
754- >>> people = launchpad_with_cache.people[:10]
755- send: ...
756- reply: ...200...
757-
758- >>> first_person = people[0]
759- >>> first_person.lp_refresh()
760- send: ...
761- reply: ...304...
762-
763-Note that if you get an object as part of a collection and then get it
764-some other way, a conditional GET request will *not* be made.
765-
766- >>> launchpad_with_cache.people[first_person.name]
767- send: ...
768- reply: ...200...
769-
770-The default launchpadlib cache directory is a temporary directory
771-that's deleted when the Python process ends. (If the process is
772-killed, the directory will stick around in /tmp.) It's much more
773-efficient to keep a cache directory across multiple uses of
774-launchpadlib.
775-
776-You can provide a cache directory name as argument when creating a
777-Launchpad object. This directory will fill up with cached HTTP
778-responses, and since it's a directory you control it will persist
779-across launchpadlib sessions.
780-
781- >>> import tempfile
782- >>> tempdir = tempfile.mkdtemp()
783-
784- >>> first_launchpad = salgado_with_full_permissions.login(tempdir)
785- connect: ...
786- send: 'GET /beta/ ...
787- reply: ...200...
788- ...
789- send: 'GET /beta/ ...
790- reply: ...200...
791- ...
792-
793- >>> print first_launchpad.projects['firefox'].name
794- send: 'GET /beta/firefox ...
795- reply: ...200...
796- ...
797- firefox
798-
799-This will save you a *lot* of time in subsequent sessions, because
800-you'll be able to use cached versions of the initial (very expensive)
801-documents.
802-
803- >>> second_launchpad = salgado_with_full_permissions.login(tempdir)
804- connect: ...
805- send: 'GET /beta/ ...
806- reply: ...304...
807- ...
808- send: 'GET /beta/ ...
809- reply: ...304...
810- ...
811-
812- >>> print second_launchpad.projects['firefox'].name
813- send: 'GET /beta/firefox ...
814- reply: ...304...
815- ...
816- firefox
817-
818-Of course, if you ever need to clear the cache directory, you'll have
819-to do it yourself.
820-
821- >>> httplib2.debuglevel = 0
822- >>> import shutil
823- >>> shutil.rmtree(tempdir)
824
825=== modified file 'src/launchpadlib/docs/hosted-files.txt'
826--- src/launchpadlib/docs/hosted-files.txt 2008-10-02 16:08:22 +0000
827+++ src/launchpadlib/docs/hosted-files.txt 2009-04-16 19:30:01 +0000
828@@ -1,23 +1,31 @@
829-Some resources published by Launchpad can have binary
830-representations. launchpadlib gives access to these resources.
831+************
832+Hosted files
833+************
834+
835+The Launchpad web service sets restrictions on what kinds of documents
836+can be written to a particular file. This test shows what happens when
837+you try to upload a non-image for a field that expects an image.
838
839 >>> from launchpadlib.testing.helpers import salgado_with_full_permissions
840 >>> launchpad = salgado_with_full_permissions.login()
841-
842-An example of a hosted binary file is a person's mugshot. The
843-"salgado" user starts off with no mugshot.
844+ >>> from launchpadlib.errors import HTTPError
845
846 >>> mugshot = launchpad.me.mugshot
847- >>> sorted(dir(mugshot))
848- [..., 'open']
849-
850- >>> mugshot.open()
851- Traceback (most recent call last):
852- ...
853- HTTPError: HTTP Error 404: Not Found
854-
855-You can open a hosted file for write access and write to it as though
856-it were a file on disk.
857+ >>> file_handle = mugshot.open("w", "image/png", "nonimage.txt")
858+ >>> file_handle.content_type
859+ 'image/png'
860+ >>> file_handle.filename
861+ 'nonimage.txt'
862+ >>> file_handle.write("Not an image.")
863+ >>> try:
864+ ... file_handle.close()
865+ ... except HTTPError, e:
866+ ... print e.content
867+ <BLANKLINE>
868+ The file uploaded was not recognized as an image; please
869+ check it and retry.
870+
871+Of course, uploading an image works fine.
872
873 >>> import os
874 >>> def load_image(filename):
875@@ -29,18 +37,9 @@
876 2260
877
878 >>> file_handle = mugshot.open("w", "image/png", "a-mugshot.png")
879- >>> file_handle.content_type
880- 'image/png'
881- >>> file_handle.filename
882- 'a-mugshot.png'
883- >>> print file_handle.last_modified
884- None
885 >>> file_handle.write(image)
886 >>> file_handle.close()
887
888-Once it exists on the server, you can open a hosted file for read
889-access and read it.
890-
891 >>> file_handle = mugshot.open()
892 >>> file_handle.content_type
893 'image/png'
894@@ -50,112 +49,3 @@
895 False
896 >>> len(file_handle.read())
897 2260
898-
899-Modifying a file will change its 'last_modified' attribute.
900-
901- >>> file_handle = mugshot.open("w", "image/png", "another-mugshot.png")
902- >>> file_handle.write(image)
903- >>> file_handle.close()
904-
905- >>> file_handle = mugshot.open()
906- >>> file_handle.filename
907- 'another-mugshot.png'
908-
909-Once it exists, a file can be deleted.
910-
911- >>> mugshot.delete()
912- >>> mugshot.open()
913- Traceback (most recent call last):
914- ...
915- HTTPError: HTTP Error 404: Not Found
916-
917-
918-== Error handling ==
919-
920-The only access modes supported are 'r' and 'w'.
921-
922- >>> mugshot.open("r+")
923- Traceback (most recent call last):
924- ...
925- ValueError: Invalid mode. Supported modes are: r, w
926-
927-When opening a file for write access, you must specify the
928-content_type argument.
929-
930- >>> mugshot.open("w")
931- Traceback (most recent call last):
932- ...
933- ValueError: Files opened for write access must specify content_type.
934-
935- >>> mugshot.open("w", "image/png")
936- Traceback (most recent call last):
937- ...
938- ValueError: Files opened for write access must specify filename.
939-
940-When opening a file for read access, you must *not* specify the
941-content_type argument--it comes from the server.
942-
943- >>> mugshot.open("r", "image/png")
944- Traceback (most recent call last):
945- ...
946- ValueError: Files opened for read access can't specify content_type.
947-
948- >>> mugshot.open("r", filename="foo.png")
949- Traceback (most recent call last):
950- ...
951- ValueError: Files opened for read access can't specify filename.
952-
953-The server may set restrictions on what kinds of documents can be
954-written to a particular file.
955-
956- >>> file_handle = mugshot.open("w", "image/png", "nonimage.txt")
957- >>> file_handle.content_type
958- 'image/png'
959- >>> file_handle.filename
960- 'nonimage.txt'
961- >>> file_handle.write("Not an image.")
962- >>> file_handle.close()
963- Traceback (most recent call last):
964- ...
965- HTTPError: HTTP Error 400: Bad Request
966-
967-
968-== Caching ==
969-
970-Hosted file resources implement the normal server-side caching
971-mechanism.
972-
973- >>> file_handle = mugshot.open("w", "image/png", "image.png")
974- >>> file_handle.write(image)
975- >>> file_handle.close()
976-
977- >>> import httplib2
978- >>> httplib2.debuglevel = 1
979- >>> launchpad = salgado_with_full_permissions.login()
980- connect: ...
981- >>> mugshot = launchpad.me.mugshot
982- send: ...
983-
984-The first request for a file retrieves the file from the server.
985-
986- >>> len(mugshot.open().read())
987- send: ...
988- reply: 'HTTP/1.1 303 See Other...
989- reply: 'HTTP/1.1 200 OK...
990- 2260
991-
992-The second request retrieves the file from the cache.
993-
994- >>> len(mugshot.open().read())
995- send: ...
996- reply: 'HTTP/1.1 303 See Other...
997- reply: 'HTTP/1.1 304 Not Modified...
998- 2260
999-
1000-Finally, some cleanup code that deletes the mugshot.
1001-
1002- >>> mugshot.delete()
1003- send: 'DELETE...
1004- reply: 'HTTP/1.1 200...
1005-
1006- >>> httplib2.debuglevel = 0
1007
1008=== modified file 'src/launchpadlib/docs/introduction.txt'
1009--- src/launchpadlib/docs/introduction.txt 2009-03-22 23:50:49 +0000
1010+++ src/launchpadlib/docs/introduction.txt 2009-06-23 21:19:33 +0000
1011@@ -174,6 +174,16 @@
1012 >>> authorization_url
1013 'http://launchpad.dev:8085/+authorize-token?oauth_token=...&lp.context=firefox'
1014
1015+More sophisticated clients can get JSON representations of the request
1016+token.
1017+
1018+ >>> json_body = credentials.get_request_token(
1019+ ... context='firefox', web_root='http://launchpad.dev:8085/',
1020+ ... token_format=Credentials.JSON_FORMAT)
1021+ >>> json_data = simplejson.loads(json_body)
1022+ >>> sorted(json_data.keys())
1023+ ['access_levels', 'oauth_token_secret', 'oauth_token']
1024+
1025 Now the user must authorize that token, so we'll hand-craft a request
1026 to pretend the user is authorizing it.
1027
1028
1029=== removed file 'src/launchpadlib/docs/modifications.txt'
1030--- src/launchpadlib/docs/modifications.txt 2009-03-03 15:57:11 +0000
1031+++ src/launchpadlib/docs/modifications.txt 1970-01-01 00:00:00 +0000
1032@@ -1,351 +0,0 @@
1033-= Modifications =
1034-
1035-Objects available through the web interface, such as people, have a readable
1036-interface which is available through direct attribute access.
1037-
1038- >>> from launchpadlib.testing.helpers import salgado_with_full_permissions
1039- >>> launchpad = salgado_with_full_permissions.login()
1040-
1041- >>> salgado = launchpad.people['salgado']
1042- >>> print salgado.display_name
1043- Guilherme Salgado
1044-
1045-These objects may have a number of attributes, as well as associated
1046-collections and entries. Introspection methods give you access to this
1047-information.
1048-
1049- >>> sorted(dir(salgado))
1050- [...'acceptInvitationToBeMemberOf', 'addMember', 'admins', ...]
1051- >>> sorted(salgado.lp_attributes)
1052- ['date_created', 'display_name', 'hide_email_addresses', ...]
1053- >>> sorted(salgado.lp_entries)
1054- ['archive', 'mugshot', 'preferred_email_address', 'team_owner']
1055- >>> sorted(salgado.lp_collections)
1056- ['admins', 'confirmed_email_addresses', 'deactivated_members', ...]
1057- >>> sorted(salgado.lp_operations)
1058- ['acceptInvitationToBeMemberOf', 'addMember', ...]
1059-
1060-Some of these attributes can be changed. For example, Salgado can change his
1061-display name. When changing attribute values though, the changes are not
1062-pushed to the web service until the entry is explicitly saved. This allows
1063-Salgado to batch the changes over the wire for efficiency.
1064-
1065- >>> salgado.display_name = u'Salgado'
1066- >>> print launchpad.people['salgado'].display_name
1067- Guilherme Salgado
1068-
1069-Once the changes are saved though, they are saved on the web service.
1070-
1071-XXX BarryWarsaw 12-Jun-2008 We currently make no guarantees about the
1072-synchronization between the local object's state and the remote
1073-object's state. Future development will add a "conditional PATCH"
1074-feature based on Last-Modified/ETag headers; this will serve as a
1075-transction number, so that if the two objects get out of sync, the
1076-.lp_save() would fail. Since this is not yet implemented, we will do
1077-a [] lookup every time we want to guarantee that we have the
1078-up-to-date state of the object. The only other time we can make this
1079-guarantee is when we change an attribute that causes a 301 'Moved
1080-permanently' HTTP error, because we implicitly re-fetch the object's
1081-state in that case. However, this latter condition is not exposed
1082-through the web service.
1083-
1084- >>> salgado.lp_save()
1085- >>> print launchpad.people['salgado'].display_name
1086- Salgado
1087-
1088-The entry object is a normal Python object like any other. Attributes
1089-of the entry, like 'display_name', are available as attributes on the
1090-resource, and may be set. Only the attributes of the entry can be set
1091-or read as Python attributes.
1092-
1093- >>> salgado.display_name = u'Guilherme Salgado'
1094- >>> salgado.is_great = True
1095- Traceback (most recent call last):
1096- ...
1097- AttributeError: 'Entry' object has no attribute 'is_great'
1098-
1099- >>> salgado.is_great
1100- Traceback (most recent call last):
1101- ...
1102- AttributeError: 'Entry' object has no attribute 'is_great'
1103-
1104-The client can set more than one attribute on Salgado at a time:
1105-they'll all be changed when the entry is saved.
1106-
1107- >>> print salgado.homepage_content
1108- None
1109- >>> salgado.hide_email_addresses
1110- False
1111- >>> print salgado.mailing_list_auto_subscribe_policy
1112- Ask me when I join a team
1113-
1114- >>> salgado.homepage_content = u'This is my home page.'
1115- >>> salgado.hide_email_addresses = True
1116- >>> salgado.mailing_list_auto_subscribe_policy = (
1117- ... u'Never subscribe to mailing lists')
1118- >>> salgado.lp_save()
1119- >>> salgado = launchpad.people['salgado']
1120-
1121- >>> print salgado.homepage_content
1122- This is my home page.
1123- >>> salgado.hide_email_addresses
1124- True
1125- >>> print salgado.mailing_list_auto_subscribe_policy
1126- Never subscribe to mailing lists
1127-
1128-Salgado cannot set his time zone to an illegal value.
1129-
1130- >>> from launchpadlib.errors import HTTPError
1131- >>> def print_error_on_save(entry):
1132- ... try:
1133- ... entry.lp_save()
1134- ... except HTTPError, error:
1135- ... for line in sorted(error.content.splitlines()):
1136- ... print line
1137- ... else:
1138- ... print 'Did not get expected HTTPError!'
1139-
1140- >>> salgado.time_zone = 'SouthPole'
1141- >>> print_error_on_save(salgado)
1142- time_zone: u'SouthPole' isn't a valid token
1143-
1144-Teams also have attributes that can be changed. For example, Salgado creates
1145-the most awesome team in the world.
1146-
1147- >>> bassists = launchpad.people.newTeam(
1148- ... name='bassists', display_name='Awesome Rock Bass Players')
1149-
1150-Then Salgado realizes he wants to express the awesomeness of this team in its
1151-description. Salgado also understands that anybody can achieve awesomeness.
1152-
1153- >>> print bassists.team_description
1154- None
1155- >>> print bassists.subscription_policy
1156- Moderated Team
1157-
1158- >>> bassists.team_description = (
1159- ... u'The most important instrument in the world')
1160- >>> bassists.subscription_policy = u'Open Team'
1161- >>> bassists_copy = launchpad.people['bassists']
1162- >>> bassists.lp_save()
1163-
1164-A resource object is automatically refreshed after saving.
1165-
1166- >>> print bassists.team_description
1167- The most important instrument in the world
1168-
1169-Any other version of that resource will still have the old data.
1170-
1171- >>> print bassists_copy.team_description
1172- None
1173-
1174-But you can also refresh a resource object manually.
1175-
1176- >>> bassists_copy.lp_refresh()
1177- >>> print bassists.team_description
1178- The most important instrument in the world
1179- >>> print bassists.subscription_policy
1180- Open Team
1181-
1182-Some of a resource's attributes may take other resources as values.
1183-
1184- >>> ubuntu = launchpad.distributions['ubuntu']
1185- >>> print ubuntu.driver
1186- None
1187- >>> ubuntu.driver = salgado
1188- >>> ubuntu.lp_save()
1189- >>> print ubuntu.driver
1190- http://api.launchpad.dev:8085/beta/~salgado
1191-
1192-Resources may also be used as arguments to named operations.
1193-
1194- >>> bug_one = launchpad.bugs[1]
1195- >>> task = [task for task in bug_one.bug_tasks][0]
1196- >>> print task.assignee.display_name
1197- Mark Shuttleworth
1198- >>> task.transitionToAssignee(assignee=salgado)
1199- >>> print task.assignee.display_name
1200- Guilherme Salgado
1201-
1202- # XXX: salgado, 2008-08-01: Commented because method has been Unexported;
1203- # it should be re-enabled after the operation is exported again.
1204- # >>> salgado.inTeam(team=bassists)
1205- # True
1206-
1207-
1208-== Server-side data massage ==
1209-
1210-Send bad data and your request will be rejected. But if you send data
1211-that's not quite what the server is expecting, the server may accept
1212-it while tweaking it. This means that the state of your object after
1213-you call lp_save() may be slightly different from the object before
1214-you called lp_save().
1215-
1216- >>> firefox = launchpad.projects['firefox']
1217- >>> print firefox.wiki_url
1218- None
1219- >>> firefox.wiki_url = ' http://foo.com '
1220- >>> firefox.wiki_url
1221- ' http://foo.com '
1222- >>> firefox.lp_save()
1223- >>> firefox.wiki_url
1224- u'http://foo.com/'
1225-
1226-
1227-== Moving an entry ==
1228-
1229-Salgado can actually rename and move his person by changing the 'name'
1230-attribute.
1231-
1232- >>> salgado = launchpad.people['salgado']
1233- >>> salgado.name = u'guilherme'
1234- >>> salgado.lp_save()
1235-
1236-Once this is done, he can no longer access his data through the old name. But
1237-Salgado's person is available through the new name.
1238-
1239- >>> launchpad.people['salgado']
1240- Traceback (most recent call last):
1241- ...
1242- KeyError: 'salgado'
1243-
1244- >>> print launchpad.people['guilherme'].display_name
1245- Guilherme Salgado
1246-
1247-Under the covers though, a refresh of the original object has been retrieved
1248-from Launchpad, so it's save to continue using, and changing it.
1249-
1250- >>> salgado.display_name = u'Salgado!'
1251- >>> salgado.lp_save()
1252- >>> print launchpad.people['guilherme'].display_name
1253- Salgado!
1254-
1255-It's just as easy to move Salgado back to the old name.
1256-
1257- >>> salgado.name = u'salgado'
1258- >>> salgado.lp_save()
1259- >>> launchpad.people['guilherme']
1260- Traceback (most recent call last):
1261- ...
1262- KeyError: 'guilherme'
1263-
1264- >>> print launchpad.people['salgado'].display_name
1265- Salgado!
1266-
1267-
1268-== Read-only attributes ==
1269-
1270-Some attributes are read-only, such as a person's karma.
1271-
1272- >>> salgado.karma
1273- 0
1274- >>> salgado.karma = 1000000
1275- >>> print_error_on_save(salgado)
1276- karma: You tried to modify a read-only attribute.
1277-
1278-If Salgado tries to change several read-only attributes at the same time, he
1279-gets useful feedback about his error.
1280-
1281- >>> salgado.date_created = u'2003-06-06T08:59:51.596025+00:00'
1282- >>> salgado.is_team = True
1283- >>> print_error_on_save(salgado)
1284- date_created: You tried to modify a read-only attribute.
1285- is_team: You tried to modify a read-only attribute.
1286- karma: You tried to modify a read-only attribute.
1287-
1288-
1289-== Avoiding conflicts ==
1290-
1291-Launchpad and launchpadlib work together to try to avoid situations
1292-where one person unknowingly overwrites another's work. Here, two
1293-different clients are interested in the same Launchpad object.
1294-
1295- >>> first_launchpad = salgado_with_full_permissions.login()
1296- >>> first_firefox = first_launchpad.projects['firefox']
1297- >>> first_firefox.description
1298- u'The Mozilla Firefox web browser'
1299-
1300- >>> second_launchpad = salgado_with_full_permissions.login()
1301- >>> second_firefox = second_launchpad.projects['firefox']
1302- >>> second_firefox.description
1303- u'The Mozilla Firefox web browser'
1304-
1305-The first client decides to change the description.
1306-
1307- >>> first_firefox.description = 'A description.'
1308- >>> first_firefox.lp_save()
1309-
1310-The second client tries to make a conflicting change, but the server
1311-detects that the second client doesn't have the latest information,
1312-and rejects the request.
1313-
1314- >>> second_firefox.description = 'A conflicting description.'
1315- >>> second_firefox.lp_save()
1316- Traceback (most recent call last):
1317- ...
1318- HTTPError: HTTP Error 412: Precondition Failed
1319-
1320-Now the second client has a chance to look at the changes that were
1321-made, before making their own changes.
1322-
1323- >>> second_firefox.lp_refresh()
1324- >>> print second_firefox.description
1325- A description.
1326-
1327- >>> second_firefox.description = 'A conflicting description.'
1328- >>> second_firefox.lp_save()
1329-
1330-Conflict detection works even when you operate on an object you
1331-retrieved from a collection.
1332-
1333- >>> first_user = first_launchpad.people[:10][0]
1334- >>> second_user = second_launchpad.people[:10][0]
1335- >>> first_user.name == second_user.name
1336- True
1337-
1338- >>> first_user.display_name = "A display name"
1339- >>> first_user.lp_save()
1340-
1341- >>> second_user.display_name = "A conflicting display name"
1342- >>> second_user.lp_save()
1343- Traceback (most recent call last):
1344- ...
1345- HTTPError: HTTP Error 412: Precondition Failed
1346-
1347- >>> second_user.lp_refresh()
1348- >>> print second_user.display_name
1349- A display name
1350-
1351- >>> second_user.display_name = "A conflicting display name"
1352- >>> second_user.lp_save()
1353-
1354- >>> first_user.lp_refresh()
1355- >>> print first_user.display_name
1356- A conflicting display name
1357-
1358-
1359-== Data types ==
1360-
1361-From the perspective of the launchpadlib user, date and date-time
1362-fields always look like Python datetime objects or None.
1363-
1364- >>> firefox = launchpad.projects['firefox']
1365- >>> for milestone in firefox.all_milestones:
1366- ... if milestone.name == '1.0':
1367- ... break
1368- >>> milestone.date_targeted
1369- datetime.datetime(2056, 10, 16,...)
1370-
1371-These fields can be changed and written back to the server, just like
1372-objects of other types.
1373-
1374- >>> from datetime import datetime
1375- >>> milestone.date_targeted = datetime(2009, 1, 1)
1376- >>> milestone.lp_save()
1377-
1378-A datetime object may also be used as an argument to a named operation.
1379-
1380- >>> series = firefox.series[0]
1381- >>> series.newMilestone(
1382- ... name="test", date_targeted=datetime(2009, 1, 1))
1383- <milestone at ...>
1384
1385=== removed file 'src/launchpadlib/docs/operations.txt'
1386--- src/launchpadlib/docs/operations.txt 2008-12-08 14:26:55 +0000
1387+++ src/launchpadlib/docs/operations.txt 1970-01-01 00:00:00 +0000
1388@@ -1,29 +0,0 @@
1389-= Named operations =
1390-
1391-Entries and collections support named operations: one-off
1392-functionality that's been given a name and a set of parameters.
1393-
1394- >>> from launchpadlib.testing.helpers import salgado_with_full_permissions
1395- >>> launchpad = salgado_with_full_permissions.login()
1396-
1397-Arguments to named operations are automatically converted to JSON for
1398-transmission over the wire.
1399-
1400- >>> ubuntu = launchpad.distributions['ubuntu']
1401- >>> [task for task in ubuntu.searchTasks(has_cve=True)]
1402- [...]
1403-
1404-Strings that happen to be numbers are handled properly. Here, if "1.234"
1405-were converted into a number at any point in the chain, the 'find'
1406-operation on the server wouldn't know how to handle it and the request
1407-would fail.
1408-
1409- >>> [people for people in launchpad.people.find(text="1.234")]
1410- []
1411-
1412-The JSON conversion works for POST as well as GET operations.
1413-
1414- >>> bug = launchpad.bugs.createBug(target=ubuntu, title="Test bug",
1415- ... description="Testing named operations", tags=["foo", "bar"])
1416- >>> sorted(bug.tags)
1417- [u'bar', u'foo']
1418
1419=== modified file 'src/launchpadlib/docs/people.txt'
1420--- src/launchpadlib/docs/people.txt 2009-01-21 19:44:46 +0000
1421+++ src/launchpadlib/docs/people.txt 2009-04-16 19:30:01 +0000
1422@@ -1,16 +1,16 @@
1423 = People and Teams =
1424
1425-Just as with Launchpad, the web service exposes a uniform interface to people
1426-and teams. In other words, people and teams occupy the same namespace. You
1427-treat people and teams as the same type of object, and need to inspect the
1428-object to know whether you're dealing with a person or a team.
1429+The Launchpad web service, like Launchpad itself, exposes a unified
1430+interface to people and teams. In other words, people and teams
1431+occupy the same namespace. You treat people and teams as the same
1432+type of object, and need to inspect the object to know whether you're
1433+dealing with a person or a team.
1434
1435
1436 == People ==
1437
1438-You can access Launchpad people, and the set of people with the most karma,
1439-through the web service interface. The set of people with the most karma is
1440-available from the service root.
1441+You can access Launchpad people through the web service interface.
1442+The list of people is available from the service root.
1443
1444 >>> from launchpadlib.testing.helpers import salgado_with_full_permissions
1445 >>> launchpad = salgado_with_full_permissions.login()
1446@@ -51,136 +51,21 @@
1447 ...
1448 KeyError: 'not-a-registered-person'
1449
1450-You can find a person by email.
1451-
1452- >>> email = salgado.preferred_email_address.email
1453- >>> salgado = launchpad.people.getByEmail(email=email)
1454- >>> salgado.name
1455- u'salgado'
1456-
1457-Once you have a person, you can store their URL and use it to look
1458-them up later.
1459-
1460- >>> me.self_link
1461- u'http://api.launchpad.dev:8085/beta/~salgado'
1462- >>> launchpad.load(me.self_link).name
1463- u'salgado'
1464- >>> launchpad.load('http://launchpad.dev:8085/')
1465- Traceback (most recent call last):
1466- ...
1467- ValueError: ... doesn't serve a JSON document.
1468- >>> url_without_type = ('http://api.launchpad.dev:8085/beta/people' +
1469- ... '?ws.op=find&text=salgado')
1470- >>> launchpad.load(url_without_type)
1471- Traceback (most recent call last):
1472- ...
1473- ValueError: Couldn't determine the resource type of...
1474-
1475-You can iterate through all the people in the set.
1476-
1477- >>> names = sorted(person.name for person in launchpad.people)
1478- >>> len(names)
1479- 4
1480- >>> names
1481- [u'carlos', u'name12', u'name16', u'sabdfl']
1482-
1483-You can get a slice of the list of people, so long as you provide
1484-start and end points keyed to the beginning of the list. This set-up
1485-code creates a regular Python list of all people on the site, for
1486-comparison with a launchpadlib Collection object representing the same
1487-list.
1488-
1489- >>> all_people = [person for person in launchpad.people]
1490- >>> people = launchpad.people
1491-
1492-Calling len() on the Collection object makes sure that the first page
1493-of representations is cached, which forces this test to test an
1494-optimization.
1495-
1496- >>> ignored = len(people)
1497-
1498-These tests demonstrate that slicing the collection resource gives the
1499-same results as collecting all the entries in the collection, and
1500-slicing an ordinary list.
1501-
1502- >>> def slices_match(slice):
1503- ... """Slice two lists of people, then make sure they're the same."""
1504- ... list1 = people[slice]
1505- ... list2 = all_people[slice]
1506- ... if len(list1) != len(list2):
1507- ... raise ("Lists are different sizes: %d vs. %d" %
1508- ... (len(list1), len(list2)))
1509- ... for index in range(0, len(list1)):
1510- ... if list1[index].name != list2[index].name:
1511- ... raise ("%s doesn't match %s in position %d" %
1512- ... (list1[index].name, list2[index].name, index))
1513- ... return True
1514-
1515- >>> slices_match(slice(3))
1516- True
1517- >>> slices_match(slice(50))
1518- True
1519- >>> slices_match(slice(1,2))
1520- True
1521- >>> slices_match(slice(10,21))
1522- True
1523- >>> slices_match(slice(10,21,3))
1524- True
1525-
1526- >>> slices_match(slice(0, 200))
1527- True
1528- >>> slices_match(slice(30, 200))
1529- True
1530- >>> slices_match(slice(60, 100))
1531- True
1532-
1533- >>> people[5:]
1534- Traceback (most recent call last):
1535- ...
1536- ValueError: Collection slices must have a definite, nonnegative end point.
1537-
1538- >>> people[10:-1]
1539- Traceback (most recent call last):
1540- ...
1541- ValueError: Collection slices must have a definite, nonnegative end point.
1542-
1543- >>> people[-1:]
1544- Traceback (most recent call last):
1545- ...
1546- ValueError: Collection slices must have a nonnegative start point.
1547-
1548- >>> people[:]
1549- Traceback (most recent call last):
1550- ...
1551- ValueError: Collection slices must have a definite, nonnegative end point.
1552-
1553 It's not possible to slice a single person from the top-level
1554 collection of people. launchpadlib will try to use the value you pass
1555 in as a person's name, which will almost always fail.
1556
1557- >>> people[1]
1558+ >>> launchpad.people[1]
1559 Traceback (most recent call last):
1560 ...
1561 KeyError: 1
1562
1563-You can slice a collection that's the return value of a named
1564-operation.
1565-
1566- >>> a_people = launchpad.people.find(text='a')
1567- >>> len(a_people[1:3])
1568- 2
1569-
1570-You can also access individual items in this collection by
1571-index. Unlike with the top-level collection, your index won't be
1572-interpreted as a person's name.
1573-
1574- >>> a_people[1].name
1575- u'andrelop'
1576-
1577- >>> a_people[1000]
1578- Traceback (most recent call last):
1579- ...
1580- IndexError: list index out of range
1581+You can find a person by email.
1582+
1583+ >>> email = salgado.preferred_email_address.email
1584+ >>> salgado = launchpad.people.getByEmail(email=email)
1585+ >>> salgado.name
1586+ u'salgado'
1587
1588 Besides a name and a display name, a person has many other attributes that you
1589 can read.
1590
1591=== modified file 'src/launchpadlib/docs/toplevel.txt'
1592--- src/launchpadlib/docs/toplevel.txt 2008-09-12 19:16:52 +0000
1593+++ src/launchpadlib/docs/toplevel.txt 2009-04-16 19:30:01 +0000
1594@@ -29,13 +29,3 @@
1595
1596 >>> launchpad.distributions['ubuntu'].name
1597 u'ubuntu'
1598-
1599-You can iterate over the top-level collections.
1600-
1601- >>> sorted([project.name for project in launchpad.projects])
1602- [u'a52dec', ... u'upstart']
1603-
1604-But it's almost always better to slice them.
1605-
1606- >>> sorted([project.name for project in launchpad.projects[:2]])
1607- [u'landscape', u'redfish']
1608
1609=== modified file 'src/launchpadlib/errors.py'
1610--- src/launchpadlib/errors.py 2009-03-20 20:46:06 +0000
1611+++ src/launchpadlib/errors.py 2009-03-26 21:07:35 +0000
1612@@ -14,50 +14,7 @@
1613 # You should have received a copy of the GNU Lesser General Public License
1614 # along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
1615
1616-"""launchpadlib errors."""
1617-
1618-__metaclass__ = type
1619-__all__ = [
1620- 'CredentialsError',
1621- 'CredentialsFileError',
1622- 'HTTPError',
1623- 'LaunchpadError',
1624- 'ResponseError',
1625- 'UnexpectedResponseError',
1626- ]
1627-
1628-
1629-class LaunchpadError(Exception):
1630- """Base error for the Launchpad API library."""
1631-
1632-
1633-class CredentialsError(LaunchpadError):
1634- """Base credentials/authentication error."""
1635-
1636-
1637-class CredentialsFileError(CredentialsError):
1638- """Error in credentials file."""
1639-
1640-
1641-class ResponseError(LaunchpadError):
1642- """Error in response."""
1643-
1644- def __init__(self, response, content):
1645- LaunchpadError.__init__(self)
1646- self.response = response
1647- self.content = content
1648-
1649-
1650-class UnexpectedResponseError(ResponseError):
1651- """An unexpected response was received."""
1652-
1653- def __str__(self):
1654- return '%s: %s' % (self.response.status, self.response.reason)
1655-
1656-
1657-class HTTPError(ResponseError):
1658- """An HTTP non-2xx response code was received."""
1659-
1660- def __str__(self):
1661- return 'HTTP Error %s: %s' % (
1662- self.response.status, self.response.reason)
1663+
1664+"""Reimport errors from restfulclient for convenience's sake."""
1665+
1666+from lazr.restfulclient.errors import *
1667
1668=== modified file 'src/launchpadlib/launchpad.py'
1669--- src/launchpadlib/launchpad.py 2009-03-23 21:50:35 +0000
1670+++ src/launchpadlib/launchpad.py 2009-03-26 21:07:35 +0000
1671@@ -21,32 +21,64 @@
1672 'Launchpad',
1673 ]
1674
1675-import os
1676-import shutil
1677-import simplejson
1678-import stat
1679 import sys
1680-import tempfile
1681-import urlparse
1682 import webbrowser
1683
1684-from wadllib.application import Resource as WadlResource
1685 from lazr.uri import URI
1686-
1687-from launchpadlib._browser import Browser
1688-from launchpadlib.resource import Resource
1689+from lazr.restfulclient._browser import RestfulHttp
1690+from lazr.restfulclient.resource import (
1691+ CollectionWithKeyBasedLookup, HostedFile, ServiceRoot)
1692 from launchpadlib.credentials import AccessToken, Credentials
1693+from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
1694
1695+OAUTH_REALM = 'https://api.launchpad.net'
1696 STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/beta/'
1697 EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/beta/'
1698
1699-class Launchpad(Resource):
1700+
1701+class PersonSet(CollectionWithKeyBasedLookup):
1702+ """A custom subclass capable of person lookup by username."""
1703+
1704+ def _get_url_from_id(self, key):
1705+ """Transform a username into the URL to a person resource."""
1706+ return str(self._root._root_uri.ensureSlash()) + '~' + str(key)
1707+
1708+
1709+class BugSet(CollectionWithKeyBasedLookup):
1710+ """A custom subclass capable of bug lookup by bug ID."""
1711+
1712+ def _get_url_from_id(self, key):
1713+ """Transform a bug ID into the URL to a bug resource."""
1714+ return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key)
1715+
1716+
1717+class PillarSet(CollectionWithKeyBasedLookup):
1718+ """A custom subclass capable of lookup by pillar name.
1719+
1720+ Projects, project groups, and distributions are all pillars.
1721+ """
1722+
1723+ def _get_url_from_id(self, key):
1724+ """Transform a project name into the URL to a project resource."""
1725+ return str(self._root._root_uri.ensureSlash()) + str(key)
1726+
1727+
1728+class Launchpad(ServiceRoot):
1729 """Root Launchpad API class.
1730
1731 :ivar credentials: The credentials instance used to access Launchpad.
1732 :type credentials: `Credentials`
1733 """
1734
1735+ RESOURCE_TYPE_CLASSES = {
1736+ 'bugs': BugSet,
1737+ 'distributions': PillarSet,
1738+ 'HostedFile': HostedFile,
1739+ 'people': PersonSet,
1740+ 'project_groups': PillarSet,
1741+ 'projects': PillarSet,
1742+ }
1743+
1744 def __init__(self, credentials, service_root=STAGING_SERVICE_ROOT,
1745 cache=None, timeout=None, proxy_info=None):
1746 """Root access to the Launchpad API.
1747@@ -56,34 +88,11 @@
1748 :param service_root: The URL to the root of the web service.
1749 :type service_root: string
1750 """
1751- self._root_uri = URI(service_root)
1752- self.credentials = credentials
1753- # Get the WADL definition.
1754- self._browser = Browser(self.credentials, cache, timeout, proxy_info)
1755- self._wadl = self._browser.get_wadl_application(self._root_uri)
1756-
1757- # Get the root resource.
1758- root_resource = self._wadl.get_resource_by_path('')
1759- bound_root = root_resource.bind(
1760- self._browser.get(root_resource), 'application/json')
1761- super(Launchpad, self).__init__(None, bound_root)
1762-
1763- def load(self, url):
1764- """Load a resource given its URL."""
1765- document = self._browser.get(url)
1766- try:
1767- representation = simplejson.loads(document)
1768- except ValueError:
1769- raise ValueError("%s doesn't serve a JSON document." % url)
1770- type_link = representation.get("resource_type_link")
1771- if type_link is None:
1772- raise ValueError("Couldn't determine the resource type of %s."
1773- % url)
1774- resource_type = self._root._wadl.get_resource_type(type_link)
1775- wadl_resource = WadlResource(self._root._wadl, url, resource_type.tag)
1776- return self._create_bound_resource(
1777- self._root, wadl_resource, representation, 'application/json',
1778- representation_needs_processing=False)
1779+ super(Launchpad, self).__init__(
1780+ credentials, service_root, cache, timeout, proxy_info)
1781+
1782+ def httpFactory(self, credentials, cache, timeout, proxy_info):
1783+ return OAuthSigningHttp(credentials, cache, timeout, proxy_info)
1784
1785 @classmethod
1786 def login(cls, consumer_name, token_string, access_secret,
1787@@ -157,69 +166,27 @@
1788 credentials.exchange_request_token_for_access_token(web_root)
1789 return cls(credentials, service_root, cache, timeout, proxy_info)
1790
1791- @classmethod
1792- def login_with(cls, consumer_name,
1793- service_root=STAGING_SERVICE_ROOT,
1794- launchpadlib_dir=None, timeout=None, proxy_info=None):
1795- """Log in to Launchpad with possibly cached credentials.
1796-
1797- This is a convenience method for either setting up new login
1798- credentials, or re-using existing ones. When a login token is
1799- generated using this method, the resulting credentials will be
1800- saved in the `launchpadlib_dir` directory. If the same
1801- `launchpadlib_dir` is passed in a second time, the credentials
1802- in `launchpadlib_dir` for the consumer will be used
1803- automatically.
1804-
1805- Each consumer has their own credentials per service root in
1806- `launchpadlib_dir`. `launchpadlib_dir` is also used for caching
1807- fetched objects. The cache is per service root, and shared by
1808- all consumers.
1809-
1810- See `Launchpad.get_token_and_login()` for more information about
1811- how new tokens are generated.
1812-
1813- :param consumer_name: The consumer name, as appropriate for the
1814- `Consumer` constructor
1815- :type consumer_name: string
1816- :param service_root: The URL to the root of the web service.
1817- :type service_root: string
1818- :param launchpadlib_dir: The directory where the cache and
1819- credentials are stored.
1820- :type launchpadlib_dir: string
1821- :return: The web service root
1822- :rtype: `Launchpad`
1823-
1824- """
1825- if launchpadlib_dir is None:
1826- home_dir = os.environ['HOME']
1827- launchpadlib_dir = os.path.join(home_dir, '.launchpadlib')
1828- launchpadlib_dir = os.path.expanduser(launchpadlib_dir)
1829- # Each service root has its own cache and credential dirs.
1830- scheme, host_name, path, query, fragment = urlparse.urlsplit(
1831- service_root)
1832- service_root_dir = os.path.join(launchpadlib_dir, host_name)
1833- cache_path = os.path.join(service_root_dir, 'cache')
1834- if not os.path.exists(cache_path):
1835- os.makedirs(cache_path)
1836- credentials_path = os.path.join(service_root_dir, 'credentials')
1837- if not os.path.exists(credentials_path):
1838- os.makedirs(credentials_path)
1839- consumer_credentials_path = os.path.join(
1840- credentials_path, consumer_name)
1841- if os.path.exists(consumer_credentials_path):
1842- credentials = Credentials.load_from_path(
1843- consumer_credentials_path)
1844- launchpad = cls(
1845- credentials, service_root=service_root, cache=cache_path,
1846- timeout=timeout, proxy_info=proxy_info)
1847- else:
1848- launchpad = cls.get_token_and_login(
1849- consumer_name, service_root=service_root, cache=cache_path,
1850- timeout=timeout, proxy_info=proxy_info)
1851- launchpad.credentials.save_to_path(
1852- os.path.join(credentials_path, consumer_name))
1853- os.chmod(
1854- os.path.join(credentials_path, consumer_name),
1855- stat.S_IREAD | stat.S_IWRITE)
1856- return launchpad
1857+
1858+class OAuthSigningHttp(RestfulHttp):
1859+ """A client that signs every outgoing request with OAuth credentials."""
1860+
1861+ def _request(self, conn, host, absolute_uri, request_uri, method, body,
1862+ headers, redirections, cachekey):
1863+ """Sign a request with OAuth credentials before sending it."""
1864+ oauth_request = OAuthRequest.from_consumer_and_token(
1865+ self.restful_credentials.consumer,
1866+ self.restful_credentials.access_token,
1867+ http_url=absolute_uri)
1868+ oauth_request.sign_request(
1869+ OAuthSignatureMethod_PLAINTEXT(),
1870+ self.restful_credentials.consumer,
1871+ self.restful_credentials.access_token)
1872+ if headers.has_key('authorization'):
1873+ # There's an authorization header left over from a
1874+ # previous request that resulted in a redirect. Remove it
1875+ # and start again.
1876+ del headers['authorization']
1877+ headers.update(oauth_request.to_header(OAUTH_REALM))
1878+ return super(OAuthSigningHttp, self)._request(
1879+ conn, host, absolute_uri, request_uri, method, body, headers,
1880+ redirections, cachekey)
1881
1882=== removed file 'src/launchpadlib/resource.py'
1883--- src/launchpadlib/resource.py 2009-03-20 20:46:06 +0000
1884+++ src/launchpadlib/resource.py 1970-01-01 00:00:00 +0000
1885@@ -1,830 +0,0 @@
1886-# Copyright 2008 Canonical Ltd.
1887-
1888-# This file is part of launchpadlib.
1889-#
1890-# launchpadlib is free software: you can redistribute it and/or modify it
1891-# under the terms of the GNU Lesser General Public License as published by the
1892-# Free Software Foundation, version 3 of the License.
1893-#
1894-# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
1895-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
1896-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
1897-# for more details.
1898-#
1899-# You should have received a copy of the GNU Lesser General Public License
1900-# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
1901-
1902-"""Common support for web service resources."""
1903-
1904-__metaclass__ = type
1905-__all__ = [
1906- 'Collection',
1907- 'Entry',
1908- 'NamedOperation',
1909- 'Resource',
1910- ]
1911-
1912-
1913-import cgi
1914-import simplejson
1915-from StringIO import StringIO
1916-import urllib
1917-from urlparse import urlparse
1918-from lazr.uri import URI
1919-
1920-from launchpadlib._json import DatetimeJSONEncoder
1921-from launchpadlib.errors import HTTPError
1922-from wadllib.application import Resource as WadlResource
1923-
1924-
1925-class HeaderDictionary:
1926- """A dictionary that bridges httplib2's and wadllib's expectations.
1927-
1928- httplib2 expects all header dictionary access to give lowercase
1929- header names. wadllib expects to access the header exactly as it's
1930- specified in the WADL file, which means the official HTTP header name.
1931-
1932- This class transforms keys to lowercase before doing a lookup on
1933- the underlying dictionary. That way wadllib can pass in the
1934- official header name and httplib2 will get the lowercased name.
1935- """
1936- def __init__(self, wrapped_dictionary):
1937- self.wrapped_dictionary = wrapped_dictionary
1938-
1939- def get(self, key, default=None):
1940- """Retrieve a value, converting the key to lowercase."""
1941- return self.wrapped_dictionary.get(key.lower())
1942-
1943- def __getitem__(self, key):
1944- """Retrieve a value, converting the key to lowercase."""
1945- missing = object()
1946- value = self.get(key, missing)
1947- if value is missing:
1948- raise KeyError(key)
1949- return value
1950-
1951-
1952-class LaunchpadBase:
1953- """Base class for classes that know about Launchpad."""
1954-
1955- JSON_MEDIA_TYPE = 'application/json'
1956-
1957- def _transform_resources_to_links(self, dictionary):
1958- new_dictionary = {}
1959- for key, value in dictionary.items():
1960- if isinstance(value, Resource):
1961- value = value.self_link
1962- new_dictionary[self._get_external_param_name(key)] = value
1963- return new_dictionary
1964-
1965- def _get_external_param_name(self, param_name):
1966- """Turn a launchpadlib name into something to be sent over HTTP.
1967-
1968- For resources this may involve sticking '_link' or
1969- '_collection_link' on the end of the parameter name. For
1970- arguments to named operations, the parameter name is returned
1971- as is.
1972- """
1973- return param_name
1974-
1975-
1976-class Resource(LaunchpadBase):
1977- """Base class for Launchpad's HTTP resources."""
1978-
1979- def __init__(self, root, wadl_resource):
1980- """Initialize with respect to a wadllib Resource object."""
1981- if root is None:
1982- # This _is_ the root.
1983- root = self
1984- # These values need to be put directly into __dict__ to avoid
1985- # calling __setattr__, which would cause an infinite recursion.
1986- self.__dict__['_root'] = root
1987- self.__dict__['_wadl_resource'] = wadl_resource
1988-
1989- FIND_COLLECTIONS = object()
1990- FIND_ENTRIES = object()
1991- FIND_ATTRIBUTES = object()
1992-
1993- @property
1994- def lp_collections(self):
1995- """Name the collections this resource links to."""
1996- return self._get_parameter_names(self.FIND_COLLECTIONS)
1997-
1998- @property
1999- def lp_entries(self):
2000- """Name the entries this resource links to."""
2001- return self._get_parameter_names(self.FIND_ENTRIES)
2002-
2003- @property
2004- def lp_attributes(self):
2005- """Name this resource's scalar attributes."""
2006- return self._get_parameter_names(self.FIND_ATTRIBUTES)
2007-
2008- @property
2009- def lp_operations(self):
2010- """Name all of this resource's custom operations."""
2011- # This library distinguishes between named operations by the
2012- # value they give for ws.op, not by their WADL names or IDs.
2013- names = []
2014- form_encoded_type = 'application/x-www-form-urlencoded'
2015- for method in self._wadl_resource.method_iter:
2016- name = method.name.lower()
2017- if name == 'get':
2018- params = method.request.params(['query', 'plain'])
2019- elif name == 'post':
2020- definition = method.request.representation_definition(
2021- form_encoded_type).resolve_definition()
2022- params = definition.params(self._wadl_resource)
2023- for param in params:
2024- if param.name == 'ws.op':
2025- names.append(param.fixed_value)
2026- break
2027- return names
2028-
2029- @property
2030- def __members__(self):
2031- """A hook into dir() that returns web service-derived members."""
2032- return self._get_parameter_names(
2033- self.FIND_COLLECTIONS, self.FIND_ENTRIES, self.FIND_ATTRIBUTES)
2034-
2035- __methods__ = lp_operations
2036-
2037- def _get_parameter_names(self, *kinds):
2038- """Retrieve some subset of the resource's parameters."""
2039- names = []
2040- for name in self._wadl_resource.parameter_names(
2041- self.JSON_MEDIA_TYPE):
2042- if name.endswith('_collection_link'):
2043- if self.FIND_COLLECTIONS in kinds:
2044- names.append(name[:-16])
2045- elif (name.endswith('_link')
2046- and name not in ('self_link', 'resource_type_link')):
2047- # launchpadlib_obj.self will work, but is never
2048- # necessary. launchpadlib_obj.resource_type is also
2049- # unneccessary, and won't work anyway because
2050- # resource_type_link points to a WADL description,
2051- # not a normal Launchpad resource.
2052- if self.FIND_ENTRIES in kinds:
2053- names.append(name[:-5])
2054- elif self.FIND_ATTRIBUTES in kinds:
2055- names.append(name)
2056- return names
2057-
2058- def lp_has_parameter(self, param_name):
2059- """Does this resource have a parameter with the given name?"""
2060- return self._get_external_param_name(param_name) is not None
2061-
2062- def lp_get_parameter(self, param_name):
2063- """Get the value of one of the resource's parameters.
2064-
2065- :return: A scalar value if the parameter is not a link. A new
2066- Resource object, whose resource is bound to a
2067- representation, if the parameter is a link.
2068- """
2069- self._ensure_representation()
2070- for suffix in ['_link', '_collection_link']:
2071- param = self._wadl_resource.get_parameter(
2072- param_name + suffix, self.JSON_MEDIA_TYPE)
2073- if param is not None:
2074- if param.get_value() is None:
2075- # This parameter is a link to another object, but
2076- # there's no other object. Return None rather than
2077- # chasing down the nonexistent other object.
2078- return None
2079- linked_resource = param.linked_resource
2080- return self._create_bound_resource(
2081- self._root, linked_resource, param_name=param.name)
2082- param = self._wadl_resource.get_parameter(param_name)
2083- if param is None:
2084- raise KeyError("No such parameter: %s" % param_name)
2085- return param.get_value()
2086-
2087- def lp_get_named_operation(self, operation_name):
2088- """Get a custom operation with the given name.
2089-
2090- :return: A NamedOperation instance that can be called with
2091- appropriate arguments to invoke the operation.
2092- """
2093- params = { 'ws.op' : operation_name }
2094- method = self._wadl_resource.get_method('get', query_params=params)
2095- if method is None:
2096- method = self._wadl_resource.get_method(
2097- 'post', representation_params=params)
2098- if method is None:
2099- raise KeyError("No operation with name: %s" % operation_name)
2100- return NamedOperation(self._root, self, method)
2101-
2102- @classmethod
2103- def _create_bound_resource(
2104- cls, root, resource, representation=None,
2105- representation_media_type='application/json',
2106- representation_needs_processing=True, representation_definition=None,
2107- param_name=None):
2108- """Create a launchpadlib Resource subclass from a wadllib Resource.
2109-
2110- :param resource: The wadllib Resource to wrap.
2111- :param representation: A previously fetched representation of
2112- this resource, to be reused. If not provided, this method
2113- will act just like the Resource constructor.
2114- :param representation_media_type: The media type of any previously
2115- fetched representation.
2116- :param representation_needs_processing: Set to False if the
2117- 'representation' parameter should be used as
2118- is.
2119- :param representation_definition: A wadllib
2120- RepresentationDefinition object describing the structure
2121- of this representation. Used in cases when the representation
2122- isn't the result of sending a standard GET to the resource.
2123- :param param_name: The name of the link that was followed to get
2124- to this resource.
2125- :return: An instance of the appropriate launchpadlib Resource
2126- subclass.
2127- """
2128- # We happen to know that all Launchpad resource types are
2129- # defined in a single document. Turn the resource's type_url
2130- # into an anchor into that document: this is its resource
2131- # type. Then look up a client-side class that corresponds to
2132- # the resource type.
2133- type_url = resource.type_url
2134- resource_type = urlparse(type_url)[-1]
2135- default = Entry
2136- if (type_url.endswith('-page')
2137- or (param_name is not None
2138- and param_name.endswith('_collection_link'))):
2139- default = Collection
2140- r_class = RESOURCE_TYPE_CLASSES.get(resource_type, default)
2141- if representation is not None:
2142- # We've been given a representation. Bind the resource
2143- # immediately.
2144- resource = resource.bind(
2145- representation, representation_media_type,
2146- representation_needs_processing,
2147- representation_definition=representation_definition)
2148- else:
2149- # We'll fetch a representation and bind the resource when
2150- # necessary.
2151- pass
2152- return r_class(root, resource)
2153-
2154- def lp_refresh(self, new_url=None, etag=None):
2155- """Update this resource's representation."""
2156- if new_url is not None:
2157- self._wadl_resource._url = new_url
2158- headers = {}
2159- if etag is not None:
2160- headers['If-None-Match'] = etag
2161- try:
2162- representation = self._root._browser.get(
2163- self._wadl_resource, headers=headers)
2164- except HTTPError, e:
2165- if e.response['status'] == '304':
2166- # The entry wasn't modified. No need to do anything.
2167- return
2168- else:
2169- raise e
2170- # __setattr__ assumes we're setting an attribute of the resource,
2171- # so we manipulate __dict__ directly.
2172- self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
2173- representation, self.JSON_MEDIA_TYPE)
2174-
2175- def __getattr__(self, attr):
2176- """Try to retrive a named operation or parameter of the given name."""
2177- try:
2178- return self.lp_get_parameter(attr)
2179- except KeyError:
2180- pass
2181- try:
2182- return self.lp_get_named_operation(attr)
2183- except KeyError:
2184- raise AttributeError("'%s' object has no attribute '%s'"
2185- % (self.__class__.__name__, attr))
2186-
2187- def _get_external_param_name(self, param_name):
2188- """What's this parameter's name in the underlying representation?"""
2189- for suffix in ['_link', '_collection_link', '']:
2190- name = param_name + suffix
2191- if self._wadl_resource.get_parameter(name):
2192- return name
2193- return None
2194-
2195- def _ensure_representation(self):
2196- """Make sure this resource has a representation fetched."""
2197- if self._wadl_resource.representation is None:
2198- # Get a representation of the linked resource.
2199- representation = self._root._browser.get(self._wadl_resource)
2200- self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
2201- representation, self.JSON_MEDIA_TYPE)
2202-
2203-
2204-class NamedOperation(LaunchpadBase):
2205- """A class for a named operation to be invoked with GET or POST."""
2206-
2207- def __init__(self, root, resource, wadl_method):
2208- """Initialize with respect to a WADL Method object"""
2209- self.root = root
2210- self.resource = resource
2211- self.wadl_method = wadl_method
2212-
2213- def __call__(self, *args, **kwargs):
2214- """Invoke the method and process the result."""
2215- if len(args) > 0:
2216- raise TypeError('Method must be called with keyword args.')
2217- http_method = self.wadl_method.name
2218- args = self._transform_resources_to_links(kwargs)
2219- for key, value in args.items():
2220- args[key] = simplejson.dumps(value, cls=DatetimeJSONEncoder)
2221- if http_method in ('get', 'head', 'delete'):
2222- url = self.wadl_method.build_request_url(**args)
2223- in_representation = ''
2224- extra_headers = {}
2225- else:
2226- url = self.wadl_method.build_request_url()
2227- (media_type,
2228- in_representation) = self.wadl_method.build_representation(
2229- **args)
2230- extra_headers = { 'Content-type' : media_type }
2231- response, content = self.root._browser._request(
2232- url, in_representation, http_method, extra_headers=extra_headers)
2233-
2234- if response.status == 201:
2235- return self._handle_201_response(url, response, content)
2236- else:
2237- if http_method == 'post':
2238- # The method call probably modified this resource in
2239- # an unknown way. Refresh its representation.
2240- self.resource.lp_refresh()
2241- return self._handle_200_response(url, response, content)
2242-
2243- def _handle_201_response(self, url, response, content):
2244- """Handle the creation of a new resource by fetching it."""
2245- wadl_response = self.wadl_method.response.bind(
2246- HeaderDictionary(response))
2247- wadl_parameter = wadl_response.get_parameter('Location')
2248- wadl_resource = wadl_parameter.linked_resource
2249- # Fetch a representation of the new resource.
2250- response, content = self.root._browser._request(
2251- wadl_resource.url)
2252- # Return an instance of the appropriate launchpadlib
2253- # Resource subclass.
2254- return Resource._create_bound_resource(
2255- self.root, wadl_resource, content, response['content-type'])
2256-
2257- def _handle_200_response(self, url, response, content):
2258- """Process the return value of an operation."""
2259- content_type = response['content-type']
2260- # Process the returned content, assuming we know how.
2261- response_definition = self.wadl_method.response
2262- representation_definition = (
2263- response_definition.get_representation_definition(
2264- content_type))
2265-
2266- if representation_definition is None:
2267- # The operation returned a document with nothing
2268- # special about it.
2269- if content_type == self.JSON_MEDIA_TYPE:
2270- return simplejson.loads(content)
2271- # We don't know how to process the content.
2272- return content
2273-
2274- # The operation returned a representation of some
2275- # resource. Instantiate a Resource object for it.
2276- document = simplejson.loads(content)
2277- if "self_link" in document and "resource_type_link" in document:
2278- # The operation returned an entry. Use the self_link and
2279- # resource_type_link of the entry representation to build
2280- # a Resource object of the appropriate type. That way this
2281- # object will support all of the right named operations.
2282- url = document["self_link"]
2283- resource_type = self.root._wadl.get_resource_type(
2284- document["resource_type_link"])
2285- wadl_resource = WadlResource(self.root._wadl, url,
2286- resource_type.tag)
2287- else:
2288- # The operation returned a collection. It's probably an ad
2289- # hoc collection that doesn't correspond to any resource
2290- # type. Instantiate it as a resource backed by the
2291- # representation type defined in the return value, instead
2292- # of a resource type tag.
2293- representation_definition = (
2294- representation_definition.resolve_definition())
2295- wadl_resource = WadlResource(
2296- self.root._wadl, url, representation_definition.tag)
2297-
2298- return Resource._create_bound_resource(
2299- self.root, wadl_resource, document, content_type,
2300- representation_needs_processing=False,
2301- representation_definition=representation_definition)
2302-
2303- def _get_external_param_name(self, param_name):
2304- """Named operation parameter names are sent as is."""
2305- return param_name
2306-
2307-
2308-class Entry(Resource):
2309- """A class for an entry-type resource that can be updated with PATCH."""
2310-
2311- def __init__(self, root, wadl_resource):
2312- super(Entry, self).__init__(root, wadl_resource)
2313- # Initialize this here in a semi-magical way so as to stop a
2314- # particular infinite loop that would follow. Setting
2315- # self._dirty_attributes would call __setattr__(), which would
2316- # turn around immediately and get self._dirty_attributes. If
2317- # this latter was not in the instance dictionary, that would
2318- # end up calling __getattr__(), which would again reference
2319- # self._dirty_attributes. This is where the infloop would
2320- # occur. Poking this directly into self.__dict__ means that
2321- # the check for self._dirty_attributes won't call __getattr__(),
2322- # breaking the cycle.
2323- self.__dict__['_dirty_attributes'] = {}
2324- super(Entry, self).__init__(root, wadl_resource)
2325-
2326- def __repr__(self):
2327- """Return the WADL resource type and the URL to the resource."""
2328- return '<%s at %s>' % (
2329- URI(self.resource_type_link).fragment, self.self_link)
2330-
2331- def __str__(self):
2332- """Return the URL to the resource."""
2333- return self.self_link
2334-
2335- def __getattr__(self, name):
2336- """Try to retrive a parameter of the given name."""
2337- if name != '_dirty_attributes':
2338- if name in self._dirty_attributes:
2339- return self._dirty_attributes[name]
2340- return super(Entry, self).__getattr__(name)
2341-
2342- def __setattr__(self, name, value):
2343- """Set the parameter of the given name."""
2344- if not self.lp_has_parameter(name):
2345- raise AttributeError("'%s' object has no attribute '%s'" %
2346- (self.__class__.__name__, name))
2347- self._dirty_attributes[name] = value
2348-
2349- def lp_refresh(self, new_url=None):
2350- """Update this resource's representation."""
2351- etag = getattr(self, 'http_etag', None)
2352- super(Entry, self).lp_refresh(new_url, etag)
2353- self._dirty_attributes.clear()
2354-
2355- def lp_save(self):
2356- """Save changes to the entry."""
2357- representation = self._transform_resources_to_links(
2358- self._dirty_attributes)
2359-
2360- # If the entry contains an ETag, set the If-Match header
2361- # to that value.
2362- headers = {}
2363- etag = getattr(self, 'http_etag', None)
2364- if etag is not None:
2365- headers['If-Match'] = etag
2366-
2367- # PATCH the new representation to the 'self' link. It's possible that
2368- # this will cause the object to be permanently moved. Catch that
2369- # exception and refresh our representation.
2370- try:
2371- response, content = self._root._browser.patch(
2372- URI(self.self_link), representation, headers)
2373- except HTTPError, error:
2374- if error.response.status == 301:
2375- response = error.response
2376- self.lp_refresh(error.response['location'])
2377- else:
2378- raise
2379- self._dirty_attributes.clear()
2380-
2381- content_type = response['content-type']
2382- if response.status == 209 and content_type == self.JSON_MEDIA_TYPE:
2383- # The server sent back a new representation of the object.
2384- # Use it in preference to the existing representation.
2385- new_representation = simplejson.loads(content)
2386- self._wadl_resource.representation = new_representation
2387- self._wadl_resource.media_type = content_type
2388-
2389-
2390-class Collection(Resource):
2391- """A collection-type resource that supports pagination."""
2392-
2393- def __init__(self, root, wadl_resource):
2394- """Create a collection object."""
2395- super(Collection, self).__init__(root, wadl_resource)
2396-
2397- def __len__(self):
2398- """The number of items in the collection.
2399-
2400- :return: length of the collection
2401- :rtype: int
2402- """
2403- try:
2404- return int(self.total_size)
2405- except AttributeError:
2406- raise TypeError('collection size is not available')
2407-
2408- def __iter__(self):
2409- """Iterate over the items in the collection.
2410-
2411- :return: iterator
2412- :rtype: sequence of `Entry`
2413- """
2414- self._ensure_representation()
2415- current_page = self._wadl_resource.representation
2416- while True:
2417- for resource in self._convert_dicts_to_entries(
2418- current_page.get('entries', {})):
2419- yield resource
2420- next_link = current_page.get('next_collection_link')
2421- if next_link is None:
2422- break
2423- current_page = simplejson.loads(
2424- self._root._browser.get(URI(next_link)))
2425-
2426- def __getitem__(self, key):
2427- """Look up a slice, or a subordinate resource by index.
2428-
2429- To discourage situations where a launchpadlib client fetches
2430- all of an enormous list, all collection slices must have a
2431- definitive end point. For performance reasons, all collection
2432- slices must be indexed from the start of the list rather than
2433- the end.
2434- """
2435- if isinstance(key, slice):
2436- return self._get_slice(key)
2437- else:
2438- # Look up a single item by its position in the list.
2439- found_slice = self._get_slice(slice(key, key+1))
2440- if len(found_slice) != 1:
2441- raise IndexError("list index out of range")
2442- return found_slice[0]
2443-
2444- def _get_slice(self, slice):
2445- """Retrieve a slice of a collection."""
2446- start = slice.start or 0
2447- stop = slice.stop
2448-
2449- if start < 0:
2450- raise ValueError("Collection slices must have a nonnegative "
2451- "start point.")
2452- if stop < 0:
2453- raise ValueError("Collection slices must have a definite, "
2454- "nonnegative end point.")
2455-
2456- existing_representation = self._wadl_resource.representation
2457- if (existing_representation is not None
2458- and start < len(existing_representation['entries'])):
2459- # An optimization: the first page of entries has already
2460- # been loaded. This can happen if this collection is the
2461- # return value of a named operation, or if the client did
2462- # something like check the length of the collection.
2463- #
2464- # Either way, we've already made an HTTP request and
2465- # gotten some entries back. The client has requested a
2466- # slice that includes some of the entries we already have.
2467- # In the best case, we can fulfil the slice immediately,
2468- # without making another HTTP request.
2469- #
2470- # Even if we can't fulfil the entire slice, we can get one
2471- # or more objects from the first page and then have fewer
2472- # objects to retrieve from the server later. This saves us
2473- # time and bandwidth, and it might let us save a whole
2474- # HTTP request.
2475- entry_page = existing_representation['entries']
2476-
2477- first_page_size = len(entry_page)
2478- entry_dicts = entry_page[start:stop]
2479- page_url = existing_representation.get('next_collection_link')
2480- else:
2481- # No part of this collection has been loaded yet, or the
2482- # slice starts beyond the part that has been loaded. We'll
2483- # use our secret knowledge of Launchpad to set a value for
2484- # the ws.start variable. That way we start reading entries
2485- # from the first one we want.
2486- first_page_size = None
2487- entry_dicts = []
2488- page_url = self._with_url_query_variable_set(
2489- self._wadl_resource.url, 'ws.start', start)
2490-
2491- desired_size = stop-start
2492- more_needed = desired_size - len(entry_dicts)
2493-
2494- # Iterate over pages until we have the correct number of entries.
2495- while more_needed > 0 and page_url is not None:
2496- representation = simplejson.loads(
2497- self._root._browser.get(page_url))
2498- current_page_entries = representation['entries']
2499- entry_dicts += current_page_entries[:more_needed]
2500- more_needed = desired_size - len(entry_dicts)
2501-
2502- page_url = representation.get('next_collection_link')
2503- if page_url is None:
2504- # We've gotten the entire collection; there are no
2505- # more entries.
2506- break
2507- if first_page_size is None:
2508- first_page_size = len(current_page_entries)
2509- if more_needed > 0 and more_needed < first_page_size:
2510- # An optimization: it's likely that we need less than
2511- # a full page of entries, because the number we need
2512- # is less than the size of the first page we got.
2513- # Instead of requesting a full-sized page, we'll
2514- # request only the number of entries we think we'll
2515- # need. If we're wrong, there's no problem; we'll just
2516- # keep looping.
2517- page_url = self._with_url_query_variable_set(
2518- page_url, 'ws.size', more_needed)
2519-
2520- if slice.step is not None:
2521- entry_dicts = entry_dicts[::slice.step]
2522-
2523- # Convert entry_dicts into a list of Entry objects.
2524- return [resource for resource
2525- in self._convert_dicts_to_entries(entry_dicts)]
2526-
2527- def _convert_dicts_to_entries(self, entries):
2528- """Convert dictionaries describing entries to Entry objects.
2529-
2530- The dictionaries come from the 'entries' field of the JSON
2531- dictionary you get when you GET a page of a collection. Each
2532- dictionary is the same as you'd get if you sent a GET request
2533- to the corresponding entry resource. So each of these
2534- dictionaries can be treated as a preprocessed representation
2535- of an entry resource, and turned into an Entry instance.
2536-
2537- :yield: A sequence of Entry instances.
2538- """
2539- for entry_dict in entries:
2540- resource_url = entry_dict['self_link']
2541- resource_type_link = entry_dict['resource_type_link']
2542- wadl_application = self._wadl_resource.application
2543- resource_type = wadl_application.get_resource_type(
2544- resource_type_link)
2545- resource = WadlResource(
2546- self._wadl_resource.application, resource_url,
2547- resource_type.tag)
2548- yield Resource._create_bound_resource(
2549- self._root, resource, entry_dict, self.JSON_MEDIA_TYPE,
2550- False)
2551-
2552- def _with_url_query_variable_set(self, url, variable, new_value):
2553- """A helper method to set a query variable in a URL."""
2554- uri = URI(url)
2555- if uri.query is None:
2556- params = {}
2557- else:
2558- params = cgi.parse_qs(uri.query)
2559- params[variable] = str(new_value)
2560- uri.query = urllib.urlencode(params, True)
2561- return str(uri)
2562-
2563-
2564-class CollectionWithKeyBasedLookup(Collection):
2565- """A collection-type resource that supports key-based lookup.
2566-
2567- This collection can be sliced, but any single index passed into
2568- __getitem__ will be treated as a custom lookup key.
2569- """
2570-
2571- def __getitem__(self, key):
2572- """Look up a slice, or a subordinate resource by unique ID."""
2573- if isinstance(key, slice):
2574- return super(CollectionWithKeyBasedLookup, self).__getitem__(key)
2575- try:
2576- url = self._get_url_from_id(key)
2577- except NotImplementedError:
2578- raise TypeError("unsubscriptable object")
2579- if url is None:
2580- raise KeyError(key)
2581-
2582- # We don't know what kind of resource this is. Even the
2583- # subclass doesn't necessarily know, because some resources
2584- # (the person list) are gateways to more than one kind of
2585- # resource (people, and teams). The only way to know for sure
2586- # is to retrieve a representation of the resource and see how
2587- # the resource describes itself.
2588- try:
2589- representation = simplejson.loads(self._root._browser.get(url))
2590- except HTTPError, error:
2591- # There's no resource corresponding to the given ID.
2592- if error.response.status == 404:
2593- raise KeyError(key)
2594- raise
2595- # We know that every Launchpad resource has a 'resource_type_link'
2596- # in its representation.
2597- resource_type_link = representation['resource_type_link']
2598- resource = WadlResource(self._root._wadl, url, resource_type_link)
2599- return self._create_bound_resource(
2600- self._root, resource, representation=representation,
2601- representation_needs_processing=False)
2602-
2603-
2604- def _get_url_from_id(self, key):
2605- """Transform the unique ID of an object into its URL."""
2606- raise NotImplementedError()
2607-
2608-
2609-class HostedFile(Resource):
2610- """A resource represnting a file hosted on Launchpad's server."""
2611-
2612- def open(self, mode='r', content_type=None, filename=None):
2613- """Open the file on the server for read or write access."""
2614- if mode in ('r', 'w'):
2615- return HostedFileBuffer(self, mode, content_type, filename)
2616- else:
2617- raise ValueError("Invalid mode. Supported modes are: r, w")
2618-
2619- def delete(self):
2620- """Delete the file from the server."""
2621- self._root._browser.delete(self._wadl_resource.url)
2622-
2623- def _get_parameter_names(self, *kinds):
2624- """HostedFile objects define no web service parameters."""
2625- return []
2626-
2627-
2628-class HostedFileBuffer(StringIO):
2629- """The contents of a file hosted on Launchpad's server."""
2630- def __init__(self, hosted_file, mode, content_type=None, filename=None):
2631- self.url = hosted_file._wadl_resource.url
2632- if mode == 'r':
2633- if content_type is not None:
2634- raise ValueError("Files opened for read access can't "
2635- "specify content_type.")
2636- if filename is not None:
2637- raise ValueError("Files opened for read access can't "
2638- "specify filename.")
2639- response, value = hosted_file._root._browser.get(
2640- self.url, return_response=True)
2641- content_type = response['content-type']
2642- last_modified = response['last-modified']
2643-
2644- # The Content-Location header contains the URL of the file
2645- # in the Launchpad library. We happen to know that the
2646- # final component of the URL is the name of the uploaded
2647- # file.
2648- content_location = response['content-location']
2649- path = urlparse(content_location)[2]
2650- filename = urllib.unquote(path.split("/")[-1])
2651- elif mode == 'w':
2652- value = ''
2653- if content_type is None:
2654- raise ValueError("Files opened for write access must "
2655- "specify content_type.")
2656- if filename is None:
2657- raise ValueError("Files opened for write access must "
2658- "specify filename.")
2659- last_modified = None
2660- else:
2661- raise ValueError("Invalid mode. Supported modes are: r, w")
2662-
2663- self.hosted_file = hosted_file
2664- self.mode = mode
2665- self.content_type = content_type
2666- self.filename = filename
2667- self.last_modified = last_modified
2668- StringIO.__init__(self, value)
2669-
2670- def close(self):
2671- if self.mode == 'w':
2672- disposition = 'attachment; filename="%s"' % self.filename
2673- self.hosted_file._root._browser.put(
2674- self.url, self.getvalue(),
2675- self.content_type, {'Content-Disposition' : disposition})
2676- StringIO.close(self)
2677-
2678-
2679-class PersonSet(CollectionWithKeyBasedLookup):
2680- """A custom subclass capable of person lookup by username."""
2681-
2682- def _get_url_from_id(self, key):
2683- """Transform a username into the URL to a person resource."""
2684- return str(self._root._root_uri.ensureSlash()) + '~' + str(key)
2685-
2686-
2687-class BugSet(CollectionWithKeyBasedLookup):
2688- """A custom subclass capable of bug lookup by bug ID."""
2689-
2690- def _get_url_from_id(self, key):
2691- """Transform a bug ID into the URL to a bug resource."""
2692- return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key)
2693-
2694-
2695-class PillarSet(CollectionWithKeyBasedLookup):
2696- """A custom subclass capable of lookup by pillar name.
2697-
2698- Projects, project groups, and distributions are all pillars.
2699- """
2700-
2701- def _get_url_from_id(self, key):
2702- """Transform a project name into the URL to a project resource."""
2703- return str(self._root._root_uri.ensureSlash()) + str(key)
2704-
2705-
2706-# A mapping of resource type IDs to the client-side classes that handle
2707-# those resource types.
2708-RESOURCE_TYPE_CLASSES = {
2709- 'bugs': BugSet,
2710- 'distributions': PillarSet,
2711- 'HostedFile': HostedFile,
2712- 'people': PersonSet,
2713- 'project_groups': PillarSet,
2714- 'projects': PillarSet,
2715- }

Subscribers

People subscribed via source and target branches