Merge lp:~leonardr/launchpadlib/uses-restfulclient into lp:~launchpad-pqm/launchpadlib/devel

Proposed by Leonard Richardson
Status: Merged
Merge reported by: Gavin Panella
Merged at revision: not available
Proposed branch: lp:~leonardr/launchpadlib/uses-restfulclient
Merge into: lp:~launchpad-pqm/launchpadlib/devel
Diff against target: None lines
To merge this branch: bzr merge lp:~leonardr/launchpadlib/uses-restfulclient
Reviewer Review Type Date Requested Status
launchpadlib developers Pending
Review via email: mp+5348@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

Diff: https://pastebin.canonical.com/16096/
Test: ./test.py -vvt launchpadlib (within a Launchpad checkout with a hacked launchpadlib link)

This branch removes almost all the code from launchpadlib, in favor of
the equivalent code from lazr.restfulclient.

The vast majority of the diff is code removal. Here are the more
interesting parts:

PersonSet, BugSet, etc. have been moved to
launchpad.py. RESOURCE_TYPE_CLASSES, which maps WADL resource types to
these classes, has been made a member of the Launchpad class.

The way launchpadlib intercepts outgoing HTTP requests to sign them
with OAuth has been changed to fit into lazr.restfulclient's
factory-based system.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'setup.py'
2--- setup.py 2009-03-23 22:48:16 +0000
3+++ setup.py 2009-03-30 17:17:08 +0000
4@@ -60,6 +60,7 @@
5 license='LGPL v3',
6 install_requires=[
7 'httplib2',
8+ 'lazr.restfulclient',
9 'lazr.uri',
10 'oauth',
11 'setuptools',
12
13=== removed file 'src/launchpadlib/_browser.py'
14--- src/launchpadlib/_browser.py 2009-03-20 20:46:06 +0000
15+++ src/launchpadlib/_browser.py 1970-01-01 00:00:00 +0000
16@@ -1,265 +0,0 @@
17-# Copyright 2008 Canonical Ltd.
18-
19-# This file is part of launchpadlib.
20-#
21-# launchpadlib is free software: you can redistribute it and/or modify it
22-# under the terms of the GNU Lesser General Public License as published by the
23-# Free Software Foundation, version 3 of the License.
24-#
25-# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
26-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
27-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
28-# for more details.
29-#
30-# You should have received a copy of the GNU Lesser General Public License
31-# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
32-
33-"""Browser object to make requests of Launchpad web service.
34-
35-The `Browser` class implements OAuth authenticated communications with
36-Launchpad. It is not part of the public launchpadlib API.
37-"""
38-
39-__metaclass__ = type
40-__all__ = [
41- 'Browser',
42- ]
43-
44-
45-import atexit
46-from cStringIO import StringIO
47-import gzip
48-from httplib2 import (
49- FailedToDecompressContent, FileCache, Http, safename, urlnorm)
50-from lazr.uri import URI
51-from oauth.oauth import (
52- OAuthRequest, OAuthSignatureMethod_PLAINTEXT)
53-import shutil
54-import simplejson
55-import tempfile
56-from urllib import urlencode
57-from wadllib.application import Application
58-import zlib
59-
60-from launchpadlib.errors import HTTPError
61-from launchpadlib._json import DatetimeJSONEncoder
62-
63-
64-OAUTH_REALM = 'https://api.launchpad.net'
65-
66-# A drop-in replacement for httplib2's _decompressContent, which looks
67-# in the Transfer-Encoding header instead of in Content-Encoding.
68-def _decompressContent(response, new_content):
69- content = new_content
70- try:
71- encoding = response.get('transfer-encoding', None)
72- if encoding in ['gzip', 'deflate']:
73- if encoding == 'gzip':
74- content = gzip.GzipFile(
75- fileobj=StringIO.StringIO(new_content)).read()
76- if encoding == 'deflate':
77- content = zlib.decompress(content)
78- response['content-length'] = str(len(content))
79- del response['transfer-encoding']
80- except IOError:
81- content = ""
82- raise FailedToDecompressContent(
83- ("Content purported to be compressed with %s but failed "
84- "to decompress." % response.get('transfer-encoding')),
85- response, content)
86- return content
87-
88-
89-class OAuthSigningHttp(Http):
90- """A client that signs every outgoing request with OAuth credentials."""
91-
92- def __init__(self, oauth_credentials, cache=None, timeout=None,
93- proxy_info=None):
94- self.oauth_credentials = oauth_credentials
95- Http.__init__(self, cache, timeout, proxy_info)
96-
97- def _request(self, conn, host, absolute_uri, request_uri, method, body,
98- headers, redirections, cachekey):
99- """Sign a request with OAuth credentials before sending it."""
100- oauth_request = OAuthRequest.from_consumer_and_token(
101- self.oauth_credentials.consumer,
102- self.oauth_credentials.access_token,
103- http_url=absolute_uri)
104- oauth_request.sign_request(
105- OAuthSignatureMethod_PLAINTEXT(),
106- self.oauth_credentials.consumer,
107- self.oauth_credentials.access_token)
108- if headers.has_key('authorization'):
109- # There's an authorization header left over from a
110- # previous request that resulted in a redirect. Remove it
111- # and start again.
112- del headers['authorization']
113-
114- # httplib2 asks for compressed representations in
115- # Accept-Encoding. But a different content-encoding means a
116- # different ETag, which can cause problems later when we make
117- # a conditional request. We don't want to treat a
118- # representation differently based on whether or not we asked
119- # for a compressed version of it.
120- #
121- # So we move the compression request from Accept-Encoding to
122- # TE. Transfer-encoding compression can be handled transparently.
123- if 'accept-encoding' in headers:
124- headers['te'] = 'deflate, gzip'
125- del headers['accept-encoding']
126- headers.update(oauth_request.to_header(OAUTH_REALM))
127- return super(OAuthSigningHttp, self)._request(
128- conn, host, absolute_uri, request_uri, method, body, headers,
129- redirections, cachekey)
130-
131- def _conn_request(self, conn, request_uri, method, body, headers):
132- """Decompress content using our version of _decompressContent."""
133- response, content = super(OAuthSigningHttp, self)._conn_request(
134- conn, request_uri, method, body, headers)
135- # Decompress the response, if it was compressed.
136- if method != "HEAD":
137- content = _decompressContent(response, content)
138- return (response, content)
139-
140- def _getCachedHeader(self, uri, header):
141- """Retrieve a cached value for an HTTP header."""
142- if isinstance(self.cache, MultipleRepresentationCache):
143- return self.cache._getCachedHeader(uri, header)
144- return None
145-
146-
147-class MultipleRepresentationCache(FileCache):
148- """A cache that can hold different representations of the same resource.
149-
150- If a resource has two representations with two media types,
151- FileCache will only store the most recently fetched
152- representation. This cache can keep track of multiple
153- representations of the same resource.
154-
155- This class works on the assumption that outside calling code sets
156- an instance's request_media_type attribute to the value of the
157- 'Accept' header before initiating the request.
158-
159- This class is very much not thread-safe, but FileCache isn't
160- thread-safe anyway.
161- """
162- def __init__(self, cache):
163- """Tell FileCache to call append_media_type when generating keys."""
164- super(MultipleRepresentationCache, self).__init__(
165- cache, self.append_media_type)
166- self.request_media_type = None
167-
168- def append_media_type(self, key):
169- """Append the request media type to the cache key.
170-
171- This ensures that representations of the same resource will be
172- cached separately, so long as they're served as different
173- media types.
174- """
175- if self.request_media_type is not None:
176- key = key + '-' + self.request_media_type
177- return safename(key)
178-
179-
180- def _getCachedHeader(self, uri, header):
181- """Retrieve a cached value for an HTTP header."""
182- (scheme, authority, request_uri, cachekey) = urlnorm(uri)
183- cached_value = self.get(cachekey)
184- header_start = header + ':'
185- if cached_value is not None:
186- for line in StringIO(cached_value):
187- if line.startswith(header_start):
188- return line[len(header_start):].strip()
189- return None
190-
191-
192-class Browser:
193- """A class for making calls to Launchpad web services."""
194-
195- def __init__(self, credentials, cache=None, timeout=None,
196- proxy_info=None):
197- """Initialize, possibly creating a cache.
198-
199- If no cache is provided, a temporary directory will be used as
200- a cache. The temporary directory will be automatically removed
201- when the Python process exits.
202- """
203- if cache is None:
204- cache = tempfile.mkdtemp()
205- atexit.register(shutil.rmtree, cache)
206- if isinstance(cache, str):
207- cache = MultipleRepresentationCache(cache)
208- self._connection = OAuthSigningHttp(
209- credentials, cache, timeout, proxy_info)
210-
211- def _request(self, url, data=None, method='GET',
212- media_type='application/json', extra_headers=None):
213- """Create an authenticated request object."""
214- # Add extra headers for the request.
215- headers = {'Accept' : media_type}
216- if isinstance(self._connection.cache, MultipleRepresentationCache):
217- self._connection.cache.request_media_type = media_type
218- if extra_headers is not None:
219- headers.update(extra_headers)
220- # Make the request. It will be signed automatically when
221- # _request is called.
222- response, content = self._connection.request(
223- str(url), method=method, body=data, headers=headers)
224- # Turn non-2xx responses into exceptions.
225- if response.status // 100 != 2:
226- raise HTTPError(response, content)
227- return response, content
228-
229- def get(self, resource_or_uri, headers=None, return_response=False):
230- """GET a representation of the given resource or URI."""
231- if isinstance(resource_or_uri, (basestring, URI)):
232- url = resource_or_uri
233- else:
234- method = resource_or_uri.get_method('get')
235- url = method.build_request_url()
236- response, content = self._request(url, extra_headers=headers)
237- if return_response:
238- return (response, content)
239- return content
240-
241- def get_wadl_application(self, url):
242- """GET a WADL representation of the resource at the requested url."""
243- response, content = self._request(
244- url, media_type='application/vd.sun.wadl+xml')
245- return Application(str(url), content)
246-
247- def post(self, url, method_name, **kws):
248- """POST a request to the web service."""
249- kws['ws.op'] = method_name
250- data = urlencode(kws)
251- return self._request(url, data, 'POST')
252-
253- def put(self, url, representation, media_type, headers=None):
254- """PUT the given representation to the URL."""
255- extra_headers = {'Content-Type': media_type}
256- if headers is not None:
257- extra_headers.update(headers)
258- return self._request(
259- url, representation, 'PUT', extra_headers=extra_headers)
260-
261- def delete(self, url):
262- """DELETE the resource at the given URL."""
263- self._request(url, method='DELETE')
264-
265- def patch(self, url, representation, headers=None):
266- """PATCH the object at url with the updated representation."""
267- extra_headers = {'Content-Type': 'application/json'}
268- if headers is not None:
269- extra_headers.update(headers)
270- # httplib2 doesn't know about the PATCH method, so we need to
271- # do some work ourselves. Pull any cached value of "ETag" out
272- # and use it as the value for "If-Match".
273- cached_etag = self._connection._getCachedHeader(str(url), 'etag')
274- if cached_etag is not None and not self._connection.ignore_etag:
275- # http://www.w3.org/1999/04/Editing/
276- headers['If-Match'] = cached_etag
277-
278- return self._request(
279- url, simplejson.dumps(representation,
280- cls=DatetimeJSONEncoder),
281- 'PATCH', extra_headers=extra_headers)
282
283=== removed file 'src/launchpadlib/_json.py'
284--- src/launchpadlib/_json.py 2009-03-20 20:46:06 +0000
285+++ src/launchpadlib/_json.py 1970-01-01 00:00:00 +0000
286@@ -1,33 +0,0 @@
287-# Copyright 2009 Canonical Ltd.
288-
289-# This file is part of launchpadlib.
290-#
291-# launchpadlib is free software: you can redistribute it and/or modify it
292-# under the terms of the GNU Lesser General Public License as published by the
293-# Free Software Foundation, version 3 of the License.
294-#
295-# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
296-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
297-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
298-# for more details.
299-#
300-# You should have received a copy of the GNU Lesser General Public License
301-# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
302-
303-"""Classes for working with JSON."""
304-
305-__metaclass__ = type
306-__all__ = ['DatetimeJSONEncoder']
307-
308-import datetime
309-import simplejson
310-
311-class DatetimeJSONEncoder(simplejson.JSONEncoder):
312- """A JSON encoder that understands datetime objects.
313-
314- Datetime objects are formatted according to ISO 1601.
315- """
316- def default(self, obj):
317- if isinstance(obj, datetime.datetime):
318- return obj.isoformat()
319- return simplejson.JSONEncoder.default(self, obj)
320
321=== modified file 'src/launchpadlib/credentials.py'
322--- src/launchpadlib/credentials.py 2009-03-23 21:50:35 +0000
323+++ src/launchpadlib/credentials.py 2009-03-26 21:07:35 +0000
324@@ -29,7 +29,7 @@
325 from oauth.oauth import OAuthConsumer, OAuthToken
326 from urllib import urlencode
327
328-from launchpadlib.errors import CredentialsFileError, HTTPError
329+from lazr.restfulclient.errors import CredentialsFileError, HTTPError
330
331
332 CREDENTIALS_FILE_VERSION = '1'
333
334=== modified file 'src/launchpadlib/errors.py'
335--- src/launchpadlib/errors.py 2009-03-20 20:46:06 +0000
336+++ src/launchpadlib/errors.py 2009-03-26 21:07:35 +0000
337@@ -14,50 +14,7 @@
338 # You should have received a copy of the GNU Lesser General Public License
339 # along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
340
341-"""launchpadlib errors."""
342-
343-__metaclass__ = type
344-__all__ = [
345- 'CredentialsError',
346- 'CredentialsFileError',
347- 'HTTPError',
348- 'LaunchpadError',
349- 'ResponseError',
350- 'UnexpectedResponseError',
351- ]
352-
353-
354-class LaunchpadError(Exception):
355- """Base error for the Launchpad API library."""
356-
357-
358-class CredentialsError(LaunchpadError):
359- """Base credentials/authentication error."""
360-
361-
362-class CredentialsFileError(CredentialsError):
363- """Error in credentials file."""
364-
365-
366-class ResponseError(LaunchpadError):
367- """Error in response."""
368-
369- def __init__(self, response, content):
370- LaunchpadError.__init__(self)
371- self.response = response
372- self.content = content
373-
374-
375-class UnexpectedResponseError(ResponseError):
376- """An unexpected response was received."""
377-
378- def __str__(self):
379- return '%s: %s' % (self.response.status, self.response.reason)
380-
381-
382-class HTTPError(ResponseError):
383- """An HTTP non-2xx response code was received."""
384-
385- def __str__(self):
386- return 'HTTP Error %s: %s' % (
387- self.response.status, self.response.reason)
388+
389+"""Reimport errors from restfulclient for convenience's sake."""
390+
391+from lazr.restfulclient.errors import *
392
393=== modified file 'src/launchpadlib/launchpad.py'
394--- src/launchpadlib/launchpad.py 2009-03-23 21:50:35 +0000
395+++ src/launchpadlib/launchpad.py 2009-03-26 21:07:35 +0000
396@@ -21,32 +21,64 @@
397 'Launchpad',
398 ]
399
400-import os
401-import shutil
402-import simplejson
403-import stat
404 import sys
405-import tempfile
406-import urlparse
407 import webbrowser
408
409-from wadllib.application import Resource as WadlResource
410 from lazr.uri import URI
411-
412-from launchpadlib._browser import Browser
413-from launchpadlib.resource import Resource
414+from lazr.restfulclient._browser import RestfulHttp
415+from lazr.restfulclient.resource import (
416+ CollectionWithKeyBasedLookup, HostedFile, ServiceRoot)
417 from launchpadlib.credentials import AccessToken, Credentials
418+from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
419
420+OAUTH_REALM = 'https://api.launchpad.net'
421 STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/beta/'
422 EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/beta/'
423
424-class Launchpad(Resource):
425+
426+class PersonSet(CollectionWithKeyBasedLookup):
427+ """A custom subclass capable of person lookup by username."""
428+
429+ def _get_url_from_id(self, key):
430+ """Transform a username into the URL to a person resource."""
431+ return str(self._root._root_uri.ensureSlash()) + '~' + str(key)
432+
433+
434+class BugSet(CollectionWithKeyBasedLookup):
435+ """A custom subclass capable of bug lookup by bug ID."""
436+
437+ def _get_url_from_id(self, key):
438+ """Transform a bug ID into the URL to a bug resource."""
439+ return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key)
440+
441+
442+class PillarSet(CollectionWithKeyBasedLookup):
443+ """A custom subclass capable of lookup by pillar name.
444+
445+ Projects, project groups, and distributions are all pillars.
446+ """
447+
448+ def _get_url_from_id(self, key):
449+ """Transform a project name into the URL to a project resource."""
450+ return str(self._root._root_uri.ensureSlash()) + str(key)
451+
452+
453+class Launchpad(ServiceRoot):
454 """Root Launchpad API class.
455
456 :ivar credentials: The credentials instance used to access Launchpad.
457 :type credentials: `Credentials`
458 """
459
460+ RESOURCE_TYPE_CLASSES = {
461+ 'bugs': BugSet,
462+ 'distributions': PillarSet,
463+ 'HostedFile': HostedFile,
464+ 'people': PersonSet,
465+ 'project_groups': PillarSet,
466+ 'projects': PillarSet,
467+ }
468+
469 def __init__(self, credentials, service_root=STAGING_SERVICE_ROOT,
470 cache=None, timeout=None, proxy_info=None):
471 """Root access to the Launchpad API.
472@@ -56,34 +88,11 @@
473 :param service_root: The URL to the root of the web service.
474 :type service_root: string
475 """
476- self._root_uri = URI(service_root)
477- self.credentials = credentials
478- # Get the WADL definition.
479- self._browser = Browser(self.credentials, cache, timeout, proxy_info)
480- self._wadl = self._browser.get_wadl_application(self._root_uri)
481-
482- # Get the root resource.
483- root_resource = self._wadl.get_resource_by_path('')
484- bound_root = root_resource.bind(
485- self._browser.get(root_resource), 'application/json')
486- super(Launchpad, self).__init__(None, bound_root)
487-
488- def load(self, url):
489- """Load a resource given its URL."""
490- document = self._browser.get(url)
491- try:
492- representation = simplejson.loads(document)
493- except ValueError:
494- raise ValueError("%s doesn't serve a JSON document." % url)
495- type_link = representation.get("resource_type_link")
496- if type_link is None:
497- raise ValueError("Couldn't determine the resource type of %s."
498- % url)
499- resource_type = self._root._wadl.get_resource_type(type_link)
500- wadl_resource = WadlResource(self._root._wadl, url, resource_type.tag)
501- return self._create_bound_resource(
502- self._root, wadl_resource, representation, 'application/json',
503- representation_needs_processing=False)
504+ super(Launchpad, self).__init__(
505+ credentials, service_root, cache, timeout, proxy_info)
506+
507+ def httpFactory(self, credentials, cache, timeout, proxy_info):
508+ return OAuthSigningHttp(credentials, cache, timeout, proxy_info)
509
510 @classmethod
511 def login(cls, consumer_name, token_string, access_secret,
512@@ -157,69 +166,27 @@
513 credentials.exchange_request_token_for_access_token(web_root)
514 return cls(credentials, service_root, cache, timeout, proxy_info)
515
516- @classmethod
517- def login_with(cls, consumer_name,
518- service_root=STAGING_SERVICE_ROOT,
519- launchpadlib_dir=None, timeout=None, proxy_info=None):
520- """Log in to Launchpad with possibly cached credentials.
521-
522- This is a convenience method for either setting up new login
523- credentials, or re-using existing ones. When a login token is
524- generated using this method, the resulting credentials will be
525- saved in the `launchpadlib_dir` directory. If the same
526- `launchpadlib_dir` is passed in a second time, the credentials
527- in `launchpadlib_dir` for the consumer will be used
528- automatically.
529-
530- Each consumer has their own credentials per service root in
531- `launchpadlib_dir`. `launchpadlib_dir` is also used for caching
532- fetched objects. The cache is per service root, and shared by
533- all consumers.
534-
535- See `Launchpad.get_token_and_login()` for more information about
536- how new tokens are generated.
537-
538- :param consumer_name: The consumer name, as appropriate for the
539- `Consumer` constructor
540- :type consumer_name: string
541- :param service_root: The URL to the root of the web service.
542- :type service_root: string
543- :param launchpadlib_dir: The directory where the cache and
544- credentials are stored.
545- :type launchpadlib_dir: string
546- :return: The web service root
547- :rtype: `Launchpad`
548-
549- """
550- if launchpadlib_dir is None:
551- home_dir = os.environ['HOME']
552- launchpadlib_dir = os.path.join(home_dir, '.launchpadlib')
553- launchpadlib_dir = os.path.expanduser(launchpadlib_dir)
554- # Each service root has its own cache and credential dirs.
555- scheme, host_name, path, query, fragment = urlparse.urlsplit(
556- service_root)
557- service_root_dir = os.path.join(launchpadlib_dir, host_name)
558- cache_path = os.path.join(service_root_dir, 'cache')
559- if not os.path.exists(cache_path):
560- os.makedirs(cache_path)
561- credentials_path = os.path.join(service_root_dir, 'credentials')
562- if not os.path.exists(credentials_path):
563- os.makedirs(credentials_path)
564- consumer_credentials_path = os.path.join(
565- credentials_path, consumer_name)
566- if os.path.exists(consumer_credentials_path):
567- credentials = Credentials.load_from_path(
568- consumer_credentials_path)
569- launchpad = cls(
570- credentials, service_root=service_root, cache=cache_path,
571- timeout=timeout, proxy_info=proxy_info)
572- else:
573- launchpad = cls.get_token_and_login(
574- consumer_name, service_root=service_root, cache=cache_path,
575- timeout=timeout, proxy_info=proxy_info)
576- launchpad.credentials.save_to_path(
577- os.path.join(credentials_path, consumer_name))
578- os.chmod(
579- os.path.join(credentials_path, consumer_name),
580- stat.S_IREAD | stat.S_IWRITE)
581- return launchpad
582+
583+class OAuthSigningHttp(RestfulHttp):
584+ """A client that signs every outgoing request with OAuth credentials."""
585+
586+ def _request(self, conn, host, absolute_uri, request_uri, method, body,
587+ headers, redirections, cachekey):
588+ """Sign a request with OAuth credentials before sending it."""
589+ oauth_request = OAuthRequest.from_consumer_and_token(
590+ self.restful_credentials.consumer,
591+ self.restful_credentials.access_token,
592+ http_url=absolute_uri)
593+ oauth_request.sign_request(
594+ OAuthSignatureMethod_PLAINTEXT(),
595+ self.restful_credentials.consumer,
596+ self.restful_credentials.access_token)
597+ if headers.has_key('authorization'):
598+ # There's an authorization header left over from a
599+ # previous request that resulted in a redirect. Remove it
600+ # and start again.
601+ del headers['authorization']
602+ headers.update(oauth_request.to_header(OAUTH_REALM))
603+ return super(OAuthSigningHttp, self)._request(
604+ conn, host, absolute_uri, request_uri, method, body, headers,
605+ redirections, cachekey)
606
607=== removed file 'src/launchpadlib/resource.py'
608--- src/launchpadlib/resource.py 2009-03-20 20:46:06 +0000
609+++ src/launchpadlib/resource.py 1970-01-01 00:00:00 +0000
610@@ -1,830 +0,0 @@
611-# Copyright 2008 Canonical Ltd.
612-
613-# This file is part of launchpadlib.
614-#
615-# launchpadlib is free software: you can redistribute it and/or modify it
616-# under the terms of the GNU Lesser General Public License as published by the
617-# Free Software Foundation, version 3 of the License.
618-#
619-# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
620-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
621-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
622-# for more details.
623-#
624-# You should have received a copy of the GNU Lesser General Public License
625-# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
626-
627-"""Common support for web service resources."""
628-
629-__metaclass__ = type
630-__all__ = [
631- 'Collection',
632- 'Entry',
633- 'NamedOperation',
634- 'Resource',
635- ]
636-
637-
638-import cgi
639-import simplejson
640-from StringIO import StringIO
641-import urllib
642-from urlparse import urlparse
643-from lazr.uri import URI
644-
645-from launchpadlib._json import DatetimeJSONEncoder
646-from launchpadlib.errors import HTTPError
647-from wadllib.application import Resource as WadlResource
648-
649-
650-class HeaderDictionary:
651- """A dictionary that bridges httplib2's and wadllib's expectations.
652-
653- httplib2 expects all header dictionary access to give lowercase
654- header names. wadllib expects to access the header exactly as it's
655- specified in the WADL file, which means the official HTTP header name.
656-
657- This class transforms keys to lowercase before doing a lookup on
658- the underlying dictionary. That way wadllib can pass in the
659- official header name and httplib2 will get the lowercased name.
660- """
661- def __init__(self, wrapped_dictionary):
662- self.wrapped_dictionary = wrapped_dictionary
663-
664- def get(self, key, default=None):
665- """Retrieve a value, converting the key to lowercase."""
666- return self.wrapped_dictionary.get(key.lower())
667-
668- def __getitem__(self, key):
669- """Retrieve a value, converting the key to lowercase."""
670- missing = object()
671- value = self.get(key, missing)
672- if value is missing:
673- raise KeyError(key)
674- return value
675-
676-
677-class LaunchpadBase:
678- """Base class for classes that know about Launchpad."""
679-
680- JSON_MEDIA_TYPE = 'application/json'
681-
682- def _transform_resources_to_links(self, dictionary):
683- new_dictionary = {}
684- for key, value in dictionary.items():
685- if isinstance(value, Resource):
686- value = value.self_link
687- new_dictionary[self._get_external_param_name(key)] = value
688- return new_dictionary
689-
690- def _get_external_param_name(self, param_name):
691- """Turn a launchpadlib name into something to be sent over HTTP.
692-
693- For resources this may involve sticking '_link' or
694- '_collection_link' on the end of the parameter name. For
695- arguments to named operations, the parameter name is returned
696- as is.
697- """
698- return param_name
699-
700-
701-class Resource(LaunchpadBase):
702- """Base class for Launchpad's HTTP resources."""
703-
704- def __init__(self, root, wadl_resource):
705- """Initialize with respect to a wadllib Resource object."""
706- if root is None:
707- # This _is_ the root.
708- root = self
709- # These values need to be put directly into __dict__ to avoid
710- # calling __setattr__, which would cause an infinite recursion.
711- self.__dict__['_root'] = root
712- self.__dict__['_wadl_resource'] = wadl_resource
713-
714- FIND_COLLECTIONS = object()
715- FIND_ENTRIES = object()
716- FIND_ATTRIBUTES = object()
717-
718- @property
719- def lp_collections(self):
720- """Name the collections this resource links to."""
721- return self._get_parameter_names(self.FIND_COLLECTIONS)
722-
723- @property
724- def lp_entries(self):
725- """Name the entries this resource links to."""
726- return self._get_parameter_names(self.FIND_ENTRIES)
727-
728- @property
729- def lp_attributes(self):
730- """Name this resource's scalar attributes."""
731- return self._get_parameter_names(self.FIND_ATTRIBUTES)
732-
733- @property
734- def lp_operations(self):
735- """Name all of this resource's custom operations."""
736- # This library distinguishes between named operations by the
737- # value they give for ws.op, not by their WADL names or IDs.
738- names = []
739- form_encoded_type = 'application/x-www-form-urlencoded'
740- for method in self._wadl_resource.method_iter:
741- name = method.name.lower()
742- if name == 'get':
743- params = method.request.params(['query', 'plain'])
744- elif name == 'post':
745- definition = method.request.representation_definition(
746- form_encoded_type).resolve_definition()
747- params = definition.params(self._wadl_resource)
748- for param in params:
749- if param.name == 'ws.op':
750- names.append(param.fixed_value)
751- break
752- return names
753-
754- @property
755- def __members__(self):
756- """A hook into dir() that returns web service-derived members."""
757- return self._get_parameter_names(
758- self.FIND_COLLECTIONS, self.FIND_ENTRIES, self.FIND_ATTRIBUTES)
759-
760- __methods__ = lp_operations
761-
762- def _get_parameter_names(self, *kinds):
763- """Retrieve some subset of the resource's parameters."""
764- names = []
765- for name in self._wadl_resource.parameter_names(
766- self.JSON_MEDIA_TYPE):
767- if name.endswith('_collection_link'):
768- if self.FIND_COLLECTIONS in kinds:
769- names.append(name[:-16])
770- elif (name.endswith('_link')
771- and name not in ('self_link', 'resource_type_link')):
772- # launchpadlib_obj.self will work, but is never
773- # necessary. launchpadlib_obj.resource_type is also
774- # unneccessary, and won't work anyway because
775- # resource_type_link points to a WADL description,
776- # not a normal Launchpad resource.
777- if self.FIND_ENTRIES in kinds:
778- names.append(name[:-5])
779- elif self.FIND_ATTRIBUTES in kinds:
780- names.append(name)
781- return names
782-
783- def lp_has_parameter(self, param_name):
784- """Does this resource have a parameter with the given name?"""
785- return self._get_external_param_name(param_name) is not None
786-
787- def lp_get_parameter(self, param_name):
788- """Get the value of one of the resource's parameters.
789-
790- :return: A scalar value if the parameter is not a link. A new
791- Resource object, whose resource is bound to a
792- representation, if the parameter is a link.
793- """
794- self._ensure_representation()
795- for suffix in ['_link', '_collection_link']:
796- param = self._wadl_resource.get_parameter(
797- param_name + suffix, self.JSON_MEDIA_TYPE)
798- if param is not None:
799- if param.get_value() is None:
800- # This parameter is a link to another object, but
801- # there's no other object. Return None rather than
802- # chasing down the nonexistent other object.
803- return None
804- linked_resource = param.linked_resource
805- return self._create_bound_resource(
806- self._root, linked_resource, param_name=param.name)
807- param = self._wadl_resource.get_parameter(param_name)
808- if param is None:
809- raise KeyError("No such parameter: %s" % param_name)
810- return param.get_value()
811-
812- def lp_get_named_operation(self, operation_name):
813- """Get a custom operation with the given name.
814-
815- :return: A NamedOperation instance that can be called with
816- appropriate arguments to invoke the operation.
817- """
818- params = { 'ws.op' : operation_name }
819- method = self._wadl_resource.get_method('get', query_params=params)
820- if method is None:
821- method = self._wadl_resource.get_method(
822- 'post', representation_params=params)
823- if method is None:
824- raise KeyError("No operation with name: %s" % operation_name)
825- return NamedOperation(self._root, self, method)
826-
827- @classmethod
828- def _create_bound_resource(
829- cls, root, resource, representation=None,
830- representation_media_type='application/json',
831- representation_needs_processing=True, representation_definition=None,
832- param_name=None):
833- """Create a launchpadlib Resource subclass from a wadllib Resource.
834-
835- :param resource: The wadllib Resource to wrap.
836- :param representation: A previously fetched representation of
837- this resource, to be reused. If not provided, this method
838- will act just like the Resource constructor.
839- :param representation_media_type: The media type of any previously
840- fetched representation.
841- :param representation_needs_processing: Set to False if the
842- 'representation' parameter should be used as
843- is.
844- :param representation_definition: A wadllib
845- RepresentationDefinition object describing the structure
846- of this representation. Used in cases when the representation
847- isn't the result of sending a standard GET to the resource.
848- :param param_name: The name of the link that was followed to get
849- to this resource.
850- :return: An instance of the appropriate launchpadlib Resource
851- subclass.
852- """
853- # We happen to know that all Launchpad resource types are
854- # defined in a single document. Turn the resource's type_url
855- # into an anchor into that document: this is its resource
856- # type. Then look up a client-side class that corresponds to
857- # the resource type.
858- type_url = resource.type_url
859- resource_type = urlparse(type_url)[-1]
860- default = Entry
861- if (type_url.endswith('-page')
862- or (param_name is not None
863- and param_name.endswith('_collection_link'))):
864- default = Collection
865- r_class = RESOURCE_TYPE_CLASSES.get(resource_type, default)
866- if representation is not None:
867- # We've been given a representation. Bind the resource
868- # immediately.
869- resource = resource.bind(
870- representation, representation_media_type,
871- representation_needs_processing,
872- representation_definition=representation_definition)
873- else:
874- # We'll fetch a representation and bind the resource when
875- # necessary.
876- pass
877- return r_class(root, resource)
878-
879- def lp_refresh(self, new_url=None, etag=None):
880- """Update this resource's representation."""
881- if new_url is not None:
882- self._wadl_resource._url = new_url
883- headers = {}
884- if etag is not None:
885- headers['If-None-Match'] = etag
886- try:
887- representation = self._root._browser.get(
888- self._wadl_resource, headers=headers)
889- except HTTPError, e:
890- if e.response['status'] == '304':
891- # The entry wasn't modified. No need to do anything.
892- return
893- else:
894- raise e
895- # __setattr__ assumes we're setting an attribute of the resource,
896- # so we manipulate __dict__ directly.
897- self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
898- representation, self.JSON_MEDIA_TYPE)
899-
900- def __getattr__(self, attr):
901- """Try to retrive a named operation or parameter of the given name."""
902- try:
903- return self.lp_get_parameter(attr)
904- except KeyError:
905- pass
906- try:
907- return self.lp_get_named_operation(attr)
908- except KeyError:
909- raise AttributeError("'%s' object has no attribute '%s'"
910- % (self.__class__.__name__, attr))
911-
912- def _get_external_param_name(self, param_name):
913- """What's this parameter's name in the underlying representation?"""
914- for suffix in ['_link', '_collection_link', '']:
915- name = param_name + suffix
916- if self._wadl_resource.get_parameter(name):
917- return name
918- return None
919-
920- def _ensure_representation(self):
921- """Make sure this resource has a representation fetched."""
922- if self._wadl_resource.representation is None:
923- # Get a representation of the linked resource.
924- representation = self._root._browser.get(self._wadl_resource)
925- self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
926- representation, self.JSON_MEDIA_TYPE)
927-
928-
929-class NamedOperation(LaunchpadBase):
930- """A class for a named operation to be invoked with GET or POST."""
931-
932- def __init__(self, root, resource, wadl_method):
933- """Initialize with respect to a WADL Method object"""
934- self.root = root
935- self.resource = resource
936- self.wadl_method = wadl_method
937-
938- def __call__(self, *args, **kwargs):
939- """Invoke the method and process the result."""
940- if len(args) > 0:
941- raise TypeError('Method must be called with keyword args.')
942- http_method = self.wadl_method.name
943- args = self._transform_resources_to_links(kwargs)
944- for key, value in args.items():
945- args[key] = simplejson.dumps(value, cls=DatetimeJSONEncoder)
946- if http_method in ('get', 'head', 'delete'):
947- url = self.wadl_method.build_request_url(**args)
948- in_representation = ''
949- extra_headers = {}
950- else:
951- url = self.wadl_method.build_request_url()
952- (media_type,
953- in_representation) = self.wadl_method.build_representation(
954- **args)
955- extra_headers = { 'Content-type' : media_type }
956- response, content = self.root._browser._request(
957- url, in_representation, http_method, extra_headers=extra_headers)
958-
959- if response.status == 201:
960- return self._handle_201_response(url, response, content)
961- else:
962- if http_method == 'post':
963- # The method call probably modified this resource in
964- # an unknown way. Refresh its representation.
965- self.resource.lp_refresh()
966- return self._handle_200_response(url, response, content)
967-
968- def _handle_201_response(self, url, response, content):
969- """Handle the creation of a new resource by fetching it."""
970- wadl_response = self.wadl_method.response.bind(
971- HeaderDictionary(response))
972- wadl_parameter = wadl_response.get_parameter('Location')
973- wadl_resource = wadl_parameter.linked_resource
974- # Fetch a representation of the new resource.
975- response, content = self.root._browser._request(
976- wadl_resource.url)
977- # Return an instance of the appropriate launchpadlib
978- # Resource subclass.
979- return Resource._create_bound_resource(
980- self.root, wadl_resource, content, response['content-type'])
981-
982- def _handle_200_response(self, url, response, content):
983- """Process the return value of an operation."""
984- content_type = response['content-type']
985- # Process the returned content, assuming we know how.
986- response_definition = self.wadl_method.response
987- representation_definition = (
988- response_definition.get_representation_definition(
989- content_type))
990-
991- if representation_definition is None:
992- # The operation returned a document with nothing
993- # special about it.
994- if content_type == self.JSON_MEDIA_TYPE:
995- return simplejson.loads(content)
996- # We don't know how to process the content.
997- return content
998-
999- # The operation returned a representation of some
1000- # resource. Instantiate a Resource object for it.
1001- document = simplejson.loads(content)
1002- if "self_link" in document and "resource_type_link" in document:
1003- # The operation returned an entry. Use the self_link and
1004- # resource_type_link of the entry representation to build
1005- # a Resource object of the appropriate type. That way this
1006- # object will support all of the right named operations.
1007- url = document["self_link"]
1008- resource_type = self.root._wadl.get_resource_type(
1009- document["resource_type_link"])
1010- wadl_resource = WadlResource(self.root._wadl, url,
1011- resource_type.tag)
1012- else:
1013- # The operation returned a collection. It's probably an ad
1014- # hoc collection that doesn't correspond to any resource
1015- # type. Instantiate it as a resource backed by the
1016- # representation type defined in the return value, instead
1017- # of a resource type tag.
1018- representation_definition = (
1019- representation_definition.resolve_definition())
1020- wadl_resource = WadlResource(
1021- self.root._wadl, url, representation_definition.tag)
1022-
1023- return Resource._create_bound_resource(
1024- self.root, wadl_resource, document, content_type,
1025- representation_needs_processing=False,
1026- representation_definition=representation_definition)
1027-
1028- def _get_external_param_name(self, param_name):
1029- """Named operation parameter names are sent as is."""
1030- return param_name
1031-
1032-
1033-class Entry(Resource):
1034- """A class for an entry-type resource that can be updated with PATCH."""
1035-
1036- def __init__(self, root, wadl_resource):
1037- super(Entry, self).__init__(root, wadl_resource)
1038- # Initialize this here in a semi-magical way so as to stop a
1039- # particular infinite loop that would follow. Setting
1040- # self._dirty_attributes would call __setattr__(), which would
1041- # turn around immediately and get self._dirty_attributes. If
1042- # this latter was not in the instance dictionary, that would
1043- # end up calling __getattr__(), which would again reference
1044- # self._dirty_attributes. This is where the infloop would
1045- # occur. Poking this directly into self.__dict__ means that
1046- # the check for self._dirty_attributes won't call __getattr__(),
1047- # breaking the cycle.
1048- self.__dict__['_dirty_attributes'] = {}
1049- super(Entry, self).__init__(root, wadl_resource)
1050-
1051- def __repr__(self):
1052- """Return the WADL resource type and the URL to the resource."""
1053- return '<%s at %s>' % (
1054- URI(self.resource_type_link).fragment, self.self_link)
1055-
1056- def __str__(self):
1057- """Return the URL to the resource."""
1058- return self.self_link
1059-
1060- def __getattr__(self, name):
1061- """Try to retrive a parameter of the given name."""
1062- if name != '_dirty_attributes':
1063- if name in self._dirty_attributes:
1064- return self._dirty_attributes[name]
1065- return super(Entry, self).__getattr__(name)
1066-
1067- def __setattr__(self, name, value):
1068- """Set the parameter of the given name."""
1069- if not self.lp_has_parameter(name):
1070- raise AttributeError("'%s' object has no attribute '%s'" %
1071- (self.__class__.__name__, name))
1072- self._dirty_attributes[name] = value
1073-
1074- def lp_refresh(self, new_url=None):
1075- """Update this resource's representation."""
1076- etag = getattr(self, 'http_etag', None)
1077- super(Entry, self).lp_refresh(new_url, etag)
1078- self._dirty_attributes.clear()
1079-
1080- def lp_save(self):
1081- """Save changes to the entry."""
1082- representation = self._transform_resources_to_links(
1083- self._dirty_attributes)
1084-
1085- # If the entry contains an ETag, set the If-Match header
1086- # to that value.
1087- headers = {}
1088- etag = getattr(self, 'http_etag', None)
1089- if etag is not None:
1090- headers['If-Match'] = etag
1091-
1092- # PATCH the new representation to the 'self' link. It's possible that
1093- # this will cause the object to be permanently moved. Catch that
1094- # exception and refresh our representation.
1095- try:
1096- response, content = self._root._browser.patch(
1097- URI(self.self_link), representation, headers)
1098- except HTTPError, error:
1099- if error.response.status == 301:
1100- response = error.response
1101- self.lp_refresh(error.response['location'])
1102- else:
1103- raise
1104- self._dirty_attributes.clear()
1105-
1106- content_type = response['content-type']
1107- if response.status == 209 and content_type == self.JSON_MEDIA_TYPE:
1108- # The server sent back a new representation of the object.
1109- # Use it in preference to the existing representation.
1110- new_representation = simplejson.loads(content)
1111- self._wadl_resource.representation = new_representation
1112- self._wadl_resource.media_type = content_type
1113-
1114-
1115-class Collection(Resource):
1116- """A collection-type resource that supports pagination."""
1117-
1118- def __init__(self, root, wadl_resource):
1119- """Create a collection object."""
1120- super(Collection, self).__init__(root, wadl_resource)
1121-
1122- def __len__(self):
1123- """The number of items in the collection.
1124-
1125- :return: length of the collection
1126- :rtype: int
1127- """
1128- try:
1129- return int(self.total_size)
1130- except AttributeError:
1131- raise TypeError('collection size is not available')
1132-
1133- def __iter__(self):
1134- """Iterate over the items in the collection.
1135-
1136- :return: iterator
1137- :rtype: sequence of `Entry`
1138- """
1139- self._ensure_representation()
1140- current_page = self._wadl_resource.representation
1141- while True:
1142- for resource in self._convert_dicts_to_entries(
1143- current_page.get('entries', {})):
1144- yield resource
1145- next_link = current_page.get('next_collection_link')
1146- if next_link is None:
1147- break
1148- current_page = simplejson.loads(
1149- self._root._browser.get(URI(next_link)))
1150-
1151- def __getitem__(self, key):
1152- """Look up a slice, or a subordinate resource by index.
1153-
1154- To discourage situations where a launchpadlib client fetches
1155- all of an enormous list, all collection slices must have a
1156- definitive end point. For performance reasons, all collection
1157- slices must be indexed from the start of the list rather than
1158- the end.
1159- """
1160- if isinstance(key, slice):
1161- return self._get_slice(key)
1162- else:
1163- # Look up a single item by its position in the list.
1164- found_slice = self._get_slice(slice(key, key+1))
1165- if len(found_slice) != 1:
1166- raise IndexError("list index out of range")
1167- return found_slice[0]
1168-
1169- def _get_slice(self, slice):
1170- """Retrieve a slice of a collection."""
1171- start = slice.start or 0
1172- stop = slice.stop
1173-
1174- if start < 0:
1175- raise ValueError("Collection slices must have a nonnegative "
1176- "start point.")
1177- if stop < 0:
1178- raise ValueError("Collection slices must have a definite, "
1179- "nonnegative end point.")
1180-
1181- existing_representation = self._wadl_resource.representation
1182- if (existing_representation is not None
1183- and start < len(existing_representation['entries'])):
1184- # An optimization: the first page of entries has already
1185- # been loaded. This can happen if this collection is the
1186- # return value of a named operation, or if the client did
1187- # something like check the length of the collection.
1188- #
1189- # Either way, we've already made an HTTP request and
1190- # gotten some entries back. The client has requested a
1191- # slice that includes some of the entries we already have.
1192- # In the best case, we can fulfil the slice immediately,
1193- # without making another HTTP request.
1194- #
1195- # Even if we can't fulfil the entire slice, we can get one
1196- # or more objects from the first page and then have fewer
1197- # objects to retrieve from the server later. This saves us
1198- # time and bandwidth, and it might let us save a whole
1199- # HTTP request.
1200- entry_page = existing_representation['entries']
1201-
1202- first_page_size = len(entry_page)
1203- entry_dicts = entry_page[start:stop]
1204- page_url = existing_representation.get('next_collection_link')
1205- else:
1206- # No part of this collection has been loaded yet, or the
1207- # slice starts beyond the part that has been loaded. We'll
1208- # use our secret knowledge of Launchpad to set a value for
1209- # the ws.start variable. That way we start reading entries
1210- # from the first one we want.
1211- first_page_size = None
1212- entry_dicts = []
1213- page_url = self._with_url_query_variable_set(
1214- self._wadl_resource.url, 'ws.start', start)
1215-
1216- desired_size = stop-start
1217- more_needed = desired_size - len(entry_dicts)
1218-
1219- # Iterate over pages until we have the correct number of entries.
1220- while more_needed > 0 and page_url is not None:
1221- representation = simplejson.loads(
1222- self._root._browser.get(page_url))
1223- current_page_entries = representation['entries']
1224- entry_dicts += current_page_entries[:more_needed]
1225- more_needed = desired_size - len(entry_dicts)
1226-
1227- page_url = representation.get('next_collection_link')
1228- if page_url is None:
1229- # We've gotten the entire collection; there are no
1230- # more entries.
1231- break
1232- if first_page_size is None:
1233- first_page_size = len(current_page_entries)
1234- if more_needed > 0 and more_needed < first_page_size:
1235- # An optimization: it's likely that we need less than
1236- # a full page of entries, because the number we need
1237- # is less than the size of the first page we got.
1238- # Instead of requesting a full-sized page, we'll
1239- # request only the number of entries we think we'll
1240- # need. If we're wrong, there's no problem; we'll just
1241- # keep looping.
1242- page_url = self._with_url_query_variable_set(
1243- page_url, 'ws.size', more_needed)
1244-
1245- if slice.step is not None:
1246- entry_dicts = entry_dicts[::slice.step]
1247-
1248- # Convert entry_dicts into a list of Entry objects.
1249- return [resource for resource
1250- in self._convert_dicts_to_entries(entry_dicts)]
1251-
1252- def _convert_dicts_to_entries(self, entries):
1253- """Convert dictionaries describing entries to Entry objects.
1254-
1255- The dictionaries come from the 'entries' field of the JSON
1256- dictionary you get when you GET a page of a collection. Each
1257- dictionary is the same as you'd get if you sent a GET request
1258- to the corresponding entry resource. So each of these
1259- dictionaries can be treated as a preprocessed representation
1260- of an entry resource, and turned into an Entry instance.
1261-
1262- :yield: A sequence of Entry instances.
1263- """
1264- for entry_dict in entries:
1265- resource_url = entry_dict['self_link']
1266- resource_type_link = entry_dict['resource_type_link']
1267- wadl_application = self._wadl_resource.application
1268- resource_type = wadl_application.get_resource_type(
1269- resource_type_link)
1270- resource = WadlResource(
1271- self._wadl_resource.application, resource_url,
1272- resource_type.tag)
1273- yield Resource._create_bound_resource(
1274- self._root, resource, entry_dict, self.JSON_MEDIA_TYPE,
1275- False)
1276-
1277- def _with_url_query_variable_set(self, url, variable, new_value):
1278- """A helper method to set a query variable in a URL."""
1279- uri = URI(url)
1280- if uri.query is None:
1281- params = {}
1282- else:
1283- params = cgi.parse_qs(uri.query)
1284- params[variable] = str(new_value)
1285- uri.query = urllib.urlencode(params, True)
1286- return str(uri)
1287-
1288-
1289-class CollectionWithKeyBasedLookup(Collection):
1290- """A collection-type resource that supports key-based lookup.
1291-
1292- This collection can be sliced, but any single index passed into
1293- __getitem__ will be treated as a custom lookup key.
1294- """
1295-
1296- def __getitem__(self, key):
1297- """Look up a slice, or a subordinate resource by unique ID."""
1298- if isinstance(key, slice):
1299- return super(CollectionWithKeyBasedLookup, self).__getitem__(key)
1300- try:
1301- url = self._get_url_from_id(key)
1302- except NotImplementedError:
1303- raise TypeError("unsubscriptable object")
1304- if url is None:
1305- raise KeyError(key)
1306-
1307- # We don't know what kind of resource this is. Even the
1308- # subclass doesn't necessarily know, because some resources
1309- # (the person list) are gateways to more than one kind of
1310- # resource (people, and teams). The only way to know for sure
1311- # is to retrieve a representation of the resource and see how
1312- # the resource describes itself.
1313- try:
1314- representation = simplejson.loads(self._root._browser.get(url))
1315- except HTTPError, error:
1316- # There's no resource corresponding to the given ID.
1317- if error.response.status == 404:
1318- raise KeyError(key)
1319- raise
1320- # We know that every Launchpad resource has a 'resource_type_link'
1321- # in its representation.
1322- resource_type_link = representation['resource_type_link']
1323- resource = WadlResource(self._root._wadl, url, resource_type_link)
1324- return self._create_bound_resource(
1325- self._root, resource, representation=representation,
1326- representation_needs_processing=False)
1327-
1328-
1329- def _get_url_from_id(self, key):
1330- """Transform the unique ID of an object into its URL."""
1331- raise NotImplementedError()
1332-
1333-
1334-class HostedFile(Resource):
1335- """A resource represnting a file hosted on Launchpad's server."""
1336-
1337- def open(self, mode='r', content_type=None, filename=None):
1338- """Open the file on the server for read or write access."""
1339- if mode in ('r', 'w'):
1340- return HostedFileBuffer(self, mode, content_type, filename)
1341- else:
1342- raise ValueError("Invalid mode. Supported modes are: r, w")
1343-
1344- def delete(self):
1345- """Delete the file from the server."""
1346- self._root._browser.delete(self._wadl_resource.url)
1347-
1348- def _get_parameter_names(self, *kinds):
1349- """HostedFile objects define no web service parameters."""
1350- return []
1351-
1352-
1353-class HostedFileBuffer(StringIO):
1354- """The contents of a file hosted on Launchpad's server."""
1355- def __init__(self, hosted_file, mode, content_type=None, filename=None):
1356- self.url = hosted_file._wadl_resource.url
1357- if mode == 'r':
1358- if content_type is not None:
1359- raise ValueError("Files opened for read access can't "
1360- "specify content_type.")
1361- if filename is not None:
1362- raise ValueError("Files opened for read access can't "
1363- "specify filename.")
1364- response, value = hosted_file._root._browser.get(
1365- self.url, return_response=True)
1366- content_type = response['content-type']
1367- last_modified = response['last-modified']
1368-
1369- # The Content-Location header contains the URL of the file
1370- # in the Launchpad library. We happen to know that the
1371- # final component of the URL is the name of the uploaded
1372- # file.
1373- content_location = response['content-location']
1374- path = urlparse(content_location)[2]
1375- filename = urllib.unquote(path.split("/")[-1])
1376- elif mode == 'w':
1377- value = ''
1378- if content_type is None:
1379- raise ValueError("Files opened for write access must "
1380- "specify content_type.")
1381- if filename is None:
1382- raise ValueError("Files opened for write access must "
1383- "specify filename.")
1384- last_modified = None
1385- else:
1386- raise ValueError("Invalid mode. Supported modes are: r, w")
1387-
1388- self.hosted_file = hosted_file
1389- self.mode = mode
1390- self.content_type = content_type
1391- self.filename = filename
1392- self.last_modified = last_modified
1393- StringIO.__init__(self, value)
1394-
1395- def close(self):
1396- if self.mode == 'w':
1397- disposition = 'attachment; filename="%s"' % self.filename
1398- self.hosted_file._root._browser.put(
1399- self.url, self.getvalue(),
1400- self.content_type, {'Content-Disposition' : disposition})
1401- StringIO.close(self)
1402-
1403-
1404-class PersonSet(CollectionWithKeyBasedLookup):
1405- """A custom subclass capable of person lookup by username."""
1406-
1407- def _get_url_from_id(self, key):
1408- """Transform a username into the URL to a person resource."""
1409- return str(self._root._root_uri.ensureSlash()) + '~' + str(key)
1410-
1411-
1412-class BugSet(CollectionWithKeyBasedLookup):
1413- """A custom subclass capable of bug lookup by bug ID."""
1414-
1415- def _get_url_from_id(self, key):
1416- """Transform a bug ID into the URL to a bug resource."""
1417- return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key)
1418-
1419-
1420-class PillarSet(CollectionWithKeyBasedLookup):
1421- """A custom subclass capable of lookup by pillar name.
1422-
1423- Projects, project groups, and distributions are all pillars.
1424- """
1425-
1426- def _get_url_from_id(self, key):
1427- """Transform a project name into the URL to a project resource."""
1428- return str(self._root._root_uri.ensureSlash()) + str(key)
1429-
1430-
1431-# A mapping of resource type IDs to the client-side classes that handle
1432-# those resource types.
1433-RESOURCE_TYPE_CLASSES = {
1434- 'bugs': BugSet,
1435- 'distributions': PillarSet,
1436- 'HostedFile': HostedFile,
1437- 'people': PersonSet,
1438- 'project_groups': PillarSet,
1439- 'projects': PillarSet,
1440- }

Subscribers

People subscribed via source and target branches