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

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

Pre-imple: flacoste
Tests: ./test.py -vvt launchpadlib.
Diff: https://pastebin.canonical.com/15558/

This branch refactors almost all of the launchpadlib code into a
generic library called "restfulclient". In a future branch this code
will be separated from launchpadlib and put in the lazr.restfulclient
project.

launchpadlib is now a very small specialization. The
Launchpad-specific code is as follows:

* PersonSet, BugSet, PillarSet: Launchpad-specific data object classes.

* Launchpad: Now a specialization of restfulclient's ServiceRoot, the
  Launchpad class includes code for tracking OAuth credentials.

* OAuthSigningHttp: A specialization of restfulclient's RestfulHttp
  which signs an outgoing HTTP request with OAuth credentials.

* credentials.py is pretty much unchanged.

One thing I haven't done is move wadl-to-refhtml.xsl into
restfulclient. This would mean changing the Launchpad Makefile, which
would mean landing Launchpad and launchpadlib changes
simultaneously. Then I'd have to do it all again when I separate the
restfulclient directory out into the lazr.restfulclient project. I'll
move wadl-to-refhtml.xsl directly into lazr.restfulclient in my next
branch, and save myself the trouble

Revision history for this message
Celso Providelo (cprov) wrote :

Hi Leonard,

Tests pass and the code looks good.

The transition with the restfulclient library within launchpadlib was a very good idea, it makes the migration much easier. Thanks for thinking about it.

r=me, nothing to complain about ;)

review: Approve
45. By Leonard Richardson

Removed xsl file that was moved to restfulclient.

Unmerged revisions

45. By Leonard Richardson

Removed xsl file that was moved to restfulclient.

44. By Leonard Richardson

Changed imports to reduce the size of the diff.

43. By Leonard Richardson

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

42. By Leonard Richardson

Renamed LaunchpadError.

41. By Leonard Richardson

Removed now-useless file.

40. By Leonard Richardson

