Repository: aws/aws-cli

Issue:
text output format empty
I am not sure if this an error or default condition, when listing stuff in text format instead of default json, data is lost/unavailable.

When i listed identities for SES in json i could see the  identity 
[***_@+++ tmp]# aws ses list-identities --output json
{
    "Identities": [
        "example.com"
    ],
    "ResponseMetadata": {
        "RequestId": "51**_********_2-ade8-1**_********5"
    }
}

for text format only metadata is available 

[****@+++ tmp]# aws ses list-verified-email-addresses --output text

RESPONSEMETADATA        3**************_11e2-8d25-535d**_******a

Need text output from aws-cli like ec2-describe-instances
It's impossible to script using the "--output text" option in the "new" aws CLI, because it doesn't return column headings like the ec2-describe-instances and other ec2-xxx-xxx commands.  It's faster to use "aws ec2 ..." but the output is only reliable with JSON, or table options.

Using the "--output table" is good, but it would be great to eliminate all the border characters so this data can be used to script with.  It is easy to grep with perl or other parsers, but having to constantly remove the extra junk characters is extra work, and not efficient.

Would it be too hard to simply get rid of all the pipe characters in "--output table" ?  

Or at least give consistent output on "aws ec2 describe-instances --output text" so that this data can be parsed predictably.  There's no way to walk the text output and know what is what, it's basically useless.   Otherwise we're all forced to decipher JSON and the other ooptions are basically useless--you may as well remove text and table unless it's for show.



Discussion:
The current text output is inadequate.  We are working on a new text formatting module.  We will update this ticket when it has been commited.

Thanks

Yes, we agree that a better text format is needed.  We are working on it.  Thanks for the feedback!


Resolving patch:
diff --git a/awscli/formatter.py b/awscli/formatter.py
--- a/awscli/formatter.py
+++ b/awscli/formatter.py
@@ -13,10 +13,10 @@
 import logging
 import sys
 import json
-
-import six
+import jmespath
 
 from awscli.table import MultiTable, Styler, ColorizedStyler
+from awscli import text
 
 
 LOG = logging.getLogger(__name__)
@@ -54,6 +54,9 @@ def __call__(self, operation, response, stream=None):
             response_data = response
         try:
             self._remove_request_id(response_data)
+            if self._args.query is not None:
+                expression = jmespath.compile(self._args.query)
+                response_data = expression.search(response_data)
             self._format_response(operation, response_data, stream)
         finally:
             # flush is needed to avoid the "close failed in file object
@@ -110,13 +113,19 @@ def _format_response(self, operation, response, stream):
     def _build_table(self, title, current, indent_level=0):
         if not current:
             return False
-        self.table.new_section(title, indent_level=indent_level)
+        if title is not None:
+            self.table.new_section(title, indent_level=indent_level)
         if isinstance(current, list):
             if isinstance(current[0], dict):
                 self._build_sub_table_from_list(current, indent_level, title)
             else:
                 for item in current:
-                    self.table.add_row([item])
+                    if self._scalar_type(item):
+                        self.table.add_row([item])
+                    elif all(self._scalar_type(el) for el in item):
+                        self.table.add_row(item)
+                    else:
+                        self._build_table(title=None, current=item)
         if isinstance(current, dict):
             # Render a single row section with keys as header
             # and the row as the values, unless the value
@@ -193,37 +202,38 @@ def _group_scalar_keys(self, current):
         return headers, more
 
 
-class TextFormatter(FullyBufferedFormatter):
-
-    def _output(self, data, stream, label=None):
-        """
-        A very simple, very stupid text formatter that has no
-        knowledge of the output as defined in the JSON model.
-        """
-        if isinstance(data, dict):
-            scalars = []
-            non_scalars = []
-            for key, val in data.items():
-                if isinstance(val, dict):
-                    non_scalars.append((key, val))
-                elif isinstance(val, list):
-                    non_scalars.append((key, val))
-                elif not isinstance(val, six.string_types):
-                    scalars.append(str(val))
-                else:
-                    scalars.append(val)
-            if label:
-                scalars.insert(0, label.upper())
-            stream.write('\t'.join(scalars))
-            stream.write('\n')
-            for label, non_scalar in non_scalars:
-                self._output(non_scalar, stream, label)
-        elif isinstance(data, list):
-            for d in data:
-                self._output(d, stream)
+class TextFormatter(Formatter):
 
