Merge lp:~leonardr/launchpadlib/trusted-client into lp:~launchpad-pqm/launchpadlib/devel
- trusted-client
- Merge into devel
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Aaron Bentley (community) | Needs Fixing | ||
Review via email: mp+8012@code.launchpad.net |
Commit message
Description of the change
Leonard Richardson (leonardr) wrote : | # |
Aaron Bentley (abentley) wrote : | # |
As discussed in IRC, using this diff: https:/
- 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-
- please separate standard library imports like sys from external imports like simplejson
- consider limiting the number of password prompts to 3.
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
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
523 | + print self.ALREADY_APPROVED % self.default_args |
524 | + self.pressEnterToExit() |
525 | + if not 'Almost finished' in content: |
526 | |
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 | |
555 | + print self.BAD_CREDENTIALS |
556 | + continue |
557 | + if response.get('content-type') != 'application/json': |
558 | |
559 | + print self.SERVER_ERROR |
560 | + self.pressEnterToExit() |
561 | + token_info = simplejson.loads(content) |
562 | + |
563 | |
564 | + self._checkConsumer(token_info) |
565 | + access_level = self.chooseAccessLevel(token_info) |
566 | |
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 | |
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 | - } |
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