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
=== modified file 'setup.py'
--- setup.py 2009-03-23 22:48:16 +0000
+++ setup.py 2009-03-30 17:17:08 +0000
@@ -60,6 +60,7 @@
60 license='LGPL v3',60 license='LGPL v3',
61 install_requires=[61 install_requires=[
62 'httplib2',62 'httplib2',
63 'lazr.restfulclient',
63 'lazr.uri',64 'lazr.uri',
64 'oauth',65 'oauth',
65 'setuptools',66 'setuptools',
6667
=== removed file 'src/launchpadlib/_browser.py'
--- src/launchpadlib/_browser.py 2009-03-20 20:46:06 +0000
+++ src/launchpadlib/_browser.py 1970-01-01 00:00:00 +0000
@@ -1,265 +0,0 @@
1# Copyright 2008 Canonical Ltd.
2
3# This file is part of launchpadlib.
4#
5# launchpadlib is free software: you can redistribute it and/or modify it
6# under the terms of the GNU Lesser General Public License as published by the
7# Free Software Foundation, version 3 of the License.
8#
9# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
12# for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
16
17"""Browser object to make requests of Launchpad web service.
18
19The `Browser` class implements OAuth authenticated communications with
20Launchpad. It is not part of the public launchpadlib API.
21"""
22
23__metaclass__ = type
24__all__ = [
25 'Browser',
26 ]
27
28
29import atexit
30from cStringIO import StringIO
31import gzip
32from httplib2 import (
33 FailedToDecompressContent, FileCache, Http, safename, urlnorm)
34from lazr.uri import URI
35from oauth.oauth import (
36 OAuthRequest, OAuthSignatureMethod_PLAINTEXT)
37import shutil
38import simplejson
39import tempfile
40from urllib import urlencode
41from wadllib.application import Application
42import zlib
43
44from launchpadlib.errors import HTTPError
45from launchpadlib._json import DatetimeJSONEncoder
46
47
48OAUTH_REALM = 'https://api.launchpad.net'
49
50# A drop-in replacement for httplib2's _decompressContent, which looks
51# in the Transfer-Encoding header instead of in Content-Encoding.
52def _decompressContent(response, new_content):
53 content = new_content
54 try:
55 encoding = response.get('transfer-encoding', None)
56 if encoding in ['gzip', 'deflate']:
57 if encoding == 'gzip':
58 content = gzip.GzipFile(
59 fileobj=StringIO.StringIO(new_content)).read()
60 if encoding == 'deflate':
61 content = zlib.decompress(content)
62 response['content-length'] = str(len(content))
63 del response['transfer-encoding']
64 except IOError:
65 content = ""
66 raise FailedToDecompressContent(
67 ("Content purported to be compressed with %s but failed "
68 "to decompress." % response.get('transfer-encoding')),
69 response, content)
70 return content
71
72
73class OAuthSigningHttp(Http):
74 """A client that signs every outgoing request with OAuth credentials."""
75
76 def __init__(self, oauth_credentials, cache=None, timeout=None,
77 proxy_info=None):
78 self.oauth_credentials = oauth_credentials
79 Http.__init__(self, cache, timeout, proxy_info)
80
81 def _request(self, conn, host, absolute_uri, request_uri, method, body,
82 headers, redirections, cachekey):
83 """Sign a request with OAuth credentials before sending it."""
84 oauth_request = OAuthRequest.from_consumer_and_token(
85 self.oauth_credentials.consumer,
86 self.oauth_credentials.access_token,
87 http_url=absolute_uri)
88 oauth_request.sign_request(
89 OAuthSignatureMethod_PLAINTEXT(),
90 self.oauth_credentials.consumer,
91 self.oauth_credentials.access_token)
92 if headers.has_key('authorization'):
93 # There's an authorization header left over from a
94 # previous request that resulted in a redirect. Remove it
95 # and start again.
96 del headers['authorization']
97
98 # httplib2 asks for compressed representations in
99 # Accept-Encoding. But a different content-encoding means a
100 # different ETag, which can cause problems later when we make
101 # a conditional request. We don't want to treat a
102 # representation differently based on whether or not we asked
103 # for a compressed version of it.
104 #
105 # So we move the compression request from Accept-Encoding to
106 # TE. Transfer-encoding compression can be handled transparently.
107 if 'accept-encoding' in headers:
108 headers['te'] = 'deflate, gzip'
109 del headers['accept-encoding']
110 headers.update(oauth_request.to_header(OAUTH_REALM))
111 return super(OAuthSigningHttp, self)._request(
112 conn, host, absolute_uri, request_uri, method, body, headers,
113 redirections, cachekey)
114
115 def _conn_request(self, conn, request_uri, method, body, headers):
116 """Decompress content using our version of _decompressContent."""
117 response, content = super(OAuthSigningHttp, self)._conn_request(
118 conn, request_uri, method, body, headers)
119 # Decompress the response, if it was compressed.
120 if method != "HEAD":
121 content = _decompressContent(response, content)
122 return (response, content)
123
124 def _getCachedHeader(self, uri, header):
125 """Retrieve a cached value for an HTTP header."""
126 if isinstance(self.cache, MultipleRepresentationCache):
127 return self.cache._getCachedHeader(uri, header)
128 return None
129
130
131class MultipleRepresentationCache(FileCache):
132 """A cache that can hold different representations of the same resource.
133
134 If a resource has two representations with two media types,
135 FileCache will only store the most recently fetched
136 representation. This cache can keep track of multiple
137 representations of the same resource.
138
139 This class works on the assumption that outside calling code sets
140 an instance's request_media_type attribute to the value of the
141 'Accept' header before initiating the request.
142
143 This class is very much not thread-safe, but FileCache isn't
144 thread-safe anyway.
145 """
146 def __init__(self, cache):
147 """Tell FileCache to call append_media_type when generating keys."""
148 super(MultipleRepresentationCache, self).__init__(
149 cache, self.append_media_type)
150 self.request_media_type = None
151
152 def append_media_type(self, key):
153 """Append the request media type to the cache key.
154
155 This ensures that representations of the same resource will be
156 cached separately, so long as they're served as different
157 media types.
158 """
159 if self.request_media_type is not None:
160 key = key + '-' + self.request_media_type
161 return safename(key)
162
163
164 def _getCachedHeader(self, uri, header):
165 """Retrieve a cached value for an HTTP header."""
166 (scheme, authority, request_uri, cachekey) = urlnorm(uri)
167 cached_value = self.get(cachekey)
168 header_start = header + ':'
169 if cached_value is not None:
170 for line in StringIO(cached_value):
171 if line.startswith(header_start):
172 return line[len(header_start):].strip()
173 return None
174
175
176class Browser:
177 """A class for making calls to Launchpad web services."""
178
179 def __init__(self, credentials, cache=None, timeout=None,
180 proxy_info=None):
181 """Initialize, possibly creating a cache.
182
183 If no cache is provided, a temporary directory will be used as
184 a cache. The temporary directory will be automatically removed
185 when the Python process exits.
186 """
187 if cache is None:
188 cache = tempfile.mkdtemp()
189 atexit.register(shutil.rmtree, cache)
190 if isinstance(cache, str):
191 cache = MultipleRepresentationCache(cache)
192 self._connection = OAuthSigningHttp(
193 credentials, cache, timeout, proxy_info)
194
195 def _request(self, url, data=None, method='GET',
196 media_type='application/json', extra_headers=None):
197 """Create an authenticated request object."""
198 # Add extra headers for the request.
199 headers = {'Accept' : media_type}
200 if isinstance(self._connection.cache, MultipleRepresentationCache):
201 self._connection.cache.request_media_type = media_type
202 if extra_headers is not None:
203 headers.update(extra_headers)
204 # Make the request. It will be signed automatically when
205 # _request is called.
206 response, content = self._connection.request(
207 str(url), method=method, body=data, headers=headers)
208 # Turn non-2xx responses into exceptions.
209 if response.status // 100 != 2:
210 raise HTTPError(response, content)
211 return response, content
212
213 def get(self, resource_or_uri, headers=None, return_response=False):
214 """GET a representation of the given resource or URI."""
215 if isinstance(resource_or_uri, (basestring, URI)):
216 url = resource_or_uri
217 else:
218 method = resource_or_uri.get_method('get')
219 url = method.build_request_url()
220 response, content = self._request(url, extra_headers=headers)
221 if return_response:
222 return (response, content)
223 return content
224
225 def get_wadl_application(self, url):
226 """GET a WADL representation of the resource at the requested url."""
227 response, content = self._request(
228 url, media_type='application/vd.sun.wadl+xml')
229 return Application(str(url), content)
230
231 def post(self, url, method_name, **kws):
232 """POST a request to the web service."""
233 kws['ws.op'] = method_name
234 data = urlencode(kws)
235 return self._request(url, data, 'POST')
236
237 def put(self, url, representation, media_type, headers=None):
238 """PUT the given representation to the URL."""
239 extra_headers = {'Content-Type': media_type}
240 if headers is not None:
241 extra_headers.update(headers)
242 return self._request(
243 url, representation, 'PUT', extra_headers=extra_headers)
244
245 def delete(self, url):
246 """DELETE the resource at the given URL."""
247 self._request(url, method='DELETE')
248
249 def patch(self, url, representation, headers=None):
250 """PATCH the object at url with the updated representation."""
251 extra_headers = {'Content-Type': 'application/json'}
252 if headers is not None:
253 extra_headers.update(headers)
254 # httplib2 doesn't know about the PATCH method, so we need to
255 # do some work ourselves. Pull any cached value of "ETag" out
256 # and use it as the value for "If-Match".
257 cached_etag = self._connection._getCachedHeader(str(url), 'etag')
258 if cached_etag is not None and not self._connection.ignore_etag:
259 # http://www.w3.org/1999/04/Editing/
260 headers['If-Match'] = cached_etag
261
262 return self._request(
263 url, simplejson.dumps(representation,
264 cls=DatetimeJSONEncoder),
265 'PATCH', extra_headers=extra_headers)
2660
=== removed file 'src/launchpadlib/_json.py'
--- src/launchpadlib/_json.py 2009-03-20 20:46:06 +0000
+++ src/launchpadlib/_json.py 1970-01-01 00:00:00 +0000
@@ -1,33 +0,0 @@
1# Copyright 2009 Canonical Ltd.
2
3# This file is part of launchpadlib.
4#
5# launchpadlib is free software: you can redistribute it and/or modify it
6# under the terms of the GNU Lesser General Public License as published by the
7# Free Software Foundation, version 3 of the License.
8#
9# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
12# for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
16
17"""Classes for working with JSON."""
18
19__metaclass__ = type
20__all__ = ['DatetimeJSONEncoder']
21
22import datetime
23import simplejson
24
25class DatetimeJSONEncoder(simplejson.JSONEncoder):
26 """A JSON encoder that understands datetime objects.
27
28 Datetime objects are formatted according to ISO 1601.
29 """
30 def default(self, obj):
31 if isinstance(obj, datetime.datetime):
32 return obj.isoformat()
33 return simplejson.JSONEncoder.default(self, obj)
340
=== modified file 'src/launchpadlib/credentials.py'
--- src/launchpadlib/credentials.py 2009-03-23 21:50:35 +0000
+++ src/launchpadlib/credentials.py 2009-03-26 21:07:35 +0000
@@ -29,7 +29,7 @@
29from oauth.oauth import OAuthConsumer, OAuthToken29from oauth.oauth import OAuthConsumer, OAuthToken
30from urllib import urlencode30from urllib import urlencode
3131
32from launchpadlib.errors import CredentialsFileError, HTTPError32from lazr.restfulclient.errors import CredentialsFileError, HTTPError
3333
3434
35CREDENTIALS_FILE_VERSION = '1'35CREDENTIALS_FILE_VERSION = '1'
3636
=== modified file 'src/launchpadlib/errors.py'
--- src/launchpadlib/errors.py 2009-03-20 20:46:06 +0000
+++ src/launchpadlib/errors.py 2009-03-26 21:07:35 +0000
@@ -14,50 +14,7 @@
14# You should have received a copy of the GNU Lesser General Public License14# You should have received a copy of the GNU Lesser General Public License
15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
1616
17"""launchpadlib errors."""17
1818"""Reimport errors from restfulclient for convenience's sake."""
19__metaclass__ = type19
20__all__ = [20from lazr.restfulclient.errors import *
21 'CredentialsError',
22 'CredentialsFileError',
23 'HTTPError',
24 'LaunchpadError',
25 'ResponseError',
26 'UnexpectedResponseError',
27 ]
28
29
30class LaunchpadError(Exception):
31 """Base error for the Launchpad API library."""
32
33
34class CredentialsError(LaunchpadError):
35 """Base credentials/authentication error."""
36
37
38class CredentialsFileError(CredentialsError):
39 """Error in credentials file."""
40
41
42class ResponseError(LaunchpadError):
43 """Error in response."""
44
45 def __init__(self, response, content):
46 LaunchpadError.__init__(self)
47 self.response = response
48 self.content = content
49
50
51class UnexpectedResponseError(ResponseError):
52 """An unexpected response was received."""
53
54 def __str__(self):
55 return '%s: %s' % (self.response.status, self.response.reason)
56
57
58class HTTPError(ResponseError):
59 """An HTTP non-2xx response code was received."""
60
61 def __str__(self):
62 return 'HTTP Error %s: %s' % (
63 self.response.status, self.response.reason)
6421
=== modified file 'src/launchpadlib/launchpad.py'
--- src/launchpadlib/launchpad.py 2009-03-23 21:50:35 +0000
+++ src/launchpadlib/launchpad.py 2009-03-26 21:07:35 +0000
@@ -21,32 +21,64 @@
21 'Launchpad',21 'Launchpad',
22 ]22 ]
2323
24import os
25import shutil
26import simplejson
27import stat
28import sys24import sys
29import tempfile
30import urlparse
31import webbrowser25import webbrowser
3226
33from wadllib.application import Resource as WadlResource
34from lazr.uri import URI27from lazr.uri import URI
3528from lazr.restfulclient._browser import RestfulHttp
36from launchpadlib._browser import Browser29from lazr.restfulclient.resource import (
37from launchpadlib.resource import Resource30 CollectionWithKeyBasedLookup, HostedFile, ServiceRoot)
38from launchpadlib.credentials import AccessToken, Credentials31from launchpadlib.credentials import AccessToken, Credentials
32from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
3933
34OAUTH_REALM = 'https://api.launchpad.net'
40STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/beta/'35STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/beta/'
41EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/beta/'36EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/beta/'
4237
43class Launchpad(Resource):38
39class PersonSet(CollectionWithKeyBasedLookup):
40 """A custom subclass capable of person lookup by username."""
41
42 def _get_url_from_id(self, key):
43 """Transform a username into the URL to a person resource."""
44 return str(self._root._root_uri.ensureSlash()) + '~' + str(key)
45
46
47class BugSet(CollectionWithKeyBasedLookup):
48 """A custom subclass capable of bug lookup by bug ID."""
49
50 def _get_url_from_id(self, key):
51 """Transform a bug ID into the URL to a bug resource."""
52 return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key)
53
54
55class PillarSet(CollectionWithKeyBasedLookup):
56 """A custom subclass capable of lookup by pillar name.
57
58 Projects, project groups, and distributions are all pillars.
59 """
60
61 def _get_url_from_id(self, key):
62 """Transform a project name into the URL to a project resource."""
63 return str(self._root._root_uri.ensureSlash()) + str(key)
64
65
66class Launchpad(ServiceRoot):
44 """Root Launchpad API class.67 """Root Launchpad API class.
4568
46 :ivar credentials: The credentials instance used to access Launchpad.69 :ivar credentials: The credentials instance used to access Launchpad.
47 :type credentials: `Credentials`70 :type credentials: `Credentials`
48 """71 """
4972
73 RESOURCE_TYPE_CLASSES = {
74 'bugs': BugSet,
75 'distributions': PillarSet,
76 'HostedFile': HostedFile,
77 'people': PersonSet,
78 'project_groups': PillarSet,
79 'projects': PillarSet,
80 }
81
50 def __init__(self, credentials, service_root=STAGING_SERVICE_ROOT,82 def __init__(self, credentials, service_root=STAGING_SERVICE_ROOT,
51 cache=None, timeout=None, proxy_info=None):83 cache=None, timeout=None, proxy_info=None):
52 """Root access to the Launchpad API.84 """Root access to the Launchpad API.
@@ -56,34 +88,11 @@
56 :param service_root: The URL to the root of the web service.88 :param service_root: The URL to the root of the web service.
57 :type service_root: string89 :type service_root: string
58 """90 """
59 self._root_uri = URI(service_root)91 super(Launchpad, self).__init__(
60 self.credentials = credentials92 credentials, service_root, cache, timeout, proxy_info)
61 # Get the WADL definition.93
62 self._browser = Browser(self.credentials, cache, timeout, proxy_info)94 def httpFactory(self, credentials, cache, timeout, proxy_info):
63 self._wadl = self._browser.get_wadl_application(self._root_uri)95 return OAuthSigningHttp(credentials, cache, timeout, proxy_info)
64
65 # Get the root resource.
66 root_resource = self._wadl.get_resource_by_path('')
67 bound_root = root_resource.bind(
68 self._browser.get(root_resource), 'application/json')
69 super(Launchpad, self).__init__(None, bound_root)
70
71 def load(self, url):
72 """Load a resource given its URL."""
73 document = self._browser.get(url)
74 try:
75 representation = simplejson.loads(document)
76 except ValueError:
77 raise ValueError("%s doesn't serve a JSON document." % url)
78 type_link = representation.get("resource_type_link")
79 if type_link is None:
80 raise ValueError("Couldn't determine the resource type of %s."
81 % url)
82 resource_type = self._root._wadl.get_resource_type(type_link)
83 wadl_resource = WadlResource(self._root._wadl, url, resource_type.tag)
84 return self._create_bound_resource(
85 self._root, wadl_resource, representation, 'application/json',
86 representation_needs_processing=False)
8796
88 @classmethod97 @classmethod
89 def login(cls, consumer_name, token_string, access_secret,98 def login(cls, consumer_name, token_string, access_secret,
@@ -157,69 +166,27 @@
157 credentials.exchange_request_token_for_access_token(web_root)166 credentials.exchange_request_token_for_access_token(web_root)
158 return cls(credentials, service_root, cache, timeout, proxy_info)167 return cls(credentials, service_root, cache, timeout, proxy_info)
159168
160 @classmethod169
161 def login_with(cls, consumer_name,170class OAuthSigningHttp(RestfulHttp):
162 service_root=STAGING_SERVICE_ROOT,171 """A client that signs every outgoing request with OAuth credentials."""
163 launchpadlib_dir=None, timeout=None, proxy_info=None):172
164 """Log in to Launchpad with possibly cached credentials.173 def _request(self, conn, host, absolute_uri, request_uri, method, body,
165174 headers, redirections, cachekey):
166 This is a convenience method for either setting up new login175 """Sign a request with OAuth credentials before sending it."""
167 credentials, or re-using existing ones. When a login token is176 oauth_request = OAuthRequest.from_consumer_and_token(
168 generated using this method, the resulting credentials will be177 self.restful_credentials.consumer,
169 saved in the `launchpadlib_dir` directory. If the same178 self.restful_credentials.access_token,
170 `launchpadlib_dir` is passed in a second time, the credentials179 http_url=absolute_uri)
171 in `launchpadlib_dir` for the consumer will be used180 oauth_request.sign_request(
172 automatically.181 OAuthSignatureMethod_PLAINTEXT(),
173182 self.restful_credentials.consumer,
174 Each consumer has their own credentials per service root in183 self.restful_credentials.access_token)
175 `launchpadlib_dir`. `launchpadlib_dir` is also used for caching184 if headers.has_key('authorization'):
176 fetched objects. The cache is per service root, and shared by185 # There's an authorization header left over from a
177 all consumers.186 # previous request that resulted in a redirect. Remove it
178187 # and start again.
179 See `Launchpad.get_token_and_login()` for more information about188 del headers['authorization']
180 how new tokens are generated.189 headers.update(oauth_request.to_header(OAUTH_REALM))
181190 return super(OAuthSigningHttp, self)._request(
182 :param consumer_name: The consumer name, as appropriate for the191 conn, host, absolute_uri, request_uri, method, body, headers,
183 `Consumer` constructor192 redirections, cachekey)
184 :type consumer_name: string
185 :param service_root: The URL to the root of the web service.
186 :type service_root: string
187 :param launchpadlib_dir: The directory where the cache and
188 credentials are stored.
189 :type launchpadlib_dir: string
190 :return: The web service root
191 :rtype: `Launchpad`
192
193 """
194 if launchpadlib_dir is None:
195 home_dir = os.environ['HOME']
196 launchpadlib_dir = os.path.join(home_dir, '.launchpadlib')
197 launchpadlib_dir = os.path.expanduser(launchpadlib_dir)
198 # Each service root has its own cache and credential dirs.
199 scheme, host_name, path, query, fragment = urlparse.urlsplit(
200 service_root)
201 service_root_dir = os.path.join(launchpadlib_dir, host_name)
202 cache_path = os.path.join(service_root_dir, 'cache')
203 if not os.path.exists(cache_path):
204 os.makedirs(cache_path)
205 credentials_path = os.path.join(service_root_dir, 'credentials')
206 if not os.path.exists(credentials_path):
207 os.makedirs(credentials_path)
208 consumer_credentials_path = os.path.join(
209 credentials_path, consumer_name)
210 if os.path.exists(consumer_credentials_path):
211 credentials = Credentials.load_from_path(
212 consumer_credentials_path)
213 launchpad = cls(
214 credentials, service_root=service_root, cache=cache_path,
215 timeout=timeout, proxy_info=proxy_info)
216 else:
217 launchpad = cls.get_token_and_login(
218 consumer_name, service_root=service_root, cache=cache_path,
219 timeout=timeout, proxy_info=proxy_info)
220 launchpad.credentials.save_to_path(
221 os.path.join(credentials_path, consumer_name))
222 os.chmod(
223 os.path.join(credentials_path, consumer_name),
224 stat.S_IREAD | stat.S_IWRITE)
225 return launchpad
226193
=== removed file 'src/launchpadlib/resource.py'
--- src/launchpadlib/resource.py 2009-03-20 20:46:06 +0000
+++ src/launchpadlib/resource.py 1970-01-01 00:00:00 +0000
@@ -1,830 +0,0 @@
1# Copyright 2008 Canonical Ltd.
2
3# This file is part of launchpadlib.
4#
5# launchpadlib is free software: you can redistribute it and/or modify it
6# under the terms of the GNU Lesser General Public License as published by the
7# Free Software Foundation, version 3 of the License.
8#
9# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
12# for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
16
17"""Common support for web service resources."""
18
19__metaclass__ = type
20__all__ = [
21 'Collection',
22 'Entry',
23 'NamedOperation',
24 'Resource',
25 ]
26
27
28import cgi
29import simplejson
30from StringIO import StringIO
31import urllib
32from urlparse import urlparse
33from lazr.uri import URI
34
35from launchpadlib._json import DatetimeJSONEncoder
36from launchpadlib.errors import HTTPError
37from wadllib.application import Resource as WadlResource
38
39
40class HeaderDictionary:
41 """A dictionary that bridges httplib2's and wadllib's expectations.
42
43 httplib2 expects all header dictionary access to give lowercase
44 header names. wadllib expects to access the header exactly as it's
45 specified in the WADL file, which means the official HTTP header name.
46
47 This class transforms keys to lowercase before doing a lookup on
48 the underlying dictionary. That way wadllib can pass in the
49 official header name and httplib2 will get the lowercased name.
50 """
51 def __init__(self, wrapped_dictionary):
52 self.wrapped_dictionary = wrapped_dictionary
53
54 def get(self, key, default=None):
55 """Retrieve a value, converting the key to lowercase."""
56 return self.wrapped_dictionary.get(key.lower())
57
58 def __getitem__(self, key):
59 """Retrieve a value, converting the key to lowercase."""
60 missing = object()
61 value = self.get(key, missing)
62 if value is missing:
63 raise KeyError(key)
64 return value
65
66
67class LaunchpadBase:
68 """Base class for classes that know about Launchpad."""
69
70 JSON_MEDIA_TYPE = 'application/json'
71
72 def _transform_resources_to_links(self, dictionary):
73 new_dictionary = {}
74 for key, value in dictionary.items():
75 if isinstance(value, Resource):
76 value = value.self_link
77 new_dictionary[self._get_external_param_name(key)] = value
78 return new_dictionary
79
80 def _get_external_param_name(self, param_name):
81 """Turn a launchpadlib name into something to be sent over HTTP.
82
83 For resources this may involve sticking '_link' or
84 '_collection_link' on the end of the parameter name. For
85 arguments to named operations, the parameter name is returned
86 as is.
87 """
88 return param_name
89
90
91class Resource(LaunchpadBase):
92 """Base class for Launchpad's HTTP resources."""
93
94 def __init__(self, root, wadl_resource):
95 """Initialize with respect to a wadllib Resource object."""
96 if root is None:
97 # This _is_ the root.
98 root = self
99 # These values need to be put directly into __dict__ to avoid
100 # calling __setattr__, which would cause an infinite recursion.
101 self.__dict__['_root'] = root
102 self.__dict__['_wadl_resource'] = wadl_resource
103
104 FIND_COLLECTIONS = object()
105 FIND_ENTRIES = object()
106 FIND_ATTRIBUTES = object()
107
108 @property
109 def lp_collections(self):
110 """Name the collections this resource links to."""
111 return self._get_parameter_names(self.FIND_COLLECTIONS)
112
113 @property
114 def lp_entries(self):
115 """Name the entries this resource links to."""
116 return self._get_parameter_names(self.FIND_ENTRIES)
117
118 @property
119 def lp_attributes(self):
120 """Name this resource's scalar attributes."""
121 return self._get_parameter_names(self.FIND_ATTRIBUTES)
122
123 @property
124 def lp_operations(self):
125 """Name all of this resource's custom operations."""
126 # This library distinguishes between named operations by the
127 # value they give for ws.op, not by their WADL names or IDs.
128 names = []
129 form_encoded_type = 'application/x-www-form-urlencoded'
130 for method in self._wadl_resource.method_iter:
131 name = method.name.lower()
132 if name == 'get':
133 params = method.request.params(['query', 'plain'])
134 elif name == 'post':
135 definition = method.request.representation_definition(
136 form_encoded_type).resolve_definition()
137 params = definition.params(self._wadl_resource)
138 for param in params:
139 if param.name == 'ws.op':
140 names.append(param.fixed_value)
141 break
142 return names
143
144 @property
145 def __members__(self):
146 """A hook into dir() that returns web service-derived members."""
147 return self._get_parameter_names(
148 self.FIND_COLLECTIONS, self.FIND_ENTRIES, self.FIND_ATTRIBUTES)
149
150 __methods__ = lp_operations
151
152 def _get_parameter_names(self, *kinds):
153 """Retrieve some subset of the resource's parameters."""
154 names = []
155 for name in self._wadl_resource.parameter_names(
156 self.JSON_MEDIA_TYPE):
157 if name.endswith('_collection_link'):
158 if self.FIND_COLLECTIONS in kinds:
159 names.append(name[:-16])
160 elif (name.endswith('_link')
161 and name not in ('self_link', 'resource_type_link')):
162 # launchpadlib_obj.self will work, but is never
163 # necessary. launchpadlib_obj.resource_type is also
164 # unneccessary, and won't work anyway because
165 # resource_type_link points to a WADL description,
166 # not a normal Launchpad resource.
167 if self.FIND_ENTRIES in kinds:
168 names.append(name[:-5])
169 elif self.FIND_ATTRIBUTES in kinds:
170 names.append(name)
171 return names
172
173 def lp_has_parameter(self, param_name):
174 """Does this resource have a parameter with the given name?"""
175 return self._get_external_param_name(param_name) is not None
176
177 def lp_get_parameter(self, param_name):
178 """Get the value of one of the resource's parameters.
179
180 :return: A scalar value if the parameter is not a link. A new
181 Resource object, whose resource is bound to a
182 representation, if the parameter is a link.
183 """
184 self._ensure_representation()
185 for suffix in ['_link', '_collection_link']:
186 param = self._wadl_resource.get_parameter(
187 param_name + suffix, self.JSON_MEDIA_TYPE)
188 if param is not None:
189 if param.get_value() is None:
190 # This parameter is a link to another object, but
191 # there's no other object. Return None rather than
192 # chasing down the nonexistent other object.
193 return None
194 linked_resource = param.linked_resource
195 return self._create_bound_resource(
196 self._root, linked_resource, param_name=param.name)
197 param = self._wadl_resource.get_parameter(param_name)
198 if param is None:
199 raise KeyError("No such parameter: %s" % param_name)
200 return param.get_value()
201
202 def lp_get_named_operation(self, operation_name):
203 """Get a custom operation with the given name.
204
205 :return: A NamedOperation instance that can be called with
206 appropriate arguments to invoke the operation.
207 """
208 params = { 'ws.op' : operation_name }
209 method = self._wadl_resource.get_method('get', query_params=params)
210 if method is None:
211 method = self._wadl_resource.get_method(
212 'post', representation_params=params)
213 if method is None:
214 raise KeyError("No operation with name: %s" % operation_name)
215 return NamedOperation(self._root, self, method)
216
217 @classmethod
218 def _create_bound_resource(
219 cls, root, resource, representation=None,
220 representation_media_type='application/json',
221 representation_needs_processing=True, representation_definition=None,
222 param_name=None):
223 """Create a launchpadlib Resource subclass from a wadllib Resource.
224
225 :param resource: The wadllib Resource to wrap.
226 :param representation: A previously fetched representation of
227 this resource, to be reused. If not provided, this method
228 will act just like the Resource constructor.
229 :param representation_media_type: The media type of any previously
230 fetched representation.
231 :param representation_needs_processing: Set to False if the
232 'representation' parameter should be used as
233 is.
234 :param representation_definition: A wadllib
235 RepresentationDefinition object describing the structure
236 of this representation. Used in cases when the representation
237 isn't the result of sending a standard GET to the resource.
238 :param param_name: The name of the link that was followed to get
239 to this resource.
240 :return: An instance of the appropriate launchpadlib Resource
241 subclass.
242 """
243 # We happen to know that all Launchpad resource types are
244 # defined in a single document. Turn the resource's type_url
245 # into an anchor into that document: this is its resource
246 # type. Then look up a client-side class that corresponds to
247 # the resource type.
248 type_url = resource.type_url
249 resource_type = urlparse(type_url)[-1]
250 default = Entry
251 if (type_url.endswith('-page')
252 or (param_name is not None
253 and param_name.endswith('_collection_link'))):
254 default = Collection
255 r_class = RESOURCE_TYPE_CLASSES.get(resource_type, default)
256 if representation is not None:
257 # We've been given a representation. Bind the resource
258 # immediately.
259 resource = resource.bind(
260 representation, representation_media_type,
261 representation_needs_processing,
262 representation_definition=representation_definition)
263 else:
264 # We'll fetch a representation and bind the resource when
265 # necessary.
266 pass
267 return r_class(root, resource)
268
269 def lp_refresh(self, new_url=None, etag=None):
270 """Update this resource's representation."""
271 if new_url is not None:
272 self._wadl_resource._url = new_url
273 headers = {}
274 if etag is not None:
275 headers['If-None-Match'] = etag
276 try:
277 representation = self._root._browser.get(
278 self._wadl_resource, headers=headers)
279 except HTTPError, e:
280 if e.response['status'] == '304':
281 # The entry wasn't modified. No need to do anything.
282 return
283 else:
284 raise e
285 # __setattr__ assumes we're setting an attribute of the resource,
286 # so we manipulate __dict__ directly.
287 self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
288 representation, self.JSON_MEDIA_TYPE)
289
290 def __getattr__(self, attr):
291 """Try to retrive a named operation or parameter of the given name."""
292 try:
293 return self.lp_get_parameter(attr)
294 except KeyError:
295 pass
296 try:
297 return self.lp_get_named_operation(attr)
298 except KeyError:
299 raise AttributeError("'%s' object has no attribute '%s'"
300 % (self.__class__.__name__, attr))
301
302 def _get_external_param_name(self, param_name):
303 """What's this parameter's name in the underlying representation?"""
304 for suffix in ['_link', '_collection_link', '']:
305 name = param_name + suffix
306 if self._wadl_resource.get_parameter(name):
307 return name
308 return None
309
310 def _ensure_representation(self):
311 """Make sure this resource has a representation fetched."""
312 if self._wadl_resource.representation is None:
313 # Get a representation of the linked resource.
314 representation = self._root._browser.get(self._wadl_resource)
315 self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
316 representation, self.JSON_MEDIA_TYPE)
317
318
319class NamedOperation(LaunchpadBase):
320 """A class for a named operation to be invoked with GET or POST."""
321
322 def __init__(self, root, resource, wadl_method):
323 """Initialize with respect to a WADL Method object"""
324 self.root = root
325 self.resource = resource
326 self.wadl_method = wadl_method
327
328 def __call__(self, *args, **kwargs):
329 """Invoke the method and process the result."""
330 if len(args) > 0:
331 raise TypeError('Method must be called with keyword args.')
332 http_method = self.wadl_method.name
333 args = self._transform_resources_to_links(kwargs)
334 for key, value in args.items():
335 args[key] = simplejson.dumps(value, cls=DatetimeJSONEncoder)
336 if http_method in ('get', 'head', 'delete'):
337 url = self.wadl_method.build_request_url(**args)
338 in_representation = ''
339 extra_headers = {}
340 else:
341 url = self.wadl_method.build_request_url()
342 (media_type,
343 in_representation) = self.wadl_method.build_representation(
344 **args)
345 extra_headers = { 'Content-type' : media_type }
346 response, content = self.root._browser._request(
347 url, in_representation, http_method, extra_headers=extra_headers)
348
349 if response.status == 201:
350 return self._handle_201_response(url, response, content)
351 else:
352 if http_method == 'post':
353 # The method call probably modified this resource in
354 # an unknown way. Refresh its representation.
355 self.resource.lp_refresh()
356 return self._handle_200_response(url, response, content)
357
358 def _handle_201_response(self, url, response, content):
359 """Handle the creation of a new resource by fetching it."""
360 wadl_response = self.wadl_method.response.bind(
361 HeaderDictionary(response))
362 wadl_parameter = wadl_response.get_parameter('Location')
363 wadl_resource = wadl_parameter.linked_resource
364 # Fetch a representation of the new resource.
365 response, content = self.root._browser._request(
366 wadl_resource.url)
367 # Return an instance of the appropriate launchpadlib
368 # Resource subclass.
369 return Resource._create_bound_resource(
370 self.root, wadl_resource, content, response['content-type'])
371
372 def _handle_200_response(self, url, response, content):
373 """Process the return value of an operation."""
374 content_type = response['content-type']
375 # Process the returned content, assuming we know how.
376 response_definition = self.wadl_method.response
377 representation_definition = (
378 response_definition.get_representation_definition(
379 content_type))
380
381 if representation_definition is None:
382 # The operation returned a document with nothing
383 # special about it.
384 if content_type == self.JSON_MEDIA_TYPE:
385 return simplejson.loads(content)
386 # We don't know how to process the content.
387 return content
388
389 # The operation returned a representation of some
390 # resource. Instantiate a Resource object for it.
391 document = simplejson.loads(content)
392 if "self_link" in document and "resource_type_link" in document:
393 # The operation returned an entry. Use the self_link and
394 # resource_type_link of the entry representation to build
395 # a Resource object of the appropriate type. That way this
396 # object will support all of the right named operations.
397 url = document["self_link"]
398 resource_type = self.root._wadl.get_resource_type(
399 document["resource_type_link"])
400 wadl_resource = WadlResource(self.root._wadl, url,
401 resource_type.tag)
402 else:
403 # The operation returned a collection. It's probably an ad
404 # hoc collection that doesn't correspond to any resource
405 # type. Instantiate it as a resource backed by the
406 # representation type defined in the return value, instead
407 # of a resource type tag.
408 representation_definition = (
409 representation_definition.resolve_definition())
410 wadl_resource = WadlResource(
411 self.root._wadl, url, representation_definition.tag)
412
413 return Resource._create_bound_resource(
414 self.root, wadl_resource, document, content_type,
415 representation_needs_processing=False,
416 representation_definition=representation_definition)
417
418 def _get_external_param_name(self, param_name):
419 """Named operation parameter names are sent as is."""
420 return param_name
421
422
423class Entry(Resource):
424 """A class for an entry-type resource that can be updated with PATCH."""
425
426 def __init__(self, root, wadl_resource):
427 super(Entry, self).__init__(root, wadl_resource)
428 # Initialize this here in a semi-magical way so as to stop a
429 # particular infinite loop that would follow. Setting
430 # self._dirty_attributes would call __setattr__(), which would
431 # turn around immediately and get self._dirty_attributes. If
432 # this latter was not in the instance dictionary, that would
433 # end up calling __getattr__(), which would again reference
434 # self._dirty_attributes. This is where the infloop would
435 # occur. Poking this directly into self.__dict__ means that
436 # the check for self._dirty_attributes won't call __getattr__(),
437 # breaking the cycle.
438 self.__dict__['_dirty_attributes'] = {}
439 super(Entry, self).__init__(root, wadl_resource)
440
441 def __repr__(self):
442 """Return the WADL resource type and the URL to the resource."""
443 return '<%s at %s>' % (
444 URI(self.resource_type_link).fragment, self.self_link)
445
446 def __str__(self):
447 """Return the URL to the resource."""
448 return self.self_link
449
450 def __getattr__(self, name):
451 """Try to retrive a parameter of the given name."""
452 if name != '_dirty_attributes':
453 if name in self._dirty_attributes:
454 return self._dirty_attributes[name]
455 return super(Entry, self).__getattr__(name)
456
457 def __setattr__(self, name, value):
458 """Set the parameter of the given name."""
459 if not self.lp_has_parameter(name):
460 raise AttributeError("'%s' object has no attribute '%s'" %
461 (self.__class__.__name__, name))
462 self._dirty_attributes[name] = value
463
464 def lp_refresh(self, new_url=None):
465 """Update this resource's representation."""
466 etag = getattr(self, 'http_etag', None)
467 super(Entry, self).lp_refresh(new_url, etag)
468 self._dirty_attributes.clear()
469
470 def lp_save(self):
471 """Save changes to the entry."""
472 representation = self._transform_resources_to_links(
473 self._dirty_attributes)
474
475 # If the entry contains an ETag, set the If-Match header
476 # to that value.
477 headers = {}
478 etag = getattr(self, 'http_etag', None)
479 if etag is not None:
480 headers['If-Match'] = etag
481
482 # PATCH the new representation to the 'self' link. It's possible that
483 # this will cause the object to be permanently moved. Catch that
484 # exception and refresh our representation.
485 try:
486 response, content = self._root._browser.patch(
487 URI(self.self_link), representation, headers)
488 except HTTPError, error:
489 if error.response.status == 301:
490 response = error.response
491 self.lp_refresh(error.response['location'])
492 else:
493 raise
494 self._dirty_attributes.clear()
495
496 content_type = response['content-type']
497 if response.status == 209 and content_type == self.JSON_MEDIA_TYPE:
498 # The server sent back a new representation of the object.
499 # Use it in preference to the existing representation.
500 new_representation = simplejson.loads(content)
501 self._wadl_resource.representation = new_representation
502 self._wadl_resource.media_type = content_type
503
504
505class Collection(Resource):
506 """A collection-type resource that supports pagination."""
507
508 def __init__(self, root, wadl_resource):
509 """Create a collection object."""
510 super(Collection, self).__init__(root, wadl_resource)
511
512 def __len__(self):
513 """The number of items in the collection.
514
515 :return: length of the collection
516 :rtype: int
517 """
518 try:
519 return int(self.total_size)
520 except AttributeError:
521 raise TypeError('collection size is not available')
522
523 def __iter__(self):
524 """Iterate over the items in the collection.
525
526 :return: iterator
527 :rtype: sequence of `Entry`
528 """
529 self._ensure_representation()
530 current_page = self._wadl_resource.representation
531 while True:
532 for resource in self._convert_dicts_to_entries(
533 current_page.get('entries', {})):
534 yield resource
535 next_link = current_page.get('next_collection_link')
536 if next_link is None:
537 break
538 current_page = simplejson.loads(
539 self._root._browser.get(URI(next_link)))
540
541 def __getitem__(self, key):
542 """Look up a slice, or a subordinate resource by index.
543
544 To discourage situations where a launchpadlib client fetches
545 all of an enormous list, all collection slices must have a
546 definitive end point. For performance reasons, all collection
547 slices must be indexed from the start of the list rather than
548 the end.
549 """
550 if isinstance(key, slice):
551 return self._get_slice(key)
552 else:
553 # Look up a single item by its position in the list.
554 found_slice = self._get_slice(slice(key, key+1))
555 if len(found_slice) != 1:
556 raise IndexError("list index out of range")
557 return found_slice[0]
558
559 def _get_slice(self, slice):
560 """Retrieve a slice of a collection."""
561 start = slice.start or 0
562 stop = slice.stop
563
564 if start < 0:
565 raise ValueError("Collection slices must have a nonnegative "
566 "start point.")
567 if stop < 0:
568 raise ValueError("Collection slices must have a definite, "
569 "nonnegative end point.")
570
571 existing_representation = self._wadl_resource.representation
572 if (existing_representation is not None
573 and start < len(existing_representation['entries'])):
574 # An optimization: the first page of entries has already
575 # been loaded. This can happen if this collection is the
576 # return value of a named operation, or if the client did
577 # something like check the length of the collection.
578 #
579 # Either way, we've already made an HTTP request and
580 # gotten some entries back. The client has requested a
581 # slice that includes some of the entries we already have.
582 # In the best case, we can fulfil the slice immediately,
583 # without making another HTTP request.
584 #
585 # Even if we can't fulfil the entire slice, we can get one
586 # or more objects from the first page and then have fewer
587 # objects to retrieve from the server later. This saves us
588 # time and bandwidth, and it might let us save a whole
589 # HTTP request.
590 entry_page = existing_representation['entries']
591
592 first_page_size = len(entry_page)
593 entry_dicts = entry_page[start:stop]
594 page_url = existing_representation.get('next_collection_link')
595 else:
596 # No part of this collection has been loaded yet, or the
597 # slice starts beyond the part that has been loaded. We'll
598 # use our secret knowledge of Launchpad to set a value for
599 # the ws.start variable. That way we start reading entries
600 # from the first one we want.
601 first_page_size = None
602 entry_dicts = []
603 page_url = self._with_url_query_variable_set(
604 self._wadl_resource.url, 'ws.start', start)
605
606 desired_size = stop-start
607 more_needed = desired_size - len(entry_dicts)
608
609 # Iterate over pages until we have the correct number of entries.
610 while more_needed > 0 and page_url is not None:
611 representation = simplejson.loads(
612 self._root._browser.get(page_url))
613 current_page_entries = representation['entries']
614 entry_dicts += current_page_entries[:more_needed]
615 more_needed = desired_size - len(entry_dicts)
616
617 page_url = representation.get('next_collection_link')
618 if page_url is None:
619 # We've gotten the entire collection; there are no
620 # more entries.
621 break
622 if first_page_size is None:
623 first_page_size = len(current_page_entries)
624 if more_needed > 0 and more_needed < first_page_size:
625 # An optimization: it's likely that we need less than
626 # a full page of entries, because the number we need
627 # is less than the size of the first page we got.
628 # Instead of requesting a full-sized page, we'll
629 # request only the number of entries we think we'll
630 # need. If we're wrong, there's no problem; we'll just
631 # keep looping.
632 page_url = self._with_url_query_variable_set(
633 page_url, 'ws.size', more_needed)
634
635 if slice.step is not None:
636 entry_dicts = entry_dicts[::slice.step]
637
638 # Convert entry_dicts into a list of Entry objects.
639 return [resource for resource
640 in self._convert_dicts_to_entries(entry_dicts)]
641
642 def _convert_dicts_to_entries(self, entries):
643 """Convert dictionaries describing entries to Entry objects.
644
645 The dictionaries come from the 'entries' field of the JSON
646 dictionary you get when you GET a page of a collection. Each
647 dictionary is the same as you'd get if you sent a GET request
648 to the corresponding entry resource. So each of these
649 dictionaries can be treated as a preprocessed representation
650 of an entry resource, and turned into an Entry instance.
651
652 :yield: A sequence of Entry instances.
653 """
654 for entry_dict in entries:
655 resource_url = entry_dict['self_link']
656 resource_type_link = entry_dict['resource_type_link']
657 wadl_application = self._wadl_resource.application
658 resource_type = wadl_application.get_resource_type(
659 resource_type_link)
660 resource = WadlResource(
661 self._wadl_resource.application, resource_url,
662 resource_type.tag)
663 yield Resource._create_bound_resource(
664 self._root, resource, entry_dict, self.JSON_MEDIA_TYPE,
665 False)
666
667 def _with_url_query_variable_set(self, url, variable, new_value):
668 """A helper method to set a query variable in a URL."""
669 uri = URI(url)
670 if uri.query is None:
671 params = {}
672 else:
673 params = cgi.parse_qs(uri.query)
674 params[variable] = str(new_value)
675 uri.query = urllib.urlencode(params, True)
676 return str(uri)
677
678
679class CollectionWithKeyBasedLookup(Collection):
680 """A collection-type resource that supports key-based lookup.
681
682 This collection can be sliced, but any single index passed into
683 __getitem__ will be treated as a custom lookup key.
684 """
685
686 def __getitem__(self, key):
687 """Look up a slice, or a subordinate resource by unique ID."""
688 if isinstance(key, slice):
689 return super(CollectionWithKeyBasedLookup, self).__getitem__(key)
690 try:
691 url = self._get_url_from_id(key)
692 except NotImplementedError:
693 raise TypeError("unsubscriptable object")
694 if url is None:
695 raise KeyError(key)
696
697 # We don't know what kind of resource this is. Even the
698 # subclass doesn't necessarily know, because some resources
699 # (the person list) are gateways to more than one kind of
700 # resource (people, and teams). The only way to know for sure
701 # is to retrieve a representation of the resource and see how
702 # the resource describes itself.
703 try:
704 representation = simplejson.loads(self._root._browser.get(url))
705 except HTTPError, error:
706 # There's no resource corresponding to the given ID.
707 if error.response.status == 404:
708 raise KeyError(key)
709 raise
710 # We know that every Launchpad resource has a 'resource_type_link'
711 # in its representation.
712 resource_type_link = representation['resource_type_link']
713 resource = WadlResource(self._root._wadl, url, resource_type_link)
714 return self._create_bound_resource(
715 self._root, resource, representation=representation,
716 representation_needs_processing=False)
717
718
719 def _get_url_from_id(self, key):
720 """Transform the unique ID of an object into its URL."""
721 raise NotImplementedError()
722
723
724class HostedFile(Resource):
725 """A resource represnting a file hosted on Launchpad's server."""
726
727 def open(self, mode='r', content_type=None, filename=None):
728 """Open the file on the server for read or write access."""
729 if mode in ('r', 'w'):
730 return HostedFileBuffer(self, mode, content_type, filename)
731 else:
732 raise ValueError("Invalid mode. Supported modes are: r, w")
733
734 def delete(self):
735 """Delete the file from the server."""
736 self._root._browser.delete(self._wadl_resource.url)
737
738 def _get_parameter_names(self, *kinds):
739 """HostedFile objects define no web service parameters."""
740 return []
741
742
743class HostedFileBuffer(StringIO):
744 """The contents of a file hosted on Launchpad's server."""
745 def __init__(self, hosted_file, mode, content_type=None, filename=None):
746 self.url = hosted_file._wadl_resource.url
747 if mode == 'r':
748 if content_type is not None:
749 raise ValueError("Files opened for read access can't "
750 "specify content_type.")
751 if filename is not None:
752 raise ValueError("Files opened for read access can't "
753 "specify filename.")
754 response, value = hosted_file._root._browser.get(
755 self.url, return_response=True)
756 content_type = response['content-type']
757 last_modified = response['last-modified']
758
759 # The Content-Location header contains the URL of the file
760 # in the Launchpad library. We happen to know that the
761 # final component of the URL is the name of the uploaded
762 # file.
763 content_location = response['content-location']
764 path = urlparse(content_location)[2]
765 filename = urllib.unquote(path.split("/")[-1])
766 elif mode == 'w':
767 value = ''
768 if content_type is None:
769 raise ValueError("Files opened for write access must "
770 "specify content_type.")
771 if filename is None:
772 raise ValueError("Files opened for write access must "
773 "specify filename.")
774 last_modified = None
775 else:
776 raise ValueError("Invalid mode. Supported modes are: r, w")
777
778 self.hosted_file = hosted_file
779 self.mode = mode
780 self.content_type = content_type
781 self.filename = filename
782 self.last_modified = last_modified
783 StringIO.__init__(self, value)
784
785 def close(self):
786 if self.mode == 'w':
787 disposition = 'attachment; filename="%s"' % self.filename
788 self.hosted_file._root._browser.put(
789 self.url, self.getvalue(),
790 self.content_type, {'Content-Disposition' : disposition})
791 StringIO.close(self)
792
793
794class PersonSet(CollectionWithKeyBasedLookup):
795 """A custom subclass capable of person lookup by username."""
796
797 def _get_url_from_id(self, key):
798 """Transform a username into the URL to a person resource."""
799 return str(self._root._root_uri.ensureSlash()) + '~' + str(key)
800
801
802class BugSet(CollectionWithKeyBasedLookup):
803 """A custom subclass capable of bug lookup by bug ID."""
804
805 def _get_url_from_id(self, key):
806 """Transform a bug ID into the URL to a bug resource."""
807 return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key)
808
809
810class PillarSet(CollectionWithKeyBasedLookup):
811 """A custom subclass capable of lookup by pillar name.
812
813 Projects, project groups, and distributions are all pillars.
814 """
815
816 def _get_url_from_id(self, key):
817 """Transform a project name into the URL to a project resource."""
818 return str(self._root._root_uri.ensureSlash()) + str(key)
819
820
821# A mapping of resource type IDs to the client-side classes that handle
822# those resource types.
823RESOURCE_TYPE_CLASSES = {
824 'bugs': BugSet,
825 'distributions': PillarSet,
826 'HostedFile': HostedFile,
827 'people': PersonSet,
828 'project_groups': PillarSet,
829 'projects': PillarSet,
830 }

Subscribers

People subscribed via source and target branches