summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore11
-rw-r--r--.zuul.yaml15
-rw-r--r--cliff/_argparse.py8
-rw-r--r--cliff/app.py12
-rw-r--r--cliff/columns.py5
-rw-r--r--cliff/command.py3
-rw-r--r--cliff/commandmanager.py5
-rw-r--r--cliff/formatters/table.py13
-rw-r--r--cliff/formatters/value.py2
-rw-r--r--cliff/help.py89
-rw-r--r--cliff/interactive.py14
-rw-r--r--cliff/lister.py92
-rw-r--r--cliff/tests/test_app.py41
-rw-r--r--cliff/tests/test_columns.py15
-rw-r--r--cliff/tests/test_command.py46
-rw-r--r--cliff/tests/test_formatters_csv.py10
-rw-r--r--cliff/tests/test_help.py2
-rw-r--r--cliff/tests/test_lister.py69
-rw-r--r--cliff/utils.py7
-rw-r--r--demoapp/cliffdemo/encoding.py4
-rw-r--r--doc/source/conf.py16
-rw-r--r--doc/source/index.rst1
-rw-r--r--doc/source/user/list_commands.rst2
-rw-r--r--doc/source/user/show_commands.rst2
-rw-r--r--lower-constraints.txt33
-rw-r--r--releasenotes/notes/add-Lister-sort-direction-5f34dba3c9743572.yaml21
-rw-r--r--releasenotes/notes/comparable-FormattableColumn-31c0030ced70b7fb.yaml9
-rw-r--r--releasenotes/notes/handle-none-values-when-sorting-de40e36c66ad95ca.yaml8
-rw-r--r--requirements.txt5
-rw-r--r--tox.ini28
30 files changed, 406 insertions, 182 deletions
diff --git a/.gitignore b/.gitignore
index 34b01c9..8329192 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.zuul.yaml b/.zuul.yaml
index 5d5177a..aa72958 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -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
diff --git a/tox.ini b/tox.ini
index b794c83..49d0d38 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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