diff options
author | Armin Ronacher <armin.ronacher@active-4.com> | 2014-06-05 16:08:11 +0600 |
---|---|---|
committer | Armin Ronacher <armin.ronacher@active-4.com> | 2014-06-05 16:08:11 +0600 |
commit | c2e187b7361a8d7bc2ddab27c159132b94722ef9 (patch) | |
tree | aef6f68b8789cb9babbc05d56cd73b60d320526c | |
parent | 59b5a5ecf03241b2cbf14873f191e4197686279f (diff) | |
download | click-c2e187b7361a8d7bc2ddab27c159132b94722ef9.tar.gz |
User interface improvements.
- Changed how the help function is implemented to allow global overriding.
- Added support for token normalization to implement case insensitive handling.
- Added support for providing defaults for context settings.
-rw-r--r-- | CHANGES | 4 | ||||
-rw-r--r-- | click/core.py | 117 | ||||
-rw-r--r-- | click/formatting.py | 21 | ||||
-rw-r--r-- | click/parser.py | 14 | ||||
-rw-r--r-- | click/types.py | 10 | ||||
-rw-r--r-- | tests/test_normalization.py | 39 | ||||
-rw-r--r-- | tests/test_options.py | 12 |
7 files changed, 184 insertions, 33 deletions
@@ -40,6 +40,10 @@ Version 2.0 built-in but becomes an automatic noop if the application is not run through a terminal. - added a bit of extra information about missing choice parameters. +- changed how the help function is implemented to allow global overriding + of the help option. +- added support for token normalization to implement case insensitive handling. +- added support for providing defaults for context settings. Version 1.1 ----------- diff --git a/click/core.py b/click/core.py index d145c0f..107b347 100644 --- a/click/core.py +++ b/click/core.py @@ -8,10 +8,10 @@ from .types import convert_type, IntRange, BOOL from .utils import make_str, make_default_short_help, echo from .exceptions import ClickException, UsageError, BadParameter, Abort from .termui import prompt, confirm -from .formatting import HelpFormatter +from .formatting import HelpFormatter, join_options from .parser import OptionParser, split_opt -from ._compat import PY2, isidentifier +from ._compat import PY2, isidentifier, iteritems _missing = object() @@ -94,6 +94,10 @@ class Context(object): A context can be used as context manager in which case it will call :meth:`close` on teardown. + .. versionadded:: 2.0 + Added the `resilient_parsing`, `help_option_names`, + `token_normalize_func` parameters. + :param command: the command class for this context. :param parent: the parent context. :param info_name: the info name for this invokation. Generally this @@ -117,11 +121,19 @@ class Context(object): parse without any interactivity or callback invocation. This is useful for implementing things such as completion support. + :param help_option_names: optionally a list of strings that define how + the default help parameter is named. The + default is ``['--help']``. + :param token_normalize_func: an optional function that is used to + normalize tokens (options, choices, + etc.). This for instance can be used to + implement case insensitive behavior. """ def __init__(self, command, parent=None, info_name=None, obj=None, auto_envvar_prefix=None, default_map=None, - terminal_width=None, resilient_parsing=False): + terminal_width=None, resilient_parsing=False, + help_option_names=None, token_normalize_func=None): #: the parent context or `None` if none exists. self.parent = parent #: the :class:`Command` for this context. @@ -151,6 +163,22 @@ class Context(object): #: The width of the terminal (None is autodetection). self.terminal_width = terminal_width + if help_option_names is None: + if parent is not None: + help_option_names = parent.help_option_names + else: + help_option_names = ['--help'] + + #: The name for the help options. + self.help_option_names = help_option_names + + if token_normalize_func is None and parent is not None: + token_normalize_func = parent.token_normalize_func + + #: An optional normalization function for tokens. This is + #: options, choices, commands etc. + self.token_normalize_func = token_normalize_func + #: Indicates if resilient parsing is enabled. In that case click #: will do its best to not cause any failures. self.resilient_parsing = resilient_parsing @@ -326,15 +354,22 @@ class BaseCommand(object): operations. For instance, they cannot be used with the decorators usually and they have no built-in callback system. + .. versionchanged:: 2.0 + Added the `context_defaults` parameter. + :param name: the name of the command to use unless a group overrides it. + :param context_defaults: an optional dictionary with defaults that are + passed to the context object. """ - def __init__(self, name): + def __init__(self, name, context_defaults=None): #: the name the command thinks it has. Upon registering a command #: on a :class:`Group` the group will default the command name #: with this information. You should instead use the #: :class:`Context`\'s :attr:`~Context.info_name` attribute. self.name = name + #: an optional dictionary with defaults passed to the context. + self.context_defaults = context_defaults def get_usage(self, ctx): raise NotImplementedError('Base commands cannot get usage') @@ -362,6 +397,11 @@ class BaseCommand(object): if parent is not None and parent.default_map is not None: default_map = parent.default_map.get(info_name) extra['default_map'] = default_map + + for key, value in iteritems(self.context_defaults or {}): + if key not in extra: + extra[key] = value + ctx = Context(self, info_name=info_name, parent=parent, **extra) self.parse_args(ctx, args) return ctx @@ -456,7 +496,12 @@ class Command(BaseCommand): click. A basic command handles command line parsing and might dispatch more parsing to commands nested below it. + .. versionchanged:: 2.0 + Added the `context_defaults` parameter. + :param name: the name of the command to use unless a group overrides it. + :param context_defaults: an optional dictionary with defaults that are + passed to the context object. :param callback: the callback to invoke. This is optional. :param params: the parameters to register with this command. This can be either :class:`Option` or :class:`Argument` objects. @@ -470,10 +515,10 @@ class Command(BaseCommand): """ allow_extra_args = False - def __init__(self, name, callback=None, params=None, help=None, - epilog=None, short_help=None, + def __init__(self, name, context_defaults=None, callback=None, + params=None, help=None, epilog=None, short_help=None, options_metavar='[OPTIONS]', add_help_option=True): - BaseCommand.__init__(self, name) + BaseCommand.__init__(self, name, context_defaults) #: the callback to execute when the command fires. This might be #: `None` in which case nothing happens. self.callback = callback @@ -487,12 +532,7 @@ class Command(BaseCommand): if short_help is None and help: short_help = make_default_short_help(help) self.short_help = short_help - if add_help_option: - self.add_help_option() - - def add_help_option(self): - """Adds a help option to the command.""" - help_option()(self) + self.add_help_option = add_help_option def get_usage(self, ctx): formatter = ctx.make_formatter() @@ -513,11 +553,26 @@ class Command(BaseCommand): rv.extend(param.get_usage_pieces(ctx)) return rv + def get_help_option_names(self, ctx): + """Returns the names for the help option.""" + all_names = set(ctx.help_option_names) + for param in self.params: + all_names.difference_update(param.opts) + all_names.difference_update(param.secondary_opts) + return all_names + def make_parser(self, ctx): """Creates the underlying option parser for this command.""" parser = OptionParser(ctx) for param in self.params: param.add_to_parser(parser, ctx) + + if self.add_help_option: + help_options = self.get_help_option_names(ctx) + if help_options: + parser.add_option(help_options, action='store_const', + const=True, dest='$help') + return parser def get_help(self, ctx): @@ -558,6 +613,12 @@ class Command(BaseCommand): if rv is not None: opts.append(rv) + if self.add_help_option: + help_options = self.get_help_option_names(ctx) + if help_options: + opts.append([join_options(help_options)[0], + 'Show this message and exit.']) + if opts: with formatter.section('Options'): formatter.write_dl(opts) @@ -573,6 +634,10 @@ class Command(BaseCommand): parser = self.make_parser(ctx) opts, args, param_order = parser.parse_args(args=args) + if opts.get('$help') and not ctx.resilient_parsing: + echo(ctx.get_help()) + ctx.exit() + for param in iter_params_for_processing(param_order, self.params): value, args = param.handle_parse_result(ctx, opts, args) @@ -665,8 +730,17 @@ class MultiCommand(Command): ctx.fail('Missing command.') cmd_name = make_str(ctx.args[0]) + original_cmd_name = cmd_name + + # Get the command cmd = self.get_command(ctx, cmd_name) + # If we can't find the command but there is a normalization + # function available, we try with that one. + if cmd is None and ctx.token_normalize_func is not None: + cmd_name = ctx.token_normalize_func(cmd_name) + cmd = self.get_command(ctx, cmd_name) + # If we don't find the command we want to show an error message # to the user that it was not provided. However, there is # something else we should do: if the first argument looks like @@ -676,7 +750,7 @@ class MultiCommand(Command): if cmd is None: if split_opt(cmd_name)[0]: self.parse_args(ctx, ctx.args) - ctx.fail('No such command "%s".' % cmd_name) + ctx.fail('No such command "%s".' % original_cmd_name) return self.invoke_subcommand(ctx, cmd, cmd_name, ctx.args[1:]) @@ -1126,16 +1200,9 @@ class Option(Parameter): any_prefix_is_slash = [] def _write_opts(opts): - rv = [] - for opt in opts: - prefix = split_opt(opt)[0] - if prefix == '/': - any_prefix_is_slash[:] = [True] - rv.append((len(prefix), opt)) - - rv.sort(key=lambda x: x[0]) - - rv = ', '.join(x[1] for x in rv) + rv, any_slashes = join_options(opts) + if any_slashes: + any_prefix_is_slash[:] = [True] if not self.is_flag: rv += ' ' + self.make_metavar() return rv @@ -1264,4 +1331,4 @@ class Argument(Parameter): # Circular dependency between decorators and core -from .decorators import command, group, help_option +from .decorators import command, group diff --git a/click/formatting.py b/click/formatting.py index 8264201..7b9fa7b 100644 --- a/click/formatting.py +++ b/click/formatting.py @@ -1,5 +1,6 @@ from contextlib import contextmanager from .termui import get_terminal_size +from .parser import split_opt from ._compat import term_len @@ -216,3 +217,23 @@ class HelpFormatter(object): def getvalue(self): """Returns the buffer contents.""" return ''.join(self.buffer) + + +def join_options(options): + """Given a list of option strings this joins them in the most appropriate + way and returns them in the form ``(formatted_string, + any_prefix_is_slash)`` where the second item in the tuple is a flag that + indicates if any of the option prefixes was a slash. + """ + rv = [] + any_prefix_is_slash = False + for opt in options: + prefix = split_opt(opt)[0] + if prefix == '/': + any_prefix_is_slash = True + rv.append((len(prefix), opt)) + + rv.sort(key=lambda x: x[0]) + + rv = ', '.join(x[1] for x in rv) + return rv, any_prefix_is_slash diff --git a/click/parser.py b/click/parser.py index 17b73c8..acf4f3d 100644 --- a/click/parser.py +++ b/click/parser.py @@ -29,6 +29,13 @@ def split_opt(opt): return first, opt[1:] +def normalize_opt(opt, ctx): + if ctx is None or ctx.token_normalize_func is None: + return opt + prefix, opt = split_opt(opt) + return prefix + ctx.token_normalize_func(opt) + + def split_arg_string(string): """Given an argument string this attempts to split it into small parts.""" rv = [] @@ -156,6 +163,7 @@ class OptionParser(object): """ if obj is None: obj = dest + opts = [normalize_opt(opt, self.ctx) for opt in opts] option = Option(opts, dest, action=action, nargs=nargs, const=const, obj=obj) self._opt_prefixes.update(option.prefixes) @@ -268,6 +276,8 @@ class OptionParser(object): opt = arg had_explicit_value = False + opt = normalize_opt(opt, self.ctx) + opt = self._match_long_opt(opt) option = self._long_opt[opt] if option.takes_value: @@ -292,14 +302,14 @@ class OptionParser(object): option.process(value, state) def _process_opts(self, arg, state): - if '=' in arg or arg in self._long_opt: + if '=' in arg or normalize_opt(arg, self.ctx) in self._long_opt: return self._process_long_opt(arg, state) stop = False i = 1 prefix = arg[0] for ch in arg[1:]: - opt = prefix + ch + opt = normalize_opt(prefix + ch, self.ctx) option = self._short_opt.get(opt) i += 1 diff --git a/click/types.py b/click/types.py index 02b9ba0..92983b5 100644 --- a/click/types.py +++ b/click/types.py @@ -123,8 +123,18 @@ class Choice(ParamType): return 'Choose from %s.' % ', '.join(self.choices) def convert(self, value, param, ctx): + # Exact match if value in self.choices: return value + + # Match through normalization + if ctx is not None and \ + ctx.token_normalize_func is not None: + value = ctx.token_normalize_func(value) + for choice in self.choices: + if ctx.token_normalize_func(choice) == value: + return choice + self.fail('invalid choice: %s. (choose from %s)' % (value, ', '.join(self.choices)), param, ctx) diff --git a/tests/test_normalization.py b/tests/test_normalization.py new file mode 100644 index 0000000..91b59bf --- /dev/null +++ b/tests/test_normalization.py @@ -0,0 +1,39 @@ +import click + + +CONTEXT_DEFAULTS = dict(token_normalize_func=lambda x: x.lower()) + + +def test_option_normalization(runner): + @click.command(context_defaults=CONTEXT_DEFAULTS) + @click.option('--foo') + @click.option('-x') + def cli(foo, x): + click.echo(foo) + click.echo(x) + + result = runner.invoke(cli, ['--FOO', '42', '-X', 23]) + assert result.output == '42\n23\n' + + +def test_choice_normalization(runner): + @click.command(context_defaults=CONTEXT_DEFAULTS) + @click.option('--choice', type=click.Choice(['Foo', 'Bar'])) + def cli(choice): + click.echo('Foo') + + result = runner.invoke(cli, ['--CHOICE', 'FOO']) + assert result.output == 'Foo\n' + + +def test_command_normalization(runner): + @click.group(context_defaults=CONTEXT_DEFAULTS) + def cli(): + pass + + @cli.command() + def foo(): + click.echo('here!') + + result = runner.invoke(cli, ['FOO']) + assert result.output == 'here!\n' diff --git a/tests/test_options.py b/tests/test_options.py index fdfffaf..d6ed483 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -130,20 +130,20 @@ def test_custom_validation(runner): def test_winstyle_options(runner): - @click.command(add_help_option=False) + @click.command() @click.option('/debug;/no-debug', help='Enables or disables debug mode.') - @click.help_option('/?') def cmd(debug): click.echo(debug) - result = runner.invoke(cmd, ['/debug']) + result = runner.invoke(cmd, ['/debug'], help_option_names=['/?']) assert result.output == 'True\n' - result = runner.invoke(cmd, ['/no-debug']) + result = runner.invoke(cmd, ['/no-debug'], help_option_names=['/?']) assert result.output == 'False\n' - result = runner.invoke(cmd, []) + result = runner.invoke(cmd, [], help_option_names=['/?']) assert result.output == 'False\n' - result = runner.invoke(cmd, ['/?']) + result = runner.invoke(cmd, ['/?'], help_option_names=['/?']) assert '/debug; /no-debug Enables or disables debug mode.' in result.output + assert '/? Show this message and exit.' in result.output def test_legacy_options(runner): |