-    def _format_response(self, operation, response, stream):
-        self._output(response, stream)
+    def __call__(self, operation, response, stream=None):
+        if stream is None:
+            stream = sys.stdout
+        try:
+            if operation.can_paginate and self._args.paginate:
+                result_keys = response.result_keys
+                for _, page in response:
+                    current = {}
+                    for result_key in result_keys:
+                        current[result_key] = page[result_key]
+                    self._format_response(current, stream)
+                if response.resume_token:
+                    # Tell the user about the next token so they can continue
+                    # if they want.
+                    self._format_response(
+                        {'NextToken': {'NextToken': response.resume_token}},
+                        stream)
+            else:
+                self._remove_request_id(response)
+                self._format_response(response, stream)
+        finally:
+            # flush is needed to avoid the "close failed in file object
+            # destructor" in python2.x (see http://bugs.python.org/issue11380).
+            stream.flush()
+
+    def _format_response(self, response, stream):
+        if self._args.query is not None:
+            expression = jmespath.compile(self._args.query)
+            response = expression.search(response)
+        text.format_text(response, stream)
 
 
 def get_formatter(format_type, args):
diff --git a/awscli/text.py b/awscli/text.py
new file mode 100644
--- /dev/null
+++ b/awscli/text.py
@@ -0,0 +1,79 @@
+# Copyright 2012-2013 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+# Licensed under the Apache License, Version 2.0 (the "License"). You
+# may not use this file except in compliance with the License. A copy of
+# the License is located at
+
+#     http://aws.amazon.com/apache2.0/
+
+# or in the "license" file accompanying this file. This file is
+# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
+# ANY KIND, either express or implied. See the License for the specific
+# language governing permissions and limitations under the License.
+import six
+
+
+def format_text(data, stream):
+    _format_text(data, stream)
+
+
+def _format_text(item, stream, identifier=None, scalar_keys=None):
+    if isinstance(item, dict):
+        scalars, non_scalars = _partition_dict(item, scalar_keys=scalar_keys)
+        if scalars:
+            if identifier is not None:
+                scalars.insert(0, identifier.upper())
+            stream.write('\t'.join(scalars))
+            stream.write('\n')
+        for new_identifier, non_scalar in non_scalars:
+            _format_text(item=non_scalar, stream=stream,
+                            identifier=new_identifier)
+    elif item and isinstance(item, list):
+        if isinstance(item[0], dict):
+            all_keys = _all_scalar_keys(item)
+            for element in item:
+                _format_text(element,
+                                stream=stream,
+                                identifier=identifier,
+                                scalar_keys=all_keys)
+        elif isinstance(item[0], list):
+            for list_element in item:
+                _format_text(list_element, stream=stream,
+                             identifier=identifier)
+        else:
+            # For a bare list, just print the contents.
+            stream.write('\t'.join([six.text_type(el) for el in item]))
+            stream.write('\n')
+
+
+def _all_scalar_keys(list_of_dicts):
+    keys_seen = set()
+    for item_dict in list_of_dicts:
+        for key, value in item_dict.items():
+            if not isinstance(value, (dict, list)):
+                keys_seen.add(key)
+    return list(sorted(keys_seen))
+
+
+def _partition_dict(item_dict, scalar_keys):
+    # Given a dictionary, partition it into two list based on the
+    # values associated with the keys.
+    # {'foo': 'scalar', 'bar': 'scalar', 'baz': ['not, 'scalar']}
+    # scalar = [('foo', 'scalar'), ('bar', 'scalar')]
+    # non_scalar = [('baz', ['not', 'scalar'])]
+    scalar = []
+    non_scalar = []
+    if scalar_keys is None:
+        for key, value in sorted(item_dict.items()):
+            if isinstance(value, (dict, list)):
+                non_scalar.append((key, value))
+            else:
+                scalar.append(six.text_type(value))
+    else:
+        for key in scalar_keys:
+            scalar.append(six.text_type(item_dict.get(key, '')))
+        remaining_keys = sorted(set(item_dict.keys()) - set(scalar_keys))
+        for remaining_key in remaining_keys:
+            if remaining_key in item_dict:
+                non_scalar.append((remaining_key, item_dict[remaining_key]))
+    return scalar, non_scalar


