summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArmin Ronacher <armin.ronacher@active-4.com>2014-06-05 16:08:11 +0600
committerArmin Ronacher <armin.ronacher@active-4.com>2014-06-05 16:08:11 +0600
commitc2e187b7361a8d7bc2ddab27c159132b94722ef9 (patch)
treeaef6f68b8789cb9babbc05d56cd73b60d320526c
parent59b5a5ecf03241b2cbf14873f191e4197686279f (diff)
downloadclick-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--CHANGES4
-rw-r--r--click/core.py117
-rw-r--r--click/formatting.py21
-rw-r--r--click/parser.py14
-rw-r--r--click/types.py10
-rw-r--r--tests/test_normalization.py39
-rw-r--r--tests/test_options.py12
7 files changed, 184 insertions, 33 deletions
diff --git a/CHANGES b/CHANGES
index 40cebd5..7859b1f 100644
--- a/CHANGES
+++ b/CHANGES
@@ -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):