Fixed copyright notices.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== removed file 'src/launchpadlib/_browser.py'
2--- src/launchpadlib/_browser.py 2009-03-20 20:46:06 +0000
3+++ src/launchpadlib/_browser.py 1970-01-01 00:00:00 +0000
4@@ -1,265 +0,0 @@
5-# Copyright 2008 Canonical Ltd.
6-
7-# This file is part of launchpadlib.
8-#
9-# launchpadlib is free software: you can redistribute it and/or modify it
10-# under the terms of the GNU Lesser General Public License as published by the
11-# Free Software Foundation, version 3 of the License.
12-#
13-# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
14-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
15-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
16-# for more details.
17-#
18-# You should have received a copy of the GNU Lesser General Public License
19-# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
20-
21-"""Browser object to make requests of Launchpad web service.
22-
23-The `Browser` class implements OAuth authenticated communications with
24-Launchpad. It is not part of the public launchpadlib API.
25-"""
26-
27-__metaclass__ = type
28-__all__ = [
29- 'Browser',
30- ]
31-
32-
33-import atexit
34-from cStringIO import StringIO
35-import gzip
36-from httplib2 import (
37- FailedToDecompressContent, FileCache, Http, safename, urlnorm)
38-from lazr.uri import URI
39-from oauth.oauth import (
40- OAuthRequest, OAuthSignatureMethod_PLAINTEXT)
41-import shutil
42-import simplejson
43-import tempfile
44-from urllib import urlencode
45-from wadllib.application import Application
46-import zlib
47-
48-from launchpadlib.errors import HTTPError
49-from launchpadlib._json import DatetimeJSONEncoder
50-
51-
52-OAUTH_REALM = 'https://api.launchpad.net'
53-
54-# A drop-in replacement for httplib2's _decompressContent, which looks
55-# in the Transfer-Encoding header instead of in Content-Encoding.
56-def _decompressContent(response, new_content):
57- content = new_content
58- try:
59- encoding = response.get('transfer-encoding', None)
60- if encoding in ['gzip', 'deflate']:
61- if encoding == 'gzip':
62- content = gzip.GzipFile(
63- fileobj=StringIO.StringIO(new_content)).read()
64- if encoding == 'deflate':
65- content = zlib.decompress(content)
66- response['content-length'] = str(len(content))
67- del response['transfer-encoding']
68- except IOError:
69- content = ""
70- raise FailedToDecompressContent(
71- ("Content purported to be compressed with %s but failed "
72- "to decompress." % response.get('transfer-encoding')),
73- response, content)
74- return content
75-
76-
77-class OAuthSigningHttp(Http):
78- """A client that signs every outgoing request with OAuth credentials."""
79-
80- def __init__(self, oauth_credentials, cache=None, timeout=None,
81- proxy_info=None):
82- self.oauth_credentials = oauth_credentials
83- Http.__init__(self, cache, timeout, proxy_info)
84-
85- def _request(self, conn, host, absolute_uri, request_uri, method, body,
86- headers, redirections, cachekey):
87- """Sign a request with OAuth credentials before sending it."""
88- oauth_request = OAuthRequest.from_consumer_and_token(
89- self.oauth_credentials.consumer,
90- self.oauth_credentials.access_token,
91- http_url=absolute_uri)
92- oauth_request.sign_request(
93- OAuthSignatureMethod_PLAINTEXT(),
94- self.oauth_credentials.consumer,
95- self.oauth_credentials.access_token)
96- if headers.has_key('authorization'):
97- # There's an authorization header left over from a
98- # previous request that resulted in a redirect. Remove it
99- # and start again.
100- del headers['authorization']
101-
102- # httplib2 asks for compressed representations in
103- # Accept-Encoding. But a different content-encoding means a
104- # different ETag, which can cause problems later when we make
105- # a conditional request. We don't want to treat a
106- # representation differently based on whether or not we asked
107- # for a compressed version of it.
108- #
109- # So we move the compression request from Accept-Encoding to
110- # TE. Transfer-encoding compression can be handled transparently.
111- if 'accept-encoding' in headers:
112- headers['te'] = 'deflate, gzip'
113- del headers['accept-encoding']
114- headers.update(oauth_request.to_header(OAUTH_REALM))
115- return super(OAuthSigningHttp, self)._request(
116- conn, host, absolute_uri, request_uri, method, body, headers,
117- redirections, cachekey)
118-
119- def _conn_request(self, conn, request_uri, method, body, headers):
120- """Decompress content using our version of _decompressContent."""
121- response, content = super(OAuthSigningHttp, self)._conn_request(
122- conn, request_uri, method, body, headers)
123- # Decompress the response, if it was compressed.
124- if method != "HEAD":
125- content = _decompressContent(response, content)
126- return (response, content)
127-
128- def _getCachedHeader(self, uri, header):
129- """Retrieve a cached value for an HTTP header."""
130- if isinstance(self.cache, MultipleRepresentationCache):
131- return self.cache._getCachedHeader(uri, header)
132- return None
133-
134-
135-class MultipleRepresentationCache(FileCache):
136- """A cache that can hold different representations of the same resource.
137-
138- If a resource has two representations with two media types,
139- FileCache will only store the most recently fetched
140- representation. This cache can keep track of multiple
141- representations of the same resource.
142-
143- This class works on the assumption that outside calling code sets
144- an instance's request_media_type attribute to the value of the
145- 'Accept' header before initiating the request.
146-
147- This class is very much not thread-safe, but FileCache isn't
148- thread-safe anyway.
149- """
150- def __init__(self, cache):
151- """Tell FileCache to call append_media_type when generating keys."""
152- super(MultipleRepresentationCache, self).__init__(
153- cache, self.append_media_type)
154- self.request_media_type = None
155-
156- def append_media_type(self, key):
157- """Append the request media type to the cache key.
158-
159- This ensures that representations of the same resource will be
160- cached separately, so long as they're served as different
161- media types.
162- """
163- if self.request_media_type is not None:
164- key = key + '-' + self.request_media_type
165- return safename(key)
166-
167-
168- def _getCachedHeader(self, uri, header):
169- """Retrieve a cached value for an HTTP header."""
170- (scheme, authority, request_uri, cachekey) = urlnorm(uri)
171- cached_value = self.get(cachekey)
172- header_start = header + ':'
173- if cached_value is not None:
174- for line in StringIO(cached_value):
175- if line.startswith(header_start):
176- return line[len(header_start):].strip()
177- return None
178-
179-
180-class Browser:
181- """A class for making calls to Launchpad web services."""
182-
183- def __init__(self, credentials, cache=None, timeout=None,
184- proxy_info=None):
185- """Initialize, possibly creating a cache.
186-
187- If no cache is provided, a temporary directory will be used as
188- a cache. The temporary directory will be automatically removed
189- when the Python process exits.
190- """
191- if cache is None:
192- cache = tempfile.mkdtemp()
193- atexit.register(shutil.rmtree, cache)
194- if isinstance(cache, str):
195- cache = MultipleRepresentationCache(cache)
196- self._connection = OAuthSigningHttp(
197- credentials, cache, timeout, proxy_info)
198-
199- def _request(self, url, data=None, method='GET',
200- media_type='application/json', extra_headers=None):
201- """Create an authenticated request object."""
202- # Add extra headers for the request.
203- headers = {'Accept' : media_type}
204- if isinstance(self._connection.cache, MultipleRepresentationCache):
205- self._connection.cache.request_media_type = media_type
206- if extra_headers is not None:
207- headers.update(extra_headers)
208- # Make the request. It will be signed automatically when
209- # _request is called.
210- response, content = self._connection.request(
211- str(url), method=method, body=data, headers=headers)
212- # Turn non-2xx responses into exceptions.
213- if response.status // 100 != 2:
214- raise HTTPError(response, content)
215- return response, content
216-
217- def get(self, resource_or_uri, headers=None, return_response=False):
218- """GET a representation of the given resource or URI."""
219- if isinstance(resource_or_uri, (basestring, URI)):
220- url = resource_or_uri
221- else:
222- method = resource_or_uri.get_method('get')
223- url = method.build_request_url()
224- response, content = self._request(url, extra_headers=headers)
225- if return_response:
226- return (response, content)
227- return content
228-
229- def get_wadl_application(self, url):
230- """GET a WADL representation of the resource at the requested url."""
231- response, content = self._request(
232- url, media_type='application/vd.sun.wadl+xml')
233- return Application(str(url), content)
234-
235- def post(self, url, method_name, **kws):
236- """POST a request to the web service."""
237- kws['ws.op'] = method_name
238- data = urlencode(kws)
239- return self._request(url, data, 'POST')
240-
241- def put(self, url, representation, media_type, headers=None):
242- """PUT the given representation to the URL."""
243- extra_headers = {'Content-Type': media_type}
244- if headers is not None:
245- extra_headers.update(headers)
246- return self._request(
247- url, representation, 'PUT', extra_headers=extra_headers)
248-
249- def delete(self, url):
250- """DELETE the resource at the given URL."""
251- self._request(url, method='DELETE')
252-
253- def patch(self, url, representation, headers=None):
254- """PATCH the object at url with the updated representation."""
255- extra_headers = {'Content-Type': 'application/json'}
256- if headers is not None:
257- extra_headers.update(headers)
258- # httplib2 doesn't know about the PATCH method, so we need to
259- # do some work ourselves. Pull any cached value of "ETag" out
260- # and use it as the value for "If-Match".
261- cached_etag = self._connection._getCachedHeader(str(url), 'etag')
262- if cached_etag is not None and not self._connection.ignore_etag:
263- # http://www.w3.org/1999/04/Editing/
264- headers['If-Match'] = cached_etag
265-
266- return self._request(
267- url, simplejson.dumps(representation,
268- cls=DatetimeJSONEncoder),
269- 'PATCH', extra_headers=extra_headers)
270
271=== modified file 'src/launchpadlib/credentials.py'
272--- src/launchpadlib/credentials.py 2009-03-23 21:50:35 +0000
273+++ src/launchpadlib/credentials.py 2009-03-26 19:26:13 +0000
274@@ -29,7 +29,7 @@
275 from oauth.oauth import OAuthConsumer, OAuthToken
276 from urllib import urlencode
277
278-from launchpadlib.errors import CredentialsFileError, HTTPError
279+from launchpadlib.restfulclient.errors import CredentialsFileError, HTTPError
280
281
282 CREDENTIALS_FILE_VERSION = '1'
283
284=== modified file 'src/launchpadlib/errors.py'
285--- src/launchpadlib/errors.py 2009-03-20 20:46:06 +0000
286+++ src/launchpadlib/errors.py 2009-03-26 19:08:48 +0000
287@@ -14,50 +14,7 @@
288 # You should have received a copy of the GNU Lesser General Public License
289 # along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
290
291-"""launchpadlib errors."""
292-
293-__metaclass__ = type
294-__all__ = [
295- 'CredentialsError',
296- 'CredentialsFileError',
297- 'HTTPError',
298- 'LaunchpadError',
299- 'ResponseError',
300- 'UnexpectedResponseError',
301- ]
302-
303-
304-class LaunchpadError(Exception):
305- """Base error for the Launchpad API library."""
306-
307-
308-class CredentialsError(LaunchpadError):
309- """Base credentials/authentication error."""
310-
311-
312-class CredentialsFileError(CredentialsError):
313- """Error in credentials file."""
314-
315-
316-class ResponseError(LaunchpadError):
317- """Error in response."""
318-
319- def __init__(self, response, content):
320- LaunchpadError.__init__(self)
321- self.response = response
322- self.content = content
323-
324-
325-class UnexpectedResponseError(ResponseError):
326- """An unexpected response was received."""
327-
328- def __str__(self):
329- return '%s: %s' % (self.response.status, self.response.reason)
330-
331-
332-class HTTPError(ResponseError):
333- """An HTTP non-2xx response code was received."""
334-
335- def __str__(self):
336- return 'HTTP Error %s: %s' % (
337- self.response.status, self.response.reason)
338+
339+"""Reimport errors from restfulclient for convenience's sake."""
340+
341+from launchpadlib.restfulclient.errors import *
342
343=== modified file 'src/launchpadlib/launchpad.py'
344--- src/launchpadlib/launchpad.py 2009-03-23 21:50:35 +0000
345+++ src/launchpadlib/launchpad.py 2009-03-26 19:26:13 +0000
346@@ -21,32 +21,64 @@
347 'Launchpad',
348 ]
349
350-import os
351-import shutil
352-import simplejson
353-import stat
354 import sys
355-import tempfile
356-import urlparse
357 import webbrowser
358
359-from wadllib.application import Resource as WadlResource
360 from lazr.uri import URI
361-
362-from launchpadlib._browser import Browser
363-from launchpadlib.resource import Resource
364+from launchpadlib.restfulclient.resource import (
365+ CollectionWithKeyBasedLookup, HostedFile, ServiceRoot)
366 from launchpadlib.credentials import AccessToken, Credentials
367+from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
368+from launchpadlib.restfulclient._browser import RestfulHttp
369
370+OAUTH_REALM = 'https://api.launchpad.net'
371 STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/beta/'
372 EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/beta/'
373
374-class Launchpad(Resource):
375+
376+class PersonSet(CollectionWithKeyBasedLookup):
377+ """A custom subclass capable of person lookup by username."""
378+
379+ def _get_url_from_id(self, key):
380+ """Transform a username into the URL to a person resource."""
381+ return str(self._root._root_uri.ensureSlash()) + '~' + str(key)
382+
383+
384+class BugSet(CollectionWithKeyBasedLookup):
385+ """A custom subclass capable of bug lookup by bug ID."""
386+
387+ def _get_url_from_id(self, key):
388+ """Transform a bug ID into the URL to a bug resource."""
389+ return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key)
390+
391+
392+class PillarSet(CollectionWithKeyBasedLookup):
393+ """A custom subclass capable of lookup by pillar name.
394+
395+ Projects, project groups, and distributions are all pillars.
396+ """
397+
398+ def _get_url_from_id(self, key):
399+ """Transform a project name into the URL to a project resource."""
400+ return str(self._root._root_uri.ensureSlash()) + str(key)
401+
402+
403+class Launchpad(ServiceRoot):
404 """Root Launchpad API class.
405
406 :ivar credentials: The credentials instance used to access Launchpad.
407 :type credentials: `Credentials`
408 """
409
410+ RESOURCE_TYPE_CLASSES = {
411+ 'bugs': BugSet,
412+ 'distributions': PillarSet,
413+ 'HostedFile': HostedFile,
414+ 'people': PersonSet,
415+ 'project_groups': PillarSet,
416+ 'projects': PillarSet,
417+ }
418+
419 def __init__(self, credentials, service_root=STAGING_SERVICE_ROOT,
420 cache=None, timeout=None, proxy_info=None):
421 """Root access to the Launchpad API.
422@@ -56,34 +88,11 @@
423 :param service_root: The URL to the root of the web service.
424 :type service_root: string
425 """
426- self._root_uri = URI(service_root)
427- self.credentials = credentials
428- # Get the WADL definition.
429- self._browser = Browser(self.credentials, cache, timeout, proxy_info)
430- self._wadl = self._browser.get_wadl_application(self._root_uri)
431-
432- # Get the root resource.
433- root_resource = self._wadl.get_resource_by_path('')
434- bound_root = root_resource.bind(
435- self._browser.get(root_resource), 'application/json')
436- super(Launchpad, self).__init__(None, bound_root)
437-
438- def load(self, url):
439- """Load a resource given its URL."""
440- document = self._browser.get(url)
441- try:
442- representation = simplejson.loads(document)
443- except ValueError:
444- raise ValueError("%s doesn't serve a JSON document." % url)
445- type_link = representation.get("resource_type_link")
446- if type_link is None:
447- raise ValueError("Couldn't determine the resource type of %s."
448- % url)
449- resource_type = self._root._wadl.get_resource_type(type_link)
450- wadl_resource = WadlResource(self._root._wadl, url, resource_type.tag)
451- return self._create_bound_resource(
452- self._root, wadl_resource, representation, 'application/json',
453- representation_needs_processing=False)
454+ super(Launchpad, self).__init__(
455+ credentials, service_root, cache, timeout, proxy_info)
456+
457+ def httpFactory(self, credentials, cache, timeout, proxy_info):
458+ return OAuthSigningHttp(credentials, cache, timeout, proxy_info)
459
460 @classmethod
461 def login(cls, consumer_name, token_string, access_secret,
462@@ -157,69 +166,27 @@
463 credentials.exchange_request_token_for_access_token(web_root)
464 return cls(credentials, service_root, cache, timeout, proxy_info)
465
466- @classmethod
467- def login_with(cls, consumer_name,
468- service_root=STAGING_SERVICE_ROOT,
469- launchpadlib_dir=None, timeout=None, proxy_info=None):
470- """Log in to Launchpad with possibly cached credentials.
471-
472- This is a convenience method for either setting up new login
473- credentials, or re-using existing ones. When a login token is
474- generated using this method, the resulting credentials will be
475- saved in the `launchpadlib_dir` directory. If the same
476- `launchpadlib_dir` is passed in a second time, the credentials
477- in `launchpadlib_dir` for the consumer will be used
478- automatically.
479-
480- Each consumer has their own credentials per service root in
481- `launchpadlib_dir`. `launchpadlib_dir` is also used for caching
482- fetched objects. The cache is per service root, and shared by
483- all consumers.
484-
485- See `Launchpad.get_token_and_login()` for more information about
486- how new tokens are generated.
487-
488- :param consumer_name: The consumer name, as appropriate for the
489- `Consumer` constructor
490- :type consumer_name: string
491- :param service_root: The URL to the root of the web service.
492- :type service_root: string
493- :param launchpadlib_dir: The directory where the cache and
494- credentials are stored.
495- :type launchpadlib_dir: string
496- :return: The web service root
497- :rtype: `Launchpad`
498-
499- """
500- if launchpadlib_dir is None:
501- home_dir = os.environ['HOME']
502- launchpadlib_dir = os.path.join(home_dir, '.launchpadlib')
503- launchpadlib_dir = os.path.expanduser(launchpadlib_dir)
504- # Each service root has its own cache and credential dirs.
505- scheme, host_name, path, query, fragment = urlparse.urlsplit(
506- service_root)
507- service_root_dir = os.path.join(launchpadlib_dir, host_name)
508- cache_path = os.path.join(service_root_dir, 'cache')
509- if not os.path.exists(cache_path):
510- os.makedirs(cache_path)
511- credentials_path = os.path.join(service_root_dir, 'credentials')
512- if not os.path.exists(credentials_path):
513- os.makedirs(credentials_path)
514- consumer_credentials_path = os.path.join(
515- credentials_path, consumer_name)
516- if os.path.exists(consumer_credentials_path):
517- credentials = Credentials.load_from_path(
518- consumer_credentials_path)
519- launchpad = cls(
520- credentials, service_root=service_root, cache=cache_path,
521- timeout=timeout, proxy_info=proxy_info)
522- else:
523- launchpad = cls.get_token_and_login(
524- consumer_name, service_root=service_root, cache=cache_path,
525- timeout=timeout, proxy_info=proxy_info)
526- launchpad.credentials.save_to_path(
527- os.path.join(credentials_path, consumer_name))
528- os.chmod(
529- os.path.join(credentials_path, consumer_name),
530- stat.S_IREAD | stat.S_IWRITE)
531- return launchpad
532+
533+class OAuthSigningHttp(RestfulHttp):
534+ """A client that signs every outgoing request with OAuth credentials."""
535+
536+ def _request(self, conn, host, absolute_uri, request_uri, method, body,
537+ headers, redirections, cachekey):
538+ """Sign a request with OAuth credentials before sending it."""
539+ oauth_request = OAuthRequest.from_consumer_and_token(
540+ self.restful_credentials.consumer,
541+ self.restful_credentials.access_token,
542+ http_url=absolute_uri)
543+ oauth_request.sign_request(
544+ OAuthSignatureMethod_PLAINTEXT(),
545+ self.restful_credentials.consumer,
546+ self.restful_credentials.access_token)
547+ if headers.has_key('authorization'):
548+ # There's an authorization header left over from a
549+ # previous request that resulted in a redirect. Remove it
550+ # and start again.
551+ del headers['authorization']
552+ headers.update(oauth_request.to_header(OAUTH_REALM))
553+ return super(OAuthSigningHttp, self)._request(
554+ conn, host, absolute_uri, request_uri, method, body, headers,
555+ redirections, cachekey)
556
557=== removed file 'src/launchpadlib/resource.py'
558--- src/launchpadlib/resource.py 2009-03-20 20:46:06 +0000
559+++ src/launchpadlib/resource.py 1970-01-01 00:00:00 +0000
560@@ -1,830 +0,0 @@
561-# Copyright 2008 Canonical Ltd.
562-
563-# This file is part of launchpadlib.
564-#
565-# launchpadlib is free software: you can redistribute it and/or modify it
566-# under the terms of the GNU Lesser General Public License as published by the
567-# Free Software Foundation, version 3 of the License.
568-#
569-# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
570-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
571-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
572-# for more details.
573-#
574-# You should have received a copy of the GNU Lesser General Public License
575-# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
576-
577-"""Common support for web service resources."""
578-
579-__metaclass__ = type
580-__all__ = [
581- 'Collection',
582- 'Entry',
583- 'NamedOperation',
584- 'Resource',
585- ]
586-
587-
588-import cgi
589-import simplejson
590-from StringIO import StringIO
591-import urllib
592-from urlparse import urlparse
593-from lazr.uri import URI
594-
595-from launchpadlib._json import DatetimeJSONEncoder
596-from launchpadlib.errors import HTTPError
597-from wadllib.application import Resource as WadlResource
598-
599-
600-class HeaderDictionary:
601- """A dictionary that bridges httplib2's and wadllib's expectations.
602-
603- httplib2 expects all header dictionary access to give lowercase
604- header names. wadllib expects to access the header exactly as it's
605- specified in the WADL file, which means the official HTTP header name.
606-
607- This class transforms keys to lowercase before doing a lookup on
608- the underlying dictionary. That way wadllib can pass in the
609- official header name and httplib2 will get the lowercased name.
610- """
611- def __init__(self, wrapped_dictionary):
612- self.wrapped_dictionary = wrapped_dictionary
613-
614- def get(self, key, default=None):
615- """Retrieve a value, converting the key to lowercase."""
616- return self.wrapped_dictionary.get(key.lower())
617-
618- def __getitem__(self, key):
619- """Retrieve a value, converting the key to lowercase."""
620- missing = object()
621- value = self.get(key, missing)
622- if value is missing:
623- raise KeyError(key)
624- return value
625-
626-
627-class LaunchpadBase:
628- """Base class for classes that know about Launchpad."""
629-
630- JSON_MEDIA_TYPE = 'application/json'
631-
632- def _transform_resources_to_links(self, dictionary):
633- new_dictionary = {}
634- for key, value in dictionary.items():
635- if isinstance(value, Resource):
636- value = value.self_link
637- new_dictionary[self._get_external_param_name(key)] = value
638- return new_dictionary
639-
640- def _get_external_param_name(self, param_name):
641- """Turn a launchpadlib name into something to be sent over HTTP.
642-
643- For resources this may involve sticking '_link' or
644- '_collection_link' on the end of the parameter name. For
645- arguments to named operations, the parameter name is returned
646- as is.
647- """
648- return param_name
649-
650-
651-class Resource(LaunchpadBase):
652- """Base class for Launchpad's HTTP resources."""
653-
654- def __init__(self, root, wadl_resource):
655- """Initialize with respect to a wadllib Resource object."""
656- if root is None:
657- # This _is_ the root.
658- root = self
659- # These values need to be put directly into __dict__ to avoid
660- # calling __setattr__, which would cause an infinite recursion.
661- self.__dict__['_root'] = root
662- self.__dict__['_wadl_resource'] = wadl_resource
663-
664- FIND_COLLECTIONS = object()
665- FIND_ENTRIES = object()
666- FIND_ATTRIBUTES = object()
667-
668- @property
669- def lp_collections(self):
670- """Name the collections this resource links to."""
671- return self._get_parameter_names(self.FIND_COLLECTIONS)
672-
673- @property
674- def lp_entries(self):
675- """Name the entries this resource links to."""
676- return self._get_parameter_names(self.FIND_ENTRIES)
677-
678- @property
679- def lp_attributes(self):
680- """Name this resource's scalar attributes."""
681- return self._get_parameter_names(self.FIND_ATTRIBUTES)
682-
683- @property
684- def lp_operations(self):
685- """Name all of this resource's custom operations."""
686- # This library distinguishes between named operations by the
687- # value they give for ws.op, not by their WADL names or IDs.
688- names = []
689- form_encoded_type = 'application/x-www-form-urlencoded'
690- for method in self._wadl_resource.method_iter:
691- name = method.name.lower()
692- if name == 'get':
693- params = method.request.params(['query', 'plain'])
694- elif name == 'post':
695- definition = method.request.representation_definition(
696- form_encoded_type).resolve_definition()
697- params = definition.params(self._wadl_resource)
698- for param in params:
699- if param.name == 'ws.op':
700- names.append(param.fixed_value)
701- break
702- return names
703-
704- @property
705- def __members__(self):
706- """A hook into dir() that returns web service-derived members."""
707- return self._get_parameter_names(
708- self.FIND_COLLECTIONS, self.FIND_ENTRIES, self.FIND_ATTRIBUTES)
709-
710- __methods__ = lp_operations
711-
712- def _get_parameter_names(self, *kinds):
713- """Retrieve some subset of the resource's parameters."""
714- names = []
715- for name in self._wadl_resource.parameter_names(
716- self.JSON_MEDIA_TYPE):
717- if name.endswith('_collection_link'):
718- if self.FIND_COLLECTIONS in kinds:
719- names.append(name[:-16])
720- elif (name.endswith('_link')
721- and name not in ('self_link', 'resource_type_link')):
722- # launchpadlib_obj.self will work, but is never
723- # necessary. launchpadlib_obj.resource_type is also
724- # unneccessary, and won't work anyway because
725- # resource_type_link points to a WADL description,
726- # not a normal Launchpad resource.
727- if self.FIND_ENTRIES in kinds:
728- names.append(name[:-5])
729- elif self.FIND_ATTRIBUTES in kinds:
730- names.append(name)
731- return names
732-
733- def lp_has_parameter(self, param_name):
734- """Does this resource have a parameter with the given name?"""
735- return self._get_external_param_name(param_name) is not None
736-
737- def lp_get_parameter(self, param_name):
738- """Get the value of one of the resource's parameters.
739-
740- :return: A scalar value if the parameter is not a link. A new
741- Resource object, whose resource is bound to a
742- representation, if the parameter is a link.
743- """
744- self._ensure_representation()
745- for suffix in ['_link', '_collection_link']:
746- param = self._wadl_resource.get_parameter(
747- param_name + suffix, self.JSON_MEDIA_TYPE)
748- if param is not None:
749- if param.get_value() is None:
750- # This parameter is a link to another object, but
751- # there's no other object. Return None rather than
752- # chasing down the nonexistent other object.
753- return None
754- linked_resource = param.linked_resource
755- return self._create_bound_resource(
756- self._root, linked_resource, param_name=param.name)
757- param = self._wadl_resource.get_parameter(param_name)
758- if param is None:
759- raise KeyError("No such parameter: %s" % param_name)
760- return param.get_value()
761-
762- def lp_get_named_operation(self, operation_name):
763- """Get a custom operation with the given name.
764-
765- :return: A NamedOperation instance that can be called with
766- appropriate arguments to invoke the operation.
767- """
768- params = { 'ws.op' : operation_name }
769- method = self._wadl_resource.get_method('get', query_params=params)
770- if method is None:
771- method = self._wadl_resource.get_method(
772- 'post', representation_params=params)
773- if method is None:
774- raise KeyError("No operation with name: %s" % operation_name)
775- return NamedOperation(self._root, self, method)
776-
777- @classmethod
778- def _create_bound_resource(
779- cls, root, resource, representation=None,
780- representation_media_type='application/json',
781- representation_needs_processing=True, representation_definition=None,
782- param_name=None):
783- """Create a launchpadlib Resource subclass from a wadllib Resource.
784-
785- :param resource: The wadllib Resource to wrap.
786- :param representation: A previously fetched representation of
787- this resource, to be reused. If not provided, this method
788- will act just like the Resource constructor.
789- :param representation_media_type: The media type of any previously
790- fetched representation.
791- :param representation_needs_processing: Set to False if the
792- 'representation' parameter should be used as
793- is.
794- :param representation_definition: A wadllib
795- RepresentationDefinition object describing the structure
796- of this representation. Used in cases when the representation
797- isn't the result of sending a standard GET to the resource.
798- :param param_name: The name of the link that was followed to get
799- to this resource.
800- :return: An instance of the appropriate launchpadlib Resource
801- subclass.
802- """
803- # We happen to know that all Launchpad resource types are
804- # defined in a single document. Turn the resource's type_url
805- # into an anchor into that document: this is its resource
806- # type. Then look up a client-side class that corresponds to
807- # the resource type.
808- type_url = resource.type_url
809- resource_type = urlparse(type_url)[-1]
810- default = Entry
811- if (type_url.endswith('-page')
812- or (param_name is not None
813- and param_name.endswith('_collection_link'))):
814- default = Collection
815- r_class = RESOURCE_TYPE_CLASSES.get(resource_type, default)
816- if representation is not None:
817- # We've been given a representation. Bind the resource
818- # immediately.
819- resource = resource.bind(
820- representation, representation_media_type,
821- representation_needs_processing,
822- representation_definition=representation_definition)
823- else:
824- # We'll fetch a representation and bind the resource when
825- # necessary.
826- pass
827- return r_class(root, resource)
828-
829- def lp_refresh(self, new_url=None, etag=None):
830- """Update this resource's representation."""
831- if new_url is not None:
832- self._wadl_resource._url = new_url
833- headers = {}
834- if etag is not None:
835- headers['If-None-Match'] = etag
836- try:
837- representation = self._root._browser.get(
838- self._wadl_resource, headers=headers)
839- except HTTPError, e:
840- if e.response['status'] == '304':
841- # The entry wasn't modified. No need to do anything.
842- return
843- else:
844- raise e
845- # __setattr__ assumes we're setting an attribute of the resource,
846- # so we manipulate __dict__ directly.
847- self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
848- representation, self.JSON_MEDIA_TYPE)
849-
850- def __getattr__(self, attr):
851- """Try to retrive a named operation or parameter of the given name."""
852- try:
853- return self.lp_get_parameter(attr)
854- except KeyError:
855- pass
856- try:
857- return self.lp_get_named_operation(attr)
858- except KeyError:
859- raise AttributeError("'%s' object has no attribute '%s'"
860- % (self.__class__.__name__, attr))
861-
862- def _get_external_param_name(self, param_name):
863- """What's this parameter's name in the underlying representation?"""
864- for suffix in ['_link', '_collection_link', '']:
865- name = param_name + suffix
866- if self._wadl_resource.get_parameter(name):
867- return name
868- return None
869-
870- def _ensure_representation(self):
871- """Make sure this resource has a representation fetched."""
872- if self._wadl_resource.representation is None:
873- # Get a representation of the linked resource.
874- representation = self._root._browser.get(self._wadl_resource)
875- self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
876- representation, self.JSON_MEDIA_TYPE)
877-
878-
879-class NamedOperation(LaunchpadBase):
880- """A class for a named operation to be invoked with GET or POST."""
881-
882- def __init__(self, root, resource, wadl_method):
883- """Initialize with respect to a WADL Method object"""
884- self.root = root
885- self.resource = resource
886- self.wadl_method = wadl_method
887-
888- def __call__(self, *args, **kwargs):
889- """Invoke the method and process the result."""
890- if len(args) > 0:
891- raise TypeError('Method must be called with keyword args.')
892- http_method = self.wadl_method.name
893- args = self._transform_resources_to_links(kwargs)
894- for key, value in args.items():
895- args[key] = simplejson.dumps(value, cls=DatetimeJSONEncoder)
896- if http_method in ('get', 'head', 'delete'):
897- url = self.wadl_method.build_request_url(**args)
898- in_representation = ''
899- extra_headers = {}
900- else:
901- url = self.wadl_method.build_request_url()
902- (media_type,
903- in_representation) = self.wadl_method.build_representation(
904- **args)
905- extra_headers = { 'Content-type' : media_type }
906- response, content = self.root._browser._request(
907- url, in_representation, http_method, extra_headers=extra_headers)
908-
909- if response.status == 201:
910- return self._handle_201_response(url, response, content)
911- else:
912- if http_method == 'post':
913- # The method call probably modified this resource in
914- # an unknown way. Refresh its representation.
915- self.resource.lp_refresh()
916- return self._handle_200_response(url, response, content)
917-
918- def _handle_201_response(self, url, response, content):
919- """Handle the creation of a new resource by fetching it."""
920- wadl_response = self.wadl_method.response.bind(
921- HeaderDictionary(response))
922- wadl_parameter = wadl_response.get_parameter('Location')
923- wadl_resource = wadl_parameter.linked_resource
924- # Fetch a representation of the new resource.
925- response, content = self.root._browser._request(
926- wadl_resource.url)
927- # Return an instance of the appropriate launchpadlib
928- # Resource subclass.
929- return Resource._create_bound_resource(
930- self.root, wadl_resource, content, response['content-type'])
931-
932- def _handle_200_response(self, url, response, content):
933- """Process the return value of an operation."""
934- content_type = response['content-type']
935- # Process the returned content, assuming we know how.
936- response_definition = self.wadl_method.response
937- representation_definition = (
938- response_definition.get_representation_definition(
939- content_type))
940-
941- if representation_definition is None:
942- # The operation returned a document with nothing
943- # special about it.
944- if content_type == self.JSON_MEDIA_TYPE:
945- return simplejson.loads(content)
946- # We don't know how to process the content.
947- return content
948-
949- # The operation returned a representation of some
950- # resource. Instantiate a Resource object for it.
951- document = simplejson.loads(content)
952- if "self_link" in document and "resource_type_link" in document:
953- # The operation returned an entry. Use the self_link and
954- # resource_type_link of the entry representation to build
955- # a Resource object of the appropriate type. That way this
956- # object will support all of the right named operations.
957- url = document["self_link"]
958- resource_type = self.root._wadl.get_resource_type(
959- document["resource_type_link"])
960- wadl_resource = WadlResource(self.root._wadl, url,
961- resource_type.tag)
962- else:
963- # The operation returned a collection. It's probably an ad
964- # hoc collection that doesn't correspond to any resource
965- # type. Instantiate it as a resource backed by the
966- # representation type defined in the return value, instead
967- # of a resource type tag.
968- representation_definition = (
969- representation_definition.resolve_definition())
970- wadl_resource = WadlResource(
971- self.root._wadl, url, representation_definition.tag)
972-
973- return Resource._create_bound_resource(
974- self.root, wadl_resource, document, content_type,
975- representation_needs_processing=False,
976- representation_definition=representation_definition)
977-
978- def _get_external_param_name(self, param_name):
979- """Named operation parameter names are sent as is."""
980- return param_name
981-
982-
983-class Entry(Resource):
984- """A class for an entry-type resource that can be updated with PATCH."""
985-
986- def __init__(self, root, wadl_resource):
987- super(Entry, self).__init__(root, wadl_resource)
988- # Initialize this here in a semi-magical way so as to stop a
989- # particular infinite loop that would follow. Setting
990- # self._dirty_attributes would call __setattr__(), which would
991- # turn around immediately and get self._dirty_attributes. If
992- # this latter was not in the instance dictionary, that would
993- # end up calling __getattr__(), which would again reference
994- # self._dirty_attributes. This is where the infloop would
995- # occur. Poking this directly into self.__dict__ means that
996- # the check for self._dirty_attributes won't call __getattr__(),
997- # breaking the cycle.
998- self.__dict__['_dirty_attributes'] = {}
999- super(Entry, self).__init__(root, wadl_resource)
1000-
1001- def __repr__(self):
1002- """Return the WADL resource type and the URL to the resource."""
1003- return '<%s at %s>' % (
1004- URI(self.resource_type_link).fragment, self.self_link)
1005-
1006- def __str__(self):
1007- """Return the URL to the resource."""
1008- return self.self_link
1009-
1010- def __getattr__(self, name):
1011- """Try to retrive a parameter of the given name."""
1012- if name != '_dirty_attributes':
1013- if name in self._dirty_attributes:
1014- return self._dirty_attributes[name]
1015- return super(Entry, self).__getattr__(name)
1016-
1017- def __setattr__(self, name, value):
1018- """Set the parameter of the given name."""
1019- if not self.lp_has_parameter(name):
1020- raise AttributeError("'%s' object has no attribute '%s'" %
1021- (self.__class__.__name__, name))
1022- self._dirty_attributes[name] = value
1023-
1024- def lp_refresh(self, new_url=None):
1025- """Update this resource's representation."""
1026- etag = getattr(self, 'http_etag', None)
1027- super(Entry, self).lp_refresh(new_url, etag)
1028- self._dirty_attributes.clear()
1029-
1030- def lp_save(self):
1031- """Save changes to the entry."""
1032- representation = self._transform_resources_to_links(
1033- self._dirty_attributes)
1034-
1035- # If the entry contains an ETag, set the If-Match header
1036- # to that value.
1037- headers = {}
1038- etag = getattr(self, 'http_etag', None)
1039- if etag is not None:
1040- headers['If-Match'] = etag
1041-
1042- # PATCH the new representation to the 'self' link. It's possible that
1043- # this will cause the object to be permanently moved. Catch that
1044- # exception and refresh our representation.
1045- try:
1046- response, content = self._root._browser.patch(
1047- URI(self.self_link), representation, headers)
1048- except HTTPError, error:
1049- if error.response.status == 301:
1050- response = error.response
1051- self.lp_refresh(error.response['location'])
1052- else:
1053- raise
1054- self._dirty_attributes.clear()
1055-
1056- content_type = response['content-type']
1057- if response.status == 209 and content_type == self.JSON_MEDIA_TYPE:
1058- # The server sent back a new representation of the object.
1059- # Use it in preference to the existing representation.
1060- new_representation = simplejson.loads(content)
1061- self._wadl_resource.representation = new_representation
1062- self._wadl_resource.media_type = content_type
1063-
1064-
1065-class Collection(Resource):
1066- """A collection-type resource that supports pagination."""
1067-
1068- def __init__(self, root, wadl_resource):
1069- """Create a collection object."""
1070- super(Collection, self).__init__(root, wadl_resource)
1071-
1072- def __len__(self):
1073- """The number of items in the collection.
1074-
1075- :return: length of the collection
1076- :rtype: int
1077- """
1078- try:
1079- return int(self.total_size)
1080- except AttributeError:
1081- raise TypeError('collection size is not available')
1082-
1083- def __iter__(self):
1084- """Iterate over the items in the collection.
1085-
1086- :return: iterator
1087- :rtype: sequence of `Entry`
1088- """
1089- self._ensure_representation()
1090- current_page = self._wadl_resource.representation
1091- while True:
1092- for resource in self._convert_dicts_to_entries(
1093- current_page.get('entries', {})):
1094- yield resource
1095- next_link = current_page.get('next_collection_link')
1096- if next_link is None:
1097- break
1098- current_page = simplejson.loads(
1099- self._root._browser.get(URI(next_link)))
1100-
1101- def __getitem__(self, key):
1102- """Look up a slice, or a subordinate resource by index.
1103-
1104- To discourage situations where a launchpadlib client fetches
1105- all of an enormous list, all collection slices must have a
1106- definitive end point. For performance reasons, all collection
1107- slices must be indexed from the start of the list rather than
1108- the end.
1109- """
1110- if isinstance(key, slice):
1111- return self._get_slice(key)
1112- else:
1113- # Look up a single item by its position in the list.
1114- found_slice = self._get_slice(slice(key, key+1))
1115- if len(found_slice) != 1:
1116- raise IndexError("list index out of range")
1117- return found_slice[0]
1118-
1119- def _get_slice(self, slice):
1120- """Retrieve a slice of a collection."""
1121- start = slice.start or 0
1122- stop = slice.stop
1123-
1124- if start < 0:
1125- raise ValueError("Collection slices must have a nonnegative "
1126- "start point.")
1127- if stop < 0:
1128- raise ValueError("Collection slices must have a definite, "
1129- "nonnegative end point.")
1130-
1131- existing_representation = self._wadl_resource.representation
1132- if (existing_representation is not None
1133- and start < len(existing_representation['entries'])):
1134- # An optimization: the first page of entries has already
1135- # been loaded. This can happen if this collection is the
1136- # return value of a named operation, or if the client did
1137- # something like check the length of the collection.
1138- #
1139- # Either way, we've already made an HTTP request and
1140- # gotten some entries back. The client has requested a
1141- # slice that includes some of the entries we already have.
1142- # In the best case, we can fulfil the slice immediately,
1143- # without making another HTTP request.
1144- #
1145- # Even if we can't fulfil the entire slice, we can get one
1146- # or more objects from the first page and then have fewer
1147- # objects to retrieve from the server later. This saves us
1148- # time and bandwidth, and it might let us save a whole
1149- # HTTP request.
1150- entry_page = existing_representation['entries']
1151-
1152- first_page_size = len(entry_page)
1153- entry_dicts = entry_page[start:stop]
1154- page_url = existing_representation.get('next_collection_link')
1155- else:
1156- # No part of this collection has been loaded yet, or the
1157- # slice starts beyond the part that has been loaded. We'll
1158- # use our secret knowledge of Launchpad to set a value for
1159- # the ws.start variable. That way we start reading entries
1160- # from the first one we want.
1161- first_page_size = None
1162- entry_dicts = []
1163- page_url = self._with_url_query_variable_set(
1164- self._wadl_resource.url, 'ws.start', start)
1165-
1166- desired_size = stop-start
1167- more_needed = desired_size - len(entry_dicts)
1168-
1169- # Iterate over pages until we have the correct number of entries.
1170- while more_needed > 0 and page_url is not None:
1171- representation = simplejson.loads(
1172- self._root._browser.get(page_url))
1173- current_page_entries = representation['entries']
1174- entry_dicts += current_page_entries[:more_needed]
1175- more_needed = desired_size - len(entry_dicts)
1176-
1177- page_url = representation.get('next_collection_link')
1178- if page_url is None:
1179- # We've gotten the entire collection; there are no
1180- # more entries.
1181- break
1182- if first_page_size is None:
1183- first_page_size = len(current_page_entries)
1184- if more_needed > 0 and more_needed < first_page_size:
1185- # An optimization: it's likely that we need less than
1186- # a full page of entries, because the number we need
1187- # is less than the size of the first page we got.
1188- # Instead of requesting a full-sized page, we'll
1189- # request only the number of entries we think we'll
1190- # need. If we're wrong, there's no problem; we'll just
1191- # keep looping.
1192- page_url = self._with_url_query_variable_set(
1193- page_url, 'ws.size', more_needed)
1194-
1195- if slice.step is not None:
1196- entry_dicts = entry_dicts[::slice.step]
1197-
1198- # Convert entry_dicts into a list of Entry objects.
1199- return [resource for resource
1200- in self._convert_dicts_to_entries(entry_dicts)]
1201-
1202- def _convert_dicts_to_entries(self, entries):
1203- """Convert dictionaries describing entries to Entry objects.
1204-
1205- The dictionaries come from the 'entries' field of the JSON
1206- dictionary you get when you GET a page of a collection. Each
1207- dictionary is the same as you'd get if you sent a GET request
1208- to the corresponding entry resource. So each of these
1209- dictionaries can be treated as a preprocessed representation
1210- of an entry resource, and turned into an Entry instance.
1211-
1212- :yield: A sequence of Entry instances.
1213- """
1214- for entry_dict in entries:
1215- resource_url = entry_dict['self_link']
1216- resource_type_link = entry_dict['resource_type_link']
1217- wadl_application = self._wadl_resource.application
1218- resource_type = wadl_application.get_resource_type(
1219- resource_type_link)
1220- resource = WadlResource(
1221- self._wadl_resource.application, resource_url,
1222- resource_type.tag)
1223- yield Resource._create_bound_resource(
1224- self._root, resource, entry_dict, self.JSON_MEDIA_TYPE,
1225- False)
1226-
1227- def _with_url_query_variable_set(self, url, variable, new_value):
1228- """A helper method to set a query variable in a URL."""
1229- uri = URI(url)
1230- if uri.query is None:
1231- params = {}
1232- else:
1233- params = cgi.parse_qs(uri.query)
1234- params[variable] = str(new_value)
1235- uri.query = urllib.urlencode(params, True)
1236- return str(uri)
1237-
1238-
1239-class CollectionWithKeyBasedLookup(Collection):
1240- """A collection-type resource that supports key-based lookup.
1241-
1242- This collection can be sliced, but any single index passed into
1243- __getitem__ will be treated as a custom lookup key.
1244- """
1245-
1246- def __getitem__(self, key):
1247- """Look up a slice, or a subordinate resource by unique ID."""
1248- if isinstance(key, slice):
1249- return super(CollectionWithKeyBasedLookup, self).__getitem__(key)
1250- try:
1251- url = self._get_url_from_id(key)
1252- except NotImplementedError:
1253- raise TypeError("unsubscriptable object")
1254- if url is None:
1255- raise KeyError(key)
1256-
1257- # We don't know what kind of resource this is. Even the
1258- # subclass doesn't necessarily know, because some resources
1259- # (the person list) are gateways to more than one kind of
1260- # resource (people, and teams). The only way to know for sure
1261- # is to retrieve a representation of the resource and see how
1262- # the resource describes itself.
1263- try:
1264- representation = simplejson.loads(self._root._browser.get(url))
1265- except HTTPError, error:
1266- # There's no resource corresponding to the given ID.
1267- if error.response.status == 404:
1268- raise KeyError(key)
1269- raise
1270- # We know that every Launchpad resource has a 'resource_type_link'
1271- # in its representation.
1272- resource_type_link = representation['resource_type_link']
1273- resource = WadlResource(self._root._wadl, url, resource_type_link)
1274- return self._create_bound_resource(
1275- self._root, resource, representation=representation,
1276- representation_needs_processing=False)
1277-
1278-
1279- def _get_url_from_id(self, key):
1280- """Transform the unique ID of an object into its URL."""
1281- raise NotImplementedError()
1282-
1283-
1284-class HostedFile(Resource):
1285- """A resource represnting a file hosted on Launchpad's server."""
1286-
1287- def open(self, mode='r', content_type=None, filename=None):
1288- """Open the file on the server for read or write access."""
1289- if mode in ('r', 'w'):
1290- return HostedFileBuffer(self, mode, content_type, filename)
1291- else:
1292- raise ValueError("Invalid mode. Supported modes are: r, w")
1293-
1294- def delete(self):
1295- """Delete the file from the server."""
1296- self._root._browser.delete(self._wadl_resource.url)
1297-
1298- def _get_parameter_names(self, *kinds):
1299- """HostedFile objects define no web service parameters."""
1300- return []
1301-
1302-
1303-class HostedFileBuffer(StringIO):
1304- """The contents of a file hosted on Launchpad's server."""
1305- def __init__(self, hosted_file, mode, content_type=None, filename=None):
1306- self.url = hosted_file._wadl_resource.url
1307- if mode == 'r':
1308- if content_type is not None:
1309- raise ValueError("Files opened for read access can't "
1310- "specify content_type.")
1311- if filename is not None:
1312- raise ValueError("Files opened for read access can't "
1313- "specify filename.")
1314- response, value = hosted_file._root._browser.get(
1315- self.url, return_response=True)
1316- content_type = response['content-type']
1317- last_modified = response['last-modified']
1318-
1319- # The Content-Location header contains the URL of the file
1320- # in the Launchpad library. We happen to know that the
1321- # final component of the URL is the name of the uploaded
1322- # file.
1323- content_location = response['content-location']
1324- path = urlparse(content_location)[2]
1325- filename = urllib.unquote(path.split("/")[-1])
1326- elif mode == 'w':
1327- value = ''
1328- if content_type is None:
1329- raise ValueError("Files opened for write access must "
1330- "specify content_type.")
1331- if filename is None:
1332- raise ValueError("Files opened for write access must "
1333- "specify filename.")
1334- last_modified = None
1335- else:
1336- raise ValueError("Invalid mode. Supported modes are: r, w")
1337-
1338- self.hosted_file = hosted_file
1339- self.mode = mode
1340- self.content_type = content_type
1341- self.filename = filename
1342- self.last_modified = last_modified
1343- StringIO.__init__(self, value)
1344-
1345- def close(self):
1346- if self.mode == 'w':
1347- disposition = 'attachment; filename="%s"' % self.filename
1348- self.hosted_file._root._browser.put(
1349- self.url, self.getvalue(),
1350- self.content_type, {'Content-Disposition' : disposition})
1351- StringIO.close(self)
1352-
1353-
1354-class PersonSet(CollectionWithKeyBasedLookup):
1355- """A custom subclass capable of person lookup by username."""
1356-
1357- def _get_url_from_id(self, key):
1358- """Transform a username into the URL to a person resource."""
1359- return str(self._root._root_uri.ensureSlash()) + '~' + str(key)
1360-
1361-
1362-class BugSet(CollectionWithKeyBasedLookup):
1363- """A custom subclass capable of bug lookup by bug ID."""
1364-
1365- def _get_url_from_id(self, key):
1366- """Transform a bug ID into the URL to a bug resource."""
1367- return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key)
1368-
1369-
1370-class PillarSet(CollectionWithKeyBasedLookup):
1371- """A custom subclass capable of lookup by pillar name.
1372-
1373- Projects, project groups, and distributions are all pillars.
1374- """
1375-
1376- def _get_url_from_id(self, key):
1377- """Transform a project name into the URL to a project resource."""
1378- return str(self._root._root_uri.ensureSlash()) + str(key)
1379-
1380-
1381-# A mapping of resource type IDs to the client-side classes that handle
1382-# those resource types.
1383-RESOURCE_TYPE_CLASSES = {
1384- 'bugs': BugSet,
1385- 'distributions': PillarSet,
1386- 'HostedFile': HostedFile,
1387- 'people': PersonSet,
1388- 'project_groups': PillarSet,
1389- 'projects': PillarSet,
1390- }
1391
1392=== added directory 'src/launchpadlib/restfulclient'
1393=== added file 'src/launchpadlib/restfulclient/__init__.py'
1394--- src/launchpadlib/restfulclient/__init__.py 1970-01-01 00:00:00 +0000
1395+++ src/launchpadlib/restfulclient/__init__.py 2009-03-26 18:40:36 +0000
1396@@ -0,0 +1,19 @@
1397+# Copyright 2008 Canonical Ltd.
1398+
1399+# This file is part of lazr.restfulclient.
1400+#
1401+# lazr.restfulclient is free software: you can redistribute it and/or modify
1402+# it under the terms of the GNU Lesser General Public License as
1403+# published by the Free Software Foundation, either version 3 of the
1404+# License, or (at your option) any later version.
1405+#
1406+# lazr.restfulclient is distributed in the hope that it will be useful, but
1407+# WITHOUT ANY WARRANTY; without even the implied warranty of
1408+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1409+# Lesser General Public License for more details.
1410+#
1411+# You should have received a copy of the GNU Lesser General Public
1412+# License along with lazr.restfulclient. If not, see
1413+# <http://www.gnu.org/licenses/>.
1414+
1415+__version__ = '1.0'
1416
1417=== added file 'src/launchpadlib/restfulclient/_browser.py'
1418--- src/launchpadlib/restfulclient/_browser.py 1970-01-01 00:00:00 +0000
1419+++ src/launchpadlib/restfulclient/_browser.py 2009-03-26 19:26:13 +0000
1420@@ -0,0 +1,258 @@
1421+# Copyright 2008 Canonical Ltd.
1422+
1423+# This file is part of lazr.restfulclient.
1424+#
1425+# lazr.restfulclient is free software: you can redistribute it and/or modify
1426+# it under the terms of the GNU Lesser General Public License as
1427+# published by the Free Software Foundation, either version 3 of the
1428+# License, or (at your option) any later version.
1429+#
1430+# lazr.restfulclient is distributed in the hope that it will be useful, but
1431+# WITHOUT ANY WARRANTY; without even the implied warranty of
1432+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1433+# Lesser General Public License for more details.
1434+#
1435+# You should have received a copy of the GNU Lesser General Public
1436+# License along with lazr.restfulclient. If not, see
1437+# <http://www.gnu.org/licenses/>.
1438+
1439+"""Browser object to make requests of lazr.restful web services.
1440+
1441+The `Browser` class does some massage of HTTP requests and responses,
1442+and handles custom caches. It is not part of the public
1443+lazr.restfulclient API. (But maybe it should be?)
1444+"""
1445+
1446+__metaclass__ = type
1447+__all__ = [
1448+ 'Browser',
1449+ 'RestfulHttp',
1450+ ]
1451+
1452+
1453+import atexit
1454+import gzip
1455+import shutil
1456+import tempfile
1457+from httplib2 import (
1458+ FailedToDecompressContent, FileCache, Http, safename, urlnorm)
1459+import simplejson
1460+from cStringIO import StringIO
1461+import zlib
1462+
1463+from urllib import urlencode
1464+from wadllib.application import Application
1465+from lazr.uri import URI
1466+from errors import HTTPError
1467+from _json import DatetimeJSONEncoder
1468+
1469+# A drop-in replacement for httplib2's _decompressContent, which looks
1470+# in the Transfer-Encoding header instead of in Content-Encoding.
1471+def _decompressContent(response, new_content):
1472+ content = new_content
1473+ try:
1474+ encoding = response.get('transfer-encoding', None)
1475+ if encoding in ['gzip', 'deflate']:
1476+ if encoding == 'gzip':
1477+ content = gzip.GzipFile(
1478+ fileobj=StringIO.StringIO(new_content)).read()
1479+ if encoding == 'deflate':
1480+ content = zlib.decompress(content)
1481+ response['content-length'] = str(len(content))
1482+ del response['transfer-encoding']
1483+ except IOError:
1484+ content = ""
1485+ raise FailedToDecompressContent(
1486+ ("Content purported to be compressed with %s but failed "
1487+ "to decompress." % response.get('transfer-encoding')),
1488+ response, content)
1489+ return content
1490+
1491+
1492+class RestfulHttp(Http):
1493+ """An Http subclass with some custom behavior.
1494+
1495+ This Http client uses the TE header instead of the Accept-Encoding
1496+ header to ask for compressed representations. It also knows how to
1497+ react when its cache is a MultipleRepresentationCache.
1498+ """
1499+
1500+ def __init__(self, credentials, cache=None, timeout=None,
1501+ proxy_info=None):
1502+ super(RestfulHttp, self).__init__(cache, timeout, proxy_info)
1503+ # The credentials are not used in this class, but you can
1504+ # use them in a subclass.
1505+ self.restful_credentials = credentials
1506+
1507+
1508+ def _request(self, conn, host, absolute_uri, request_uri, method, body,
1509+ headers, redirections, cachekey):
1510+ """Manipulate Transfer-Encoding header before sending the request.
1511+
1512+ httplib2 asks for compressed representations in
1513+ Accept-Encoding. But a different content-encoding means a
1514+ different ETag, which can cause problems later when we make
1515+ a conditional request. We don't want to treat a
1516+ representation differently based on whether or not we asked
1517+ for a compressed version of it.
1518+
1519+ So we move the compression request from Accept-Encoding to
1520+ TE. Transfer-encoding compression can be handled
1521+ transparently, without affecting the ETag.
1522+ """
1523+ if 'accept-encoding' in headers:
1524+ headers['te'] = 'deflate, gzip'
1525+ del headers['accept-encoding']
1526+ return super(RestfulHttp, self)._request(
1527+ conn, host, absolute_uri, request_uri, method, body, headers,
1528+ redirections, cachekey)
1529+
1530+ def _conn_request(self, conn, request_uri, method, body, headers):
1531+ """Decompress content using our version of _decompressContent."""
1532+ response, content = super(RestfulHttp, self)._conn_request(
1533+ conn, request_uri, method, body, headers)
1534+ # Decompress the response, if it was compressed.
1535+ if method != "HEAD":
1536+ content = _decompressContent(response, content)
1537+ return (response, content)
1538+
1539+ def _getCachedHeader(self, uri, header):
1540+ """Retrieve a cached value for an HTTP header."""
1541+ if isinstance(self.cache, MultipleRepresentationCache):
1542+ return self.cache._getCachedHeader(uri, header)
1543+ return None
1544+
1545+
1546+class MultipleRepresentationCache(FileCache):
1547+ """A cache that can hold different representations of the same resource.
1548+
1549+ If a resource has two representations with two media types,
1550+ FileCache will only store the most recently fetched
1551+ representation. This cache can keep track of multiple
1552+ representations of the same resource.
1553+
1554+ This class works on the assumption that outside calling code sets
1555+ an instance's request_media_type attribute to the value of the
1556+ 'Accept' header before initiating the request.
1557+
1558+ This class is very much not thread-safe, but FileCache isn't
1559+ thread-safe anyway.
1560+ """
1561+ def __init__(self, cache):
1562+ """Tell FileCache to call append_media_type when generating keys."""
1563+ super(MultipleRepresentationCache, self).__init__(
1564+ cache, self.append_media_type)
1565+ self.request_media_type = None
1566+
1567+ def append_media_type(self, key):
1568+ """Append the request media type to the cache key.
1569+
1570+ This ensures that representations of the same resource will be
1571+ cached separately, so long as they're served as different
1572+ media types.
1573+ """
1574+ if self.request_media_type is not None:
1575+ key = key + '-' + self.request_media_type
1576+ return safename(key)
1577+
1578+
1579+ def _getCachedHeader(self, uri, header):
1580+ """Retrieve a cached value for an HTTP header."""
1581+ (scheme, authority, request_uri, cachekey) = urlnorm(uri)
1582+ cached_value = self.get(cachekey)
1583+ header_start = header + ':'
1584+ if cached_value is not None:
1585+ for line in StringIO(cached_value):
1586+ if line.startswith(header_start):
1587+ return line[len(header_start):].strip()
1588+ return None
1589+
1590+
1591+class Browser:
1592+ """A class for making calls to lazr.restful web services."""
1593+
1594+ def __init__(self, service_root, credentials, cache=None, timeout=None,
1595+ proxy_info=None):
1596+ """Initialize, possibly creating a cache.
1597+
1598+ If no cache is provided, a temporary directory will be used as
1599+ a cache. The temporary directory will be automatically removed
1600+ when the Python process exits.
1601+ """
1602+ if cache is None:
1603+ cache = tempfile.mkdtemp()
1604+ atexit.register(shutil.rmtree, cache)
1605+ if isinstance(cache, str):
1606+ cache = MultipleRepresentationCache(cache)
1607+ self._connection = service_root.httpFactory(
1608+ credentials, cache, timeout, proxy_info)
1609+
1610+ def _request(self, url, data=None, method='GET',
1611+ media_type='application/json', extra_headers=None):
1612+ """Create an authenticated request object."""
1613+ # Add extra headers for the request.
1614+ headers = {'Accept' : media_type}
1615+ if isinstance(self._connection.cache, MultipleRepresentationCache):
1616+ self._connection.cache.request_media_type = media_type
1617+ if extra_headers is not None:
1618+ headers.update(extra_headers)
1619+ # Make the request.
1620+ response, content = self._connection.request(
1621+ str(url), method=method, body=data, headers=headers)
1622+ # Turn non-2xx responses into exceptions.
1623+ if response.status // 100 != 2:
1624+ raise HTTPError(response, content)
1625+ return response, content
1626+
1627+ def get(self, resource_or_uri, headers=None, return_response=False):
1628+ """GET a representation of the given resource or URI."""
1629+ if isinstance(resource_or_uri, (basestring, URI)):
1630+ url = resource_or_uri
1631+ else:
1632+ method = resource_or_uri.get_method('get')
1633+ url = method.build_request_url()
1634+ response, content = self._request(url, extra_headers=headers)
1635+ if return_response:
1636+ return (response, content)
1637+ return content
1638+
1639+ def get_wadl_application(self, url):
1640+ """GET a WADL representation of the resource at the requested url."""
1641+ response, content = self._request(
1642+ url, media_type='application/vd.sun.wadl+xml')
1643+ return Application(str(url), content)
1644+
1645+ def post(self, url, method_name, **kws):
1646+ """POST a request to the web service."""
1647+ kws['ws.op'] = method_name
1648+ data = urlencode(kws)
1649+ return self._request(url, data, 'POST')
1650+
1651+ def put(self, url, representation, media_type, headers=None):
1652+ """PUT the given representation to the URL."""
1653+ extra_headers = {'Content-Type': media_type}
1654+ if headers is not None:
1655+ extra_headers.update(headers)
1656+ return self._request(
1657+ url, representation, 'PUT', extra_headers=extra_headers)
1658+
1659+ def delete(self, url):
1660+ """DELETE the resource at the given URL."""
1661+ self._request(url, method='DELETE')
1662+
1663+ def patch(self, url, representation, headers=None):
1664+ """PATCH the object at url with the updated representation."""
1665+ extra_headers = {'Content-Type': 'application/json'}
1666+ if headers is not None:
1667+ extra_headers.update(headers)
1668+ # httplib2 doesn't know about the PATCH method, so we need to
1669+ # do some work ourselves. Pull any cached value of "ETag" out
1670+ # and use it as the value for "If-Match".
1671+ cached_etag = self._connection._getCachedHeader(str(url), 'etag')
1672+ if cached_etag is not None and not self._connection.ignore_etag:
1673+ # http://www.w3.org/1999/04/Editing/
1674+ headers['If-Match'] = cached_etag
1675+
1676+ return self._request(
1677+ url, simplejson.dumps(representation, cls=DatetimeJSONEncoder),
1678+ 'PATCH', extra_headers=extra_headers)
1679
1680=== renamed file 'src/launchpadlib/_json.py' => 'src/launchpadlib/restfulclient/_json.py'
1681--- src/launchpadlib/_json.py 2009-03-20 20:46:06 +0000
1682+++ src/launchpadlib/restfulclient/_json.py 2009-03-26 18:40:36 +0000
1683@@ -1,18 +1,20 @@
1684 # Copyright 2009 Canonical Ltd.
1685
1686-# This file is part of launchpadlib.
1687-#
1688-# launchpadlib is free software: you can redistribute it and/or modify it
1689-# under the terms of the GNU Lesser General Public License as published by the
1690-# Free Software Foundation, version 3 of the License.
1691-#
1692-# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
1693-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
1694-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
1695-# for more details.
1696-#
1697-# You should have received a copy of the GNU Lesser General Public License
1698-# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
1699+# This file is part of lazr.restfulclient.
1700+#
1701+# lazr.restfulclient is free software: you can redistribute it and/or
1702+# modify it under the terms of the GNU Lesser General Public License
1703+# as published by the Free Software Foundation, version 3 of the
1704+# License.
1705+#
1706+# lazr.restfulclient is distributed in the hope that it will be
1707+# useful, but WITHOUT ANY WARRANTY; without even the implied warranty
1708+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1709+# Lesser General Public License for more details.
1710+#
1711+# You should have received a copy of the GNU Lesser General Public
1712+# License along with lazr.restfulclient. If not, see
1713+# <http://www.gnu.org/licenses/>.
1714
1715 """Classes for working with JSON."""
1716
1717@@ -22,6 +24,7 @@
1718 import datetime
1719 import simplejson
1720
1721+
1722 class DatetimeJSONEncoder(simplejson.JSONEncoder):
1723 """A JSON encoder that understands datetime objects.
1724
1725
1726=== added file 'src/launchpadlib/restfulclient/errors.py'
1727--- src/launchpadlib/restfulclient/errors.py 1970-01-01 00:00:00 +0000
1728+++ src/launchpadlib/restfulclient/errors.py 2009-03-26 19:08:48 +0000
1729@@ -0,0 +1,65 @@
1730+# Copyright 2008 Canonical Ltd.
1731+
1732+# This file is part of lazr.restfulclient.
1733+#
1734+# lazr.restfulclient is free software: you can redistribute it and/or modify
1735+# it under the terms of the GNU Lesser General Public License as
1736+# published by the Free Software Foundation, either version 3 of the
1737+# License, or (at your option) any later version.
1738+#
1739+# lazr.restfulclient is distributed in the hope that it will be useful, but
1740+# WITHOUT ANY WARRANTY; without even the implied warranty of
1741+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1742+# Lesser General Public License for more details.
1743+#
1744+# You should have received a copy of the GNU Lesser General Public
1745+# License along with lazr.restfulclient. If not, see
1746+# <http://www.gnu.org/licenses/>.
1747+
1748+"""lazr.restfulclient errors."""
1749+
1750+__metaclass__ = type
1751+__all__ = [
1752+ 'CredentialsError',
1753+ 'CredentialsFileError',
1754+ 'HTTPError',
1755+ 'RestfulError',
1756+ 'ResponseError',
1757+ 'UnexpectedResponseError',
1758+ ]
1759+
1760+
1761+class RestfulError(Exception):
1762+ """Base error for the lazr.restfulclient API library."""
1763+
1764+
1765+class CredentialsError(RestfulError):
1766+ """Base credentials/authentication error."""
1767+
1768+
1769+class CredentialsFileError(CredentialsError):
1770+ """Error in credentials file."""
1771+
1772+
1773+class ResponseError(RestfulError):
1774+ """Error in response."""
1775+
1776+ def __init__(self, response, content):
1777+ RestfulError.__init__(self)
1778+ self.response = response
1779+ self.content = content
1780+
1781+
1782+class UnexpectedResponseError(ResponseError):
1783+ """An unexpected response was received."""
1784+
1785+ def __str__(self):
1786+ return '%s: %s' % (self.response.status, self.response.reason)
1787+
1788+
1789+class HTTPError(ResponseError):
1790+ """An HTTP non-2xx response code was received."""
1791+
1792+ def __str__(self):
1793+ return 'HTTP Error %s: %s' % (
1794+ self.response.status, self.response.reason)
1795
1796=== added file 'src/launchpadlib/restfulclient/resource.py'
1797--- src/launchpadlib/restfulclient/resource.py 1970-01-01 00:00:00 +0000
1798+++ src/launchpadlib/restfulclient/resource.py 2009-03-26 18:40:36 +0000
1799@@ -0,0 +1,846 @@
1800+# Copyright 2008 Canonical Ltd.
1801+
1802+# This file is part of lazr.restfulclient.
1803+#
1804+# lazr.restfulclient is free software: you can redistribute it and/or
1805+# modify it under the terms of the GNU Lesser General Public License
1806+# as published by the Free Software Foundation, either version 3 of
1807+# the License, or (at your option) any later version.
1808+#
1809+# lazr.restfulclient is distributed in the hope that it will be
1810+# useful, but WITHOUT ANY WARRANTY; without even the implied warranty
1811+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1812+# Lesser General Public License for more details.
1813+#
1814+# You should have received a copy of the GNU Lesser General Public
1815+# License along with lazr.restfulclient. If not, see
1816+# <http://www.gnu.org/licenses/>.
1817+
1818+"""Common support for web service resources."""
1819+
1820+__metaclass__ = type
1821+__all__ = [
1822+ 'Collection',
1823+ 'Entry',
1824+ 'NamedOperation',
1825+ 'Resource',
1826+ ]
1827+
1828+
1829+import cgi
1830+import simplejson
1831+from StringIO import StringIO
1832+import urllib
1833+from urlparse import urlparse
1834+
1835+from lazr.uri import URI
1836+from wadllib.application import Resource as WadlResource
1837+from _browser import Browser, RestfulHttp
1838+from _json import DatetimeJSONEncoder
1839+from errors import HTTPError
1840+
1841+
1842+class HeaderDictionary:
1843+ """A dictionary that bridges httplib2's and wadllib's expectations.
1844+
1845+ httplib2 expects all header dictionary access to give lowercase
1846+ header names. wadllib expects to access the header exactly as it's
1847+ specified in the WADL file, which means the official HTTP header name.
1848+
1849+ This class transforms keys to lowercase before doing a lookup on
1850+ the underlying dictionary. That way wadllib can pass in the
1851+ official header name and httplib2 will get the lowercased name.
1852+ """
1853+ def __init__(self, wrapped_dictionary):
1854+ self.wrapped_dictionary = wrapped_dictionary
1855+
1856+ def get(self, key, default=None):
1857+ """Retrieve a value, converting the key to lowercase."""
1858+ return self.wrapped_dictionary.get(key.lower())
1859+
1860+ def __getitem__(self, key):
1861+ """Retrieve a value, converting the key to lowercase."""
1862+ missing = object()
1863+ value = self.get(key, missing)
1864+ if value is missing:
1865+ raise KeyError(key)
1866+ return value
1867+
1868+
1869+class RestfulBase:
1870+ """Base class for classes that know about lazr.restful services."""
1871+
1872+ JSON_MEDIA_TYPE = 'application/json'
1873+
1874+ def _transform_resources_to_links(self, dictionary):
1875+ new_dictionary = {}
1876+ for key, value in dictionary.items():
1877+ if isinstance(value, Resource):
1878+ value = value.self_link
1879+ new_dictionary[self._get_external_param_name(key)] = value
1880+ return new_dictionary
1881+
1882+ def _get_external_param_name(self, param_name):
1883+ """Turn a lazr.restful name into something to be sent over HTTP.
1884+
1885+ For resources this may involve sticking '_link' or
1886+ '_collection_link' on the end of the parameter name. For
1887+ arguments to named operations, the parameter name is returned
1888+ as is.
1889+ """
1890+ return param_name
1891+
1892+
1893+class Resource(RestfulBase):
1894+ """Base class for lazr.restful HTTP resources."""
1895+
1896+ def __init__(self, root, wadl_resource):
1897+ """Initialize with respect to a wadllib Resource object."""
1898+ if root is None:
1899+ # This _is_ the root.
1900+ root = self
1901+ # These values need to be put directly into __dict__ to avoid
1902+ # calling __setattr__, which would cause an infinite recursion.
1903+ self.__dict__['_root'] = root
1904+ self.__dict__['_wadl_resource'] = wadl_resource
1905+
1906+ FIND_COLLECTIONS = object()
1907+ FIND_ENTRIES = object()
1908+ FIND_ATTRIBUTES = object()
1909+
1910+ @property
1911+ def lp_collections(self):
1912+ """Name the collections this resource links to."""
1913+ return self._get_parameter_names(self.FIND_COLLECTIONS)
1914+
1915+ @property
1916+ def lp_entries(self):
1917+ """Name the entries this resource links to."""
1918+ return self._get_parameter_names(self.FIND_ENTRIES)
1919+
1920+ @property
1921+ def lp_attributes(self):
1922+ """Name this resource's scalar attributes."""
1923+ return self._get_parameter_names(self.FIND_ATTRIBUTES)
1924+
1925+ @property
1926+ def lp_operations(self):
1927+ """Name all of this resource's custom operations."""
1928+ # This library distinguishes between named operations by the
1929+ # value they give for ws.op, not by their WADL names or IDs.
1930+ names = []
1931+ form_encoded_type = 'application/x-www-form-urlencoded'
1932+ for method in self._wadl_resource.method_iter:
1933+ name = method.name.lower()
1934+ if name == 'get':
1935+ params = method.request.params(['query', 'plain'])
1936+ elif name == 'post':
1937+ definition = method.request.representation_definition(
1938+ form_encoded_type).resolve_definition()
1939+ params = definition.params(self._wadl_resource)
1940+ for param in params:
1941+ if param.name == 'ws.op':
1942+ names.append(param.fixed_value)
1943+ break
1944+ return names
1945+
1946+ @property
1947+ def __members__(self):
1948+ """A hook into dir() that returns web service-derived members."""
1949+ return self._get_parameter_names(
1950+ self.FIND_COLLECTIONS, self.FIND_ENTRIES, self.FIND_ATTRIBUTES)
1951+
1952+ __methods__ = lp_operations
1953+
1954+ def _get_parameter_names(self, *kinds):
1955+ """Retrieve some subset of the resource's parameters."""
1956+ names = []
1957+ for name in self._wadl_resource.parameter_names(
1958+ self.JSON_MEDIA_TYPE):
1959+ if name.endswith('_collection_link'):
1960+ if self.FIND_COLLECTIONS in kinds:
1961+ names.append(name[:-16])
1962+ elif (name.endswith('_link')
1963+ and name not in ('self_link', 'resource_type_link')):
1964+ # lazr.restful_obj.self will work, but is never
1965+ # necessary. lazr.restful_obj.resource_type is also
1966+ # unneccessary, and won't work anyway because
1967+ # resource_type_link points to a WADL description,
1968+ # not a normal lazr.restful resource.
1969+ if self.FIND_ENTRIES in kinds:
1970+ names.append(name[:-5])
1971+ elif self.FIND_ATTRIBUTES in kinds:
1972+ names.append(name)
1973+ return names
1974+
1975+ def lp_has_parameter(self, param_name):
1976+ """Does this resource have a parameter with the given name?"""
1977+ return self._get_external_param_name(param_name) is not None
1978+
1979+ def lp_get_parameter(self, param_name):
1980+ """Get the value of one of the resource's parameters.
1981+
1982+ :return: A scalar value if the parameter is not a link. A new
1983+ Resource object, whose resource is bound to a
1984+ representation, if the parameter is a link.
1985+ """
1986+ self._ensure_representation()
1987+ for suffix in ['_link', '_collection_link']:
1988+ param = self._wadl_resource.get_parameter(
1989+ param_name + suffix, self.JSON_MEDIA_TYPE)
1990+ if param is not None:
1991+ if param.get_value() is None:
1992+ # This parameter is a link to another object, but
1993+ # there's no other object. Return None rather than
1994+ # chasing down the nonexistent other object.
1995+ return None
1996+ linked_resource = param.linked_resource
1997+ return self._create_bound_resource(
1998+ self._root, linked_resource, param_name=param.name)
1999+ param = self._wadl_resource.get_parameter(param_name)
2000+ if param is None:
2001+ raise KeyError("No such parameter: %s" % param_name)
2002+ return param.get_value()
2003+
2004+ def lp_get_named_operation(self, operation_name):
2005+ """Get a custom operation with the given name.
2006+
2007+ :return: A NamedOperation instance that can be called with
2008+ appropriate arguments to invoke the operation.
2009+ """
2010+ params = { 'ws.op' : operation_name }
2011+ method = self._wadl_resource.get_method('get', query_params=params)
2012+ if method is None:
2013+ method = self._wadl_resource.get_method(
2014+ 'post', representation_params=params)
2015+ if method is None:
2016+ raise KeyError("No operation with name: %s" % operation_name)
2017+ return NamedOperation(self._root, self, method)
2018+
2019+ @classmethod
2020+ def _create_bound_resource(
2021+ cls, root, resource, representation=None,
2022+ representation_media_type='application/json',
2023+ representation_needs_processing=True, representation_definition=None,
2024+ param_name=None):
2025+ """Create a lazr.restful Resource subclass from a wadllib Resource.
2026+
2027+ :param resource: The wadllib Resource to wrap.
2028+ :param representation: A previously fetched representation of
2029+ this resource, to be reused. If not provided, this method
2030+ will act just like the Resource constructor.
2031+ :param representation_media_type: The media type of any previously
2032+ fetched representation.
2033+ :param representation_needs_processing: Set to False if the
2034+ 'representation' parameter should be used as
2035+ is.
2036+ :param representation_definition: A wadllib
2037+ RepresentationDefinition object describing the structure
2038+ of this representation. Used in cases when the representation
2039+ isn't the result of sending a standard GET to the resource.
2040+ :param param_name: The name of the link that was followed to get
2041+ to this resource.
2042+ :return: An instance of the appropriate lazr.restful Resource
2043+ subclass.
2044+ """
2045+ # We happen to know that all lazr.restful resource types are
2046+ # defined in a single document. Turn the resource's type_url
2047+ # into an anchor into that document: this is its resource
2048+ # type. Then look up a client-side class that corresponds to
2049+ # the resource type.
2050+ type_url = resource.type_url
2051+ resource_type = urlparse(type_url)[-1]
2052+ default = Entry
2053+ if (type_url.endswith('-page')
2054+ or (param_name is not None
2055+ and param_name.endswith('_collection_link'))):
2056+ default = Collection
2057+ r_class = root.RESOURCE_TYPE_CLASSES.get(resource_type, default)
2058+ if representation is not None:
2059+ # We've been given a representation. Bind the resource
2060+ # immediately.
2061+ resource = resource.bind(
2062+ representation, representation_media_type,
2063+ representation_needs_processing,
2064+ representation_definition=representation_definition)
2065+ else:
2066+ # We'll fetch a representation and bind the resource when
2067+ # necessary.
2068+ pass
2069+ return r_class(root, resource)
2070+
2071+ def lp_refresh(self, new_url=None, etag=None):
2072+ """Update this resource's representation."""
2073+ if new_url is not None:
2074+ self._wadl_resource._url = new_url
2075+ headers = {}
2076+ if etag is not None:
2077+ headers['If-None-Match'] = etag
2078+ try:
2079+ representation = self._root._browser.get(
2080+ self._wadl_resource, headers=headers)
2081+ except HTTPError, e:
2082+ if e.response['status'] == '304':
2083+ # The entry wasn't modified. No need to do anything.
2084+ return
2085+ else:
2086+ raise e
2087+ # __setattr__ assumes we're setting an attribute of the resource,
2088+ # so we manipulate __dict__ directly.
2089+ self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
2090+ representation, self.JSON_MEDIA_TYPE)
2091+
2092+ def __getattr__(self, attr):
2093+ """Try to retrive a named operation or parameter of the given name."""
2094+ try:
2095+ return self.lp_get_parameter(attr)
2096+ except KeyError:
2097+ pass
2098+ try:
2099+ return self.lp_get_named_operation(attr)
2100+ except KeyError:
2101+ raise AttributeError("'%s' object has no attribute '%s'"
2102+ % (self.__class__.__name__, attr))
2103+
2104+ def _get_external_param_name(self, param_name):
2105+ """What's this parameter's name in the underlying representation?"""
2106+ for suffix in ['_link', '_collection_link', '']:
2107+ name = param_name + suffix
2108+ if self._wadl_resource.get_parameter(name):
2109+ return name
2110+ return None
2111+
2112+ def _ensure_representation(self):
2113+ """Make sure this resource has a representation fetched."""
2114+ if self._wadl_resource.representation is None:
2115+ # Get a representation of the linked resource.
2116+ representation = self._root._browser.get(self._wadl_resource)
2117+ self.__dict__['_wadl_resource'] = self._wadl_resource.bind(
2118+ representation, self.JSON_MEDIA_TYPE)
2119+
2120+
2121+class HostedFile(Resource):
2122+ """A resource representing a file managed by a lazr.restful service."""
2123+
2124+ def open(self, mode='r', content_type=None, filename=None):
2125+ """Open the file on the server for read or write access."""
2126+ if mode in ('r', 'w'):
2127+ return HostedFileBuffer(self, mode, content_type, filename)
2128+ else:
2129+ raise ValueError("Invalid mode. Supported modes are: r, w")
2130+
2131+ def delete(self):
2132+ """Delete the file from the server."""
2133+ self._root._browser.delete(self._wadl_resource.url)
2134+
2135+ def _get_parameter_names(self, *kinds):
2136+ """HostedFile objects define no web service parameters."""
2137+ return []
2138+
2139+
2140+class ServiceRoot(Resource):
2141+ """Entry point to the service. Subclass this for a service-specific client.
2142+
2143+ :ivar credentials: The credentials instance used to access Launchpad.
2144+ """
2145+
2146+ # Custom subclasses of Entry or Collection to use when
2147+ # instantiating resources of a certain WADL type.
2148+ RESOURCE_TYPE_CLASSES = {'HostedFile': HostedFile}
2149+
2150+ def __init__(self, credentials, service_root, cache=None,
2151+ timeout=None, proxy_info=None):
2152+ """Root access to a lazr.restful API.
2153+
2154+ :param credentials: The credentials used to access the service.
2155+ :param service_root: The URL to the root of the web service.
2156+ :type service_root: string
2157+ """
2158+ self._root_uri = URI(service_root)
2159+ self.credentials = credentials
2160+ # Get the WADL definition.
2161+ self._browser = Browser(
2162+ self, self.credentials, cache, timeout, proxy_info)
2163+ self._wadl = self._browser.get_wadl_application(self._root_uri)
2164+
2165+ # Get the root resource.
2166+ root_resource = self._wadl.get_resource_by_path('')
2167+ bound_root = root_resource.bind(
2168+ self._browser.get(root_resource), 'application/json')
2169+ super(ServiceRoot, self).__init__(None, bound_root)
2170+
2171+ def httpFactory(self, credentials, cache, timeout, proxy_info):
2172+ return RestfulHttp(credentials, cache, timeout, proxy_info)
2173+
2174+ def load(self, url):
2175+ """Load a resource given its URL."""
2176+ document = self._browser.get(url)
2177+ try:
2178+ representation = simplejson.loads(document)
2179+ except ValueError:
2180+ raise ValueError("%s doesn't serve a JSON document." % url)
2181+ type_link = representation.get("resource_type_link")
2182+ if type_link is None:
2183+ raise ValueError("Couldn't determine the resource type of %s."
2184+ % url)
2185+ resource_type = self._root._wadl.get_resource_type(type_link)
2186+ wadl_resource = WadlResource(self._root._wadl, url, resource_type.tag)
2187+ return self._create_bound_resource(
2188+ self._root, wadl_resource, representation, 'application/json',
2189+ representation_needs_processing=False)
2190+
2191+
2192+class NamedOperation(RestfulBase):
2193+ """A class for a named operation to be invoked with GET or POST."""
2194+
2195+ def __init__(self, root, resource, wadl_method):
2196+ """Initialize with respect to a WADL Method object"""
2197+ self.root = root
2198+ self.resource = resource
2199+ self.wadl_method = wadl_method
2200+
2201+ def __call__(self, *args, **kwargs):
2202+ """Invoke the method and process the result."""
2203+ if len(args) > 0:
2204+ raise TypeError('Method must be called with keyword args.')
2205+ http_method = self.wadl_method.name
2206+ args = self._transform_resources_to_links(kwargs)
2207+ for key, value in args.items():
2208+ args[key] = simplejson.dumps(value, cls=DatetimeJSONEncoder)
2209+ if http_method in ('get', 'head', 'delete'):
2210+ url = self.wadl_method.build_request_url(**args)
2211+ in_representation = ''
2212+ extra_headers = {}
2213+ else:
2214+ url = self.wadl_method.build_request_url()
2215+ (media_type,
2216+ in_representation) = self.wadl_method.build_representation(
2217+ **args)
2218+ extra_headers = { 'Content-type' : media_type }
2219+ response, content = self.root._browser._request(
2220+ url, in_representation, http_method, extra_headers=extra_headers)
2221+
2222+ if response.status == 201:
2223+ return self._handle_201_response(url, response, content)
2224+ else:
2225+ if http_method == 'post':
2226+ # The method call probably modified this resource in
2227+ # an unknown way. Refresh its representation.
2228+ self.resource.lp_refresh()
2229+ return self._handle_200_response(url, response, content)
2230+
2231+ def _handle_201_response(self, url, response, content):
2232+ """Handle the creation of a new resource by fetching it."""
2233+ wadl_response = self.wadl_method.response.bind(
2234+ HeaderDictionary(response))
2235+ wadl_parameter = wadl_response.get_parameter('Location')
2236+ wadl_resource = wadl_parameter.linked_resource
2237+ # Fetch a representation of the new resource.
2238+ response, content = self.root._browser._request(
2239+ wadl_resource.url)
2240+ # Return an instance of the appropriate lazr.restful
2241+ # Resource subclass.
2242+ return Resource._create_bound_resource(
2243+ self.root, wadl_resource, content, response['content-type'])
2244+
2245+ def _handle_200_response(self, url, response, content):
2246+ """Process the return value of an operation."""
2247+ content_type = response['content-type']
2248+ # Process the returned content, assuming we know how.
2249+ response_definition = self.wadl_method.response
2250+ representation_definition = (
2251+ response_definition.get_representation_definition(
2252+ content_type))
2253+
2254+ if representation_definition is None:
2255+ # The operation returned a document with nothing
2256+ # special about it.
2257+ if content_type == self.JSON_MEDIA_TYPE:
2258+ return simplejson.loads(content)
2259+ # We don't know how to process the content.
2260+ return content
2261+
2262+ # The operation returned a representation of some
2263+ # resource. Instantiate a Resource object for it.
2264+ document = simplejson.loads(content)
2265+ if "self_link" in document and "resource_type_link" in document:
2266+ # The operation returned an entry. Use the self_link and
2267+ # resource_type_link of the entry representation to build
2268+ # a Resource object of the appropriate type. That way this
2269+ # object will support all of the right named operations.
2270+ url = document["self_link"]
2271+ resource_type = self.root._wadl.get_resource_type(
2272+ document["resource_type_link"])
2273+ wadl_resource = WadlResource(self.root._wadl, url,
2274+ resource_type.tag)
2275+ else:
2276+ # The operation returned a collection. It's probably an ad
2277+ # hoc collection that doesn't correspond to any resource
2278+ # type. Instantiate it as a resource backed by the
2279+ # representation type defined in the return value, instead
2280+ # of a resource type tag.
2281+ representation_definition = (
2282+ representation_definition.resolve_definition())
2283+ wadl_resource = WadlResource(
2284+ self.root._wadl, url, representation_definition.tag)
2285+
2286+ return Resource._create_bound_resource(
2287+ self.root, wadl_resource, document, content_type,
2288+ representation_needs_processing=False,
2289+ representation_definition=representation_definition)
2290+
2291+ def _get_external_param_name(self, param_name):
2292+ """Named operation parameter names are sent as is."""
2293+ return param_name
2294+
2295+
2296+class Entry(Resource):
2297+ """A class for an entry-type resource that can be updated with PATCH."""
2298+
2299+ def __init__(self, root, wadl_resource):
2300+ super(Entry, self).__init__(root, wadl_resource)
2301+ # Initialize this here in a semi-magical way so as to stop a
2302+ # particular infinite loop that would follow. Setting
2303+ # self._dirty_attributes would call __setattr__(), which would
2304+ # turn around immediately and get self._dirty_attributes. If
2305+ # this latter was not in the instance dictionary, that would
2306+ # end up calling __getattr__(), which would again reference
2307+ # self._dirty_attributes. This is where the infloop would
2308+ # occur. Poking this directly into self.__dict__ means that
2309+ # the check for self._dirty_attributes won't call __getattr__(),
2310+ # breaking the cycle.
2311+ self.__dict__['_dirty_attributes'] = {}
2312+ super(Entry, self).__init__(root, wadl_resource)
2313+
2314+ def __repr__(self):
2315+ """Return the WADL resource type and the URL to the resource."""
2316+ return '<%s at %s>' % (
2317+ URI(self.resource_type_link).fragment, self.self_link)
2318+
2319+ def __str__(self):
2320+ """Return the URL to the resource."""
2321+ return self.self_link
2322+
2323+ def __getattr__(self, name):
2324+ """Try to retrive a parameter of the given name."""
2325+ if name != '_dirty_attributes':
2326+ if name in self._dirty_attributes:
2327+ return self._dirty_attributes[name]
2328+ return super(Entry, self).__getattr__(name)
2329+
2330+ def __setattr__(self, name, value):
2331+ """Set the parameter of the given name."""
2332+ if not self.lp_has_parameter(name):
2333+ raise AttributeError("'%s' object has no attribute '%s'" %
2334+ (self.__class__.__name__, name))
2335+ self._dirty_attributes[name] = value
2336+
2337+ def lp_refresh(self, new_url=None):
2338+ """Update this resource's representation."""
2339+ etag = getattr(self, 'http_etag', None)
2340+ super(Entry, self).lp_refresh(new_url, etag)
2341+ self._dirty_attributes.clear()
2342+
2343+ def lp_save(self):
2344+ """Save changes to the entry."""
2345+ representation = self._transform_resources_to_links(
2346+ self._dirty_attributes)
2347+
2348+ # If the entry contains an ETag, set the If-Match header
2349+ # to that value.
2350+ headers = {}
2351+ etag = getattr(self, 'http_etag', None)
2352+ if etag is not None:
2353+ headers['If-Match'] = etag
2354+
2355+ # PATCH the new representation to the 'self' link. It's possible that
2356+ # this will cause the object to be permanently moved. Catch that
2357+ # exception and refresh our representation.
2358+ try:
2359+ response, content = self._root._browser.patch(
2360+ URI(self.self_link), representation, headers)
2361+ except HTTPError, error:
2362+ if error.response.status == 301:
2363+ response = error.response
2364+ self.lp_refresh(error.response['location'])
2365+ else:
2366+ raise
2367+ self._dirty_attributes.clear()
2368+
2369+ content_type = response['content-type']
2370+ if response.status == 209 and content_type == self.JSON_MEDIA_TYPE:
2371+ # The server sent back a new representation of the object.
2372+ # Use it in preference to the existing representation.
2373+ new_representation = simplejson.loads(content)
2374+ self._wadl_resource.representation = new_representation
2375+ self._wadl_resource.media_type = content_type
2376+
2377+
2378+class Collection(Resource):
2379+ """A collection-type resource that supports pagination."""
2380+
2381+ def __init__(self, root, wadl_resource):
2382+ """Create a collection object."""
2383+ super(Collection, self).__init__(root, wadl_resource)
2384+
2385+ def __len__(self):
2386+ """The number of items in the collection.
2387+
2388+ :return: length of the collection
2389+ :rtype: int
2390+ """
2391+ try:
2392+ return int(self.total_size)
2393+ except AttributeError:
2394+ raise TypeError('collection size is not available')
2395+
2396+ def __iter__(self):
2397+ """Iterate over the items in the collection.
2398+
2399+ :return: iterator
2400+ :rtype: sequence of `Entry`
2401+ """
2402+ self._ensure_representation()
2403+ current_page = self._wadl_resource.representation
2404+ while True:
2405+ for resource in self._convert_dicts_to_entries(
2406+ current_page.get('entries', {})):
2407+ yield resource
2408+ next_link = current_page.get('next_collection_link')
2409+ if next_link is None:
2410+ break
2411+ current_page = simplejson.loads(
2412+ self._root._browser.get(URI(next_link)))
2413+
2414+ def __getitem__(self, key):
2415+ """Look up a slice, or a subordinate resource by index.
2416+
2417+ To discourage situations where a lazr.restful client fetches
2418+ all of an enormous list, all collection slices must have a
2419+ definitive end point. For performance reasons, all collection
2420+ slices must be indexed from the start of the list rather than
2421+ the end.
2422+ """
2423+ if isinstance(key, slice):
2424+ return self._get_slice(key)
2425+ else:
2426+ # Look up a single item by its position in the list.
2427+ found_slice = self._get_slice(slice(key, key+1))
2428+ if len(found_slice) != 1:
2429+ raise IndexError("list index out of range")
2430+ return found_slice[0]
2431+
2432+ def _get_slice(self, slice):
2433+ """Retrieve a slice of a collection."""
2434+ start = slice.start or 0
2435+ stop = slice.stop
2436+
2437+ if start < 0:
2438+ raise ValueError("Collection slices must have a nonnegative "
2439+ "start point.")
2440+ if stop < 0:
2441+ raise ValueError("Collection slices must have a definite, "
2442+ "nonnegative end point.")
2443+
2444+ existing_representation = self._wadl_resource.representation
2445+ if (existing_representation is not None
2446+ and start < len(existing_representation['entries'])):
2447+ # An optimization: the first page of entries has already
2448+ # been loaded. This can happen if this collection is the
2449+ # return value of a named operation, or if the client did
2450+ # something like check the length of the collection.
2451+ #
2452+ # Either way, we've already made an HTTP request and
2453+ # gotten some entries back. The client has requested a
2454+ # slice that includes some of the entries we already have.
2455+ # In the best case, we can fulfil the slice immediately,
2456+ # without making another HTTP request.
2457+ #
2458+ # Even if we can't fulfil the entire slice, we can get one
2459+ # or more objects from the first page and then have fewer
2460+ # objects to retrieve from the server later. This saves us
2461+ # time and bandwidth, and it might let us save a whole
2462+ # HTTP request.
2463+ entry_page = existing_representation['entries']
2464+
2465+ first_page_size = len(entry_page)
2466+ entry_dicts = entry_page[start:stop]
2467+ page_url = existing_representation.get('next_collection_link')
2468+ else:
2469+ # No part of this collection has been loaded yet, or the
2470+ # slice starts beyond the part that has been loaded. We'll
2471+ # use our secret knowledge of lazr.restful to set a value for
2472+ # the ws.start variable. That way we start reading entries
2473+ # from the first one we want.
2474+ first_page_size = None
2475+ entry_dicts = []
2476+ page_url = self._with_url_query_variable_set(
2477+ self._wadl_resource.url, 'ws.start', start)
2478+
2479+ desired_size = stop-start
2480+ more_needed = desired_size - len(entry_dicts)
2481+
2482+ # Iterate over pages until we have the correct number of entries.
2483+ while more_needed > 0 and page_url is not None:
2484+ representation = simplejson.loads(
2485+ self._root._browser.get(page_url))
2486+ current_page_entries = representation['entries']
2487+ entry_dicts += current_page_entries[:more_needed]
2488+ more_needed = desired_size - len(entry_dicts)
2489+
2490+ page_url = representation.get('next_collection_link')
2491+ if page_url is None:
2492+ # We've gotten the entire collection; there are no
2493+ # more entries.
2494+ break
2495+ if first_page_size is None:
2496+ first_page_size = len(current_page_entries)
2497+ if more_needed > 0 and more_needed < first_page_size:
2498+ # An optimization: it's likely that we need less than
2499+ # a full page of entries, because the number we need
2500+ # is less than the size of the first page we got.
2501+ # Instead of requesting a full-sized page, we'll
2502+ # request only the number of entries we think we'll
2503+ # need. If we're wrong, there's no problem; we'll just
2504+ # keep looping.
2505+ page_url = self._with_url_query_variable_set(
2506+ page_url, 'ws.size', more_needed)
2507+
2508+ if slice.step is not None:
2509+ entry_dicts = entry_dicts[::slice.step]
2510+
2511+ # Convert entry_dicts into a list of Entry objects.
2512+ return [resource for resource
2513+ in self._convert_dicts_to_entries(entry_dicts)]
2514+
2515+ def _convert_dicts_to_entries(self, entries):
2516+ """Convert dictionaries describing entries to Entry objects.
2517+
2518+ The dictionaries come from the 'entries' field of the JSON
2519+ dictionary you get when you GET a page of a collection. Each
2520+ dictionary is the same as you'd get if you sent a GET request
2521+ to the corresponding entry resource. So each of these
2522+ dictionaries can be treated as a preprocessed representation
2523+ of an entry resource, and turned into an Entry instance.
2524+
2525+ :yield: A sequence of Entry instances.
2526+ """
2527+ for entry_dict in entries:
2528+ resource_url = entry_dict['self_link']
2529+ resource_type_link = entry_dict['resource_type_link']
2530+ wadl_application = self._wadl_resource.application
2531+ resource_type = wadl_application.get_resource_type(
2532+ resource_type_link)
2533+ resource = WadlResource(
2534+ self._wadl_resource.application, resource_url,
2535+ resource_type.tag)
2536+ yield Resource._create_bound_resource(
2537+ self._root, resource, entry_dict, self.JSON_MEDIA_TYPE,
2538+ False)
2539+
2540+ def _with_url_query_variable_set(self, url, variable, new_value):
2541+ """A helper method to set a query variable in a URL."""
2542+ uri = URI(url)
2543+ if uri.query is None:
2544+ params = {}
2545+ else:
2546+ params = cgi.parse_qs(uri.query)
2547+ params[variable] = str(new_value)
2548+ uri.query = urllib.urlencode(params, True)
2549+ return str(uri)
2550+
2551+
2552+class CollectionWithKeyBasedLookup(Collection):
2553+ """A collection-type resource that supports key-based lookup.
2554+
2555+ This collection can be sliced, but any single index passed into
2556+ __getitem__ will be treated as a custom lookup key.
2557+ """
2558+
2559+ def __getitem__(self, key):
2560+ """Look up a slice, or a subordinate resource by unique ID."""
2561+ if isinstance(key, slice):
2562+ return super(CollectionWithKeyBasedLookup, self).__getitem__(key)
2563+ try:
2564+ url = self._get_url_from_id(key)
2565+ except NotImplementedError:
2566+ raise TypeError("unsubscriptable object")
2567+ if url is None:
2568+ raise KeyError(key)
2569+
2570+ # We don't know what kind of resource this is. Even the
2571+ # subclass doesn't necessarily know, because some resources
2572+ # (the person list) are gateways to more than one kind of
2573+ # resource (people, and teams). The only way to know for sure
2574+ # is to retrieve a representation of the resource and see how
2575+ # the resource describes itself.
2576+ try:
2577+ representation = simplejson.loads(self._root._browser.get(url))
2578+ except HTTPError, error:
2579+ # There's no resource corresponding to the given ID.
2580+ if error.response.status == 404:
2581+ raise KeyError(key)
2582+ raise
2583+ # We know that every lazr.restful resource has a
2584+ # 'resource_type_link' in its representation.
2585+ resource_type_link = representation['resource_type_link']
2586+ resource = WadlResource(self._root._wadl, url, resource_type_link)
2587+ return self._create_bound_resource(
2588+ self._root, resource, representation=representation,
2589+ representation_needs_processing=False)
2590+
2591+
2592+ def _get_url_from_id(self, key):
2593+ """Transform the unique ID of an object into its URL."""
2594+ raise NotImplementedError()
2595+
2596+
2597+class HostedFileBuffer(StringIO):
2598+ """The contents of a file hosted by a lazr.restful service."""
2599+ def __init__(self, hosted_file, mode, content_type=None, filename=None):
2600+ self.url = hosted_file._wadl_resource.url
2601+ if mode == 'r':
2602+ if content_type is not None:
2603+ raise ValueError("Files opened for read access can't "
2604+ "specify content_type.")
2605+ if filename is not None:
2606+ raise ValueError("Files opened for read access can't "
2607+ "specify filename.")
2608+ response, value = hosted_file._root._browser.get(
2609+ self.url, return_response=True)
2610+ content_type = response['content-type']
2611+ last_modified = response['last-modified']
2612+
2613+ # The Content-Location header contains the URL of the file
2614+ # hosted by the web service. We happen to know that the
2615+ # final component of the URL is the name of the uploaded
2616+ # file.
2617+ content_location = response['content-location']
2618+ path = urlparse(content_location)[2]
2619+ filename = urllib.unquote(path.split("/")[-1])
2620+ elif mode == 'w':
2621+ value = ''
2622+ if content_type is None:
2623+ raise ValueError("Files opened for write access must "
2624+ "specify content_type.")
2625+ if filename is None:
2626+ raise ValueError("Files opened for write access must "
2627+ "specify filename.")
2628+ last_modified = None
2629+ else:
2630+ raise ValueError("Invalid mode. Supported modes are: r, w")
2631+
2632+ self.hosted_file = hosted_file
2633+ self.mode = mode
2634+ self.content_type = content_type
2635+ self.filename = filename
2636+ self.last_modified = last_modified
2637+ StringIO.__init__(self, value)
2638+
2639+ def close(self):
2640+ if self.mode == 'w':
2641+ disposition = 'attachment; filename="%s"' % self.filename
2642+ self.hosted_file._root._browser.put(
2643+ self.url, self.getvalue(),
2644+ self.content_type, {'Content-Disposition' : disposition})
2645+ StringIO.close(self)

Subscribers

People subscribed via source and target branches