diff options
author | SVN-Git Migration <python-modules-team@lists.alioth.debian.org> | 2015-10-08 12:21:53 -0700 |
---|---|---|
committer | SVN-Git Migration <python-modules-team@lists.alioth.debian.org> | 2015-10-08 12:21:53 -0700 |
commit | 13a200cb0f1837e3410279328d42bea8bdd54be2 (patch) | |
tree | ad6891ee3560f6595f808c628c73e836e57c49bc /src |
Imported Upstream version 1.5.2
Diffstat (limited to 'src')
23 files changed, 2847 insertions, 0 deletions
diff --git a/src/launchpadlib.egg-info/PKG-INFO b/src/launchpadlib.egg-info/PKG-INFO new file mode 100644 index 0000000..9d1c608 --- /dev/null +++ b/src/launchpadlib.egg-info/PKG-INFO @@ -0,0 +1,81 @@ +Metadata-Version: 1.0 +Name: launchpadlib +Version: 1.5.2 +Summary: Script Launchpad through its web services interfaces. Officially supported. +Home-page: https://help.launchpad.net/API/launchpadlib +Author: LAZR Developers +Author-email: lazr-developers@lists.launchpad.net +License: LGPL v3 +Download-URL: https://launchpad.net/launchpadlib/+download +Description: .. + This file is part of launchpadlib. + + launchpadlib is free software: you can redistribute it and/or modify it + under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, version 3 of the License. + + launchpadlib is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. + + launchpadlib + ************ + + See https://help.launchpad.net/API/launchpadlib . + + ===================== + NEWS for launchpadlib + ===================== + + 1.5.2 (2009-10-01) + ================== + + - Added a number of new sample scripts from elsewhere. + + - Added a reference to the production Launchpad instance. + + - Made it easier to specify a Launchpad instance to run against. + + 1.5.1 (2009-07-16) + ================== + + - Added a sample script for uploading a release tarball to Launchpad. + + 1.5.0 (2009-07-09) + ================== + + - Most of launchpadlib's code has been moved to the generic + lazr.restfulclient library. launchpadlib now contains only code + specific to Launchpad. There should be no changes in functionality. + + - Moved bootstrap.py into the top-level directory. Having it in a + subdirectory with a top-level symlink was breaking installation on + Windows. + + - The notice to the end-user (that we're opening their web + browser) is now better formatted. + + 1.0.1 (2009-05-30) + ================== + + - Correct tests for new launchpad cache behavior in librarian + + - Remove build dependency on setuptools_bzr because it was causing bzr to be + downloaded during installation of the package, which was unnecessary and + annoying. + + 1.0 (2009-03-24) + ================ + + - Initial release on PyPI + +Platform: UNKNOWN +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL) +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python diff --git a/src/launchpadlib.egg-info/SOURCES.txt b/src/launchpadlib.egg-info/SOURCES.txt new file mode 100644 index 0000000..606b40f --- /dev/null +++ b/src/launchpadlib.egg-info/SOURCES.txt @@ -0,0 +1,28 @@ +COPYING.txt +HACKING.txt +README.txt +ez_setup.py +setup.py +src/launchpadlib/NEWS.txt +src/launchpadlib/README.txt +src/launchpadlib/__init__.py +src/launchpadlib/credentials.py +src/launchpadlib/errors.py +src/launchpadlib/launchpad.py +src/launchpadlib/wadl-to-refhtml.xsl +src/launchpadlib.egg-info/PKG-INFO +src/launchpadlib.egg-info/SOURCES.txt +src/launchpadlib.egg-info/dependency_links.txt +src/launchpadlib.egg-info/not-zip-safe +src/launchpadlib.egg-info/requires.txt +src/launchpadlib.egg-info/top_level.txt +src/launchpadlib/docs/hosted-files.txt +src/launchpadlib/docs/introduction.txt +src/launchpadlib/docs/people.txt +src/launchpadlib/docs/toplevel.txt +src/launchpadlib/docs/files/mugshot.png +src/launchpadlib/testing/__init__.py +src/launchpadlib/testing/helpers.py +src/launchpadlib/tests/__init__.py +src/launchpadlib/tests/test_credentials.py +src/launchpadlib/tests/test_launchpad.py
\ No newline at end of file diff --git a/src/launchpadlib.egg-info/dependency_links.txt b/src/launchpadlib.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/launchpadlib.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/launchpadlib.egg-info/not-zip-safe b/src/launchpadlib.egg-info/not-zip-safe new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/launchpadlib.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/src/launchpadlib.egg-info/requires.txt b/src/launchpadlib.egg-info/requires.txt new file mode 100644 index 0000000..d1cc813 --- /dev/null +++ b/src/launchpadlib.egg-info/requires.txt @@ -0,0 +1,7 @@ +httplib2 +lazr.restfulclient +lazr.uri +oauth +setuptools +simplejson +wadllib
\ No newline at end of file diff --git a/src/launchpadlib.egg-info/top_level.txt b/src/launchpadlib.egg-info/top_level.txt new file mode 100644 index 0000000..9dc228f --- /dev/null +++ b/src/launchpadlib.egg-info/top_level.txt @@ -0,0 +1 @@ +launchpadlib diff --git a/src/launchpadlib/NEWS.txt b/src/launchpadlib/NEWS.txt new file mode 100644 index 0000000..06fb009 --- /dev/null +++ b/src/launchpadlib/NEWS.txt @@ -0,0 +1,45 @@ +===================== +NEWS for launchpadlib +===================== + +1.5.2 (2009-10-01) +================== + +- Added a number of new sample scripts from elsewhere. + +- Added a reference to the production Launchpad instance. + +- Made it easier to specify a Launchpad instance to run against. + +1.5.1 (2009-07-16) +================== + +- Added a sample script for uploading a release tarball to Launchpad. + +1.5.0 (2009-07-09) +================== + +- Most of launchpadlib's code has been moved to the generic + lazr.restfulclient library. launchpadlib now contains only code + specific to Launchpad. There should be no changes in functionality. + +- Moved bootstrap.py into the top-level directory. Having it in a + subdirectory with a top-level symlink was breaking installation on + Windows. + +- The notice to the end-user (that we're opening their web + browser) is now better formatted. + +1.0.1 (2009-05-30) +================== + +- Correct tests for new launchpad cache behavior in librarian + +- Remove build dependency on setuptools_bzr because it was causing bzr to be + downloaded during installation of the package, which was unnecessary and + annoying. + +1.0 (2009-03-24) +================ + +- Initial release on PyPI diff --git a/src/launchpadlib/README.txt b/src/launchpadlib/README.txt new file mode 100644 index 0000000..fd68370 --- /dev/null +++ b/src/launchpadlib/README.txt @@ -0,0 +1,19 @@ +.. + This file is part of launchpadlib. + + launchpadlib is free software: you can redistribute it and/or modify it + under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, version 3 of the License. + + launchpadlib is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. + +launchpadlib +************ + +See https://help.launchpad.net/API/launchpadlib . diff --git a/src/launchpadlib/__init__.py b/src/launchpadlib/__init__.py new file mode 100644 index 0000000..dae4935 --- /dev/null +++ b/src/launchpadlib/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2008 Canonical Ltd. + +# This file is part of launchpadlib. +# +# launchpadlib is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, version 3 of the License. +# +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. + +__version__ = '1.5.2' diff --git a/src/launchpadlib/credentials.py b/src/launchpadlib/credentials.py new file mode 100644 index 0000000..013c825 --- /dev/null +++ b/src/launchpadlib/credentials.py @@ -0,0 +1,246 @@ +# Copyright 2008 Canonical Ltd. + +# This file is part of launchpadlib. +# +# launchpadlib is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, version 3 of the License. +# +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. + +"""launchpadlib credentials and authentication support.""" + +__metaclass__ = type +__all__ = [ + 'AccessToken', + 'Consumer', + 'Credentials', + ] + +from ConfigParser import SafeConfigParser +import cgi +import httplib2 +from oauth.oauth import OAuthConsumer, OAuthToken +from urllib import urlencode + +from lazr.restfulclient.errors import CredentialsFileError, HTTPError + + +CREDENTIALS_FILE_VERSION = '1' +STAGING_WEB_ROOT = 'https://staging.launchpad.net/' +request_token_page = '+request-token' +access_token_page = '+access-token' +authorize_token_page = '+authorize-token' + + +class Credentials: + """Standard credentials storage and usage class. + + :ivar consumer: The consumer (application) + :type consumer: `Consumer` + :ivar access_token: Access information on behalf of the user + :type access_token: `AccessToken` + """ + _request_token = None + + def __init__(self, consumer_name=None, consumer_secret='', + access_token=None): + """The user's Launchpad API credentials. + + :param consumer_name: The name of the consumer (application) + :param consumer_secret: The secret of the consumer + :param access_token: The authenticated user access token + :type access_token: `AccessToken` + """ + self.consumer = None + if consumer_name is not None: + self.consumer = Consumer(consumer_name, consumer_secret) + self.access_token = access_token + + def load(self, readable_file): + """Load credentials from a file-like object. + + This overrides the consumer and access token given in the constructor + and replaces them with the values read from the file. + + :param readable_file: A file-like object to read the credentials from + :type readable_file: Any object supporting the file-like `read()` + method + """ + # Attempt to load the access token from the file. + parser = SafeConfigParser() + parser.readfp(readable_file) + # Check the version number and extract the access token and + # secret. Then convert these to the appropriate instances. + if not parser.has_section(CREDENTIALS_FILE_VERSION): + raise CredentialsFileError('No configuration for version %s' % + CREDENTIALS_FILE_VERSION) + consumer_key = parser.get( + CREDENTIALS_FILE_VERSION, 'consumer_key') + consumer_secret = parser.get( + CREDENTIALS_FILE_VERSION, 'consumer_secret') + self.consumer = Consumer(consumer_key, consumer_secret) + access_token = parser.get( + CREDENTIALS_FILE_VERSION, 'access_token') + access_secret = parser.get( + CREDENTIALS_FILE_VERSION, 'access_secret') + self.access_token = AccessToken(access_token, access_secret) + + @classmethod + def load_from_path(cls, path): + """Convenience method for loading credentials from a file. + + Open the file, create the Credentials and load from the file, + and finally close the file and return the newly created + Credentials instance. + + :param path: In which file the credential file should be saved. + :type path: string + :return: The loaded Credentials instance. + :rtype: `Credentials` + """ + credentials = cls() + credentials_file = open(path, 'r') + credentials.load(credentials_file) + credentials_file.close() + return credentials + + def save(self, writable_file): + """Write the credentials to the file-like object. + + :param writable_file: A file-like object to write the credentials to + :type writable_file: Any object supporting the file-like `write()` + method + :raise CredentialsFileError: when there is either no consumer or no + access token + """ + if self.consumer is None: + raise CredentialsFileError('No consumer') + if self.access_token is None: + raise CredentialsFileError('No access token') + + parser = SafeConfigParser() + parser.add_section(CREDENTIALS_FILE_VERSION) + parser.set(CREDENTIALS_FILE_VERSION, + 'consumer_key', self.consumer.key) + parser.set(CREDENTIALS_FILE_VERSION, + 'consumer_secret', self.consumer.secret) + parser.set(CREDENTIALS_FILE_VERSION, + 'access_token', self.access_token.key) + parser.set(CREDENTIALS_FILE_VERSION, + 'access_secret', self.access_token.secret) + parser.write(writable_file) + + def save_to_path(self, path): + """Convenience method for saving credentials to a file. + + Create the file, call self.save(), and close the file. Existing + files are overwritten. + + :param path: In which file the credential file should be saved. + :type path: string + """ + credentials_file = open(path, 'w') + self.save(credentials_file) + credentials_file.close() + + def get_request_token(self, context=None, web_root=STAGING_WEB_ROOT): + """Request an OAuth token to Launchpad. + + Also store the token in self._request_token. + + This method must not be called on an object with no consumer + specified or if an access token has already been obtained. + + :param context: The context of this token, that is, its scope of + validity within Launchpad. + :param web_root: The URL of the website on which the token + should be requested. + :return: The URL for the user to authorize the `OAuthToken` provided + by Launchpad. + """ + assert self.consumer is not None, "Consumer not specified." + assert self.access_token is None, "Access token already obtained." + params = dict( + oauth_consumer_key=self.consumer.key, + oauth_signature_method='PLAINTEXT', + oauth_signature='&') + url = web_root + request_token_page + response, content = httplib2.Http().request( + url, method='POST', body=urlencode(params)) + if response.status != 200: + raise HTTPError(response, content) + self._request_token = OAuthToken.from_string(content) + url = '%s%s?oauth_token=%s' % (web_root, authorize_token_page, + self._request_token.key) + if context is not None: + url += "&lp.context=%s" % context + return url + + def exchange_request_token_for_access_token( + self, web_root=STAGING_WEB_ROOT): + """Exchange the previously obtained request token for an access token. + + This method must not be called unless get_request_token() has been + called and completed successfully. + + The access token will be stored as self.access_token. + + :param web_root: The base URL of the website that granted the + request token. + """ + assert self._request_token is not None, ( + "get_request_token() doesn't seem to have been called.") + params = dict( + oauth_consumer_key=self.consumer.key, + oauth_signature_method='PLAINTEXT', + oauth_token=self._request_token.key, + oauth_signature='&%s' % self._request_token.secret) + url = web_root + access_token_page + response, content = httplib2.Http().request( + url, method='POST', body=urlencode(params)) + if response.status != 200: + raise HTTPError(response, content) + self.access_token = AccessToken.from_string(content) + + +# These two classes are provided for convenience (so applications don't need +# to import from launchpadlib._oauth.oauth), and to provide a default argument +# for secret. + +class Consumer(OAuthConsumer): + """An OAuth consumer (application).""" + + def __init__(self, key, secret=''): + super(Consumer, self).__init__(key, secret) + + +class AccessToken(OAuthToken): + """An OAuth access token.""" + + def __init__(self, key, secret='', context=None): + super(AccessToken, self).__init__(key, secret) + self.context = context + + @classmethod + def from_string(cls, query_string): + """Create and return a new `AccessToken` from the given string.""" + params = cgi.parse_qs(query_string, keep_blank_values=False) + key = params['oauth_token'] + assert len(key) == 1, "Query string must have exactly one key." + key = key[0] + secret = params['oauth_token_secret'] + assert len(secret) == 1, "Query string must have exactly one secret." + secret = secret[0] + context = params.get('lp.context') + if context is not None: + assert len(context) == 1, ( + "Query string must have exactly one context") + context = context[0] + return cls(key, secret, context) diff --git a/src/launchpadlib/docs/files/mugshot.png b/src/launchpadlib/docs/files/mugshot.png Binary files differnew file mode 100644 index 0000000..94a7e76 --- /dev/null +++ b/src/launchpadlib/docs/files/mugshot.png diff --git a/src/launchpadlib/docs/hosted-files.txt b/src/launchpadlib/docs/hosted-files.txt new file mode 100644 index 0000000..40c2e52 --- /dev/null +++ b/src/launchpadlib/docs/hosted-files.txt @@ -0,0 +1,104 @@ +************ +Hosted files +************ + +The Launchpad web service sets restrictions on what kinds of documents +can be written to a particular file. This test shows what happens when +you try to upload a non-image for a field that expects an image. + + >>> from launchpadlib.testing.helpers import salgado_with_full_permissions + >>> launchpad = salgado_with_full_permissions.login() + >>> from launchpadlib.errors import HTTPError + + >>> mugshot = launchpad.me.mugshot + >>> file_handle = mugshot.open("w", "image/png", "nonimage.txt") + >>> file_handle.content_type + 'image/png' + >>> file_handle.filename + 'nonimage.txt' + >>> file_handle.write("Not an image.") + >>> try: + ... file_handle.close() + ... except HTTPError, e: + ... print e.content + <BLANKLINE> + The file uploaded was not recognized as an image; please + check it and retry. + +Of course, uploading an image works fine. + + >>> import os + >>> def load_image(filename): + ... image_file = os.path.join( + ... os.path.dirname(__file__), 'files', filename) + ... return open(image_file).read() + >>> image = load_image("mugshot.png") + >>> len(image) + 2260 + + >>> file_handle = mugshot.open("w", "image/png", "a-mugshot.png") + >>> file_handle.write(image) + >>> file_handle.close() + + +== Error handling == + +The server may set restrictions on what kinds of documents can be +written to a particular file. + + >>> file_handle = mugshot.open("w", "image/png", "nonimage.txt") + >>> file_handle.content_type + 'image/png' + >>> file_handle.filename + 'nonimage.txt' + >>> file_handle.write("Not an image.") + >>> file_handle.close() + Traceback (most recent call last): + ... + HTTPError: HTTP Error 400: Bad Request + + +== Caching == + +Hosted file resources implement the normal server-side caching +mechanism. + + >>> file_handle = mugshot.open("w", "image/png", "image.png") + >>> file_handle.write(image) + >>> file_handle.close() + + >>> import httplib2 + >>> httplib2.debuglevel = 1 + >>> launchpad = salgado_with_full_permissions.login() + connect: ... + >>> mugshot = launchpad.me.mugshot + send: ... + +The first request for a file retrieves the file from the server. + + >>> len(mugshot.open().read()) + send: ... + reply: 'HTTP/1.1 303 See Other... + reply: 'HTTP/1.1 200 OK... + 2260 + +The second request retrieves the file from the cache. After receiving +the 303 request with its Location header, no further HTTP requests are +issued because the Librarian's Cache-Control: headers tell us we +already have a fresh copy. + + >>> len(mugshot.open().read()) + send: ... + reply: 'HTTP/1.1 303 See Other... + header: Location: http://localhost:58000/.../image.png + header: Vary: Cookie, Authorization, Accept + header: Content-Type: text/plain + 2260 + +Finally, some cleanup code that deletes the mugshot. + + >>> mugshot.delete() + send: 'DELETE... + reply: 'HTTP/1.1 200... + + >>> httplib2.debuglevel = 0 diff --git a/src/launchpadlib/docs/introduction.txt b/src/launchpadlib/docs/introduction.txt new file mode 100644 index 0000000..c06fefb --- /dev/null +++ b/src/launchpadlib/docs/introduction.txt @@ -0,0 +1,335 @@ += launchpadlib = + +launchpadlib is the standalone Python language bindings to Launchpad's web +services API. It is officially supported by Canonical, although third party +packages may be available to provide bindings to other programming languages. + + +== OAuth authentication == + +The Launchpad API requires user authentication via OAuth, and launchpadlib +provides a high level interface to OAuth for the most common use cases. +Several pieces of information are necessary to complete the OAuth request: + + * A consumer key, which is unique to the application using the API + * An access token, which represents the user to the web service + * An access token secret, essentially a password for the token + +Consumer keys are hard-baked into the application. They are generated by the +application developer and registered with Launchpad independently of the use +of the application. Since consumer keys are arbitrary, a registered consumer +key can be paired with a secret, but most open source applications will forgo +this since it's not really a secret anyway. + +The access token cannot be provided directly. Instead, the application +generates an unauthenticated request token, exchanging this for an access +token and a secret after obtaining approval to do so from the user. This +permission is typically gained by redirecting the user through their trusted +web browser, then back to the application. + +This entire exchange is managed by launchpadlib's credentials classes. +Credentials can be stored in a file, though the security of this depends on +the implementation of the file object. In the simplest case, the application +will request a new access token every time. + + >>> from launchpadlib.credentials import Consumer + >>> consumer = Consumer('launchpad-library') + >>> consumer.key + 'launchpad-library' + >>> consumer.secret + '' + +Salgado has full access to the Launchpad API. Out of band, the application +itself obtains Salgado's approval to access the Launchpad API on his behalf. +How the application does this is up to the application, provided it conforms +to the OAuth protocol. Once this happens, we have Salgado's credentials for +accessing Launchpad. + + >>> from launchpadlib.credentials import AccessToken + >>> access_token = AccessToken('salgado-change-anything', 'test') + +And now these credentials are used to access the root service on Salgado's +behalf. + + >>> from launchpadlib.credentials import Credentials + >>> credentials = Credentials( + ... consumer_name=consumer.key, consumer_secret=consumer.secret, + ... access_token=access_token) + + >>> from launchpadlib.testing.helpers import ( + ... TestableLaunchpad as Launchpad) + >>> launchpad = Launchpad(credentials=credentials) + >>> sorted(launchpad.people) + [...] + >>> sorted(launchpad.bugs) + [...] + +For convenience, the application may store the credentials on the file system, +so that the next time Salgado interacts with the application, he won't have +to go through the whole OAuth request dance. + + >>> import os + >>> import tempfile + >>> fd, path = tempfile.mkstemp('.credentials') + >>> os.close(fd) + +Once Salgado's credentials are obtained for the first time, just set the +appropriate instance variables and use the save() method. + + >>> credentials.consumer = consumer + >>> credentials.access_token = access_token + >>> credentials_file = open(path, 'w') + >>> credentials.save(credentials_file) + >>> credentials_file.close() + +And the credentials are perfectly valid for accessing Launchpad. + + >>> launchpad = Launchpad(credentials=credentials) + >>> sorted(launchpad.people) + [...] + >>> sorted(launchpad.bugs) + [...] + +The credentials can also be retrieved from the file, so that the OAuth request +dance can be avoided. + + >>> credentials = Credentials() + >>> credentials_file = open(path) + >>> credentials.load(credentials_file) + >>> credentials_file.close() + >>> credentials.consumer.key + 'launchpad-library' + >>> credentials.consumer.secret + '' + >>> credentials.access_token.key + 'salgado-change-anything' + >>> credentials.access_token.secret + 'test' + +These credentials too, are perfectly usable to access Launchpad. + + >>> launchpad = Launchpad(credentials=credentials) + >>> sorted(launchpad.people) + [...] + >>> sorted(launchpad.bugs) + [...] + +The security of the stored credentials is left up to the file-like object. +Here, the application decides to use a dubious encryption algorithm to hide +Salgado's credentials. + + >>> from StringIO import StringIO + >>> from codecs import EncodedFile + >>> encrypted_file = StringIO() + >>> stream = EncodedFile(encrypted_file, 'rot_13', 'ascii') + >>> credentials.save(stream) + >>> print encrypted_file.getvalue() + [1] + pbafhzre_frperg = + npprff_gbxra = fnytnqb-punatr-nalguvat + pbafhzre_xrl = ynhapucnq-yvoenel + npprff_frperg = grfg + <BLANKLINE> + <BLANKLINE> + + >>> stream.seek(0) + >>> credentials = Credentials() + >>> credentials.load(stream) + >>> credentials.consumer.key + 'launchpad-library' + >>> credentials.consumer.secret + '' + >>> credentials.access_token.key + 'salgado-change-anything' + >>> credentials.access_token.secret + 'test' + + +== Convenience == + +When the consumer name, access token and access secret are all known up-front, +a convenience method is available for logging into the web service in one +function call. + + >>> launchpad = Launchpad.login( + ... 'launchpad-library', 'salgado-change-anything', 'test') + >>> sorted(launchpad.people) + [...] + +If that is not the case the application should obtain authorization from +the user and get the credentials directly from Launchpad. + +First we must get a request token. + + >>> import launchpadlib.credentials + >>> credentials = Credentials('consumer') + >>> authorization_url = credentials.get_request_token( + ... context='firefox', web_root='http://launchpad.dev:8085/') + >>> authorization_url + 'http://launchpad.dev:8085/+authorize-token?oauth_token=...&lp.context=firefox' + +Now the user must authorize that token, so we'll hand-craft a request +to pretend the user is authorizing it. + + >>> import httplib2 + >>> from urllib import urlencode + >>> params = {'field.actions.WRITE_PRIVATE': 1, + ... 'oauth_token': credentials._request_token.key, + ... 'lp.context': 'firefox'} + >>> foo_bar_auth = 'Basic %s' % ( + ... 'foo.bar@canonical.com:test'.encode('base64')) + >>> headers = {'Authorization': foo_bar_auth} + >>> response, content = httplib2.Http().request( + ... authorization_url, method='POST', body=urlencode(params), + ... headers=headers) + >>> response['status'] + '200' + +After that we can exchange that request token for an access token. + + >>> credentials.exchange_request_token_for_access_token( + ... web_root='http://launchpad.dev:8085/') + +Once that's done, our credentials will be complete and ready to use. + + >>> credentials.consumer.key + 'consumer' + >>> credentials.access_token + <launchpadlib.credentials.AccessToken... + >>> credentials.access_token.key is not None + True + >>> credentials.access_token.secret is not None + True + >>> credentials.access_token.context + 'firefox' + +There's also a convenience method which does the access token +negotiation and logs into the web service. It uses the methods +documented above and once it has the request token's authorization URL +it opens up a web browser for the user to authoriza it and asks him to +come back and press <Enter> once that's done. When he does it, the +request token is exchanged for an access token and the authentication is +complete. + + # Since this will open up a web browser we're not going to actually run it + # here. + >>> # consumer_name = 'launchpadlib' + >>> # launchpad = Launchpad.get_token_and_login(consumer_name) + + +== Credentials file errors == + +If the credentials file is empty, loading it raises an exception. + + >>> credentials = Credentials() + >>> credentials.load(StringIO()) + Traceback (most recent call last): + ... + CredentialsFileError: No configuration for version 1 + +It is an error to save a credentials file when no consumer or access token is +available. + + >>> credentials.consumer = None + >>> credentials.save(StringIO()) + Traceback (most recent call last): + ... + CredentialsFileError: No consumer + + >>> credentials.consumer = consumer + >>> credentials.access_token = None + >>> credentials.save(StringIO()) + Traceback (most recent call last): + ... + CredentialsFileError: No access token + +The credentials file is not intended to be edited, but because it's human +readable, that's of course possible. If the credentials file gets corrupted, +an error is raised. + + >>> credentials_file = StringIO("""\ + ... [1] + ... #consumer_key: aardvark + ... consumer_secret: badger + ... access_token: caribou + ... access_secret: dingo + ... """) + >>> credentials.load(credentials_file) + Traceback (most recent call last): + ... + NoOptionError: No option 'consumer_key' in section: '1' + + >>> credentials_file = StringIO("""\ + ... [1] + ... consumer_key: aardvark + ... #consumer_secret: badger + ... access_token: caribou + ... access_secret: dingo + ... """) + >>> credentials.load(credentials_file) + Traceback (most recent call last): + ... + NoOptionError: No option 'consumer_secret' in section: '1' + + >>> credentials_file = StringIO("""\ + ... [1] + ... consumer_key: aardvark + ... consumer_secret: badger + ... #access_token: caribou + ... access_secret: dingo + ... """) + >>> credentials.load(credentials_file) + Traceback (most recent call last): + ... + NoOptionError: No option 'access_token' in section: '1' + + >>> credentials_file = StringIO("""\ + ... [1] + ... consumer_key: aardvark + ... consumer_secret: badger + ... access_token: caribou + ... #access_secret: dingo + ... """) + >>> credentials.load(credentials_file) + Traceback (most recent call last): + ... + NoOptionError: No option 'access_secret' in section: '1' + + +== Bad credentials == + +The application is not allowed to access Launchpad if there are no +credentials. + + >>> credentials = Credentials(consumer) + >>> launchpad = Launchpad(credentials=credentials) + Traceback (most recent call last): + ... + HTTPError: HTTP Error 401: Unauthorized + +The application is not allowed to access Launchpad with a bad access token. + + >>> access_token = AccessToken('bad', 'no-secret') + >>> credentials = Credentials( + ... consumer_name=consumer.key, consumer_secret=consumer.secret, + ... access_token=access_token) + >>> launchpad = Launchpad(credentials=credentials) + Traceback (most recent call last): + ... + HTTPError: HTTP Error 401: Unauthorized + +The application is not allowed to access Launchpad with a bad access secret. + + >>> access_token = AccessToken('hgm2VK35vXD6rLg5pxWw', 'bad-secret') + >>> credentials = Credentials( + ... consumer_name=consumer.key, consumer_secret=consumer.secret, + ... access_token=access_token) + >>> launchpad = Launchpad(credentials=credentials) + Traceback (most recent call last): + ... + HTTPError: HTTP Error 401: Unauthorized + + +== Clean up == + + >>> os.remove(path) diff --git a/src/launchpadlib/docs/people.txt b/src/launchpadlib/docs/people.txt new file mode 100644 index 0000000..defc54d --- /dev/null +++ b/src/launchpadlib/docs/people.txt @@ -0,0 +1,218 @@ += People and Teams = + +The Launchpad web service, like Launchpad itself, exposes a unified +interface to people and teams. In other words, people and teams +occupy the same namespace. You treat people and teams as the same +type of object, and need to inspect the object to know whether you're +dealing with a person or a team. + + +== People == + +You can access Launchpad people through the web service interface. +The list of people is available from the service root. + + >>> from launchpadlib.testing.helpers import salgado_with_full_permissions + >>> launchpad = salgado_with_full_permissions.login() + >>> people = launchpad.people + +The list of people is not fetched until you actually use data. + + >>> print people._wadl_resource.representation + None + + >>> len(people) + 4 + + >>> print people._wadl_resource.representation + {...} + +The 'me' attribute is also available from the service root. It's a +quick way to get a reference to your own user account. + + >>> me = launchpad.me + >>> me.name + u'salgado' + +You can find a person by name. + + >>> salgado = launchpad.people['salgado'] + >>> salgado.name + u'salgado' + >>> salgado.display_name + u'Guilherme Salgado' + >>> salgado.is_team + False + +But if no person by that name is registered, you get the expected KeyError. + + >>> launchpad.people['not-a-registered-person'] + Traceback (most recent call last): + ... + KeyError: 'not-a-registered-person' + +It's not possible to slice a single person from the top-level +collection of people. launchpadlib will try to use the value you pass +in as a person's name, which will almost always fail. + + >>> launchpad.people[1] + Traceback (most recent call last): + ... + KeyError: 1 + +You can find a person by email. + + >>> email = salgado.preferred_email_address.email + >>> salgado = launchpad.people.getByEmail(email=email) + >>> salgado.name + u'salgado' + +Besides a name and a display name, a person has many other attributes that you +can read. + + XXX 05-Jun-2008 BarryWarsaw Some of these attributes are links to further + collections and are not yet tested. Tests will be added in future + branches. + + >>> salgado.karma + 0 + >>> print salgado.homepage_content + None + >>> #salgado.mugshot + >>> #salgado.languages + >>> salgado.hide_email_addresses + False + >>> salgado.date_created + datetime.datetime(2005, 6, 6, 8, 59, 51, 596025, ...) + >>> print salgado.time_zone + None + >>> salgado.is_valid + True + >>> #salgado.wiki_names + >>> #salgado.irc_nicknames + >>> #salgado.jabber_ids + >>> #salgado.team_memberships + >>> #salgado.open_membership_invitations + >>> #salgado.teams_participated_in + >>> #salgado.teams_indirectly_participated_in + >>> #salgado.confirmed_email_addresses + >>> #salgado.preferred_email_address + >>> salgado.mailing_list_auto_subscribe_policy + u'Ask me when I join a team' + >>> salgado.visibility + u'Public' + + +== Teams == + +You also access teams using the same interface. + + >>> team = launchpad.people['ubuntu-team'] + >>> team.name + u'ubuntu-team' + >>> team.display_name + u'Ubuntu Team' + >>> team.is_team + True + +Regular people have team attributes, but they're not used. + + >>> print salgado.team_owner + None + +You can find out how a person has membership in a team. + + # XXX: salgado, 2008-08-01: Commented because method has been Unexported; + # it should be re-enabled after the operation is exported again. + # >>> path = salgado.findPathToTeam( + # ... team=launchpad.people['mailing-list-experts']) + # >>> [team.name for team in path] + # [u'admins', u'mailing-list-experts'] + +You can create a new team through the web interface. The simplest case of +this requires only the new team's name, owner and display name. + + >>> launchpad.people['bassists'] + Traceback (most recent call last): + ... + KeyError: 'bassists' + + >>> bassists = launchpad.people.newTeam( + ... name='bassists', display_name='Awesome Rock Bass Players') + >>> bassists.name + u'bassists' + >>> bassists.display_name + u'Awesome Rock Bass Players' + >>> bassists.is_team + True + +And of course, that team is now accessible directly. + + >>> bassists = launchpad.people['bassists'] + >>> bassists.name + u'bassists' + >>> bassists.display_name + u'Awesome Rock Bass Players' + +You cannot create the same team twice. + + >>> launchpad.people.newTeam(name='bassists', display_name='Bass Gods') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 400: Bad Request + +Actually, the exception contains other useful information. + + >>> from launchpadlib.errors import HTTPError + >>> try: + ... launchpad.people.newTeam( + ... name='bassists', display_name='Bass Gods') + ... except HTTPError, error: + ... pass + >>> error.response['status'] + '400' + >>> error.content + 'name: bassists is already in use by another person or team.' + +Besides a name and a display name, a team has many other attributes that you +can read. + + >>> bassists.karma + 0 + >>> print bassists.homepage_content + None + >>> bassists.hide_email_addresses + False + >>> bassists.date_created + datetime.datetime(...) + >>> print bassists.time_zone + None + >>> bassists.is_valid + True + >>> #bassists.team_memberships + >>> #bassists.open_membership_invitations + >>> #bassists.teams_participated_in + >>> #bassists.teams_indirectly_participated_in + >>> #bassists.confirmed_email_addresses + >>> #bassists.team_owner + >>> #bassists.preferred_email_address + >>> #bassists.members + >>> #bassists.admins + >>> #bassists.participants + >>> #bassists.deactivated_members + >>> #bassists.expired_members + >>> #bassists.invited_members + >>> #bassists.member_memberships + >>> #bassists.proposed_members + >>> bassists.visibility + u'Public' + >>> print bassists.team_description + None + >>> bassists.subscription_policy + u'Moderated Team' + >>> bassists.renewal_policy + u'invite them to apply for renewal' + >>> print bassists.default_membership_period + None + >>> print bassists.default_renewal_period + None diff --git a/src/launchpadlib/docs/toplevel.txt b/src/launchpadlib/docs/toplevel.txt new file mode 100644 index 0000000..580e6ff --- /dev/null +++ b/src/launchpadlib/docs/toplevel.txt @@ -0,0 +1,31 @@ +The launchpad web service's top-level collections provide access to +Launchpad-wide objects like projects and people. + + >>> from launchpadlib.testing.helpers import salgado_with_full_permissions + >>> launchpad = salgado_with_full_permissions.login() + +It's possible to do key-based lookups on the top-level +collections. The bug collection does lookups by bug ID. + + >>> launchpad.bugs[1].id + 1 + +The person collection does lookups by a person's Launchpad name. + + >>> launchpad.people['salgado'].name + u'salgado' + +The project collection does lookups by project name. + + >>> launchpad.projects['firefox'].name + u'firefox' + +The project group collection does lookups by project group name. + + >>> launchpad.project_groups['firefox'].name + u'firefox' + +The distribution collection does lookups by distribution name. + + >>> launchpad.distributions['ubuntu'].name + u'ubuntu' diff --git a/src/launchpadlib/errors.py b/src/launchpadlib/errors.py new file mode 100644 index 0000000..b9e59a4 --- /dev/null +++ b/src/launchpadlib/errors.py @@ -0,0 +1,20 @@ +# Copyright 2008 Canonical Ltd. + +# This file is part of launchpadlib. +# +# launchpadlib is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, version 3 of the License. +# +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. + + +"""Reimport errors from restfulclient for convenience's sake.""" + +from lazr.restfulclient.errors import * diff --git a/src/launchpadlib/launchpad.py b/src/launchpadlib/launchpad.py new file mode 100644 index 0000000..dbba313 --- /dev/null +++ b/src/launchpadlib/launchpad.py @@ -0,0 +1,279 @@ +# Copyright 2008-2009 Canonical Ltd. + +# This file is part of launchpadlib. +# +# launchpadlib is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, version 3 of the License. +# +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. + +"""Root Launchpad API class.""" + +__metaclass__ = type +__all__ = [ + 'Launchpad', + ] + +import os +import stat +import sys +import urlparse +import webbrowser + +from lazr.uri import URI +from lazr.restfulclient._browser import RestfulHttp +from lazr.restfulclient.resource import ( + CollectionWithKeyBasedLookup, HostedFile, ServiceRoot) +from launchpadlib.credentials import AccessToken, Credentials +from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT + + +OAUTH_REALM = 'https://api.launchpad.net' +LPNET_SERVICE_ROOT = 'https://api.launchpad.net/beta/' +EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/beta/' +STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/beta/' +DEV_SERVICE_ROOT = 'https://api.launchpad.dev/beta/' +DOGFOOD_SERVICE_ROOT = 'https://api.dogfood.launchpad.net/beta/' + + +class PersonSet(CollectionWithKeyBasedLookup): + """A custom subclass capable of person lookup by username.""" + + def _get_url_from_id(self, key): + """Transform a username into the URL to a person resource.""" + return str(self._root._root_uri.ensureSlash()) + '~' + str(key) + + +class BugSet(CollectionWithKeyBasedLookup): + """A custom subclass capable of bug lookup by bug ID.""" + + def _get_url_from_id(self, key): + """Transform a bug ID into the URL to a bug resource.""" + return str(self._root._root_uri.ensureSlash()) + 'bugs/' + str(key) + + +class PillarSet(CollectionWithKeyBasedLookup): + """A custom subclass capable of lookup by pillar name. + + Projects, project groups, and distributions are all pillars. + """ + + def _get_url_from_id(self, key): + """Transform a project name into the URL to a project resource.""" + return str(self._root._root_uri.ensureSlash()) + str(key) + + +class Launchpad(ServiceRoot): + """Root Launchpad API class. + + :ivar credentials: The credentials instance used to access Launchpad. + :type credentials: `Credentials` + """ + + RESOURCE_TYPE_CLASSES = { + 'bugs': BugSet, + 'distributions': PillarSet, + 'HostedFile': HostedFile, + 'people': PersonSet, + 'project_groups': PillarSet, + 'projects': PillarSet, + } + + service_roots = dict( + production=LPNET_SERVICE_ROOT, + edge=EDGE_SERVICE_ROOT, + staging=STAGING_SERVICE_ROOT, + dogfood=DOGFOOD_SERVICE_ROOT, + dev=DEV_SERVICE_ROOT, + ) + + + def __init__(self, credentials, service_root=STAGING_SERVICE_ROOT, + cache=None, timeout=None, proxy_info=None): + """Root access to the Launchpad API. + + :param credentials: The credentials used to access Launchpad. + :type credentials: `Credentials` + :param service_root: The URL to the root of the web service. + :type service_root: string + """ + super(Launchpad, self).__init__( + credentials, service_root, cache, timeout, proxy_info) + + def httpFactory(self, credentials, cache, timeout, proxy_info): + return OAuthSigningHttp(credentials, cache, timeout, proxy_info) + + @classmethod + def login(cls, consumer_name, token_string, access_secret, + service_root=STAGING_SERVICE_ROOT, + cache=None, timeout=None, proxy_info=None): + """Convenience for setting up access credentials. + + When all three pieces of credential information (the consumer + name, the access token and the access secret) are available, this + method can be used to quickly log into the service root. + + :param consumer_name: the consumer name, as appropriate for the + `Consumer` constructor + :type consumer_name: string + :param token_string: the access token, as appropriate for the + `AccessToken` constructor + :type token_string: string + :param access_secret: the access token's secret, as appropriate for + the `AccessToken` constructor + :type access_secret: string + :param service_root: The URL to the root of the web service. + :type service_root: string + :return: The web service root + :rtype: `Launchpad` + """ + access_token = AccessToken(token_string, access_secret) + credentials = Credentials( + consumer_name=consumer_name, access_token=access_token) + return cls(credentials, service_root, cache, timeout, proxy_info) + + @classmethod + def get_token_and_login(cls, consumer_name, + service_root=STAGING_SERVICE_ROOT, + cache=None, timeout=None, proxy_info=None): + """Get credentials from Launchpad and log into the service root. + + This is a convenience method which will open up the user's preferred + web browser and thus should not be used by most applications. + Applications should, instead, use Credentials.get_request_token() to + obtain the authorization URL and + Credentials.exchange_request_token_for_access_token() to obtain the + actual OAuth access token. + + This method will negotiate an OAuth access token with the service + provider, but to complete it we will need the user to log into + Launchpad and authorize us, so we'll open the authorization page in + a web browser and ask the user to come back here and tell us when they + finished the authorization process. + + :param consumer_name: The consumer name, as appropriate for the + `Consumer` constructor + :type consumer_name: string + :param service_root: The URL to the root of the web service. + :type service_root: string + :return: The web service root + :rtype: `Launchpad` + """ + credentials = Credentials(consumer_name) + web_root_uri = URI(service_root) + web_root_uri.path = "" + web_root_uri.host = web_root_uri.host.replace("api.", "", 1) + web_root = str(web_root_uri.ensureSlash()) + authorization_url = credentials.get_request_token(web_root=web_root) + webbrowser.open(authorization_url) + print "The authorization page:" + print " (%s)" % authorization_url + print "should be opening in your browser. After you have authorized" + print "this program to access Launchpad on your behalf you should come" + print "back here and press <Enter> to finish the authentication process." + sys.stdin.readline() + credentials.exchange_request_token_for_access_token(web_root) + return cls(credentials, service_root, cache, timeout, proxy_info) + + @classmethod + def login_with(cls, consumer_name, + service_root=STAGING_SERVICE_ROOT, + launchpadlib_dir=None, timeout=None, proxy_info=None): + """Log in to Launchpad with possibly cached credentials. + + This is a convenience method for either setting up new login + credentials, or re-using existing ones. When a login token is + generated using this method, the resulting credentials will be + saved in the `launchpadlib_dir` directory. If the same + `launchpadlib_dir` is passed in a second time, the credentials + in `launchpadlib_dir` for the consumer will be used + automatically. + + Each consumer has their own credentials per service root in + `launchpadlib_dir`. `launchpadlib_dir` is also used for caching + fetched objects. The cache is per service root, and shared by + all consumers. + + See `Launchpad.get_token_and_login()` for more information about + how new tokens are generated. + + :param consumer_name: The consumer name, as appropriate for the + `Consumer` constructor + :type consumer_name: string + :param service_root: The URL to the root of the web service. + :type service_root: string. Can either be the full URL to a service + or one of the short service names. + :param launchpadlib_dir: The directory where the cache and + credentials are stored. + :type launchpadlib_dir: string + :return: The web service root + :rtype: `Launchpad` + + """ + if launchpadlib_dir is None: + home_dir = os.environ['HOME'] + launchpadlib_dir = os.path.join(home_dir, '.launchpadlib') + launchpadlib_dir = os.path.expanduser(launchpadlib_dir) + # Determine the real service root. + if service_root in cls.service_roots: + service_root = cls.service_roots[service_root] + # Each service root has its own cache and credential dirs. + scheme, host_name, path, query, fragment = urlparse.urlsplit( + service_root) + service_root_dir = os.path.join(launchpadlib_dir, host_name) + cache_path = os.path.join(service_root_dir, 'cache') + if not os.path.exists(cache_path): + os.makedirs(cache_path) + credentials_path = os.path.join(service_root_dir, 'credentials') + if not os.path.exists(credentials_path): + os.makedirs(credentials_path) + consumer_credentials_path = os.path.join( + credentials_path, consumer_name) + if os.path.exists(consumer_credentials_path): + credentials = Credentials.load_from_path( + consumer_credentials_path) + launchpad = cls( + credentials, service_root=service_root, cache=cache_path, + timeout=timeout, proxy_info=proxy_info) + else: + launchpad = cls.get_token_and_login( + consumer_name, service_root=service_root, cache=cache_path, + timeout=timeout, proxy_info=proxy_info) + launchpad.credentials.save_to_path( + os.path.join(credentials_path, consumer_name)) + os.chmod( + os.path.join(credentials_path, consumer_name), + stat.S_IREAD | stat.S_IWRITE) + return launchpad + + +class OAuthSigningHttp(RestfulHttp): + """A client that signs every outgoing request with OAuth credentials.""" + + def _request(self, conn, host, absolute_uri, request_uri, method, body, + headers, redirections, cachekey): + """Sign a request with OAuth credentials before sending it.""" + oauth_request = OAuthRequest.from_consumer_and_token( + self.restful_credentials.consumer, + self.restful_credentials.access_token, + http_url=absolute_uri) + oauth_request.sign_request( + OAuthSignatureMethod_PLAINTEXT(), + self.restful_credentials.consumer, + self.restful_credentials.access_token) + if headers.has_key('authorization'): + # There's an authorization header left over from a + # previous request that resulted in a redirect. Remove it + # and start again. + del headers['authorization'] + headers.update(oauth_request.to_header(OAUTH_REALM)) + return super(OAuthSigningHttp, self)._request( + conn, host, absolute_uri, request_uri, method, body, headers, + redirections, cachekey) diff --git a/src/launchpadlib/testing/__init__.py b/src/launchpadlib/testing/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/launchpadlib/testing/__init__.py diff --git a/src/launchpadlib/testing/helpers.py b/src/launchpadlib/testing/helpers.py new file mode 100644 index 0000000..4933907 --- /dev/null +++ b/src/launchpadlib/testing/helpers.py @@ -0,0 +1,63 @@ +# Copyright 2008 Canonical Ltd. + +# This file is part of launchpadlib. +# +# launchpadlib is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# launchpadlib is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with launchpadlib. If not, see +# <http://www.gnu.org/licenses/>. + +"""launchpadlib testing helpers.""" + + +__metaclass__ = type +__all__ = [ + 'TestableLaunchpad', + 'nopriv_read_nonprivate', + 'salgado_read_nonprivate', + 'salgado_with_full_permissions', + ] + + +from launchpadlib.launchpad import Launchpad + + +class TestableLaunchpad(Launchpad): + """A base class for talking to the testing root service.""" + + # Use our test service root. + TESTING_SERVICE_ROOT = 'http://api.launchpad.dev:8085/beta/' + + def __init__(self, credentials, service_root_ignored=None, + cache=None, timeout=None, proxy_info=None): + super(TestableLaunchpad, self).__init__( + credentials, TestableLaunchpad.TESTING_SERVICE_ROOT, + cache, timeout, proxy_info) + + +class KnownTokens: + """Known access token/secret combinations.""" + + def __init__(self, token_string, access_secret): + self.token_string = token_string + self.access_secret = access_secret + + def login(self, cache=None, timeout=None, proxy_info=None): + """Login using these credentials.""" + return TestableLaunchpad.login( + 'launchpad-library', self.token_string, self.access_secret, + cache=cache, timeout=timeout, proxy_info=proxy_info) + + +salgado_with_full_permissions = KnownTokens('salgado-change-anything', 'test') +salgado_read_nonprivate = KnownTokens('salgado-read-nonprivate', 'secret') +nopriv_read_nonprivate = KnownTokens('nopriv-read-nonprivate', 'mystery') diff --git a/src/launchpadlib/tests/__init__.py b/src/launchpadlib/tests/__init__.py new file mode 100644 index 0000000..786a83f --- /dev/null +++ b/src/launchpadlib/tests/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2008 Canonical Ltd. + +# This file is part of launchpadlib. +# +# launchpadlib is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, version 3 of the License. +# +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. +"""Tests for launchpadlib""" diff --git a/src/launchpadlib/tests/test_credentials.py b/src/launchpadlib/tests/test_credentials.py new file mode 100644 index 0000000..c14e823 --- /dev/null +++ b/src/launchpadlib/tests/test_credentials.py @@ -0,0 +1,60 @@ +# Copyright 2009 Canonical Ltd. + +# This file is part of launchpadlib. +# +# launchpadlib is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, version 3 of the License. +# +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. + +"""Tests for the Credentials class.""" + +__metaclass__ = type + + +import os +import os.path +import shutil +import tempfile +import unittest + +from launchpadlib.credentials import AccessToken, Credentials + + +class TestCredentialsSaveAndLoad(unittest.TestCase): + """Test for saving and loading credentials.""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_save_to_and_load_from__path(self): + # Credentials can be saved to and loaded from a file using + # save_to_path() and load_from_path(). + credentials_path = os.path.join(self.temp_dir, 'credentials') + credentials = Credentials( + 'consumer.key', consumer_secret='consumer.secret', + access_token=AccessToken('access.key', 'access.secret')) + credentials.save_to_path(credentials_path) + self.assertTrue(os.path.exists(credentials_path)) + + loaded_credentials = Credentials.load_from_path(credentials_path) + self.assertEqual(loaded_credentials.consumer.key, 'consumer.key') + self.assertEqual( + loaded_credentials.consumer.secret, 'consumer.secret') + self.assertEqual( + loaded_credentials.access_token.key, 'access.key') + self.assertEqual( + loaded_credentials.access_token.secret, 'access.secret') + +def test_suite(): + return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/src/launchpadlib/tests/test_launchpad.py b/src/launchpadlib/tests/test_launchpad.py new file mode 100644 index 0000000..a7787d0 --- /dev/null +++ b/src/launchpadlib/tests/test_launchpad.py @@ -0,0 +1,230 @@ +# Copyright 2009 Canonical Ltd. + +# This file is part of launchpadlib. +# +# launchpadlib is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, version 3 of the License. +# +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. + +"""Tests for the Launchpad class.""" + +__metaclass__ = type + +import os +import shutil +import stat +import tempfile +import unittest + +from launchpadlib.credentials import AccessToken, Credentials +from launchpadlib.launchpad import Launchpad + + +class NoNetworkLaunchpad(Launchpad): + """A Launchpad instance for tests with no network access. + + It's only useful for making sure that certain methods were called. + It can't be used to interact with the API. + """ + + consumer_name = None + passed_in_kwargs = None + credentials = None + get_token_and_login_called = False + service_roots = dict(example='http://api.example.com/beta') + + def __init__(self, credentials, **kw): + self.credentials = credentials + self.passed_in_kwargs = kw + + @classmethod + def get_token_and_login(cls, consumer_name, **kw): + """Create fake credentials and record that we were called.""" + credentials = Credentials( + consumer_name, consumer_secret='consumer_secret:42', + access_token=AccessToken('access_key:84', 'access_secret:168')) + launchpad = cls(credentials, **kw) + launchpad.get_token_and_login_called = True + launchpad.consumer_name = consumer_name + launchpad.passed_in_kwargs = kw + return launchpad + + +class TestLaunchpadLoginWith(unittest.TestCase): + """Tests for Launchpad.login_with().""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_dirs_created(self): + # The path we pass into login_with() is the directory where + # cache and credentials for all service roots are stored. + launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') + launchpad = NoNetworkLaunchpad.login_with( + 'not important', service_root='http://api.example.com/beta', + launchpadlib_dir=launchpadlib_dir) + # The 'launchpadlib' dir got created. + self.assertTrue(os.path.isdir(launchpadlib_dir)) + # A directory for the passed in service root was created. + service_path = os.path.join(launchpadlib_dir, 'api.example.com') + self.assertTrue(os.path.isdir(service_path)) + # Inside the service root directory, there is a 'cache' and a + # 'credentials' directory. + self.assertTrue( + os.path.isdir(os.path.join(service_path, 'cache'))) + credentials_path = os.path.join(service_path, 'credentials') + self.assertTrue(os.path.isdir(credentials_path)) + + def test_no_credentials_calls_get_token_and_login(self): + # If no credentials are found, get_token_and_login() is called. + service_root = 'http://api.example.com/beta' + timeout = object() + proxy_info = object() + launchpad = NoNetworkLaunchpad.login_with( + 'app name', launchpadlib_dir=self.temp_dir, + service_root=service_root, timeout=timeout, proxy_info=proxy_info) + self.assertEqual(launchpad.consumer_name, 'app name') + expected_arguments = dict( + service_root=service_root, + timeout=timeout, + proxy_info=proxy_info, + cache=os.path.join(self.temp_dir, 'api.example.com', 'cache')) + self.assertEqual(launchpad.passed_in_kwargs, expected_arguments) + + def test_new_credentials_are_saved(self): + # After get_token_and_login() have been called, the created + # credentials are saved. + launchpad = NoNetworkLaunchpad.login_with( + 'app name', launchpadlib_dir=self.temp_dir, + service_root='http://api.example.com/beta') + credentials_path = os.path.join( + self.temp_dir, 'api.example.com', 'credentials', 'app name') + self.assertTrue(os.path.exists(credentials_path)) + # Make sure that the credentials can be loaded, thus were + # written correctly. + loaded_credentials = Credentials.load_from_path(credentials_path) + self.assertEqual(loaded_credentials.consumer.key, 'app name') + self.assertEqual( + loaded_credentials.consumer.secret, 'consumer_secret:42') + self.assertEqual( + loaded_credentials.access_token.key, 'access_key:84') + self.assertEqual( + loaded_credentials.access_token.secret, 'access_secret:168') + + def test_new_credentials_are_secure(self): + # The newly created credentials file is only readable and + # writable by the user. + launchpad = NoNetworkLaunchpad.login_with( + 'app name', launchpadlib_dir=self.temp_dir, + service_root='http://api.example.com/beta') + credentials_path = os.path.join( + self.temp_dir, 'api.example.com', 'credentials', 'app name') + statinfo = os.stat(credentials_path) + mode = stat.S_IMODE(statinfo.st_mode) + self.assertEqual(mode, stat.S_IWRITE | stat.S_IREAD) + + def test_existing_credentials_are_reused(self): + # If a credential file for the application already exists, that + # one is used. + os.makedirs( + os.path.join(self.temp_dir, 'api.example.com', 'credentials')) + credentials_file_path = os.path.join( + self.temp_dir, 'api.example.com', 'credentials', 'app name') + credentials = Credentials( + 'app name', consumer_secret='consumer_secret:42', + access_token=AccessToken('access_key:84', 'access_secret:168')) + credentials.save_to_path(credentials_file_path) + + launchpad = NoNetworkLaunchpad.login_with( + 'app name', launchpadlib_dir=self.temp_dir, + service_root='http://api.example.com/beta') + self.assertFalse(launchpad.get_token_and_login_called) + self.assertEqual(launchpad.credentials.consumer.key, 'app name') + self.assertEqual( + launchpad.credentials.consumer.secret, 'consumer_secret:42') + self.assertEqual( + launchpad.credentials.access_token.key, 'access_key:84') + self.assertEqual( + launchpad.credentials.access_token.secret, 'access_secret:168') + + def test_existing_credentials_arguments_passed_on(self): + # When re-using existing credentials, the arguments login_with + # is called with are passed on the the __init__() method. + os.makedirs( + os.path.join(self.temp_dir, 'api.example.com', 'credentials')) + credentials_file_path = os.path.join( + self.temp_dir, 'api.example.com', 'credentials', 'app name') + credentials = Credentials( + 'app name', consumer_secret='consumer_secret:42', + access_token=AccessToken('access_key:84', 'access_secret:168')) + credentials.save_to_path(credentials_file_path) + + service_root = 'http://api.example.com/beta' + timeout = object() + proxy_info = object() + launchpad = NoNetworkLaunchpad.login_with( + 'app name', launchpadlib_dir=self.temp_dir, + service_root=service_root, timeout=timeout, proxy_info=proxy_info) + expected_arguments = dict( + service_root=service_root, + timeout=timeout, + proxy_info=proxy_info, + cache=os.path.join(self.temp_dir, 'api.example.com', 'cache')) + self.assertEqual(launchpad.passed_in_kwargs, expected_arguments) + + def test_None_launchpadlib_dir(self): + # If no launchpadlib_dir is passed in to login_with, + # $HOME/.launchpadlib is used. + old_home = os.environ['HOME'] + os.environ['HOME'] = self.temp_dir + launchpad = NoNetworkLaunchpad.login_with( + 'app name', service_root='http://api.example.com/beta') + # Reset the environment to the old value. + os.environ['HOME'] = old_home + + cache_dir = launchpad.passed_in_kwargs['cache'] + launchpadlib_dir = os.path.abspath( + os.path.join(cache_dir, '..', '..')) + self.assertEqual( + launchpadlib_dir, os.path.join(self.temp_dir, '.launchpadlib')) + self.assertTrue(os.path.exists( + os.path.join(launchpadlib_dir, 'api.example.com', 'cache'))) + self.assertTrue(os.path.exists( + os.path.join(launchpadlib_dir, 'api.example.com', 'credentials'))) + + def test_short_service_name(self): + # A short service name is converted to the full service root URL. + launchpad = NoNetworkLaunchpad.login_with('app name', 'example') + self.assertEqual( + launchpad.passed_in_kwargs['service_root'], + 'http://api.example.com/beta') + + def test_short_service_name_bad(self): + # A short service name that does not match one of the pre-defined + # service root names is passed through. + launchpad = NoNetworkLaunchpad.login_with('app name', 'foo') + self.assertEqual( + launchpad.passed_in_kwargs['service_root'], + 'foo') + + def test_short_service_names(self): + # Ensure the short service names are all supported. + expected = ['production', 'edge', 'staging', 'dogfood', 'dev'] + self.assertEqual( + sorted(Launchpad.service_roots.keys()), + sorted(expected)) + + +def test_suite(): + return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/src/launchpadlib/wadl-to-refhtml.xsl b/src/launchpadlib/wadl-to-refhtml.xsl new file mode 100644 index 0000000..60feba2 --- /dev/null +++ b/src/launchpadlib/wadl-to-refhtml.xsl @@ -0,0 +1,1045 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + wadl-to-refhtml.xsl + + Generate HTML documentation for a webservice described in a WADL file. + This is tailored to WADL generated by Launchpad's web service. + + Based on wadl_documentaion.xsl from Mark Nottingham <mnot@yahoo-inc.com> + that can be found at http://www.mnot.net/webdesc/ + Copyright (c) 2006-2007 Yahoo! Inc. + Copyright (c) 2008 Canonical Ltd. + + This work is licensed under the Creative Commons Attribution-ShareAlike 2.5 + License. To view a copy of this license, visit + http://creativecommons.org/licenses/by-sa/2.5/ + or send a letter to + Creative Commons + 543 Howard Street, 5th Floor + San Francisco, California, 94105, USA +--> + +<xsl:stylesheet + xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0" + xmlns:wadl="http://research.sun.com/wadl/2006/10" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.w3.org/1999/xhtml" + exclude-result-prefixes="xsl wadl html" +> + <xsl:output + method="xml" + encoding="UTF-8" + indent="yes" + doctype-public="-//W3C//DTD XHTML 1.0 Transitional//EN" + doctype-system="http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" + /> + + + <!-- Allow using key('id', 'people') to identify unique elements, since + the document doesn't have a parsed DTD. + --> + <xsl:key name="id" match="*[@id]" use="@id"/> + + <!-- Embedded stylesheet. --> + <xsl:template name="css-stylesheet"> + <style type="text/css"> + body { + font-family: sans-serif; + font-size: 0.85em; + margin: 2em 8em; + } + .methods { + background-color: #eef; + padding: 1em; + margin-bottom: 0.5em; + } + .method { + padding-left: 4em; + } + h1 { + font-size: 2.5em; + } + h2 { + border-bottom: 1px solid black; + margin-top: 1em; + margin-bottom: 0.5em; + font-size: 2em; + } + h3 { + color: orange; + font-size: 1.75em; + margin-top: 1.25em; + margin-bottom: 0em; + } + h4 { + font-size: 1.50em; + margin: 0em; + padding: 0em; + border-bottom: 2px solid white; + } + h5 { + font-size: 1.25em; + margin-left: -3em; + } + h6 { + font-size: 1.1em; + color: #99a; + margin: 0.5em 0em 0.25em 0em; + } + dd { + margin-left: 1em; + } + tt, code { + font-size: 1.2em; + } + table { + margin-bottom: 0.5em; + } + th { + text-align: left; + font-weight: normal; + color: black; + border-bottom: 1px solid black; + padding: 3px 6px; + } + td { + padding: 3px 6px; + vertical-align: top; + background-color: #f6f6ff; + font-size: 0.85em; + } + td p { + margin: 0px; + } + ul { + padding-left: 1.75em; + } + p + ul, p + ol, p + dl { + margin-top: 0em; + } + label { + font-weight: bold; + } + .optional { + font-weight: normal; + opacity: 0.75; + } + .toc-link { + font-size: 0.85em; + } + </style> + </xsl:template> + + <!-- Contains the base URL for the webservice without a trailing + slash. --> + <xsl:variable name="base"> + <xsl:variable name="uri" select="//wadl:resources/@base"/> + <xsl:choose> + <xsl:when + test="substring($uri, string-length($uri) , 1) = '/'"> + <xsl:value-of + select="substring($uri, 1, string-length($uri) - 1)"/> + </xsl:when> + <xsl:otherwise> + <xsl:value-of select="$uri"/> + </xsl:otherwise> + </xsl:choose> + </xsl:variable> + + <!-- Generate the URL to the top-level collection. --> + <xsl:template name="resource-uri-doc"> + <xsl:param name="url"><xsl:value-of + select="$base"/>/<xsl:value-of select="@id"/></xsl:param> + <p><label>URL:</label> + <code><xsl:copy-of select="$url" /></code></p> + </xsl:template> + + <xsl:template name="entry-uri-doc"> + <xsl:call-template name="resource-uri-doc"> + <xsl:with-param name="url"> + <xsl:choose> + <xsl:when test="@id = 'has_milestones' + or @id = 'bug_target' + or @id = 'has_bugs'"> + <em>depends on the underlying entry</em> + </xsl:when> + <xsl:otherwise> + <xsl:call-template name="find-entry-uri"/> + </xsl:otherwise> + </xsl:choose> + </xsl:with-param> + </xsl:call-template> + </xsl:template> + + <xsl:template name="find-entry-uri"> + <xsl:value-of select="$base"/> + <xsl:choose> + <xsl:when test="@id = 'archive'"> + <xsl:text>/</xsl:text> + <var><distribution></var> + <xsl:text>/+archive/</xsl:text> + <var><archive.name></var> + </xsl:when> + <xsl:when test="@id = 'archive_permission'"> + <xsl:text>/</xsl:text> + <var><archive.distribution></var> + <xsl:text>/+archive/</xsl:text> + <var><archive.name></var> + <xsl:text>/+</xsl:text> + <xsl:text>name</xsl:text> + <xsl:text>/</xsl:text> + <xsl:text>person.name</xsl:text> + <xsl:text>.</xsl:text> + <xsl:text>[component or source package].name</xsl:text> + </xsl:when> + <xsl:when test="@id = 'binary_package_publishing_history'"> + <xsl:text>/</xsl:text> + <var><distribution.name></var> + <xsl:text>/+archive/</xsl:text> + <var><binary_package.name></var> + <xsl:text>/+binarypub/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'branch'"> + <xsl:text>/~</xsl:text> + <var><author.name></var> + <xsl:text>/</xsl:text> + <var><project.name></var> + <xsl:text>/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'branch_merge_proposal'"> + <xsl:text>/~</xsl:text> + <var><author.name></var> + <xsl:text>/</xsl:text> + <var><project.name></var> + <xsl:text>/</xsl:text> + <var><branch.name></var> + <xsl:text>/+merge/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'bug'"> + <xsl:text>/bugs/</xsl:text><var><id></var> + </xsl:when> + <xsl:when test="@id = 'bug_attachment'"> + <xsl:text>/bugs/</xsl:text> + <var><bug.id></var> + <xsl:text>/attachments/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'bug_subscription'"> + <xsl:text>/bugs/</xsl:text> + <var><bug.id></var> + <xsl:text>/subscriptions/</xsl:text> + <var><subscriber.name></var> + </xsl:when> + <xsl:when test="@id = 'bug_task'"> + <xsl:text>/</xsl:text> + <var><target.name></var> + <xsl:text>/+bug/</xsl:text> + <var ><bug.id></var> + </xsl:when> + <xsl:when test="@id = 'bug_watch'"> + <xsl:text>/bugs/</xsl:text> + <var><bug.id></var> + <xsl:text>/watch/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'bug_tracker'"> + <xsl:text>/bugs/bugtrackers/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'build'"> + <xsl:text>/</xsl:text> + <var><distribution.name></var> + <xsl:text>/+source/</xsl:text> + <var><source_package.name></var> + <xsl:text>/+build/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'cve'"> + <xsl:text>/bugs/cve/</xsl:text> + <var><sequence></var> + </xsl:when> + <xsl:when test="@id = 'distribution_source_package'"> + <xsl:text>/</xsl:text> + <var><distribution.name></var> + <xsl:text>/+source/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'distro_arch_series'"> + <xsl:text>/</xsl:text> + <var><distribution.name></var> + <xsl:text>/</xsl:text> + <var><distroseries.name></var> + <xsl:text>/</xsl:text> + <var><architecture_tag></var> + </xsl:when> + <xsl:when test="@id = 'distro_series'"> + <xsl:text>/</xsl:text> + <var><distribution.name></var> + <xsl:text>/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'email_address'"> + <xsl:text>/</xsl:text> + <var><person.name></var> + <xsl:text>/+email/</xsl:text> + <var><email></var> + </xsl:when> + <xsl:when test="@id = 'h_w_device'"> + <xsl:text>/+hwdb/+device/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'h_w_device_class'"> + <xsl:text>/+hwdb/+deviceclass/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'h_w_driver'"> + <xsl:text>/+hwdb/+driver/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'h_w_submission'"> + <xsl:text>/+hwdb/+submission/</xsl:text> + <var><submission-key></var> + </xsl:when> + <xsl:when test="@id = 'h_w_submission_device'"> + <xsl:text>/+hwdb/+submissiondevice/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'h_w_vendor_i_d'"> + <xsl:text>/+hwdb/+hwvendorid/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'jabber_id'"> + <xsl:text>/</xsl:text> + <var><person.name></var> + <xsl:text>/+jabberid/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'irc_id'"> + <xsl:text>/</xsl:text> + <var><person.name></var> + <xsl:text>/+ircnick/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'language'"> + <xsl:text>/+languages/</xsl:text> + <var><code></var> + </xsl:when> + <xsl:when test="@id = 'message'"> + <xsl:text>/</xsl:text> + <var><target.name></var> + <xsl:text>/+bug/</xsl:text> + <var><bug.id></var> + <xsl:text>/comments/</xsl:text> + <var><index></var> + </xsl:when> + <xsl:when test="@id = 'milestone'"> + <xsl:text>/</xsl:text> + <var><target.name></var> + <xsl:text>/+milestone/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test=" @id = 'distribution' + or @id = 'pillar' + or @id = 'product' + or @id = 'project' + or @id = 'project_group'"> + <xsl:text>/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'team' or @id = 'person'"> + <xsl:text>/~</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'product_release'"> + <xsl:text>/</xsl:text> + <var><product.name></var> + <xsl:text>/</xsl:text> + <var><product_series.name></var> + <xsl:text>/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'product_series'"> + <xsl:text>/</xsl:text> + <var><product.name></var> + <xsl:text>/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'project_release'"> + <xsl:text>/</xsl:text> + <var><project.name></var> + <xsl:text>/</xsl:text> + <var><project_series.name></var> + <xsl:text>/</xsl:text> + <var><release.version></var> + </xsl:when> + <xsl:when test="@id = 'project_release_file'"> + <xsl:text>/</xsl:text> + <var><project.name></var> + <xsl:text>/</xsl:text> + <var><project_series.name></var> + <xsl:text>/</xsl:text> + <var><release.version></var> + <xsl:text>/+file/</xsl:text> + <var><hosted_file.filename></var> + </xsl:when> + <xsl:when test="@id = 'project_series'"> + <xsl:text>/</xsl:text> + <var><project.name></var> + <xsl:text>/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'source_package'"> + <xsl:text>/</xsl:text> + <var><distribution.name></var> + <xsl:text>/</xsl:text> + <var><distro_series.name></var> + <xsl:text>/+source/</xsl:text> + <var><name></var> + </xsl:when> + <xsl:when test="@id = 'source_package_publishing_history'"> + <xsl:text>/</xsl:text> + <var><distribution></var> + <xsl:text>/+archive/</xsl:text> + <var><name></var> + <xsl:text>/+sourcepub/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'team_membership'"> + <xsl:text>/</xsl:text> + <var><team.name></var> + <xsl:text>/+member/</xsl:text> + <var><member.name></var> + </xsl:when> + <xsl:when test="@id = 'wiki_name'"> + <xsl:text>/</xsl:text> + <var><person.name></var> + <xsl:text>/+wikiname/</xsl:text> + <var><id></var> + </xsl:when> + <xsl:when test="@id = 'commercial_subscription'"> + <xsl:text>/+commercialsubscription/</xsl:text> + <var><commercial_subscription.id></var> + </xsl:when> + <xsl:otherwise> + <xsl:message>Unknown entry URL: + <xsl:value-of select="@id" /> + </xsl:message> + </xsl:otherwise> + </xsl:choose> + </xsl:template> + <!-- We start here. --> + <xsl:template match="/wadl:application"> + <xsl:variable name="title"> + <xsl:choose> + <xsl:when test="wadl:doc[@title]"> + <xsl:value-of select="wadl:doc[@title][1]/@title"/> + </xsl:when> + <xsl:otherwise>Launchpad Web Service API</xsl:otherwise> + </xsl:choose> + </xsl:variable> + <html> + <head> + <title><xsl:value-of select="$title" /></title> + <xsl:call-template name="css-stylesheet"/> + </head> + <body> + <h1><xsl:value-of select="$title" /></h1> + <xsl:apply-templates select="wadl:doc"/> + + <xsl:call-template name="table-of-contents" /> + <xsl:call-template name="top-level-collections" /> + <xsl:call-template name="entry-types" /> + </body> + </html> + </xsl:template> + + <!-- Table of contents --> + <xsl:template name="table-of-contents"> + <div id="toc"> + <h2>Table of Contents</h2> + <h3>Top-level collections</h3> + <ul> + <xsl:for-each + select="key('id', 'service-root-json')/wadl:param/wadl:link"> + <xsl:sort select="../@name" /> + <xsl:variable name="collection_id" + select="substring-after(@resource_type, '#')" /> + <xsl:if test="string-length($collection_id) > 0"> + <li><a href="#{$collection_id}"> + <xsl:call-template name="get-title-or-id"> + <xsl:with-param name="element" select="key('id', $collection_id)" /> + </xsl:call-template> + </a></li> + </xsl:if> + </xsl:for-each> + </ul> + <h3>Entry types</h3> + <ul> + <xsl:for-each select="wadl:resource_type[ + @id != 'service-root' + and @id != 'HostedFile' + and not(contains(@id, 'page-resource')) + ]"> + <xsl:sort select="@id" /> + <xsl:variable name="id" select="./@id"/> + <xsl:variable name="top_level_collections" + select="key('id', 'service-root-json')//@resource_type[ + substring-after(., '#') = $id]" /> + <xsl:if test="not($top_level_collections[contains(., $id)])"> + <li><a href="#{$id}"> + <xsl:call-template name="get-title-or-id"> + <xsl:with-param name="element" select="." /> + </xsl:call-template> + </a></li> + </xsl:if> + </xsl:for-each> + </ul> + </div> + </xsl:template> + + <!-- Top level collections container --> + <xsl:template name="top-level-collections"> + <div id="top-level-collections"> + <h2>Top-level collections</h2> + + <!-- + Top-level collections are found in the WADL by + looking at the representation of the service-root resource + and processing all the resource-type linked from it. + --> + <xsl:for-each + select="key('id', 'service-root-json')/wadl:param/wadl:link"> + <xsl:sort select="../@name" /> + <xsl:variable name="collection_id" + select="substring-after(@resource_type, '#')" /> + + <xsl:apply-templates + select="key('id', $collection_id)" + mode="top-level-collections" /> + </xsl:for-each> + </div> + </xsl:template> + + <xsl:template name="find-root-object-uri"> + <xsl:value-of select="$base"/> + <xsl:choose> + <xsl:when test="@id = 'hwdb'"> + <xsl:text>/+hwdb</xsl:text> + </xsl:when> + <xsl:otherwise> + <xsl:text>/</xsl:text><xsl:value-of select="@id" /> + </xsl:otherwise> + </xsl:choose> + </xsl:template> + + <!-- Documentation for one top-level-collection --> + <xsl:template match="wadl:resource_type" mode="top-level-collections"> + <div id="{@id}" class="top-level-collection"> + <h3><xsl:call-template name="get-title-or-id"/></h3> + <xsl:apply-templates select="wadl:doc"/> + + <xsl:call-template name="resource-uri-doc"> + <xsl:with-param name="url"> + <!-- The default URL schema used for root objects of + Launchpad's webservice is + + [urlbase]/[root-object-name] + + e.g, + + https://api.launchpad.net/beta/bugs + + while the HWDB application root's URL is + + https://api.launchpad.net/beta/+hwdb + + In other words, the URL for the HWDB application + root needs to be mangled in a form similar to + that used for non-root objects in the template + "find-entry-uri". + --> + + <xsl:call-template name="find-root-object-uri"/> + </xsl:with-param> + </xsl:call-template> + + <!-- All top-level collections supports a GET without arguments + iterating over all the resources. + The type of the resource is found by looking at the href attribute + of the default representation. Link is in the form + <resource>-page. + --> + <div class="methods standard"> + <h4>Standard method</h4> + <xsl:variable name="default_get" + select="wadl:method[not(wadl:request)][1]" /> + <xsl:variable name="resource_type" + select="substring-after( + substring-before( + $default_get//wadl:representation[ + not(@mediaType)]/@href, '-page'), + '#')" /> + <dl> + <dt>GET</dt> + <dd>Response contains a <a href="#{$resource_type}" + ><xsl:call-template name="get-title-or-id"> + <xsl:with-param name="element" + select="key('id', $resource_type)" /> + </xsl:call-template></a> + collection.</dd> + </dl> + </div> + + <xsl:call-template name="custom-GETs" /> + <xsl:call-template name="custom-POSTs" /> + <a href="#toc" class="toc-link">(back to Table of Contents)</a> + </div> + </xsl:template> + + <!-- Documentation for the standard methods on an entry --> + <xsl:template name="standard-methods"> + <div id="{@id}-standard-methods" class="methods standard"> + <h4>Standard methods</h4> + <dl> + <!-- Standard methods are the ones without a ws.op param. --> + <xsl:apply-templates + select="wadl:method[not(.//wadl:param[@name = 'ws.op'])]" + mode="standard-method"> + <xsl:sort select="@name"/> + </xsl:apply-templates> + </dl> + </div> + </xsl:template> + + <!-- Documentation for the standard GET on an entry --> + <xsl:template match="wadl:method[@name='GET']" mode="standard-method"> + <dt><xsl:value-of select="@name" /></dt> + <dd>Response contains the default + <xsl:call-template name="representation-type" /> representation + for this entry. + </dd> + </xsl:template> + + <!-- Documentation for the standard PUT on an entry --> + <xsl:template match="wadl:method[@name='PUT']" mode="standard-method"> + <dt><xsl:value-of select="@name" /></dt> + <dd>Entity body should contain a representation encoded using + <xsl:call-template name="representation-type" /> of the entry. + All fields of the default representation should be included. Only + fields marked as writeable in the default representation should be + modified. + </dd> + </xsl:template> + + <!-- Documentation for the standard PATCH on an entry --> + <xsl:template match="wadl:method[@name='PATCH']" mode="standard-method"> + <dt><xsl:value-of select="@name" /></dt> + <dd>Entity body should contain a represention encoded using + <xsl:call-template name="representation-type"/> of the entry + fields to update. Any fields of the default representation marked + as writeable can be included. + </dd> + </xsl:template> + + <!-- Documentation for the custom GET operations of the resource type --> + <xsl:template name="custom-GETs"> + <xsl:variable name="operations" select="wadl:method[ + @name = 'GET'][.//wadl:param[@name = 'ws.op']]" /> + + <xsl:if test="$operations"> + <div id="{@id}-custom-GETs" class="methods GETs"> + <h4>Custom GET methods</h4> + + <xsl:apply-templates select="$operations"> + <xsl:sort select=".//wadl:param[@name='ws.op']/@fixed"/> + </xsl:apply-templates> + </div> + </xsl:if> + </xsl:template> + + <!-- Documentation for the custom POST operations of the resource type --> + <xsl:template name="custom-POSTs"> + <xsl:variable name="operations" select="wadl:method[ + @name = 'POST'][.//wadl:param[@name = 'ws.op']]" /> + + <xsl:if test="$operations"> + <div id="{@id}-custom-POSTs" class="methods POSTs"> + <h4>Custom POST methods</h4> + + <xsl:apply-templates select="$operations"> + <xsl:sort select=".//wadl:param[@name='ws.op']/@fixed"/> + </xsl:apply-templates> + </div> + </xsl:if> + </xsl:template> + + <!-- Container for all the entry types documentation --> + <xsl:template name="entry-types"> + <h2 id="entry-types">Entry types</h2> + + <!-- Process all the resource_types, except the service-root ones, + the type describing collections of that type, + or any other ones, linked from within the service root. + --> + <xsl:for-each select="wadl:resource_type[ + @id != 'service-root' + and @id != 'HostedFile' + and not(contains(@id, 'page-resource')) + ]"> + <xsl:sort select="@id" /> + <xsl:variable name="id" select="./@id"/> + <xsl:variable name="top_level_collections" + select="key('id', 'service-root-json')//@resource_type[ + substring-after(., '#') = $id]" /> + <xsl:if test="not($top_level_collections[contains(., $id)])"> + <xsl:apply-templates select="." mode="entry-types" /> + </xsl:if> + </xsl:for-each> + </xsl:template> + + <!-- Documentation for one entry-type --> + <xsl:template match="wadl:resource_type" mode="entry-types"> + <h3 id="{@id}"><xsl:call-template name="get-title-or-id"/></h3> + <xsl:apply-templates select="wadl:doc"/> + + <xsl:call-template name="entry-uri-doc"/> + + <xsl:call-template name="default-representation" /> + <xsl:call-template name="standard-methods" /> + <xsl:call-template name="custom-GETs" /> + <xsl:call-template name="custom-POSTs" /> + <a href="#toc" class="toc-link">(back to Table of Contents)</a> + </xsl:template> + + <!-- Documentation of the default representation for an entry --> + <xsl:template name="default-representation"> + <xsl:variable name="default_get" select="wadl:method[ + @name = 'GET' and not(wadl:request)]" /> + <xsl:variable name="representation" select="key( + 'id', substring-after( + $default_get/wadl:response/wadl:representation[ + not(@mediaType)]/@href, '#'))"/> + + <div class="representation"> + <h4>Default representation + (<xsl:value-of select="$representation/@mediaType"/>)</h4> + + <table> + <tr> + <th>Key</th> + <th>Value</th> + <th>Description</th> + </tr> + <xsl:apply-templates select="$representation/wadl:param" + mode="representation"> + <xsl:sort select="@name"/> + </xsl:apply-templates> + </table> + </div> + </xsl:template> + + <!-- Output the cell containing the field name. + + current() should be a wadl:param. + --> + <xsl:template name="param-name"> + <td> + <p><strong><xsl:value-of select="@name"/></strong></p> + </td> + </xsl:template> + + <!-- Output a table cell containing the parameter description. + + current() should a wadl:param. + --> + <xsl:template name="param-description"> + <td> + <xsl:apply-templates select="wadl:doc"/> + <xsl:if test="wadl:option[wadl:doc]"> + <dl> + <xsl:apply-templates + select="wadl:option" mode="option-doc"/> + </dl> + </xsl:if> + </td> + </xsl:template> + + <!-- Output information about the parameter value. + + current() should be a wadl:param. + --> + <xsl:template name="param-value"> + <xsl:if test="wadl:option"> + <p><em>One of:</em></p> + <ul> + <xsl:apply-templates select="wadl:option"/> + </ul> + </xsl:if> + <xsl:apply-templates select="wadl:link[@resource_type]"/> + <xsl:if test="@default"> + <p> + Default: + <var><xsl:value-of select="@default"/></var> + </p> + </xsl:if> + <xsl:if test="@fixed"> + <p> + Fixed: + <var><xsl:value-of select="@fixed"/></var> + </p> + </xsl:if> + </xsl:template> + + <!-- Output row describing one field in the default representation --> + <xsl:template match="wadl:param" mode="representation"> + <xsl:variable name="resource_type" + select="substring-before(../@id, '-')" /> + <xsl:variable name="patch_representation_id" + ><xsl:value-of select="$resource_type"/>-diff</xsl:variable> + <xsl:variable name="patch_representation" + select="key('id', $patch_representation_id)"/> + <tr> + <xsl:call-template name="param-name"/> + <td> + <p> + <xsl:choose> + <xsl:when test="$patch_representation/wadl:param[@name + = current()/@name]"> + <small>(writeable)</small> + </xsl:when> + <xsl:otherwise> + <small>(read-only)</small> + </xsl:otherwise> + </xsl:choose> + </p> + <xsl:call-template name="param-value" /> + </td> + <xsl:call-template name="param-description" /> + </tr> + </xsl:template> + + <!-- Output the description of a link type in param listing --> + <xsl:template match="wadl:link[ + @resource_type and ../@name != 'self_link']"> + <xsl:variable name="resource_type" + select="substring-after(@resource_type, '#')"/> + <xsl:choose> + <xsl:when test="contains($resource_type, 'page-resource')"> + Link to a <a href="#{substring-before($resource_type, '-')}" + ><xsl:value-of + select="substring-before($resource_type, '-')" + /></a> collection. + </xsl:when> + <xsl:when test="$resource_type = 'HostedFile'"> + Link to a file resource. + </xsl:when> + <xsl:otherwise> + Link to a <a href="#{$resource_type}" + ><xsl:value-of select="$resource_type"/></a>. + </xsl:otherwise> + </xsl:choose> + </xsl:template> + + <!-- Documentation for a custom method --> + <xsl:template match="wadl:method[.//wadl:param[@name = 'ws.op']]"> + <div class="method"> + <h5 id="{@id}"><xsl:value-of + select=".//wadl:param[@name = 'ws.op']/@fixed"/></h5> + <xsl:choose> + <xsl:when test="wadl:doc|wadl:request|wadl:response"> + <xsl:apply-templates select="wadl:doc"/> + <xsl:apply-templates select="wadl:request"/> + <xsl:apply-templates select="wadl:response"/> + </xsl:when> + <xsl:otherwise> + <p><em>Missing documentation.</em></p> + </xsl:otherwise> + </xsl:choose> + </div> + </xsl:template> + + <!-- Documentation for the request parameters of a custom method --> + <xsl:template match="wadl:request"> + <h6>Parameters</h6> + <table> + <tr> + <th>Parameter</th> + <th>Value</th> + <th>Description</th> + </tr> + <xsl:apply-templates + select=".//wadl:param[@style='query'][@fixed]"/> + <xsl:apply-templates + select=".//wadl:param[@style='query'][not(@fixed)]"> + <xsl:sort select="@name" /> + </xsl:apply-templates> + </table> + </xsl:template> + + <!-- Documentation for the response of custom methods returning + and entry or a collection. + --> + <xsl:template match="wadl:response/wadl:representation[@href]"> + <xsl:variable name="id" select="substring-after(@href, '#')" /> + <xsl:variable name="resource_type" + select="substring-before($id, '-')"/> + + <p class="response">Response contains an + <xsl:apply-templates select="key('id', $id)" + mode="representation-type"/> + representation of a + <a href="#{$resource_type}"><xsl:value-of + select="$resource_type" + /></a><xsl:if test="contains($id, '-page')"> + collection + </xsl:if>. + </p> + </xsl:template> + + <!-- Documentation for request parameter. --> + <xsl:template match="wadl:param"> + <tr> + <xsl:call-template name="param-name"/> + <td> + <xsl:if test="@required or @repeating"> + <p> + <xsl:if test="@required='true'"> + <small>(required)</small> + </xsl:if> + <xsl:if test="@repeating='true'"> + <small>(repeating)</small> + </xsl:if> + </p> + </xsl:if> + <xsl:call-template name="param-value"/> + </td> + <xsl:call-template name="param-description"/> + </tr> + </xsl:template> + + <!-- Documentation for factories. + + Factory's response include a Location header pointint to a resource type. + --> + <xsl:template match="wadl:response/wadl:param[ + @name = 'Location' and @style = 'header' + and wadl:link[@resource_type]]"> + <xsl:variable name="resource_type" + select="substring-after( + wadl:link[@resource_type]/@resource_type, '#')"/> + <p>On success, the response status will be 201 and the + <var>Location</var> header will contain the link to the newly + created <a href="#{$resource_type}" + ><xsl:value-of select="$resource_type" /></a>. + </p> + </xsl:template> + + <!-- Output the available value for the parameter. --> + <xsl:template match="wadl:option"> + <li> + <tt><xsl:value-of select="@value"/></tt> + <xsl:if test="ancestor::wadl:param[1]/@default=@value"> + <small>(default)</small> + </xsl:if> + </li> + </xsl:template> + + <!-- Ouput list of the documentation for each available option. --> + <xsl:template match="wadl:option" mode="option-doc"> + <dt> + <tt><xsl:value-of select="@value"/></tt> + <xsl:if test="ancestor::wadl:param[1]/@default=@value"> + <small>(default)</small> + </xsl:if> + </dt> + <dd> + <xsl:apply-templates select="wadl:doc"/> + </dd> + </xsl:template> + + <!-- Format wadl:doc --> + <xsl:template match="wadl:doc"> + <xsl:param name="inline">0</xsl:param> + <!-- skip WADL elements --> + <xsl:choose> + <xsl:when test="node()[1]=text() and $inline=0"> + <p> + <xsl:apply-templates select="node()" mode="copy"/> + </p> + </xsl:when> + <xsl:otherwise> + <xsl:apply-templates select="node()" mode="copy"/> + </xsl:otherwise> + </xsl:choose> + </xsl:template> + + <!-- Returns the title or id of an element. + + Look for the first wadl:doc title attribute content of the + current node or fall back to the element id. + + :param element: The element to return the title or id. Defaults to the + current node. + --> + <xsl:template name="get-title-or-id"> + <xsl:param name="element" select="current()" /> + <xsl:choose> + <xsl:when test="$element/wadl:doc[@title]"> + <xsl:value-of select="$element/wadl:doc[@title][1]/@title"/> + </xsl:when> + <xsl:otherwise> + <xsl:value-of select="$element/@id"/> + </xsl:otherwise> + </xsl:choose> + </xsl:template> + + <!-- Output the mediaType attribute of the default representation. + + Should be call on an element that contain a wadl:representation element + without a mediaType attribute. + --> + <xsl:template name="representation-type"> + <xsl:apply-templates + select="key('id', + substring-after( + .//wadl:representation[not(@mediaType)]/@href, + '#'))" + mode="representation-type"/> + </xsl:template> + + <!-- Omit docutils parameter lists in methods since they are redundant + or misleading with the one we give. --> + <xsl:template match="wadl:method//html:table[ + contains(@class, 'field-list')]" + mode="copy"/> + + <!-- Output the mediaType attribute of a representation --> + <xsl:template match="wadl:representation[@mediaType]" + mode="representation-type"> + <code><xsl:value-of select="@mediaType"/></code> + </xsl:template> + + <!-- Copy html elements. --> + <xsl:template match="html:*" mode="copy"> + <!-- remove the prefix on HTML elements --> + <xsl:element name="{local-name()}"> + <xsl:for-each select="@*"> + <xsl:attribute name="{local-name()}" + ><xsl:value-of select="."/></xsl:attribute> + </xsl:for-each> + <xsl:apply-templates select="node()" mode="copy"/> + </xsl:element> + </xsl:template> + + <xsl:template match="@*|node()[ + namespace-uri()!='http://www.w3.org/1999/xhtml']" mode="copy"> + <!-- everything else goes straight through --> + <xsl:copy> + <xsl:apply-templates select="@*|node()" mode="copy"/> + </xsl:copy> + </xsl:template> + +</xsl:stylesheet> |