Test patch:
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
--- a/tests/unit/__init__.py
+++ b/tests/unit/__init__.py
@@ -65,6 +65,21 @@ def patch_make_request(self):
 
     def assert_params_for_cmd(self, cmd, params=None, expected_rc=0,
                               stderr_contains=None, ignore_params=None):
+        stdout, stderr, rc = self.run_cmd(cmd, expected_rc)
+        if stderr_contains is not None:
+            self.assertIn(stderr_contains, stderr)
+        if params is not None:
+            last_params = copy.copy(self.last_params)
+            if ignore_params is not None:
+                for key in ignore_params:
+                    try:
+                        del last_params[key]
+                    except KeyError:
+                        pass
+            self.assertDictEqual(params, last_params)
+        return stdout, stderr, rc
+
+    def run_cmd(self, cmd, expected_rc=0):
         logging.debug("Calling cmd: %s", cmd)
         self.patch_make_request()
         driver = create_clidriver()
@@ -73,25 +88,15 @@ def assert_params_for_cmd(self, cmd, params=None, expected_rc=0,
             cmdlist = cmd.split()
         else:
             cmdlist = cmd
-        if stderr_contains is not None:
-            captured = six.StringIO()
-            with mock.patch('sys.stderr', captured):
+        captured_stderr = six.StringIO()
+        captured_stdout = six.StringIO()
+        with mock.patch('sys.stderr', captured_stderr):
+            with mock.patch('sys.stdout', captured_stdout):
                 rc = driver.main(cmdlist)
-            stderr = captured.getvalue()
-            self.assertIn(stderr_contains, stderr)
-        else:
-            rc = driver.main(cmdlist)
+        stderr = captured_stderr.getvalue()
+        stdout = captured_stdout.getvalue()
         self.assertEqual(
             rc, expected_rc,
             "Unexpected rc (expected: %s, actual: %s) for command: %s" % (
                 expected_rc, rc, cmd))
-        if params is not None:
-            last_params = copy.copy(self.last_params)
-            if ignore_params is not None:
-                for key in ignore_params:
-                    try:
-                        del last_params[key]
-                    except KeyError:
-                        pass
-            self.assertDictEqual(params, last_params)
-        return rc
+        return stdout, stderr, rc
diff --git a/tests/unit/customizations/s3/test_tasks.py b/tests/unit/customizations/s3/test_tasks.py
--- a/tests/unit/customizations/s3/test_tasks.py
+++ b/tests/unit/customizations/s3/test_tasks.py
@@ -167,8 +167,6 @@ def test_randomized_stress_test(self):
         # I've run this with much larger values, but 100 is a good
         # tradeoff with coverage vs. execution time.
         for i in range(100):
-            if i % 100 == 0:
-                print(i)
             expected_parts = random.randint(2, 50)
             self.context = MultipartUploadContext(expected_parts=expected_parts)
             self.threads = []
diff --git a/tests/unit/ec2/test_get_password_data.py b/tests/unit/ec2/test_get_password_data.py
--- a/tests/unit/ec2/test_get_password_data.py
+++ b/tests/unit/ec2/test_get_password_data.py
@@ -42,9 +42,7 @@ def test_no_priv_launch_key(self):
         args = ' --instance-id i-12345678'
         cmdline = self.prefix + args
         result = {'InstanceId': 'i-12345678'}
-        with mock.patch('sys.stdout', captured):
-            self.assert_params_for_cmd(cmdline, result, expected_rc=0)
-        output = captured.getvalue()
+        output = self.assert_params_for_cmd(cmdline, result, expected_rc=0)[0]
         self.assertIn('"InstanceId": "i-12345678"', output)
         self.assertIn('"Timestamp": "2013-07-27T18:29:23.000Z"', output)
         self.assertIn('"PasswordData": "%s"' % PASSWORD_DATA, output)
@@ -54,9 +52,8 @@ def test_nonexistent_priv_launch_key(self):
         cmdline = self.prefix + args
         result = {}
         captured = cStringIO()
-        with mock.patch('sys.stderr', captured):
-            self.assert_params_for_cmd(cmdline, result, expected_rc=255)
-        error_msg = captured.getvalue()
+        error_msg = self.assert_params_for_cmd(
+            cmdline, result, expected_rc=255)[1]
         self.assertEqual(error_msg, ('priv-launch-key should be a path to '
                                      'the local SSH private key file used '
                                      'to launch the instance.\n'))
@@ -68,9 +65,7 @@ def test_priv_launch_key(self):
         args = ' --instance-id i-12345678 --priv-launch-key %s' % key_path
         cmdline = self.prefix + args
         result = {'InstanceId': 'i-12345678'}
-        with mock.patch('sys.stdout', captured):
-            self.assert_params_for_cmd(cmdline, result, expected_rc=0)
-        output = captured.getvalue()
+        output = self.assert_params_for_cmd(cmdline, result, expected_rc=0)[0]
         self.assertIn('"InstanceId": "i-12345678"', output)
         self.assertIn('"Timestamp": "2013-07-27T18:29:23.000Z"', output)
         self.assertIn('"PasswordData": "=mG8.r$o-s"', output)
diff --git a/tests/unit/ec2/test_security_group_operations.py b/tests/unit/ec2/test_security_group_operations.py
--- a/tests/unit/ec2/test_security_group_operations.py
+++ b/tests/unit/ec2/test_security_group_operations.py
@@ -73,24 +73,23 @@ def test_both(self):
         json = """[{"FromPort":8000,"ToPort":9000,"IpProtocol":"tcp","IpRanges":[{"CidrIp":"192.168.100.0/24"}]}]"""
         args = ' --group-name foobar --port 100 --ip-permissions %s' % json
         args_list = (self.prefix + args).split()
-        with mock.patch('sys.stderr', captured):
-            self.assert_params_for_cmd(args_list, {}, expected_rc=255)
+        self.assert_params_for_cmd(args_list, {}, expected_rc=255)
 
 
 class TestAuthorizeSecurityGroupEgress(BaseAWSCommandParamsTest):
 
     prefix = 'ec2 authorize-security-group-egress'
 
-        
+
 class TestRevokeSecurityGroupIngress(BaseAWSCommandParamsTest):
 
     prefix = 'ec2 revoke-security-group-ingress'
 
-        
+
 class TestRevokeSecurityGroupEgress(BaseAWSCommandParamsTest):
 
     prefix = 'ec2 revoke-security-group-egress'
 
-        
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/unit/iam/test_create_virtual_mfa_device.py b/tests/unit/iam/test_create_virtual_mfa_device.py
--- a/tests/unit/iam/test_create_virtual_mfa_device.py
+++ b/tests/unit/iam/test_create_virtual_mfa_device.py
@@ -69,5 +69,4 @@ def test_bad_filename(self):
         cmdline += ' --virtual-mfa-device-name fiebaz'
         cmdline += ' --outfile %s --bootstrap-method QRCodePNG' % outfile
         result = {}
-        with mock.patch('sys.stderr', captured):
-            self.assert_params_for_cmd(cmdline, result, expected_rc=255)
+        self.assert_params_for_cmd(cmdline, result, expected_rc=255)
diff --git a/tests/unit/output/test_json_output.py b/tests/unit/output/test_json_output.py
--- a/tests/unit/output/test_json_output.py
+++ b/tests/unit/output/test_json_output.py
@@ -12,6 +12,7 @@
 # ANY KIND, either express or implied. See the License for the specific
 # language governing permissions and limitations under the License.
 from tests.unit import BaseAWSCommandParamsTest
+import json
 import os
 import sys
 import re
@@ -24,7 +25,6 @@ class TestGetPasswordData(BaseAWSCommandParamsTest):
 
     prefix = 'iam add-user-to-group '
 
-
     def test_empty_response_prints_nothing(self):
         captured = cStringIO()
         # This is the default response, but we want to be explicit
@@ -33,9 +33,46 @@ def test_empty_response_prints_nothing(self):
         args = ' --group-name foo --user-name bar'
         cmdline = self.prefix + args
         result = {'GroupName': 'foo', 'UserName': 'bar'}
-        with mock.patch('sys.stdout', captured):
-            self.assert_params_for_cmd(cmdline, result, expected_rc=0)
-        output = captured.getvalue()
+        stdout = self.assert_params_for_cmd(cmdline, result, expected_rc=0)[0]
         # We should have printed nothing because the parsed response
         # is an empty dict: {}.
-        self.assertEqual(output, '')
+        self.assertEqual(stdout, '')
+
+
+class TestListUsers(BaseAWSCommandParamsTest):
+
+    def setUp(self):
+        super(TestListUsers, self).setUp()
+        self.parsed_response = {
+            'Users': [
+                {
+                    "UserName": "testuser-50",
+                    "Path": "/",
+                    "CreateDate": "2013-02-12T19:08:52Z",
+                    "UserId": "EXAMPLEUSERID",
+                    "Arn": "arn:aws:iam::12345:user/testuser1"
+                },
+                {
+                    "UserName": "testuser-51",
+                    "Path": "/",
+                    "CreateDate": "2012-10-14T23:53:39Z",
+                    "UserId": "EXAMPLEUSERID",
+                    "Arn": "arn:aws:iam::123456:user/testuser2"
+                },
+            ]
+        }
+
+    def test_json_response(self):
+        output = self.run_cmd('iam list-users', expected_rc=0)[0]
+        parsed_output = json.loads(output)
+        self.assertIn('Users', parsed_output)
+        self.assertEqual(len(parsed_output['Users']), 2)
+        self.assertEqual(sorted(parsed_output['Users'][0].keys()),
+                         ['Arn', 'CreateDate', 'Path', 'UserId', 'UserName'])
+
+    def test_jmespath_json_response(self):
+        jmespath_query = 'Users[*].UserName'
+        output = self.run_cmd('iam list-users --query %s' % jmespath_query,
+                              expected_rc=0)[0]
+        parsed_output = json.loads(output)
+        self.assertEqual(parsed_output, ['testuser-50', 'testuser-51'])
diff --git a/tests/unit/output/test_table_formatter.py b/tests/unit/output/test_table_formatter.py
--- a/tests/unit/output/test_table_formatter.py
+++ b/tests/unit/output/test_table_formatter.py
@@ -296,9 +296,76 @@
 |+-------------+-----------+----------+-----------+---------------+-------------+------------+-------+-------------+--------------+|
 """
 
+JMESPATH_FILTERED_RESPONSE = [
+    [
+        [
+            "i-12345",
+            "ami-12345",
+            "ebs",
+            "t1.micro",
+            "running",
+            "disabled",
+            "util"
+        ]
+    ],
+    [
+        [
+            "i-56789",
+            "ami-56789",
+            "ebs",
+            "c1.medium",
+            "running",
+            "disabled",
+            "myname"
+        ]
+    ],
+]
+JMESPATH_FILTERED_RESPONSE_TABLE = """\
+-------------------------------------------------------------------------------
+|                                OperationName                                |
++---------+------------+------+------------+----------+------------+----------+
+|  i-12345|  ami-12345 |  ebs |  t1.micro  |  running |  disabled  |  util    |
+|  i-56789|  ami-56789 |  ebs |  c1.medium |  running |  disabled  |  myname  |
++---------+------------+------+------------+----------+------------+----------+
+"""
+
+
+JMESPATH_FILTERED_RESPONSE_DICT = [
+    [
+        {
+            "InstanceId": "i-12345",
+            "RootDeviceType": "ebs",
+            "InstanceType": "t1.micro",
+            "ImageId": "ami-12345"
+        }
+    ],
+    [
+        {
+            "InstanceId": "i-56789",
+            "RootDeviceType": "ebs",
+            "InstanceType": "c1.medium",
+            "ImageId": "ami-56789"
+        }
+    ],
+]
+
+
+JMESPATH_FILTERED_RESPONSE_DICT_TABLE = """\
+---------------------------------------------------------------
+|                        OperationName                        |
++-----------+-------------+----------------+------------------+
+|  ImageId  | InstanceId  | InstanceType   | RootDeviceType   |
++-----------+-------------+----------------+------------------+
+|  ami-12345|  i-12345    |  t1.micro      |  ebs             |
+|  ami-56789|  i-56789    |  c1.medium     |  ebs             |
++-----------+-------------+----------------+------------------+
+"""
+
+
 class Object(object):
     def __init__(self, **kwargs):
         self.__dict__.update(kwargs)
+        self.query = None
 
 
 class TestTableFormatter(unittest.TestCase):
@@ -317,8 +384,14 @@ def assert_data_renders_to(self, data, table):
         self.formatter(Object(name='OperationName', can_paginate=False),
                               data, self.stream)
         rendered = self.stream.getvalue()
-        print(rendered)
-        self.assertEqual(rendered, table)
+        if rendered != table:
+            error_message = ['Expected table rendering does not match '
+                             'the actual table rendering:']
+            error_message.append('Expected:')
+            error_message.append(table)
+            error_message.append('Actual:')
+            error_message.append(rendered)
+            self.fail('\n'.join(error_message))
 
     def test_list_table(self):
         self.assert_data_renders_to(data=SIMPLE_LIST, table=SIMPLE_LIST_TABLE)
@@ -345,3 +418,11 @@ def test_missing_keys(self):
     def test_new_keys_after_first_row(self):
         self.assert_data_renders_to(data=KEYS_NOT_FROM_FIRST_ROW,
                                     table=KEYS_NOT_FROM_FIRST_ROW_TABLE)
+
+    def test_jmespath_filtered_response(self):
+        self.assert_data_renders_to(data=JMESPATH_FILTERED_RESPONSE,
+                                    table=JMESPATH_FILTERED_RESPONSE_TABLE)
+
+    def test_jmespath_filtered_dict_response(self):
+        self.assert_data_renders_to(data=JMESPATH_FILTERED_RESPONSE_DICT,
+                                    table=JMESPATH_FILTERED_RESPONSE_DICT_TABLE)
diff --git a/tests/unit/ses/test_send_email.py b/tests/unit/ses/test_send_email.py
--- a/tests/unit/ses/test_send_email.py
+++ b/tests/unit/ses/test_send_email.py
@@ -120,8 +120,7 @@ def test_both_destination_and_to(self):
                 ' --destination {"ToAddresses":["fie@baz.com"]}'
                 ' --to fie2@baz.com')
         args_list = (self.prefix + args).split()
-        with mock.patch('sys.stderr', captured):
-            self.assert_params_for_cmd(args_list, {}, expected_rc=255)
+        self.assert_params_for_cmd(args_list, {}, expected_rc=255)
 
     def test_both_message_and_text(self):
         captured = cStringIO()
@@ -130,6 +129,4 @@ def test_both_message_and_text(self):
                 ' --destination {"ToAddresses":["fie@baz.com"]}'
                 ' --text This_is_another_body')
         args_list = (self.prefix + args).split()
-        with mock.patch('sys.stderr', captured):
-            self.assert_params_for_cmd(args_list, {}, expected_rc=255)
-            
+        self.assert_params_for_cmd(args_list, {}, expected_rc=255)
diff --git a/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py
--- a/tests/unit/test_clidriver.py
+++ b/tests/unit/test_clidriver.py
@@ -43,6 +43,9 @@
                 ],
                 "metavar": "output_format"
             },
+            "query": {
+                "help": "<p>A JMESPath query to use in filtering the response data.</p>"
+            },
             "profile": {
                 "help": "Use a specific profile from your credential file",
                 "metavar": "profile_name"
diff --git a/tests/unit/test_completer.py b/tests/unit/test_completer.py
--- a/tests/unit/test_completer.py
+++ b/tests/unit/test_completer.py
@@ -20,7 +20,7 @@
 
 GLOBALOPTS = ['--debug', '--endpoint-url', '--no-verify-ssl',
               '--no-paginate', '--output', '--profile',
-              '--region', '--version', '--color']
+              '--region', '--version', '--color', '--query']
 
 COMPLETIONS = [
     ('aws ', -1, set(['autoscaling', 'cloudformation', 'cloudfront',
@@ -59,7 +59,7 @@
     ('aws ec2 --debug describe-instances --instance-ids i-12345678 - ', -1,
      set(['--filters', '--dry-run', '--no-dry-run', '--endpoint-url',
            '--no-verify-ssl', '--no-paginate', '--output', '--profile',
-              '--region', '--version', '--color'])),
+              '--region', '--version', '--color', '--query'])),
     ('aws s3', -1, set(['cp', 'mv', 'rm', 'mb', 'rb', 'ls', 'sync'])),
     ('aws s3 m', -1, set(['mv', 'mb'])),
     ('aws s3 cp -', -1, set(['--no-guess-mime-type', '--dryrun',
diff --git a/tests/unit/test_text.py b/tests/unit/test_text.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_text.py
@@ -0,0 +1,116 @@
+# Copyright (c) 2013 Amazon.com, Inc. or its affiliates.  All Rights Reserved
+#
+# Permission is hereby granted, free of charge, to any person obtaining a
+# copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish, dis-
+# tribute, sublicense, and/or sell copies of the Software, and to permit
+# persons to whom the Software is furnished to do so, subject to the fol-
+# lowing conditions:
+#
+# The above copyright notice and this permission notice shall be included
+# in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
+# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+#
+import unittest
+
+import six
+import mock
+
+from awscli import text
+
+
+class TestSection(unittest.TestCase):
+    def format_text(self, data):
+        stream = six.StringIO()
+        text.format_text(data, stream=stream)
+        return stream.getvalue()
+
+    def assert_text_renders_to(self, data, expected_rendering):
+        rendered = self.format_text(data)
+        self.assertEqual(rendered, expected_rendering)
+
+    def test_dict_format(self):
+        self.assert_text_renders_to(dict(a=1, b=2, c=3), "1\t2\t3\n")
+
+    def test_list_format(self):
+        self.assert_text_renders_to([1, 2, 3], "1\t2\t3\n")
+
+    def test_list_of_dicts(self):
+        self.assert_text_renders_to(
+            {'foo': [dict(a=1, b=2, c=3), dict(a=4, b=5, c=6)]},
+            'FOO\t1\t2\t3\n'
+            'FOO\t4\t5\t6\n')
+
+    def test_multiple_list_of_dicts(self):
+        self.assert_text_renders_to(
+            {'foo': [dict(a=1, b=2, c=3), dict(a=4, b=5, c=6)],
+             'zoo': [dict(a=7, b=8, c=9), dict(a=0, b=1, c=2)]},
+            'FOO\t1\t2\t3\n'
+            'FOO\t4\t5\t6\n'
+            'ZOO\t7\t8\t9\n'
+            'ZOO\t0\t1\t2\n'
+        )
+
+    def test_different_keys_in_sublists(self):
+        self.assert_text_renders_to(
+            #                                missing "b"        adds "d"
+            {'foo': [dict(a=1, b=2, c=3), dict(a=4, c=5), dict(a=6, d=7)]},
+            'FOO\t1\t2\t3\t\n'
+            'FOO\t4\t\t5\t\n'
+            'FOO\t6\t\t\t7\n'
+        )
+
+    def test_different_keys_in_nested_sublists(self):
+        self.assert_text_renders_to({'bar':[
+            {'foo': [dict(a=1, b=2, c=3), dict(a=4, c=5)]},
+            {'foo': [dict(b=6, d=7), dict(b=8, c=9)]},
+        ]},
+            'FOO\t1\t2\t3\n'
+            'FOO\t4\t\t5\n'
+            'FOO\t6\t\t7\n'
+            'FOO\t8\t9\t\n'
+        )
+
+    def test_scalars_and_complex_types(self):
+        self.assert_text_renders_to(
+            {'foo': [dict(a=1, b=dict(y='y', z='z'), c=3),
+                     dict(a=4, b=dict(y='y', z='z'), c=6)]},
+            'FOO\t1\t3\n'
+            'B\ty\tz\n'
+            'FOO\t4\t6\n'
+            'B\ty\tz\n')
+
+    def test_nested_list_of_lists(self):
+        self.assert_text_renders_to(
+            [['1', '2', '3'], ['4', '5', '6']],
+            '1\t2\t3\n'
+            '4\t5\t6\n'
+        )
+
+    def test_deeply_nested_lists(self):
+        self.assert_text_renders_to(
+            [
+                [['1', '2', '3'], ['4', '5', '6']],
+                [['7', '8', '9'], ['0', '1', '2']],
+            ],
+            '1\t2\t3\n'
+            '4\t5\t6\n'
+            '7\t8\t9\n'
+            '0\t1\t2\n'
+        )
+
+    def test_unicode_text(self):
+        self.assert_text_renders_to([['1', '2', u'\u2713']],
+                                    u'1\t2\t\u2713\n')
+
+
+if __name__ == '__main__':
+    unittest.main()