From d333ee5333d5787a07bad218127863e9c81029c1 Mon Sep 17 00:00:00 2001 From: Derek Bekoe Date: Wed, 24 Feb 2016 10:24:50 -0800 Subject: [PATCH 1/2] [Pivotal#114038135] JSON, Table and Text format outputs --- src/azure/cli/_output.py | 94 ++++++++++++++++++++++++ src/azure/cli/_util.py | 59 +++++++++++++-- src/azure/cli/commands/login.py | 16 ++--- src/azure/cli/main.py | 7 +- src/azure/cli/tests/test_output.py | 111 +++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 18 deletions(-) create mode 100644 src/azure/cli/_output.py create mode 100644 src/azure/cli/tests/test_output.py diff --git a/src/azure/cli/_output.py b/src/azure/cli/_output.py new file mode 100644 index 00000000000..7d26a998e30 --- /dev/null +++ b/src/azure/cli/_output.py @@ -0,0 +1,94 @@ +""" +Manage output for the CLI +""" +import sys +import json + +from enum import Enum + +from azure.cli._util import TableOutput, TextOutput + +class OutputFormats(Enum): + """ + The output formats supported by this module + """ + JSON = 1 + TABLE = 2 + TEXT = 3 + +class OutputProducer(object): + """ + Produce output for the CLI + """ + + def __init__(self, format=OutputFormats.JSON, file=sys.stdout): + """Constructor. + + Keyword arguments: + format -- the output format to use + file -- the file object to use when printing + """ + if format not in OutputFormats: + raise OutputFormatException("Unknown format {0}".format(format)) + self.format = format + # get the formatter + if self.format is OutputFormats.JSON: + self.formatter = JSONFormatter() + elif self.format is OutputFormats.TABLE: + self.formatter = TableFormatter() + elif self.format is OutputFormats.TEXT: + self.formatter = TextFormatter() + self.file = file + + def out(self, obj): + print(self.formatter(obj), file=self.file) + +class JSONFormatter(object): + + def __init__(self): + # can pass in configuration if needed + pass + + def __call__(self, obj): + input_dict = obj.__dict__ if hasattr(obj, '__dict__') else obj + return json.dumps(input_dict, indent=4, sort_keys=True) + +class TableFormatter(object): + + def __init__(self): + # can pass in configuration if needed + pass + + def __call__(self, obj): + obj_list = obj if isinstance(obj, list) else [obj] + with TableOutput() as to: + try: + for item in obj_list: + for item_key in sorted(item): + to.cell(item_key, item[item_key]) + to.end_row() + return to.dump() + except TypeError: + return '' + +class TextFormatter(object): + + def __init__(self): + # can pass in configuration if needed + pass + + def __call__(self, obj): + obj_list = obj if isinstance(obj, list) else [obj] + with TextOutput() as to: + try: + for item in obj_list: + for item_key in sorted(item): + to.add(item_key, item[item_key]) + return to.dump() + except TypeError: + return '' + +class OutputFormatException(Exception): + """The output format specified is not recognized. + """ + pass diff --git a/src/azure/cli/_util.py b/src/azure/cli/_util.py index 83f30161e5d..91a5ce90e3e 100644 --- a/src/azure/cli/_util.py +++ b/src/azure/cli/_util.py @@ -1,24 +1,39 @@ import types +import json + +try: + # Python 2 + from StringIO import StringIO +except ImportError: + # Python 3 + from io import StringIO class TableOutput(object): def __enter__(self): self._rows = [{}] self._columns = {} self._column_order = [] + self.io = StringIO() return self - def __exit__(self, ex_type, ex_value, ex_tb): - if ex_type: - return + def dump(self): + """Return the dump of the table as a string + """ if len(self._rows) == 1: return cols = [(c, self._columns[c]) for c in self._column_order] - print(' | '.join(c.center(w) for c, w in cols)) - print('-|-'.join('-' * w for c, w in cols)) + self.io.write(' | '.join(c.center(w) for c, w in cols)) + self.io.write('\n') + self.io.write('-|-'.join('-' * w for c, w in cols)) + self.io.write('\n') for r in self._rows[:-1]: - print(' | '.join(r[c].ljust(w) for c, w in cols)) - print() + self.io.write(' | '.join(r[c].ljust(w) for c, w in cols)) + self.io.write('\n') + return self.io.getvalue() + + def __exit__(self, ex_type, ex_value, ex_tb): + self.io.close() @property def any_rows(self): @@ -36,3 +51,33 @@ def cell(self, name, value): def end_row(self): self._rows.append({}) + +class TextOutput(object): + + def __enter__(self): + self.identifiers = {} + self.io = StringIO() + return self + + def __exit__(self, ex_type, ex_value, ex_tb): + self.io.close() + + def add(self, identifier, value): + if identifier in self.identifiers: + self.identifiers[identifier].append(value) + else: + self.identifiers[identifier] = [value] + + def dump(self): + for id in sorted(self.identifiers): + self.io.write(id.upper()) + self.io.write('\t') + for col in self.identifiers[id]: + if isinstance(col, str): + self.io.write(col) + else: + # TODO: Handle complex objects + self.io.write("null") + self.io.write('\t') + self.io.write('\n') + return self.io.getvalue() diff --git a/src/azure/cli/commands/login.py b/src/azure/cli/commands/login.py index b84fb4fb846..4f830f83b11 100644 --- a/src/azure/cli/commands/login.py +++ b/src/azure/cli/commands/login.py @@ -2,6 +2,8 @@ SubscriptionClientConfiguration from msrestazure.azure_active_directory import UserPassCredentials +from msrest import Serializer + from .._logging import logging from .._profile import Profile from .._util import TableOutput @@ -28,10 +30,12 @@ def login(args, unexpected): if not subscriptions: raise RuntimeError(_("No subscriptions found for this account")) + serializable = Serializer().serialize_data(subscriptions, "[Subscription]") + #keep useful properties and not json serializable consolidated = [] for s in subscriptions: - subscription = {}; + subscription = {} subscription['id'] = s.id.split('/')[-1] subscription['name'] = s.display_name subscription['state'] = s.state @@ -41,12 +45,4 @@ def login(args, unexpected): profile = Profile() profile.update(consolidated, credentials.token['access_token']) - #TODO, replace with JSON display - with TableOutput() as to: - for subscription in consolidated: - to.cell('Name', subscription['name']) - to.cell('Active', bool(subscription['active'])) - to.cell('User', subscription['user']) - to.cell('Subscription Id', subscription['id']) - to.cell('State', subscription['state']) - to.end_row() + return serializable diff --git a/src/azure/cli/main.py b/src/azure/cli/main.py index a090c8af4ae..72f40981a8a 100644 --- a/src/azure/cli/main.py +++ b/src/azure/cli/main.py @@ -4,6 +4,7 @@ from ._locale import install as locale_install from ._logging import configure_logging, logging from ._session import Session +from ._output import OutputFormats, OutputProducer # CONFIG provides external configuration options CONFIG = Session() @@ -37,7 +38,11 @@ def main(args): commands.add_to_parser(parser) try: - parser.execute(args) + result = parser.execute(args) + # Commands can return a dictionary/list of results + # If they do, we print the results. + if result: + OutputProducer().out(result) except RuntimeError as ex: logging.error(ex.args[0]) return ex.args[1] if len(ex.args) >= 2 else -1 diff --git a/src/azure/cli/tests/test_output.py b/src/azure/cli/tests/test_output.py new file mode 100644 index 00000000000..8007cccca1a --- /dev/null +++ b/src/azure/cli/tests/test_output.py @@ -0,0 +1,111 @@ +import unittest + +try: + # Python 2 + from StringIO import StringIO +except ImportError: + # Python 3 + from io import StringIO + +from azure.cli._output import OutputProducer, OutputFormats, OutputFormatException + +class TestOutput(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pass + + @classmethod + def tearDownClass(cls): + pass + + def setUp(self): + self.io = StringIO() + + def tearDown(self): + self.io.close() + + def test_unknown_format(self): + """ + Should through exception if format is unknown + """ + with self.assertRaises(OutputFormatException): + output_producer = OutputProducer(format='unknown') + + + def test_default_format_json(self): + """ + We expect the default format to be JSON + """ + output_producer = OutputProducer() + self.assertEqual(output_producer.format, OutputFormats.JSON) + + def test_set_format_table(self): + """ + The format used can be set to table + """ + output_producer = OutputProducer(format=OutputFormats.TABLE) + self.assertEqual(output_producer.format, OutputFormats.TABLE) + + def test_set_format_text(self): + """ + The format used can be set to text + """ + output_producer = OutputProducer(format=OutputFormats.TEXT) + self.assertEqual(output_producer.format, OutputFormats.TEXT) + + def test_out_json_none(self): + """ + The JSON output when the input is None is 'null' + """ + output_producer = OutputProducer(format=OutputFormats.JSON, file=self.io) + output_producer.out(None) + self.assertEqual(self.io.getvalue(), 'null\n') + + def test_out_json_valid(self): + """ + The JSON output when the input is a dict should be the dict serialized to JSON + """ + output_producer = OutputProducer(format=OutputFormats.JSON, file=self.io) + output_producer.out({'active': True, 'id': '0b1f6472'}) + self.assertEqual(self.io.getvalue(), +"""{ + "active": true, + "id": "0b1f6472" +} +""" + ) + + def test_out__table_none(self): + """ + The table format just returns a new line if object None is passed in. + """ + output_producer = OutputProducer(format=OutputFormats.TABLE, file=self.io) + output_producer.out(None) + self.assertEqual(self.io.getvalue(), '\n') + + def test_out_table_valid(self): + """ + """ + output_producer = OutputProducer(format=OutputFormats.TABLE, file=self.io) + output_producer.out({'active': True, 'id': '0b1f6472'}) + self.assertEqual(self.io.getvalue(), +"""active | id +-------|--------- +True | 0b1f6472 + +""" + ) + + def test_out_text_none(self): + """ + The text format just returns a new line if object None is passed in. + """ + output_producer = OutputProducer(format=OutputFormats.TEXT, file=self.io) + output_producer.out(None) + self.assertEqual(self.io.getvalue(), '\n') + + + +if __name__ == '__main__': + unittest.main() From 8a563d79925ba306b1e865e525908e31e14e5f57 Mon Sep 17 00:00:00 2001 From: Derek Bekoe Date: Wed, 24 Feb 2016 16:52:33 -0800 Subject: [PATCH 2/2] Make changes based on code review feedback & Python 2.7 / 3 support --- src/azure/cli/_output.py | 169 ++++++++++++++++------------- src/azure/cli/_profile.py | 2 +- src/azure/cli/_util.py | 84 +------------- src/azure/cli/commands/account.py | 10 +- src/azure/cli/commands/login.py | 2 - src/azure/cli/commands/storage.py | 15 +-- src/azure/cli/main.py | 2 +- src/azure/cli/tests/test_output.py | 84 +++----------- 8 files changed, 119 insertions(+), 249 deletions(-) diff --git a/src/azure/cli/_output.py b/src/azure/cli/_output.py index 7d26a998e30..53d84bc87c6 100644 --- a/src/azure/cli/_output.py +++ b/src/azure/cli/_output.py @@ -1,94 +1,115 @@ -""" -Manage output for the CLI -""" +from __future__ import print_function, unicode_literals + import sys import json -from enum import Enum +try: + # Python 3 + from io import StringIO +except ImportError: + # Python 2 + from StringIO import StringIO + +class OutputFormatException(Exception): + pass + +def format_json(obj): + input_dict = obj.__dict__ if hasattr(obj, '__dict__') else obj + return json.dumps(input_dict, indent=2, sort_keys=True, separators=(',', ': ')) -from azure.cli._util import TableOutput, TextOutput +def format_table(obj): + obj_list = obj if isinstance(obj, list) else [obj] + to = TableOutput() + try: + for item in obj_list: + for item_key in sorted(item): + to.cell(item_key, item[item_key]) + to.end_row() + return to.dump() + except TypeError: + return '' -class OutputFormats(Enum): - """ - The output formats supported by this module - """ - JSON = 1 - TABLE = 2 - TEXT = 3 +def format_text(obj): + obj_list = obj if isinstance(obj, list) else [obj] + to = TextOutput() + try: + for item in obj_list: + for item_key in sorted(item): + to.add(item_key, item[item_key]) + return to.dump() + except TypeError: + return '' class OutputProducer(object): - """ - Produce output for the CLI - """ - def __init__(self, format=OutputFormats.JSON, file=sys.stdout): - """Constructor. - - Keyword arguments: - format -- the output format to use - file -- the file object to use when printing - """ - if format not in OutputFormats: - raise OutputFormatException("Unknown format {0}".format(format)) - self.format = format - # get the formatter - if self.format is OutputFormats.JSON: - self.formatter = JSONFormatter() - elif self.format is OutputFormats.TABLE: - self.formatter = TableFormatter() - elif self.format is OutputFormats.TEXT: - self.formatter = TextFormatter() + def __init__(self, formatter=format_json, file=sys.stdout): + self.formatter = formatter self.file = file def out(self, obj): print(self.formatter(obj), file=self.file) -class JSONFormatter(object): - +class TableOutput(object): def __init__(self): - # can pass in configuration if needed - pass + self._rows = [{}] + self._columns = {} + self._column_order = [] + + def dump(self): + if len(self._rows) == 1: + return - def __call__(self, obj): - input_dict = obj.__dict__ if hasattr(obj, '__dict__') else obj - return json.dumps(input_dict, indent=4, sort_keys=True) + with StringIO() as io: + cols = [(c, self._columns[c]) for c in self._column_order] + io.write(' | '.join(c.center(w) for c, w in cols)) + io.write('\n') + io.write('-|-'.join('-' * w for c, w in cols)) + io.write('\n') + for r in self._rows[:-1]: + io.write(' | '.join(r[c].ljust(w) for c, w in cols)) + io.write('\n') + return io.getvalue() -class TableFormatter(object): + @property + def any_rows(self): + return len(self._rows) > 1 - def __init__(self): - # can pass in configuration if needed - pass - - def __call__(self, obj): - obj_list = obj if isinstance(obj, list) else [obj] - with TableOutput() as to: - try: - for item in obj_list: - for item_key in sorted(item): - to.cell(item_key, item[item_key]) - to.end_row() - return to.dump() - except TypeError: - return '' - -class TextFormatter(object): + def cell(self, name, value): + n = str(name) + v = str(value) + max_width = self._columns.get(n) + if max_width is None: + self._column_order.append(n) + max_width = len(n) + self._rows[-1][n] = v + self._columns[n] = max(max_width, len(v)) + + def end_row(self): + self._rows.append({}) + +class TextOutput(object): def __init__(self): - # can pass in configuration if needed - pass - - def __call__(self, obj): - obj_list = obj if isinstance(obj, list) else [obj] - with TextOutput() as to: - try: - for item in obj_list: - for item_key in sorted(item): - to.add(item_key, item[item_key]) - return to.dump() - except TypeError: - return '' + self.identifiers = {} + + def add(self, identifier, value): + if identifier in self.identifiers: + self.identifiers[identifier].append(value) + else: + self.identifiers[identifier] = [value] + + def dump(self): + with StringIO() as io: + for id in sorted(self.identifiers): + io.write(id.upper()) + io.write('\t') + for col in self.identifiers[id]: + if isinstance(col, str): + io.write(col) + else: + # TODO: Handle complex objects + io.write("null") + io.write('\t') + io.write('\n') + return io.getvalue() -class OutputFormatException(Exception): - """The output format specified is not recognized. - """ - pass diff --git a/src/azure/cli/_profile.py b/src/azure/cli/_profile.py index 1f663ba581b..f72e70ecaa5 100644 --- a/src/azure/cli/_profile.py +++ b/src/azure/cli/_profile.py @@ -12,7 +12,7 @@ def normalize_properties(user, subscriptions): consolidated = [] for s in subscriptions: consolidated.append({ - 'id': s.id.split('/')[-1], + 'id': s.id.rpartition('/')[2], 'name': s.display_name, 'state': s.state, 'user': user, diff --git a/src/azure/cli/_util.py b/src/azure/cli/_util.py index 91a5ce90e3e..6449b631737 100644 --- a/src/azure/cli/_util.py +++ b/src/azure/cli/_util.py @@ -1,83 +1,3 @@ -import types -import json -try: - # Python 2 - from StringIO import StringIO -except ImportError: - # Python 3 - from io import StringIO - -class TableOutput(object): - def __enter__(self): - self._rows = [{}] - self._columns = {} - self._column_order = [] - self.io = StringIO() - return self - - def dump(self): - """Return the dump of the table as a string - """ - if len(self._rows) == 1: - return - - cols = [(c, self._columns[c]) for c in self._column_order] - self.io.write(' | '.join(c.center(w) for c, w in cols)) - self.io.write('\n') - self.io.write('-|-'.join('-' * w for c, w in cols)) - self.io.write('\n') - for r in self._rows[:-1]: - self.io.write(' | '.join(r[c].ljust(w) for c, w in cols)) - self.io.write('\n') - return self.io.getvalue() - - def __exit__(self, ex_type, ex_value, ex_tb): - self.io.close() - - @property - def any_rows(self): - return len(self._rows) > 1 - - def cell(self, name, value): - n = str(name) - v = str(value) - max_width = self._columns.get(n) - if max_width is None: - self._column_order.append(n) - max_width = len(n) - self._rows[-1][n] = v - self._columns[n] = max(max_width, len(v)) - - def end_row(self): - self._rows.append({}) - -class TextOutput(object): - - def __enter__(self): - self.identifiers = {} - self.io = StringIO() - return self - - def __exit__(self, ex_type, ex_value, ex_tb): - self.io.close() - - def add(self, identifier, value): - if identifier in self.identifiers: - self.identifiers[identifier].append(value) - else: - self.identifiers[identifier] = [value] - - def dump(self): - for id in sorted(self.identifiers): - self.io.write(id.upper()) - self.io.write('\t') - for col in self.identifiers[id]: - if isinstance(col, str): - self.io.write(col) - else: - # TODO: Handle complex objects - self.io.write("null") - self.io.write('\t') - self.io.write('\n') - return self.io.getvalue() +def normalize_newlines(str_to_normalize): + return str_to_normalize.replace('\r\n', '\n') diff --git a/src/azure/cli/commands/account.py b/src/azure/cli/commands/account.py index 2eb554fed68..4df6168ecf4 100644 --- a/src/azure/cli/commands/account.py +++ b/src/azure/cli/commands/account.py @@ -1,5 +1,4 @@ from .._profile import Profile -from .._util import TableOutput from ..commands import command, description, option @command('account list') @@ -8,14 +7,7 @@ def list_subscriptions(args, unexpected): profile = Profile() subscriptions = profile.load_subscriptions() - with TableOutput() as to: - for subscription in subscriptions: - to.cell('Name', subscription['name']) - to.cell('Active', bool(subscription['active'])) - to.cell('User', subscription['user']) - to.cell('Subscription Id', subscription['id']) - to.cell('State', subscription['state']) - to.end_row() + return subscriptions @command('account set') @description(_('Set the current subscription')) diff --git a/src/azure/cli/commands/login.py b/src/azure/cli/commands/login.py index b84a9184dcb..92f7eadd690 100644 --- a/src/azure/cli/commands/login.py +++ b/src/azure/cli/commands/login.py @@ -5,7 +5,6 @@ from msrest import Serializer from .._profile import Profile -from .._util import TableOutput from ..commands import command, description, option CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' @@ -32,7 +31,6 @@ def login(args, unexpected): serializable = Serializer().serialize_data(subscriptions, "[Subscription]") #keep useful properties and not json serializable - profile = Profile() consolidated = Profile.normalize_properties(username, subscriptions) profile.set_subscriptions(consolidated, credentials.token['access_token']) diff --git a/src/azure/cli/commands/storage.py b/src/azure/cli/commands/storage.py index f235a26ca3b..74699c83e17 100644 --- a/src/azure/cli/commands/storage.py +++ b/src/azure/cli/commands/storage.py @@ -1,6 +1,6 @@ +from msrest import Serializer + from ..main import SESSION -from .._logging import logging -from .._util import TableOutput from ..commands import command, description, option from .._profile import Profile @@ -24,15 +24,8 @@ def list_accounts(args, unexpected): else: accounts = smc.storage_accounts.list() - with TableOutput() as to: - for acc in accounts: - assert isinstance(acc, StorageAccount) - to.cell('Name', acc.name) - to.cell('Type', acc.account_type) - to.cell('Location', acc.location) - to.end_row() - if not to.any_rows: - print('No storage accounts defined') + serializable = Serializer().serialize_data(accounts, "[StorageAccount]") + return serializable @command('storage account check') @option('--account-name ') diff --git a/src/azure/cli/main.py b/src/azure/cli/main.py index b5c4d439551..08aaeee6ea7 100644 --- a/src/azure/cli/main.py +++ b/src/azure/cli/main.py @@ -4,7 +4,7 @@ from ._locale import install as locale_install from ._logging import configure_logging, logger from ._session import Session -from ._output import OutputFormats, OutputProducer +from ._output import OutputProducer # CONFIG provides external configuration options CONFIG = Session() diff --git a/src/azure/cli/tests/test_output.py b/src/azure/cli/tests/test_output.py index 8007cccca1a..14ab85cb96c 100644 --- a/src/azure/cli/tests/test_output.py +++ b/src/azure/cli/tests/test_output.py @@ -1,13 +1,16 @@ +from __future__ import print_function + import unittest try: - # Python 2 - from StringIO import StringIO -except ImportError: # Python 3 from io import StringIO +except ImportError: + # Python 2 + from StringIO import StringIO -from azure.cli._output import OutputProducer, OutputFormats, OutputFormatException +from azure.cli._output import OutputProducer, OutputFormatException, format_json, format_table, format_text +import azure.cli._util as util class TestOutput(unittest.TestCase): @@ -25,87 +28,30 @@ def setUp(self): def tearDown(self): self.io.close() - def test_unknown_format(self): - """ - Should through exception if format is unknown - """ - with self.assertRaises(OutputFormatException): - output_producer = OutputProducer(format='unknown') - - - def test_default_format_json(self): - """ - We expect the default format to be JSON - """ - output_producer = OutputProducer() - self.assertEqual(output_producer.format, OutputFormats.JSON) - - def test_set_format_table(self): - """ - The format used can be set to table - """ - output_producer = OutputProducer(format=OutputFormats.TABLE) - self.assertEqual(output_producer.format, OutputFormats.TABLE) - - def test_set_format_text(self): - """ - The format used can be set to text - """ - output_producer = OutputProducer(format=OutputFormats.TEXT) - self.assertEqual(output_producer.format, OutputFormats.TEXT) - - def test_out_json_none(self): - """ - The JSON output when the input is None is 'null' - """ - output_producer = OutputProducer(format=OutputFormats.JSON, file=self.io) - output_producer.out(None) - self.assertEqual(self.io.getvalue(), 'null\n') - def test_out_json_valid(self): """ The JSON output when the input is a dict should be the dict serialized to JSON """ - output_producer = OutputProducer(format=OutputFormats.JSON, file=self.io) + output_producer = OutputProducer(formatter=format_json, file=self.io) output_producer.out({'active': True, 'id': '0b1f6472'}) - self.assertEqual(self.io.getvalue(), + self.assertEqual(util.normalize_newlines(self.io.getvalue()), util.normalize_newlines( """{ - "active": true, - "id": "0b1f6472" + "active": true, + "id": "0b1f6472" } -""" - ) - - def test_out__table_none(self): - """ - The table format just returns a new line if object None is passed in. - """ - output_producer = OutputProducer(format=OutputFormats.TABLE, file=self.io) - output_producer.out(None) - self.assertEqual(self.io.getvalue(), '\n') +""")) def test_out_table_valid(self): """ """ - output_producer = OutputProducer(format=OutputFormats.TABLE, file=self.io) + output_producer = OutputProducer(formatter=format_table, file=self.io) output_producer.out({'active': True, 'id': '0b1f6472'}) - self.assertEqual(self.io.getvalue(), + self.assertEqual(util.normalize_newlines(self.io.getvalue()), util.normalize_newlines( """active | id -------|--------- True | 0b1f6472 -""" - ) - - def test_out_text_none(self): - """ - The text format just returns a new line if object None is passed in. - """ - output_producer = OutputProducer(format=OutputFormats.TEXT, file=self.io) - output_producer.out(None) - self.assertEqual(self.io.getvalue(), '\n') - - +""")) if __name__ == '__main__': unittest.main()