diff options
30 files changed, 406 insertions, 182 deletions
@@ -23,17 +23,14 @@ pip-log.txt #Translations *.mo -#Mr Developer -.mr.developer.cfg - -#sample output -*.log -*.log.* - # pbr output AUTHORS ChangeLog +# reno output +RELEASENOTES.rst +releasenotes/notes/reno.cache + # Editors *~ .*.swp @@ -1,6 +1,6 @@ - job: - name: cliff-tox-py37-neutronclient-tip - parent: openstack-tox-py37 + name: cliff-tox-py38-neutronclient-tip + parent: openstack-tox-py38 description: | Run unit tests for neutronclient with master branch of cliff @@ -23,16 +23,15 @@ templates: - check-requirements - lib-forward-testing-python3 - - openstack-lower-constraints-jobs - - openstack-python3-wallaby-jobs + - openstack-python3-zed-jobs - publish-openstack-docs-pti check: jobs: - - cliff-tox-py37-neutronclient-tip - - osc-tox-py36-tips: + - cliff-tox-py38-neutronclient-tip + - osc-tox-py38-tips: branches: ^master$ gate: jobs: - - cliff-tox-py37-neutronclient-tip - - osc-tox-py36-tips: + - cliff-tox-py38-neutronclient-tip + - osc-tox-py38-tips: branches: ^master$ diff --git a/cliff/_argparse.py b/cliff/_argparse.py index c03d709..cb5e4c5 100644 --- a/cliff/_argparse.py +++ b/cliff/_argparse.py @@ -12,9 +12,11 @@ """Overrides of standard argparse behavior.""" -import argparse +import argparse as orig_argparse import warnings +from autopage import argparse + class _ArgumentContainerMixIn(object): @@ -75,12 +77,12 @@ def _handle_conflict_ignore(container, option_string_actions, ) -class _ArgumentGroup(_ArgumentContainerMixIn, argparse._ArgumentGroup): +class _ArgumentGroup(_ArgumentContainerMixIn, orig_argparse._ArgumentGroup): pass class _MutuallyExclusiveGroup(_ArgumentContainerMixIn, - argparse._MutuallyExclusiveGroup): + orig_argparse._MutuallyExclusiveGroup): pass diff --git a/cliff/app.py b/cliff/app.py index 603778d..798b41f 100644 --- a/cliff/app.py +++ b/cliff/app.py @@ -13,6 +13,7 @@ """Application base class. """ +import inspect import locale import logging import logging.handlers @@ -32,6 +33,7 @@ logging.getLogger('cliff').addHandler(logging.NullHandler()) # Exit code for exiting due to a signal is 128 + the signal number _SIGINT_EXIT = 130 +_SIGPIPE_EXIT = 141 class App(object): @@ -255,6 +257,8 @@ class App(object): remainder.insert(0, "help") self.initialize_app(remainder) self.print_help_if_requested() + except BrokenPipeError: + return _SIGPIPE_EXIT except Exception as err: if hasattr(self, 'options'): debug = self.options.debug @@ -274,6 +278,8 @@ class App(object): else: try: result = self.run_subcommand(remainder) + except BrokenPipeError: + return _SIGPIPE_EXIT except KeyboardInterrupt: return _SIGINT_EXIT return result @@ -382,7 +388,7 @@ class App(object): return 2 cmd_factory, cmd_name, sub_argv = subcommand kwargs = {} - if 'cmd_name' in utils.getargspec(cmd_factory.__init__).args: + if 'cmd_name' in inspect.getfullargspec(cmd_factory.__init__).args: kwargs['cmd_name'] = cmd_name cmd = cmd_factory(self, self.options, **kwargs) result = 1 @@ -399,6 +405,10 @@ class App(object): except SystemExit as ex: raise cmd2.exceptions.Cmd2ArgparseError from ex result = cmd.run(parsed_args) + except BrokenPipeError as err1: + result = _SIGPIPE_EXIT + err = err1 + raise except help.HelpExit: result = 0 except Exception as err1: diff --git a/cliff/columns.py b/cliff/columns.py index 6ecef64..b9cac5e 100644 --- a/cliff/columns.py +++ b/cliff/columns.py @@ -26,6 +26,11 @@ class FormattableColumn(object, metaclass=abc.ABCMeta): self.__class__ == other.__class__ and self._value == other._value ) + def __lt__(self, other): + return ( + self.__class__ == other.__class__ and self._value < other._value + ) + @abc.abstractmethod def human_readable(self): """Return a basic human readable version of the data.""" diff --git a/cliff/command.py b/cliff/command.py index f8d0501..0a02525 100644 --- a/cliff/command.py +++ b/cliff/command.py @@ -74,6 +74,7 @@ class Command(object, metaclass=abc.ABCMeta): """ deprecated = False + conflict_handler = 'ignore' _description = '' _epilog = None @@ -156,7 +157,7 @@ class Command(object, metaclass=abc.ABCMeta): epilog=self.get_epilog(), prog=prog_name, formatter_class=_argparse.SmartHelpFormatter, - conflict_handler='ignore', + conflict_handler=self.conflict_handler, ) for hook in self._hooks: hook.obj.get_parser(parser) diff --git a/cliff/commandmanager.py b/cliff/commandmanager.py index 1787fcf..4f46014 100644 --- a/cliff/commandmanager.py +++ b/cliff/commandmanager.py @@ -13,12 +13,11 @@ """Discover and lookup command plugins. """ +import inspect import logging import stevedore -from . import utils - LOG = logging.getLogger(__name__) @@ -125,7 +124,7 @@ class CommandManager(object): else: # NOTE(dhellmann): Some fake classes don't take # require as an argument. Yay? - arg_spec = utils.getargspec(cmd_ep.load) + arg_spec = inspect.getfullargspec(cmd_ep.load) if 'require' in arg_spec[0]: cmd_factory = cmd_ep.load(require=False) else: diff --git a/cliff/formatters/table.py b/cliff/formatters/table.py index 397777c..df0b087 100644 --- a/cliff/formatters/table.py +++ b/cliff/formatters/table.py @@ -10,12 +10,12 @@ # License for the specific language governing permissions and limitations # under the License. -"""Output formatters using prettytable. -""" +"""Output formatters using prettytable.""" -import prettytable import os +import prettytable + from cliff import utils from . import base from cliff import columns @@ -39,10 +39,6 @@ class TableFormatter(base.ListFormatter, base.SingleFormatter): str: 'l', float: 'r', } - try: - ALIGNMENTS[unicode] = 'l' - except NameError: - pass def add_argument_group(self, parser): group = parser.add_argument_group('table formatter') @@ -175,9 +171,6 @@ class TableFormatter(base.ListFormatter, base.SingleFormatter): @staticmethod def _assign_max_widths(stdout, x, max_width, min_width=0, fit_width=False): - if min_width: - x.min_width = min_width - if max_width > 0: term_width = max_width elif not fit_width: diff --git a/cliff/formatters/value.py b/cliff/formatters/value.py index d4e646a..5889d2b 100644 --- a/cliff/formatters/value.py +++ b/cliff/formatters/value.py @@ -29,7 +29,7 @@ class ValueFormatter(base.ListFormatter, base.SingleFormatter): str(c.machine_readable() if isinstance(c, columns.FormattableColumn) else c) - for c in row) + u'\n') + for c in row) + '\n') return def emit_one(self, column_names, data, stdout, parsed_args): diff --git a/cliff/help.py b/cliff/help.py index cb858fd..2a235de 100644 --- a/cliff/help.py +++ b/cliff/help.py @@ -14,8 +14,9 @@ import argparse import inspect import traceback +import autopage.argparse + from . import command -from . import utils class HelpExit(SystemExit): @@ -38,43 +39,53 @@ class HelpAction(argparse.Action): """ def __call__(self, parser, namespace, values, option_string=None): app = self.default - parser.print_help(app.stdout) - app.stdout.write('\nCommands:\n') - dists_by_module = command._get_distributions_by_modules() + pager = autopage.argparse.help_pager(app.stdout) + color = pager.to_terminal() + autopage.argparse.use_color_for_parser(parser, color) + with pager as out: + parser.print_help(out) + title_hl = ('\033[4m', '\033[0m') if color else ('', '') + out.write('\n%sCommands%s:\n' % title_hl) + dists_by_module = command._get_distributions_by_modules() - def dist_for_obj(obj): - name = inspect.getmodule(obj).__name__.partition('.')[0] - return dists_by_module.get(name) + def dist_for_obj(obj): + name = inspect.getmodule(obj).__name__.partition('.')[0] + return dists_by_module.get(name) - app_dist = dist_for_obj(app) - command_manager = app.command_manager - for name, ep in sorted(command_manager): - try: - factory = ep.load() - except Exception: - app.stdout.write('Could not load %r\n' % ep) - if namespace.debug: - traceback.print_exc(file=app.stdout) - continue - try: - kwargs = {} - if 'cmd_name' in utils.getargspec(factory.__init__).args: - kwargs['cmd_name'] = name - cmd = factory(app, None, **kwargs) - if cmd.deprecated: + app_dist = dist_for_obj(app) + command_manager = app.command_manager + for name, ep in sorted(command_manager): + try: + factory = ep.load() + except Exception: + out.write('Could not load %r\n' % ep) + if namespace.debug: + traceback.print_exc(file=out) + continue + try: + kwargs = {} + fact_args = inspect.getfullargspec(factory.__init__).args + if 'cmd_name' in fact_args: + kwargs['cmd_name'] = name + cmd = factory(app, None, **kwargs) + if cmd.deprecated: + continue + except Exception as err: + out.write('Could not instantiate %r: %s\n' % (ep, err)) + if namespace.debug: + traceback.print_exc(file=out) continue - except Exception as err: - app.stdout.write('Could not instantiate %r: %s\n' % (ep, err)) - if namespace.debug: - traceback.print_exc(file=app.stdout) - continue - one_liner = cmd.get_description().split('\n')[0] - dist_name = dist_for_obj(factory) - if dist_name and dist_name != app_dist: - dist_info = ' (' + dist_name + ')' - else: - dist_info = '' - app.stdout.write(' %-13s %s%s\n' % (name, one_liner, dist_info)) + one_liner = cmd.get_description().split('\n')[0] + dist_name = dist_for_obj(factory) + if dist_name and dist_name != app_dist: + dist_info = ' (' + dist_name + ')' + if color: + dist_info = '\033[90m%s\033[39m' % dist_info + else: + dist_info = '' + if color: + name = '\033[36m%s\033[39m' % name + out.write(' %-13s %s%s\n' % (name, one_liner, dist_info)) raise HelpExit() @@ -111,7 +122,7 @@ class HelpCommand(command.Command): return self.app_args.cmd = search_args kwargs = {} - if 'cmd_name' in utils.getargspec(cmd_factory.__init__).args: + if 'cmd_name' in inspect.getfullargspec(cmd_factory.__init__).args: kwargs['cmd_name'] = cmd_name cmd = cmd_factory(self.app, self.app_args, **kwargs) full_name = (cmd_name @@ -119,7 +130,11 @@ class HelpCommand(command.Command): else ' '.join([self.app.NAME, cmd_name]) ) cmd_parser = cmd.get_parser(full_name) - cmd_parser.print_help(self.app.stdout) + pager = autopage.argparse.help_pager(self.app.stdout) + with pager as out: + autopage.argparse.use_color_for_parser(cmd_parser, + pager.to_terminal()) + cmd_parser.print_help(out) else: action = HelpAction(None, None, default=self.app) action(self.app.parser, self.app.options, None, None) diff --git a/cliff/interactive.py b/cliff/interactive.py index aca7233..0a44481 100644 --- a/cliff/interactive.py +++ b/cliff/interactive.py @@ -17,6 +17,7 @@ import itertools import shlex import sys +import autopage.argparse import cmd2 @@ -140,9 +141,16 @@ class InteractiveApp(cmd2.Cmd): parsed = lambda x: x # noqa self.default(parsed('help ' + arg)) else: - cmd2.Cmd.do_help(self, arg) - cmd_names = sorted([n for n, v in self.command_manager]) - self.print_topics(self.app_cmd_header, cmd_names, 15, 80) + stdout = self.stdout + try: + with autopage.argparse.help_pager(stdout) as paged_out: + self.stdout = paged_out + + cmd2.Cmd.do_help(self, arg) + cmd_names = sorted([n for n, v in self.command_manager]) + self.print_topics(self.app_cmd_header, cmd_names, 15, 80) + finally: + self.stdout = stdout return # Create exit alias to quit the interactive shell. diff --git a/cliff/lister.py b/cliff/lister.py index cfba233..eed4875 100644 --- a/cliff/lister.py +++ b/cliff/lister.py @@ -10,17 +10,18 @@ # License for the specific language governing permissions and limitations # under the License. -"""Application base class for providing a list of data as output. -""" +"""Application base class for providing a list of data as output.""" + import abc -import operator +import logging from . import display class Lister(display.DisplayCommandBase, metaclass=abc.ABCMeta): - """Command base class for providing a list of data as output. - """ + """Command base class for providing a list of data as output.""" + + log = logging.getLogger(__name__) @property def formatter_namespace(self): @@ -35,13 +36,16 @@ class Lister(display.DisplayCommandBase, metaclass=abc.ABCMeta): """Whether sort procedure is performed by cliff itself. Should be overridden (return False) when there is a need to implement - custom sorting procedure or data is already sorted.""" + custom sorting procedure or data is already sorted. + """ return True @abc.abstractmethod def take_action(self, parsed_args): - """Return a tuple containing the column names and an iterable - containing the data to be listed. + """Run command. + + Return a tuple containing the column names and an iterable containing + the data to be listed. """ def get_parser(self, prog_name): @@ -53,30 +57,70 @@ class Lister(display.DisplayCommandBase, metaclass=abc.ABCMeta): default=[], dest='sort_columns', metavar='SORT_COLUMN', - help=("specify the column(s) to sort the data (columns specified " - "first have a priority, non-existing columns are ignored), " - "can be repeated") + help=( + 'specify the column(s) to sort the data (columns specified ' + 'first have a priority, non-existing columns are ignored), ' + 'can be repeated' + ), + ) + sort_dir_group = group.add_mutually_exclusive_group() + sort_dir_group.add_argument( + '--sort-ascending', + action='store_const', + dest='sort_direction', + const='asc', + help=('sort the column(s) in ascending order'), + ) + sort_dir_group.add_argument( + '--sort-descending', + action='store_const', + dest='sort_direction', + const='desc', + help=('sort the column(s) in descending order'), ) return parser def produce_output(self, parsed_args, column_names, data): if parsed_args.sort_columns and self.need_sort_by_cliff: - indexes = [column_names.index(c) for c in parsed_args.sort_columns - if c in column_names] - if indexes: - data = sorted(data, key=operator.itemgetter(*indexes)) - (columns_to_include, selector) = self._generate_columns_and_selector( - parsed_args, column_names) + indexes = [ + column_names.index(c) for c in parsed_args.sort_columns + if c in column_names + ] + reverse = parsed_args.sort_direction == 'desc' + for index in indexes[::-1]: + try: + # We need to handle unset values (i.e. None) so we sort on + # multiple conditions: the first comparing the results of + # an 'is None' type check and the second comparing the + # actual value. The second condition will only be checked + # if the first returns True, which only happens if the + # returns from the 'is None' check on the two values are + # the same, i.e. both None or both not-None + data = sorted( + data, key=lambda k: (k[index] is None, k[index]), + reverse=reverse, + ) + except TypeError: + # Simply log and then ignore this; sorting is best effort + self.log.warning( + "Could not sort on field '%s'; unsortable types", + parsed_args.sort_columns[index], + ) + + columns_to_include, selector = self._generate_columns_and_selector( + parsed_args, column_names, + ) if selector: # Generator expression to only return the parts of a row # of data that the user has expressed interest in # seeing. We have to convert the compress() output to a # list so the table formatter can ask for its length. - data = (list(self._compress_iterable(row, selector)) - for row in data) - self.formatter.emit_list(columns_to_include, - data, - self.app.stdout, - parsed_args, - ) + data = ( + list(self._compress_iterable(row, selector)) for row in data + ) + + self.formatter.emit_list( + columns_to_include, data, self.app.stdout, parsed_args, + ) + return 0 diff --git a/cliff/tests/test_app.py b/cliff/tests/test_app.py index 41a28b7..d38861c 100644 --- a/cliff/tests/test_app.py +++ b/cliff/tests/test_app.py @@ -54,6 +54,15 @@ def make_app(**kwargs): interrupt_command.return_value = interrupt_command_inst cmd_mgr.add_command('interrupt', interrupt_command) + # Register a command that is interrrupted by a broken pipe + pipeclose_command = mock.Mock(name='pipeclose_command', spec=c_cmd.Command) + pipeclose_command_inst = mock.Mock(spec=c_cmd.Command) + pipeclose_command_inst.run = mock.Mock( + side_effect=BrokenPipeError + ) + pipeclose_command.return_value = pipeclose_command_inst + cmd_mgr.add_command('pipe-close', pipeclose_command) + app = application.App('testing interactive mode', '1', cmd_mgr, @@ -121,6 +130,11 @@ class TestInitAndCleanup(base.TestBase): result = app.run(['interrupt']) self.assertEqual(result, 130) + def test_pipeclose_command(self): + app, command = make_app() + result = app.run(['pipe-close']) + self.assertEqual(result, 141) + def test_clean_up_success(self): app, command = make_app() app.clean_up = mock.MagicMock(name='clean_up') @@ -169,6 +183,19 @@ class TestInitAndCleanup(base.TestBase): args, kwargs = call_args self.assertIsInstance(args[2], KeyboardInterrupt) + def test_clean_up_pipeclose(self): + app, command = make_app() + + app.clean_up = mock.MagicMock(name='clean_up') + ret = app.run(['pipe-close']) + self.assertNotEqual(ret, 0) + + app.clean_up.assert_called_once_with(mock.ANY, mock.ANY, mock.ANY) + call_args = app.clean_up.call_args_list[0] + self.assertEqual(mock.call(mock.ANY, 141, mock.ANY), call_args) + args, kwargs = call_args + self.assertIsInstance(args[2], BrokenPipeError) + def test_error_handling_clean_up_raises_exception(self): app, command = make_app() @@ -356,6 +383,18 @@ class TestHelpHandling(base.TestBase): def test_interrupted_deferred_help(self): self._test_interrupted_help(True) + def _test_pipeclose_help(self, deferred_help): + app, _ = make_app(deferred_help=deferred_help) + with mock.patch('cliff.help.HelpAction.__call__', + side_effect=BrokenPipeError): + app.run(['--help']) + + def test_pipeclose_help(self): + self._test_pipeclose_help(False) + + def test_pipeclose_deferred_help(self): + self._test_pipeclose_help(True) + def test_subcommand_help(self): app, _ = make_app(deferred_help=False) @@ -493,7 +532,7 @@ class TestIO(base.TestBase): # The word "test" with the e replaced by # Unicode latin small letter e with acute, # U+00E9, utf-8 encoded as 0xC3 0xA9 - text = u't\u00E9st' + text = 't\u00E9st' text_utf8 = text.encode('utf-8') # In PY3 you can't write encoded bytes to a text writer diff --git a/cliff/tests/test_columns.py b/cliff/tests/test_columns.py index fef1128..6bce767 100644 --- a/cliff/tests/test_columns.py +++ b/cliff/tests/test_columns.py @@ -18,7 +18,7 @@ from cliff import columns class FauxColumn(columns.FormattableColumn): def human_readable(self): - return u'I made this string myself: {}'.format(self._value) + return 'I made this string myself: {}'.format(self._value) class TestColumns(unittest.TestCase): @@ -33,3 +33,16 @@ class TestColumns(unittest.TestCase): u"I made this string myself: ['list', 'of', 'values']", c.human_readable(), ) + + def test_sorting(self): + cols = [ + FauxColumn('foo'), + FauxColumn('bar'), + FauxColumn('baz'), + FauxColumn('foo'), + ] + cols.sort() + self.assertEqual( + ['bar', 'baz', 'foo', 'foo'], + [c.machine_readable() for c in cols], + ) diff --git a/cliff/tests/test_command.py b/cliff/tests/test_command.py index 29c8c33..c9513d0 100644 --- a/cliff/tests/test_command.py +++ b/cliff/tests/test_command.py @@ -172,3 +172,49 @@ class TestArgumentParser(base.TestBase): args = parser.parse_args(['-z', 'foo', 'a', 'b']) self.assertEqual(args.zippy, 'foo') self.assertEqual(args.zero, 'zero-default') + + def test_with_conflict_handler(self): + cmd = TestCommand(None, None) + cmd.conflict_handler = 'resolve' + parser = cmd.get_parser('NAME') + self.assertEqual(parser.conflict_handler, 'resolve') + + def test_raise_conflict_argument_error(self): + cmd = TestCommand(None, None) + parser = cmd.get_parser('NAME') + parser.add_argument( + '-f', '--foo', + dest='foo', + default='foo', + ) + self.assertRaises( + argparse.ArgumentError, + parser.add_argument, + '-f', + ) + + def test_resolve_conflict_argument(self): + cmd = TestCommand(None, None) + cmd.conflict_handler = 'resolve' + parser = cmd.get_parser('NAME') + parser.add_argument( + '-f', '--foo', + dest='foo', + default='foo', + ) + parser.add_argument( + '-f', '--foo', + dest='foo', + default='bar', + ) + args = parser.parse_args(['a', 'b']) + self.assertEqual(args.foo, 'bar') + + def test_wrong_conflict_handler(self): + cmd = TestCommand(None, None) + cmd.conflict_handler = 'wrong' + self.assertRaises( + ValueError, + cmd.get_parser, + 'NAME', + ) diff --git a/cliff/tests/test_formatters_csv.py b/cliff/tests/test_formatters_csv.py index 608137e..c34a0e0 100644 --- a/cliff/tests/test_formatters_csv.py +++ b/cliff/tests/test_formatters_csv.py @@ -69,12 +69,12 @@ class TestCSVFormatter(unittest.TestCase): def test_commaseparated_list_formatter_unicode(self): sf = commaseparated.CSVLister() - c = (u'a', u'b', u'c') - d1 = (u'A', u'B', u'C') - happy = u'高兴' - d2 = (u'D', u'E', happy) + c = ('a', 'b', 'c') + d1 = ('A', 'B', 'C') + happy = '高兴' + d2 = ('D', 'E', happy) data = [d1, d2] - expected = u'a,b,c\nA,B,C\nD,E,%s\n' % happy + expected = 'a,b,c\nA,B,C\nD,E,%s\n' % happy output = io.StringIO() parsed_args = mock.Mock() parsed_args.quote_mode = 'none' diff --git a/cliff/tests/test_help.py b/cliff/tests/test_help.py index 9034779..4862f25 100644 --- a/cliff/tests/test_help.py +++ b/cliff/tests/test_help.py @@ -101,7 +101,7 @@ class TestHelp(base.TestBase): help_text = stdout.getvalue() basecommand = os.path.split(sys.argv[0])[1] self.assertIn('usage: %s [--version]' % basecommand, help_text) - self.assertIn('optional arguments:\n --version', help_text) + self.assertRegex(help_text, 'option(s|al arguments):\n --version') expected = ( ' one Test command.\n' ' three word command Test command.\n' diff --git a/cliff/tests/test_lister.py b/cliff/tests/test_lister.py index 8603004..5dfa69a 100644 --- a/cliff/tests/test_lister.py +++ b/cliff/tests/test_lister.py @@ -32,16 +32,15 @@ class FauxFormatter(object): class ExerciseLister(lister.Lister): + data = [('a', 'A'), ('b', 'B'), ('c', 'A')] + def _load_formatter_plugins(self): return { 'test': FauxFormatter(), } def take_action(self, parsed_args): - return ( - parsed_args.columns, - [('a', 'A'), ('b', 'B'), ('c', 'A')], - ) + return (parsed_args.columns, self.data) class ExerciseListerCustomSort(ExerciseLister): @@ -49,6 +48,16 @@ class ExerciseListerCustomSort(ExerciseLister): need_sort_by_cliff = False +class ExerciseListerNullValues(ExerciseLister): + + data = ExerciseLister.data + [(None, None)] + + +class ExerciseListerDifferentTypes(ExerciseLister): + + data = ExerciseLister.data + [(1, 0)] + + class TestLister(base.TestBase): def test_formatter_args(self): @@ -97,6 +106,21 @@ class TestLister(base.TestBase): data = list(args[1]) self.assertEqual([['a', 'A'], ['c', 'A'], ['b', 'B']], data) + def test_sort_by_column_reverse_order(self): + test_lister = ExerciseLister(mock.Mock(), []) + parsed_args = mock.Mock() + parsed_args.columns = ('Col1', 'Col2') + parsed_args.formatter = 'test' + parsed_args.sort_columns = ['Col2', 'Col1'] + parsed_args.sort_direction = 'desc' + + test_lister.run(parsed_args) + + f = test_lister._formatter_plugins['test'] + args = f.args[0] + data = list(args[1]) + self.assertEqual([['b', 'B'], ['c', 'A'], ['a', 'A']], data) + def test_sort_by_column_data_already_sorted(self): test_lister = ExerciseListerCustomSort(mock.Mock(), []) parsed_args = mock.Mock() @@ -111,6 +135,43 @@ class TestLister(base.TestBase): data = list(args[1]) self.assertEqual([['a', 'A'], ['b', 'B'], ['c', 'A']], data) + def test_sort_by_column_with_null(self): + test_lister = ExerciseListerNullValues(mock.Mock(), []) + parsed_args = mock.Mock() + parsed_args.columns = ('Col1', 'Col2') + parsed_args.formatter = 'test' + parsed_args.sort_columns = ['Col2', 'Col1'] + + test_lister.run(parsed_args) + + f = test_lister._formatter_plugins['test'] + args = f.args[0] + data = list(args[1]) + self.assertEqual( + [['a', 'A'], ['c', 'A'], ['b', 'B'], [None, None]], data) + + def test_sort_by_column_with_different_types(self): + test_lister = ExerciseListerDifferentTypes(mock.Mock(), []) + parsed_args = mock.Mock() + parsed_args.columns = ('Col1', 'Col2') + parsed_args.formatter = 'test' + parsed_args.sort_columns = ['Col2', 'Col1'] + + with mock.patch.object(lister.Lister, 'log') as mock_log: + test_lister.run(parsed_args) + + f = test_lister._formatter_plugins['test'] + args = f.args[0] + data = list(args[1]) + # The output should be unchanged + self.assertEqual( + [['a', 'A'], ['b', 'B'], ['c', 'A'], [1, 0]], data) + # but we should have logged a warning + mock_log.warning.assert_has_calls([ + mock.call("Could not sort on field '%s'; unsortable types", col) + for col in parsed_args.sort_columns + ]) + def test_sort_by_non_displayed_column(self): test_lister = ExerciseLister(mock.Mock(), []) parsed_args = mock.Mock() diff --git a/cliff/utils.py b/cliff/utils.py index cee1087..50f3ab6 100644 --- a/cliff/utils.py +++ b/cliff/utils.py @@ -12,7 +12,6 @@ # limitations under the License. import ctypes -import inspect import os import struct import sys @@ -26,12 +25,6 @@ import sys COST = {'w': 0, 's': 2, 'a': 1, 'd': 3} -if hasattr(inspect, 'getfullargspec'): - getargspec = inspect.getfullargspec -else: - getargspec = inspect.getargspec - - def damerau_levenshtein(s1, s2, cost): """Calculates the Damerau-Levenshtein distance between two strings. diff --git a/demoapp/cliffdemo/encoding.py b/demoapp/cliffdemo/encoding.py index 6c6c751..4aac578 100644 --- a/demoapp/cliffdemo/encoding.py +++ b/demoapp/cliffdemo/encoding.py @@ -13,8 +13,8 @@ class Encoding(Lister): def take_action(self, parsed_args): messages = [ - u'pi: π', - u'GB18030:鼀丅㐀ٸཌྷᠧꌢ€', + 'pi: π', + 'GB18030:鼀丅㐀ٸཌྷᠧꌢ€', ] return ( ('UTF-8', 'Unicode'), diff --git a/doc/source/conf.py b/doc/source/conf.py index 7686f1b..acc977c 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -69,8 +69,8 @@ source_encoding = 'utf-8-sig' master_doc = 'index' # General information about the project. -project = u'cliff' -copyright = u'2012-%s, Doug Hellmann' % datetime.datetime.today().year +project = 'cliff' +copyright = '2012-%s, Doug Hellmann' % datetime.datetime.today().year # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -209,8 +209,8 @@ latex_elements = { # (source start file, target name, title, author, # documentclass [howto/manual]). latex_documents = [ - ('index', 'cliff.tex', u'cliff Documentation', - u'Doug Hellmann', 'manual'), + ('index', 'cliff.tex', 'cliff Documentation', + 'Doug Hellmann', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -239,8 +239,8 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'cliff', u'cliff Documentation', - [u'Doug Hellmann'], 1) + ('index', 'cliff', 'cliff Documentation', + ['Doug Hellmann'], 1) ] # If true, show URL addresses after external links. @@ -253,8 +253,8 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'cliff', u'cliff Documentation', - u'Doug Hellmann', 'cliff', 'One line description of project.', + ('index', 'cliff', 'cliff Documentation', + 'Doug Hellmann', 'cliff', 'One line description of project.', 'Miscellaneous'), ] diff --git a/doc/source/index.rst b/doc/source/index.rst index ec31348..8e42389 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -18,5 +18,4 @@ extensions. .. rubric:: Indices and tables * :ref:`genindex` -* :ref:`modindex` * :ref:`search` diff --git a/doc/source/user/list_commands.rst b/doc/source/user/list_commands.rst index d682ff9..5a28c7b 100644 --- a/doc/source/user/list_commands.rst +++ b/doc/source/user/list_commands.rst @@ -49,7 +49,7 @@ table The ``table`` formatter uses PrettyTable_ to produce output formatted for human consumption. -.. _PrettyTable: http://code.google.com/p/prettytable/ +.. _PrettyTable: https://pypi.org/project/prettytable/ :: diff --git a/doc/source/user/show_commands.rst b/doc/source/user/show_commands.rst index 8751285..ccbb668 100644 --- a/doc/source/user/show_commands.rst +++ b/doc/source/user/show_commands.rst @@ -34,7 +34,7 @@ table The ``table`` formatter uses PrettyTable_ to produce output formatted for human consumption. This is the default formatter. -.. _PrettyTable: http://code.google.com/p/prettytable/ +.. _PrettyTable: https://pypi.org/project/prettytable :: diff --git a/lower-constraints.txt b/lower-constraints.txt deleted file mode 100644 index 15a7668..0000000 --- a/lower-constraints.txt +++ /dev/null @@ -1,33 +0,0 @@ -alabaster==0.7.10 -bandit==1.1.0 -cmd2==0.8.0 -coverage==4.0 -docutils==0.11 -extras==1.0.0 -fixtures==3.0.0 -gitdb==0.6.4 -GitPython==1.0.1 -imagesize==0.7.1 -Jinja2==2.10 -linecache2==1.0.0 -MarkupSafe==1.1.1 -pbr==2.0.0 -prettytable==0.7.2 -Pygments==2.2.0 -pyparsing==2.1.0 -pyperclip==1.5.27 -python-mimeparse==1.6.0 -python-subunit==1.0.0 -pytz==2013.6 -PyYAML==3.12 -requests==2.14.2 -smmap==0.9.0 -snowballstemmer==1.2.1 -Sphinx==2.0.0 -sphinxcontrib-websupport==1.0.1 -stestr==1.0.0 -stevedore==2.0.1 -testscenarios==0.4 -testtools==2.2.0 -traceback2==1.4.0 -unittest2==1.1.0 diff --git a/releasenotes/notes/add-Lister-sort-direction-5f34dba3c9743572.yaml b/releasenotes/notes/add-Lister-sort-direction-5f34dba3c9743572.yaml new file mode 100644 index 0000000..219e20c --- /dev/null +++ b/releasenotes/notes/add-Lister-sort-direction-5f34dba3c9743572.yaml @@ -0,0 +1,21 @@ +--- +features: + - | + The ``cliff.lister.Lister`` base class now implements ``--sort-ascending`` + and ``--sort-descending`` options, which can be used to configure the sort + direction. For example:: + + $ hello-world list-users --sort-column email --sort-descending + +----------------+-----------------------------+ + | Name | Email | + +----------------+-----------------------------+ + | Charles Xavier | therealcharliex@example.com | + | Jim Hendrix | jim@example.com | + | John Doe | doe.john@example.com | + | Alice Baker | abaker@example.com | + +----------------+-----------------------------+ +upgrade: + - | + ``cliff.lister.Lister`` implementations that override the + ``need_sort_by_cliff`` property should now consider the + ``--sort-ascending`` and ``--sort-descending`` options. diff --git a/releasenotes/notes/comparable-FormattableColumn-31c0030ced70b7fb.yaml b/releasenotes/notes/comparable-FormattableColumn-31c0030ced70b7fb.yaml new file mode 100644 index 0000000..f32322e --- /dev/null +++ b/releasenotes/notes/comparable-FormattableColumn-31c0030ced70b7fb.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Instances of ``cliff.columns.FormattableColumn`` are now comparable. This + allows implementations of ``FormattableColumn`` storing primitive data + types or containers with primitive data types to be sorted using the + ``--sort-column`` option. Implementations of ``FormattableColumn`` that + store other types of data will still need to implement their own rich + comparison magic methods. diff --git a/releasenotes/notes/handle-none-values-when-sorting-de40e36c66ad95ca.yaml b/releasenotes/notes/handle-none-values-when-sorting-de40e36c66ad95ca.yaml new file mode 100644 index 0000000..a7368c1 --- /dev/null +++ b/releasenotes/notes/handle-none-values-when-sorting-de40e36c66ad95ca.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + Sorting output using the ``--sort-column`` option will now handle ``None`` + values. This was supported implicitly in Python 2 but was broken in the + move to Python 3. In addition, requests to sort a column containing + non-comparable types will now be ignored. Previously, these request would + result in a ``TypeError``. diff --git a/requirements.txt b/requirements.txt index cb74608..4450bd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,9 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. pbr!=2.1.0,>=2.0.0 # Apache-2.0 -cmd2>=0.8.0,!=0.8.3 # MIT -PrettyTable<0.8,>=0.7.2 # BSD +autopage>=0.4.0 # Apache 2.0 +cmd2>=1.0.0 # MIT +PrettyTable>=0.7.2 # BSD pyparsing>=2.1.0 # MIT stevedore>=2.0.1 # Apache-2.0 PyYAML>=3.12 # MIT @@ -1,15 +1,15 @@ [tox] minversion = 3.1.0 -envlist = py38,pep8 +envlist = py3,pep8 ignore_basepython_conflict = True [testenv] basepython = python3 setenv = - VIRTUAL_ENV={envdir} - OS_STDOUT_CAPTURE=1 - OS_STDERR_CAPTURE=1 - OS_TEST_TIMEOUT=60 + VIRTUAL_ENV={envdir} + OS_STDOUT_CAPTURE=1 + OS_STDERR_CAPTURE=1 + OS_TEST_TIMEOUT=60 distribute = False commands = stestr run {posargs} @@ -48,18 +48,12 @@ commands = {toxinidir}/integration-tests/openstackclient-tip.sh {envdir} deps = -r{toxinidir}/doc/requirements.txt commands = sphinx-build -W -b html doc/source doc/build/html -[testenv:lower-constraints] -deps = - -c{toxinidir}/lower-constraints.txt - -r{toxinidir}/test-requirements.txt - -r{toxinidir}/requirements.txt - [testenv:cover] setenv = - {[testenv]setenv} - PYTHON=coverage run --source cliff --parallel-mode + {[testenv]setenv} + PYTHON=coverage run --source cliff --parallel-mode commands = - stestr run {posargs} - coverage combine - coverage html -d cover - coverage xml -o cover/coverage.xml + stestr run {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml |