Jamu Kakar has proposed merging lp:~jkakar/txaws/discovery-tool into lp:txaws.
Requested reviews: txAWS Developers (txaws-dev) Related bugs: #589926 It would be nice if txAWS provided a tool to help learn how the EC2 API behaves https://bugs.launchpad.net/bugs/589926 This branch introduces the following changes: - A new bin/txaws-discover script provides a simple command-line interface for invoking EC2 API methods on a cloud endpoint. It prints the HTTP status code and response text to the screen. This makes it possible to test assumptions about how a method behaves in different conditions. - A new txaws.client.discover package contains a Command object, which can be configured to run a method and print the response to an output stream. It also contains bootstrapping code that parses command-line arguments, shows usage text if necessary, and creates a Command instance and runs it. -- https://code.launchpad.net/~jkakar/txaws/discovery-tool/+merge/26848 Your team txAWS Developers is requested to review the proposed merge of lp:~jkakar/txaws/discovery-tool into lp:txaws.
=== added file 'bin/txaws-discover' --- bin/txaws-discover 1970-01-01 00:00:00 +0000 +++ bin/txaws-discover 2010-06-04 22:50:31 +0000 @@ -0,0 +1,15 @@ +#!/usr/bin/env python + +# Copyright (C) 2010 Jamu Kakar <[email protected]> +# Licenced under the txaws licence available at /LICENSE in the txaws source. + +import os +import sys + +if os.path.isdir("txaws"): + sys.path.insert(0, ".") + +from txaws.client.discover.entry_point import main + + +sys.exit(main(sys.argv)) === added directory 'txaws/client/discover' === added file 'txaws/client/discover/__init__.py' === added file 'txaws/client/discover/command.py' --- txaws/client/discover/command.py 1970-01-01 00:00:00 +0000 +++ txaws/client/discover/command.py 2010-06-04 22:50:31 +0000 @@ -0,0 +1,72 @@ +# Copyright (C) 2010 Jamu Kakar <[email protected]> +# Licenced under the txaws licence available at /LICENSE in the txaws source. + +""" +A L{Command} object makes an arbitrary EC2 API method call and displays the +response received from the backend cloud. +""" + +import sys + +from txaws.ec2.client import Query +from txaws.service import AWSServiceRegion + + +class Command(object): + """ + An EC2 API method call command that can make a request and display the + response received from the backend cloud. + + @param key: The AWS access key ID to use when making the method call. + @param secret: The AWS secret key to sign the method call with. + @param endpoint: The URL of the cloud to invoke the method on. + @param parameters: A C{dict} with parameters to include with the method + call. + @param output: Optionally, a stream to write output to. Defaults to + C{sys.stdout}. + @param query_factory: Optionally, a factory to create the L{Query} object + used to invoke the method. Defaults to returning a L{Query} instance. + """ + + def __init__(self, key, secret, endpoint, action, parameters, output=None, + query_factory=None): + self.key = key + self.secret = secret + self.endpoint = endpoint + self.action = action + self.parameters = parameters + if output is None: + output = sys.stdout + self.output = output + if query_factory is None: + query_factory = Query + self.query_factory = query_factory + + def run(self): + """ + Run the configured method and write the HTTP response status and text + to the output stream. + """ + region = AWSServiceRegion(access_key=self.key, secret_key=self.secret, + uri=self.endpoint) + query = self.query_factory(action=self.action, creds=region.creds, + endpoint=region.ec2_endpoint, + other_params=self.parameters) + + def write_response(response): + print >>self.output, "HTTP status code: %s" % query.client.status + print >>self.output + print >>self.output, response + + def write_error(failure): + message = failure.getErrorMessage() + if message.startswith("Error Message: "): + message = message[len("Error Message: "):] + print >>self.output, "HTTP status code: %s" % query.client.status + print >>self.output + print >>self.output, message + + deferred = query.submit() + deferred.addCallback(write_response) + deferred.addErrback(write_error) + return deferred === added file 'txaws/client/discover/entry_point.py' --- txaws/client/discover/entry_point.py 1970-01-01 00:00:00 +0000 +++ txaws/client/discover/entry_point.py 2010-06-04 22:50:31 +0000 @@ -0,0 +1,178 @@ +# Copyright (C) 2010 Jamu Kakar <[email protected]> +# Licenced under the txaws licence available at /LICENSE in the txaws source. + +"""A command-line client for discovering how the EC2 API works.""" + +import os +import sys + +from txaws.client.discover.command import Command + + +class OptionError(Exception): + """ + Raised if insufficient command-line arguments are provided when creating a + L{Command}. + """ + + +class UsageError(Exception): + """Raised if the usage message should be shown.""" + + +USAGE_MESSAGE = """\ +Purpose: Invoke an EC2 API method with arbitrary parameters. +Usage: txaws-discover [--key KEY] [--secret SECRET] [--endpoint ENDPOINT] + --action ACTION [PARAMETERS, ...] + +Options: + --key The AWS access key to use when making the API request. + --secret The AWS secret key to use when making the API request. + --endpoint The region endpoint to make the API request against. + --action The name of the EC2 API to invoke. + -h, --help Show help message. + +Description: + The purpose of this program is to aid discovery of the EC2 API. It can run + any EC2 API method, with arbitrary parameters. The response received from + the backend cloud is printed to the screen, to show exactly what happened in + response to the request. The --key, --secret, --endpoint and --action + command-line arguments are required. If AWS_ENDPOINT, AWS_ACCESS_KEY_ID or + AWS_SECRET_ACCESS_KEY environment variables are defined the corresponding + options can be omitted and the values defined in the environment variables + will be used. + + Any additional parameters, beyond those defined above, will be included with + the request as method parameters. + +Examples: + The following examples omit the --key, --secret and --endpoint command-line + arguments for brevity. They must be included unless corresponding values + are available from the environment. + + Run the DescribeRegions method, without any optional parameters: + + txaws-discover --action DescribeRegions + + Run the DescribeRegions method, with an optional RegionName.0 parameter: + + txaws-discover --action DescribeRegions --RegionName.0 us-west-1 +""" + + +def parse_options(arguments): + """Parse command line arguments. + + The parsing logic is fairly simple. It can only parse long-style + parameters of the form:: + + --key value + + Several parameters can be defined in the environment and will be used + unless explicitly overridden with command-line arguments. The access key, + secret and endpoint values will be loaded from C{AWS_ACCESS_KEY_ID}, + C{AWS_SECRET_ACCESS_KEY} and C{AWS_ENDPOINT} environment variables. + + @param arguments: A list of command-line arguments. The first item is + expected to be the name of the program being run. + @raises OptionError: Raised if incorrectly formed command-line arguments + are specified, or if required command-line arguments are not present. + @raises UsageError: Raised if C{--help} is present in command-line + arguments. + @return: A C{dict} with key/value pairs extracted from the argument list. + """ + arguments = arguments[1:] + options = {} + while arguments: + key = arguments.pop(0) + if key in ("-h", "--help"): + raise UsageError("Help requested.") + if key.startswith("--"): + key = key[2:] + try: + value = arguments.pop(0) + except IndexError: + raise OptionError("'--%s' is missing a value." % key) + options[key] = value + else: + raise OptionError("Encountered unexpected value '%s'." % key) + + default_key = os.environ.get("AWS_ACCESS_KEY_ID") + if "key" not in options and default_key: + options["key"] = default_key + default_secret = os.environ.get("AWS_SECRET_ACCESS_KEY") + if "secret" not in options and default_secret: + options["secret"] = default_secret + default_endpoint = os.environ.get("AWS_ENDPOINT") + if "endpoint" not in options and default_endpoint: + options["endpoint"] = default_endpoint + for name in ("key", "secret", "endpoint", "action"): + if name not in options: + raise OptionError( + "The '--%s' command-line argument is required." % name) + + return options + + +def get_command(arguments, output=None): + """Parse C{arguments} and configure a L{Command} instance. + + An access key, secret key, endpoint and action are required. Additional + parameters included with the request are passed as parameters to the + method call. For example, the following command will create a L{Command} + object that can invoke the C{DescribeRegions} method with the optional + C{RegionName.0} parameter included in the request:: + + txaws-discover --key KEY --secret SECRET --endpoint URL \ + --action DescribeRegions --RegionName.0 us-west-1 + + @param arguments: The command-line arguments to parse. + @raises OptionError: Raised if C{arguments} can't be used to create a + L{Command} object. + @return: A L{Command} instance configured to make an EC2 API method call. + """ + options = parse_options(arguments) + key = options.pop("key") + secret = options.pop("secret") + endpoint = options.pop("endpoint") + action = options.pop("action") + return Command(key, secret, endpoint, action, options, output) + + +def main(arguments, output=None, testing_mode=None): + """ + Entry point parses command-line arguments, runs the specified EC2 API + method and prints the response to the screen. + + @param arguments: Command-line arguments, typically retrieved from + C{sys.argv}. + @param output: Optionally, a stream to write output to. + @param testing_mode: Optionally, a condition that specifies whether or not + to run in test mode. When the value is true a reactor will not be run + or stopped, to prevent interfering with the test suite. + """ + + def run_command(arguments, output, reactor): + if output is None: + output = sys.stdout + try: + command = get_command(arguments, output) + except UsageError: + print >>output, USAGE_MESSAGE.strip() + if reactor: + reactor.callLater(0, reactor.stop) + except Exception, e: + print >>output, "ERROR:", str(e) + if reactor: + reactor.callLater(0, reactor.stop) + else: + deferred = command.run() + if reactor: + deferred.addCallback(lambda ignored: reactor.stop()) + + if not testing_mode: + from twisted.internet import reactor + reactor.callLater(0, run_command, arguments, output, reactor) + reactor.run() + else: + run_command(arguments, output, None) === added directory 'txaws/client/discover/tests' === added file 'txaws/client/discover/tests/__init__.py' === added file 'txaws/client/discover/tests/test_command.py' --- txaws/client/discover/tests/test_command.py 1970-01-01 00:00:00 +0000 +++ txaws/client/discover/tests/test_command.py 2010-06-04 22:50:31 +0000 @@ -0,0 +1,164 @@ +# Copyright (C) 2010 Jamu Kakar <[email protected]> +# Licenced under the txaws licence available at /LICENSE in the txaws source. + +"""Unit tests for L{Command}.""" + +from cStringIO import StringIO + +from twisted.internet.defer import succeed, fail + +from txaws.client.discover.command import Command +from txaws.ec2.client import Query +from txaws.testing.base import TXAWSTestCase + + +class FakeHTTPClient(object): + + def __init__(self, status): + self.status = status + + +class CommandTest(TXAWSTestCase): + + def prepare_command(self, response, status, action, parameters={}, + get_page=None): + """Prepare a L{Command} for testing.""" + self.url = None + self.method = None + self.response = response + self.status = status + self.output = StringIO() + self.query = None + if get_page is None: + get_page = self.get_page + self.get_page_function = get_page + self.command = Command("key", "secret", "endpoint", action, parameters, + self.output, self.query_factory) + + def query_factory(self, other_params=None, time_tuple=None, + api_version=None, *args, **kwargs): + """ + Create a query with a hard-coded time to generate a fake response. + """ + time_tuple = (2010, 6, 4, 23, 40, 0, 0, 0, 0) + self.query = Query(other_params, time_tuple, api_version, + *args, **kwargs) + self.query.get_page = self.get_page_function + return self.query + + def get_page(self, url, method=None): + """Fake C{get_page} method simulates a successful request.""" + self.url = url + self.method = method + self.query.client = FakeHTTPClient(self.status) + return succeed(self.response) + + def get_error_page(self, url, method=None): + """Fake C{get_page} method simulates an error.""" + self.url = url + self.method = method + self.query.client = FakeHTTPClient(self.status) + return fail(Exception(self.response)) + + def test_run(self): + """ + When a method is invoked its HTTP status code and response text is + written to the output stream. + """ + self.prepare_command("The response", 200, "DescribeRegions") + + def check(result): + self.assertEqual("GET", self.method) + self.assertEqual( + "http://endpoint?AWSAccessKeyId=key&" + "Action=DescribeRegions&" + "Signature=uAlV2ALkp7qTxZrTNNuJhHl0i9xiTK5faZOhJTgGS1E%3D&" + "SignatureMethod=HmacSHA256&SignatureVersion=2&" + "Timestamp=2010-06-04T23%3A40%3A00Z&Version=2008-12-01", + self.url) + self.assertEqual("HTTP status code: 200\n" + "\n" + "The response\n", + self.output.getvalue()) + + deferred = self.command.run() + deferred.addCallback(check) + return deferred + + def test_run_with_parameters(self): + """Extra method parameters are included in the request.""" + self.prepare_command("The response", 200, "DescribeRegions", + {"RegionName.0": "us-west-1"}) + + def check(result): + self.assertEqual("GET", self.method) + self.assertEqual( + "http://endpoint?AWSAccessKeyId=key&" + "Action=DescribeRegions&RegionName.0=us-west-1&" + "Signature=P6C7cQJ7j93uIJyv2dTbpQG3EI7ArGBJT%2FzVH%2BDFhyY%3D&" + "SignatureMethod=HmacSHA256&SignatureVersion=2&" + "Timestamp=2010-06-04T23%3A40%3A00Z&Version=2008-12-01", + self.url) + self.assertEqual("HTTP status code: 200\n" + "\n" + "The response\n", + self.output.getvalue()) + + deferred = self.command.run() + deferred.addCallback(check) + return deferred + + def test_run_with_error(self): + """ + If an error message is returned by the backend cloud, it will be + written to the output stream. + """ + self.prepare_command("The error response", 400, "DescribeRegions", + {"RegionName.0": "us-west-1"}, + self.get_error_page) + + def check(result): + self.assertEqual("GET", self.method) + self.assertEqual( + "http://endpoint?AWSAccessKeyId=key&" + "Action=DescribeRegions&RegionName.0=us-west-1&" + "Signature=P6C7cQJ7j93uIJyv2dTbpQG3EI7ArGBJT%2FzVH%2BDFhyY%3D&" + "SignatureMethod=HmacSHA256&SignatureVersion=2&" + "Timestamp=2010-06-04T23%3A40%3A00Z&Version=2008-12-01", + self.url) + self.assertEqual("HTTP status code: 400\n" + "\n" + "The error response\n", + self.output.getvalue()) + + deferred = self.command.run() + deferred.addErrback(check) + return deferred + + def test_run_with_error_strips_non_response_text(self): + """ + The builtin L{AWSError} exception adds 'Error message: ' to beginning + of the text retuned by the backend cloud. This is stripped when the + message is written to the output stream. + """ + self.prepare_command("Error Message: The error response", 400, + "DescribeRegions", {"RegionName.0": "us-west-1"}, + self.get_error_page) + + def check(result): + self.assertEqual("GET", self.method) + self.assertEqual( + "http://endpoint?AWSAccessKeyId=key&" + "Action=DescribeRegions&RegionName.0=us-west-1&" + "Signature=P6C7cQJ7j93uIJyv2dTbpQG3EI7ArGBJT%2FzVH%2BDFhyY%3D&" + "SignatureMethod=HmacSHA256&SignatureVersion=2&" + "Timestamp=2010-06-04T23%3A40%3A00Z&Version=2008-12-01", + self.url) + self.assertEqual("HTTP status code: 400\n" + "\n" + "The error response\n", + self.output.getvalue()) + + deferred = self.command.run() + deferred.addErrback(check) + return deferred === added file 'txaws/client/discover/tests/test_entry_point.py' --- txaws/client/discover/tests/test_entry_point.py 1970-01-01 00:00:00 +0000 +++ txaws/client/discover/tests/test_entry_point.py 2010-06-04 22:50:31 +0000 @@ -0,0 +1,246 @@ +# Copyright (C) 2010 Jamu Kakar <[email protected]> +# Licenced under the txaws licence available at /LICENSE in the txaws source. + +"""Unit tests for L{get_command}, L{parse_options} and L{main} functions.""" + +from cStringIO import StringIO +import os +import sys + +from txaws.client.discover.entry_point import ( + OptionError, UsageError, get_command, main, parse_options, USAGE_MESSAGE) +from txaws.testing.base import TXAWSTestCase + + +class ParseOptionsTest(TXAWSTestCase): + + def test_parse_options(self): + """ + L{parse_options} returns a C{dict} contains options parsed from the + command-line. + """ + options = parse_options([ + "txaws-discover", "--key", "key", "--secret", "secret", + "--endpoint", "endpoint", "--action", "action", + "--something.else", "something.else"]) + self.assertEqual({"key": "key", "secret": "secret", + "endpoint": "endpoint", "action": "action", + "something.else": "something.else"}, + options) + + def test_parse_options_without_options(self): + """An L{OptionError} is raised if no options are provided.""" + self.assertRaises(OptionError, parse_options, ["txaws-discover"]) + + def test_parse_options_with_missing_value(self): + """ + An L{OptionError} is raised if an option is specified without a value. + """ + self.assertRaises(OptionError, parse_options, + ["txaws-discover", "--key"]) + + def test_parse_options_with_missing_option(self): + """ + An L{OptionError} is raised if a value is specified without an option + name. + """ + self.assertRaises( + OptionError, parse_options, + ["txaws-discover", "--key", "key", "--secret", "secret", + "--endpoint", "endpoint", "--action", "action", + "random-value"]) + + def test_parse_options_without_required_arguments(self): + """ + An access key, access secret, endpoint and action can be specified as + command-line arguments. An L{OptionError} is raised if any one of + these is missing. + """ + self.assertRaises(OptionError, parse_options, + ["txaws-discover", "--secret", "secret", + "--endpoint", "endpoint", "--action", "action"]) + self.assertRaises(OptionError, parse_options, + ["txaws-discover", "--key", "key", + "--endpoint", "endpoint", "--action", "action"]) + self.assertRaises(OptionError, parse_options, + ["txaws-discover", "--key", "key", + "--secret", "secret", "--action", "action"]) + self.assertRaises(OptionError, parse_options, + ["txaws-discover", "--key", "key", + "--secret", "secret", "--endpoint", "endpoint"]) + + def test_parse_options_gets_key_from_environment(self): + """ + If the C{AWS_ACCESS_KEY_ID} environment variable is present, it will + be used if the C{--key} command-line argument isn't specified. + """ + os.environ["AWS_ACCESS_KEY_ID"] = "key" + options = parse_options([ + "txaws-discover", "--secret", "secret", "--endpoint", "endpoint", + "--action", "action"]) + self.assertEqual({"key": "key", "secret": "secret", + "endpoint": "endpoint", "action": "action"}, + options) + + def test_parse_options_prefers_explicit_key(self): + """ + If an explicit C{--key} command-line argument is specified it will be + preferred over the value specified in the C{AWS_ACCESS_KEY_ID} + environment variable. + """ + os.environ["AWS_ACCESS_KEY_ID"] = "fail" + options = parse_options([ + "txaws-discover", "--key", "key", "--secret", "secret", + "--endpoint", "endpoint", "--action", "action"]) + self.assertEqual({"key": "key", "secret": "secret", + "endpoint": "endpoint", "action": "action"}, + options) + + def test_parse_options_gets_secret_from_environment(self): + """ + If the C{AWS_SECRET_ACCESS_KEY} environment variable is present, it + will be used if the C{--secret} command-line argument isn't specified. + """ + os.environ["AWS_SECRET_ACCESS_KEY"] = "secret" + options = parse_options([ + "txaws-discover", "--key", "key", "--endpoint", "endpoint", + "--action", "action"]) + self.assertEqual({"key": "key", "secret": "secret", + "endpoint": "endpoint", "action": "action"}, + options) + + def test_parse_options_prefers_explicit_secret(self): + """ + If an explicit C{--secret} command-line argument is specified it will + be preferred over the value specified in the C{AWS_SECRET_ACCESS_KEY} + environment variable. + """ + os.environ["AWS_SECRET_ACCESS_KEY"] = "fail" + options = parse_options([ + "txaws-discover", "--key", "key", "--secret", "secret", + "--endpoint", "endpoint", "--action", "action"]) + self.assertEqual({"key": "key", "secret": "secret", + "endpoint": "endpoint", "action": "action"}, + options) + + def test_parse_options_gets_endpoint_from_environment(self): + """ + If the C{AWS_ENDPOINT} environment variable is present, it will be + used if the C{--endpoint} command-line argument isn't specified. + """ + os.environ["AWS_ENDPOINT"] = "endpoint" + options = parse_options([ + "txaws-discover", "--key", "key", "--secret", "secret", + "--action", "action"]) + self.assertEqual({"key": "key", "secret": "secret", + "endpoint": "endpoint", "action": "action"}, + options) + + def test_parse_options_prefers_explicit_endpoint(self): + """ + If an explicit C{--endpoint} command-line argument is specified it + will be preferred over the value specified in the C{AWS_ENDPOINT} + environment variable. + """ + os.environ["AWS_ENDPOINT"] = "fail" + options = parse_options([ + "txaws-discover", "--key", "key", "--secret", "secret", + "--endpoint", "endpoint", "--action", "action"]) + self.assertEqual({"key": "key", "secret": "secret", + "endpoint": "endpoint", "action": "action"}, + options) + + def test_parse_options_raises_usage_error_when_help_specified(self): + """ + L{UsageError} is raised if C{-h} or C{--help} appears in command-line + arguments. + """ + self.assertRaises(UsageError, parse_options, + ["txaws-discover", "-h"]) + self.assertRaises(UsageError, parse_options, + ["txaws-discover", "--help"]) + self.assertRaises(UsageError, parse_options, + ["txaws-discover", "--key", "key", + "--secret", "secret", "--endpoint", "endpoint", + "--action", "action", "--help"]) + + +class GetCommandTest(TXAWSTestCase): + + def test_get_command_without_arguments(self): + """An L{OptionError} is raised if no arguments are provided.""" + self.assertRaises(OptionError, get_command, ["txaws-discover"]) + + def test_get_command(self): + """ + An access key, access secret, endpoint and action can be specified as + command-line arguments. + """ + command = get_command([ + "txaws-discover", "--key", "key", "--secret", "secret", + "--endpoint", "endpoint", "--action", "action"]) + self.assertEqual("key", command.key) + self.assertEqual("secret", command.secret) + self.assertEqual("endpoint", command.endpoint) + self.assertEqual("action", command.action) + self.assertIdentical(sys.stdout, command.output) + + def test_get_command_with_custom_output_stream(self): + output = StringIO() + command = get_command([ + "txaws-discover", "--key", "key", "--secret", "secret", + "--endpoint", "endpoint", "--action", "action"], output) + self.assertIdentical(output, command.output) + + def test_get_command_without_required_arguments(self): + """ + An access key, access secret, endpoint and action can be specified as + command-line arguments. An L{OptionError} is raised if any one of + these is missing. + """ + self.assertRaises(OptionError, get_command, + ["txaws-discover", "--secret", "secret", + "--endpoint", "endpoint", "--action", "action"]) + self.assertRaises(OptionError, get_command, + ["txaws-discover", "--key", "key", + "--endpoint", "endpoint", "--action", "action"]) + self.assertRaises(OptionError, get_command, + ["txaws-discover", "--key", "key", + "--secret", "secret", "--action", "action"]) + self.assertRaises(OptionError, get_command, + ["txaws-discover", "--key", "key", + "--secret", "secret", "--endpoint", "endpoint"]) + + def test_get_command_passes_additional_parameters_to_command(self): + """ + Command-line parameters beyond C{--key}, C{--secret}, C{--endpoint} + and C{--action} are passed to the L{Command} in a parameter C{dict}. + """ + command = get_command([ + "txaws-discover", "--key", "key", "--secret", "secret", + "--endpoint", "endpoint", "--action", "DescribeRegions", + "--Region.Name.0", "us-west-1"]) + self.assertEqual({"Region.Name.0": "us-west-1"}, command.parameters) + + +class MainTest(TXAWSTestCase): + + def test_usage_message(self): + """ + If a L{UsageError} is raised, the help screen is written to the output + stream. + """ + output = StringIO() + main(["txaws-discover", "--help"], output, True) + self.assertEqual(USAGE_MESSAGE, output.getvalue()) + + def test_error_message(self): + """ + If an exception is raised, its message is written to the output + stream. + """ + output = StringIO() + main(["txaws-discover"], output, True) + self.assertEqual( + "ERROR: The '--key' command-line argument is required.\n", + output.getvalue()) === modified file 'txaws/testing/base.py' --- txaws/testing/base.py 2009-09-05 00:26:12 +0000 +++ txaws/testing/base.py 2010-06-04 22:50:31 +0000 @@ -17,6 +17,8 @@ del os.environ["AWS_ACCESS_KEY_ID"] if "AWS_SECRET_ACCESS_KEY" in os.environ: del os.environ["AWS_SECRET_ACCESS_KEY"] + if "AWS_ENDPOINT" in os.environ: + del os.environ["AWS_ENDPOINT"] def _restore_environ(self): for key in set(os.environ) - set(self.orig_environ):
_______________________________________________ Mailing list: https://launchpad.net/~txaws-dev Post to : [email protected] Unsubscribe : https://launchpad.net/~txaws-dev More help : https://help.launchpad.net/ListHelp

