diff --git a/CHANGES b/CHANGES index 1a97684..83c831e 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,51 @@ Click Changelog This contains all major version changes between Click releases. +Version 4.1 +----------- + +(bugfix release, released on July 14th 2015) + +- Fix a bug where error messages would include a trailing `None` string. +- Fix a bug where Click would crash on docstrings with trailing newlines. +- Support streams with encoding set to `None` on Python 3 by barfing with + a better error. +- Handle ^C in less-pager properly. +- Handle return value of `None` from `sys.getfilesystemencoding` +- Fix crash when writing to unicode files with `click.echo`. +- Fix type inference with multiple options. + +Version 4.0 +----------- + +(codename "zoom zoom", released on March 31st 2015) + +- Added `color` parameters to lots of interfaces that directly or indirectly + call into echoing. This previously was always autodetection (with the + exception of the `echo_via_pager` function). Now you can forcefully + enable or disable it, overriding the auto detection of Click. +- Added an `UNPROCESSED` type which does not perform any type changes which + simplifies text handling on 2.x / 3.x in some special advanced usecases. +- Added `NoSuchOption` and `BadOptionUsage` exceptions for more generic + handling of errors. +- Added support for handling of unprocessed options which can be useful in + situations where arguments are forwarded to underlying tools. +- Added `max_content_width` parameter to the context which can be used to + change the maximum width of help output. By default Click will not format + content for more than 80 characters width. +- Added support for writing prompts to stderr. +- Fix a bug when showing the default for multiple arguments. +- Added support for custom subclasses to `option` and `argument`. +- Fix bug in ``clear()`` on Windows when colorama is installed. +- Reject ``nargs=-1`` for options properly. Options cannot be variadic. +- Fixed an issue with bash completion not working properly for commands with + non ASCII characters or dashes. +- Added a way to manually update the progressbar. +- Changed the formatting of missing arguments. Previously the internal + argument name was shown in error messages, now the metavar is shown if + passed. In case an automated metavar is selected, it's stripped of + extra formatting first. + Version 3.3 ----------- diff --git a/PKG-INFO b/PKG-INFO index 911c319..8477435 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 1.1 Name: click -Version: 3.3 +Version: 4.1 Summary: A simple wrapper around optparse for powerful command line utilities. Home-page: http://github.com/mitsuhiko/click Author: Armin Ronacher diff --git a/click.egg-info/PKG-INFO b/click.egg-info/PKG-INFO index 911c319..8477435 100644 --- a/click.egg-info/PKG-INFO +++ b/click.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 1.1 Name: click -Version: 3.3 +Version: 4.1 Summary: A simple wrapper around optparse for powerful command line utilities. Home-page: http://github.com/mitsuhiko/click Author: Armin Ronacher diff --git a/click.egg-info/SOURCES.txt b/click.egg-info/SOURCES.txt index 7da1ae6..e00bc4b 100644 --- a/click.egg-info/SOURCES.txt +++ b/click.egg-info/SOURCES.txt @@ -61,7 +61,6 @@ docs/_themes/click/layout.html docs/_themes/click/relations.html docs/_themes/click/theme.conf docs/_themes/click/static/click.css_t -examples/.DS_Store examples/README examples/aliases/README examples/aliases/aliases.ini @@ -77,31 +76,36 @@ examples/complex/complex/cli.py examples/complex/complex/commands/__init__.py examples/complex/complex/commands/cmd_init.py examples/complex/complex/commands/cmd_status.py -examples/imagepipe/.DS_Store examples/imagepipe/.gitignore examples/imagepipe/README examples/imagepipe/example01.jpg examples/imagepipe/example02.jpg examples/imagepipe/imagepipe.py examples/imagepipe/setup.py -examples/imagepipe/click_example_imagepipe.egg-info/PKG-INFO -examples/imagepipe/click_example_imagepipe.egg-info/SOURCES.txt -examples/imagepipe/click_example_imagepipe.egg-info/dependency_links.txt -examples/imagepipe/click_example_imagepipe.egg-info/entry_points.txt -examples/imagepipe/click_example_imagepipe.egg-info/requires.txt -examples/imagepipe/click_example_imagepipe.egg-info/top_level.txt examples/inout/README examples/inout/inout.py examples/inout/setup.py examples/naval/README examples/naval/naval.py examples/naval/setup.py +examples/plugins/BrokenPlugin/printer_bold.egg-info/PKG-INFO +examples/plugins/BrokenPlugin/printer_bold.egg-info/SOURCES.txt +examples/plugins/BrokenPlugin/printer_bold.egg-info/dependency_links.txt +examples/plugins/BrokenPlugin/printer_bold.egg-info/entry_points.txt +examples/plugins/BrokenPlugin/printer_bold.egg-info/top_level.txt +examples/plugins/printer.egg-info/PKG-INFO +examples/plugins/printer.egg-info/SOURCES.txt +examples/plugins/printer.egg-info/dependency_links.txt +examples/plugins/printer.egg-info/entry_points.txt +examples/plugins/printer.egg-info/top_level.txt examples/repo/README examples/repo/repo.py examples/repo/setup.py examples/termui/README examples/termui/setup.py examples/termui/termui.py +examples/termui/build/lib/termui.py +examples/termui/dist/click_example_termui-1.0-py3.4.egg examples/validation/README examples/validation/setup.py examples/validation/validation.py diff --git a/click/__init__.py b/click/__init__.py index 8fe2dfc..8d9b14c 100644 --- a/click/__init__.py +++ b/click/__init__.py @@ -24,8 +24,8 @@ from .decorators import pass_context, pass_obj, make_pass_decorator, \ password_option, version_option, help_option # Types -from .types import ParamType, File, Path, Choice, IntRange, STRING, INT, \ - FLOAT, BOOL, UUID +from .types import ParamType, File, Path, Choice, IntRange, Tuple, \ + STRING, INT, FLOAT, BOOL, UUID, UNPROCESSED # Utilities from .utils import echo, get_binary_stream, get_text_stream, open_file, \ @@ -38,7 +38,7 @@ from .termui import prompt, confirm, get_terminal_size, echo_via_pager, \ # Exceptions from .exceptions import ClickException, UsageError, BadParameter, \ - FileError, Abort + FileError, Abort, NoSuchOption, BadOptionUsage, MissingParameter # Formatting from .formatting import HelpFormatter, wrap_text @@ -58,8 +58,8 @@ __all__ = [ 'version_option', 'help_option', # Types - 'ParamType', 'File', 'Path', 'Choice', 'IntRange', 'STRING', 'INT', - 'FLOAT', 'BOOL', 'UUID', + 'ParamType', 'File', 'Path', 'Choice', 'IntRange', 'Tuple', 'STRING', + 'INT', 'FLOAT', 'BOOL', 'UUID', 'UNPROCESSED', # Utilities 'echo', 'get_binary_stream', 'get_text_stream', 'open_file', @@ -72,7 +72,7 @@ __all__ = [ # Exceptions 'ClickException', 'UsageError', 'BadParameter', 'FileError', - 'Abort', + 'Abort', 'NoSuchOption', 'BadOptionUsage', 'MissingParameter', # Formatting 'HelpFormatter', 'wrap_text', @@ -82,4 +82,4 @@ __all__ = [ ] -__version__ = '3.3' +__version__ = '4.1' diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py index f79aa04..43feffb 100644 --- a/click/_bashcomplete.py +++ b/click/_bashcomplete.py @@ -1,4 +1,5 @@ import os +import re from .utils import echo from .parser import split_arg_string from .core import MultiCommand, Option @@ -6,7 +7,7 @@ from .core import MultiCommand, Option COMPLETION_SCRIPT = ''' %(complete_func)s() { - COMPREPLY=( $( COMP_WORDS="${COMP_WORDS[*]}" \\ + COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ COMP_CWORD=$COMP_CWORD \\ %(autocomplete_var)s=complete $1 ) ) return 0 @@ -15,10 +16,13 @@ COMPLETION_SCRIPT = ''' complete -F %(complete_func)s -o default %(script_names)s ''' +_invalid_ident_char_re = re.compile(r'[^a-zA-Z0-9_]') + def get_completion_script(prog_name, complete_var): + cf_name = _invalid_ident_char_re.sub('', prog_name.replace('-', '_')) return (COMPLETION_SCRIPT % { - 'complete_func': '_%s_completion' % prog_name, + 'complete_func': '_%s_completion' % cf_name, 'script_names': prog_name, 'autocomplete_var': complete_var, }).strip() + ';' diff --git a/click/_compat.py b/click/_compat.py index 72c69c5..6e90408 100644 --- a/click/_compat.py +++ b/click/_compat.py @@ -14,6 +14,10 @@ DEFAULT_COLUMNS = 80 _ansi_re = re.compile('\033\[((?:\d|;)*)([a-zA-Z])') +def get_filesystem_encoding(): + return sys.getfilesystemencoding() or sys.getdefaultencoding() + + def _make_text_stream(stream, encoding, errors): if encoding is None: encoding = get_best_encoding(stream) @@ -189,7 +193,7 @@ if PY2: def filename_to_ui(value): if isinstance(value, bytes): - value = value.decode(sys.getfilesystemencoding(), 'replace') + value = value.decode(get_filesystem_encoding(), 'replace') return value else: import io @@ -255,7 +259,11 @@ else: def _stream_is_misconfigured(stream): """A stream is misconfigured if its encoding is ASCII.""" - return is_ascii_encoding(getattr(stream, 'encoding', None)) + # If the stream does not have an encoding set, we assume it's set + # to ASCII. This appears to happen in certain unittest + # environments. It's not quite clear what the correct behavior is + # but this at least will force Click to recover somehow. + return is_ascii_encoding(getattr(stream, 'encoding', None) or 'ascii') def _is_compatible_text_stream(stream, encoding, errors): stream_encoding = getattr(stream, 'encoding', None) @@ -360,7 +368,7 @@ else: def filename_to_ui(value): if isinstance(value, bytes): - value = value.decode(sys.getfilesystemencoding(), 'replace') + value = value.decode(get_filesystem_encoding(), 'replace') else: value = value.encode('utf-8', 'surrogateescape') \ .decode('utf-8', 'replace') @@ -456,6 +464,18 @@ colorama = None get_winterm_size = None +def strip_ansi(value): + return _ansi_re.sub('', value) + + +def should_strip_ansi(stream=None, color=None): + if color is None: + if stream is None: + stream = sys.stdin + return not isatty(stream) + return not color + + # If we're on Windows, we provide transparent integration through # colorama. This will make ANSI colors through the echo function # work automatically. @@ -470,7 +490,7 @@ if WIN: else: _ansi_stream_wrappers = WeakKeyDictionary() - def auto_wrap_for_ansi(stream): + def auto_wrap_for_ansi(stream, color=None): """This function wraps a stream so that calls through colorama are issued to the win32 console API to recolor on demand. It also ensures to reset the colors if a write call is interrupted @@ -482,7 +502,7 @@ if WIN: cached = None if cached is not None: return cached - strip = not isatty(stream) + strip = should_strip_ansi(stream, color) ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) rv = ansi_wrapper.stream _write = rv.write @@ -507,10 +527,6 @@ if WIN: return win.Right - win.Left, win.Bottom - win.Top -def strip_ansi(value): - return _ansi_re.sub('', value) - - def term_len(x): return len(strip_ansi(x)) diff --git a/click/_termui_impl.py b/click/_termui_impl.py index 739fb98..a5ddc5d 100644 --- a/click/_termui_impl.py +++ b/click/_termui_impl.py @@ -52,7 +52,7 @@ class ProgressBar(object): def __init__(self, iterable, length=None, fill_char='#', empty_char=' ', bar_template='%(bar)s', info_sep=' ', show_eta=True, show_percent=None, show_pos=False, item_show_func=None, - label=None, file=None, width=30): + label=None, file=None, color=None, width=30): self.fill_char = fill_char self.empty_char = empty_char self.bar_template = bar_template @@ -65,6 +65,7 @@ class ProgressBar(object): if file is None: file = _default_text_stdout() self.file = file + self.color = color self.width = width self.autowidth = width == 0 @@ -180,7 +181,7 @@ class ProgressBar(object): from .termui import get_terminal_size if self.is_hidden: - echo(self.label, file=self.file) + echo(self.label, file=self.file, color=self.color) self.file.flush() return @@ -206,12 +207,12 @@ class ProgressBar(object): if self.max_width is None or self.max_width < line_len: self.max_width = line_len # Use echo here so that we get colorama support. - echo(line, file=self.file, nl=False) + echo(line, file=self.file, nl=False, color=self.color) self.file.write(' ' * (clear_width - line_len)) self.file.flush() - def make_step(self): - self.pos += 1 + def make_step(self, n_steps): + self.pos += n_steps if self.length_known and self.pos >= self.length: self.finished = True @@ -223,6 +224,10 @@ class ProgressBar(object): self.eta_known = self.length_known + def update(self, n_steps): + self.make_step(n_steps) + self.render_progress() + def finish(self): self.eta_known = 0 self.current_item = None @@ -239,8 +244,7 @@ class ProgressBar(object): self.render_progress() raise StopIteration() else: - self.make_step() - self.render_progress() + self.update(1) return rv if not PY2: @@ -302,9 +306,24 @@ def _pipepager(text, cmd, color): try: c.stdin.write(text.encode(encoding, 'replace')) c.stdin.close() - except IOError: + except (IOError, KeyboardInterrupt): pass - c.wait() + + # Less doesn't respect ^C, but catches it for its own UI purposes (aborting + # search or other commands inside less). + # + # That means when the user hits ^C, the parent process (click) terminates, + # but less is still alive, paging the output and messing up the terminal. + # + # If the user wants to make the pager exit on ^C, they should set + # `LESS='-K'`. It's not our decision to make. + while True: + try: + c.wait() + except KeyboardInterrupt: + pass + else: + break def _tempfilepager(text, cmd, color): @@ -348,7 +367,7 @@ class Editor(object): if WIN: return 'notepad' for editor in 'vim', 'nano': - if os.system('which %s &> /dev/null' % editor) == 0: + if os.system('which %s >/dev/null 2>&1' % editor) == 0: return editor return 'vi' diff --git a/click/core.py b/click/core.py index 7e2e020..3035931 100644 --- a/click/core.py +++ b/click/core.py @@ -2,12 +2,13 @@ import os import sys import codecs from contextlib import contextmanager -from itertools import chain, repeat +from itertools import repeat from functools import update_wrapper 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 .exceptions import ClickException, UsageError, BadParameter, Abort, \ + MissingParameter from .termui import prompt, confirm from .formatting import HelpFormatter, join_options from .parser import OptionParser, split_opt @@ -107,11 +108,15 @@ class Context(object): Added the `allow_extra_args` and `allow_interspersed_args` parameters. + .. versionadded:: 4.0 + Added the `color`, `ignore_unknown_options`, and + `max_content_width` 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 + :param info_name: the info name for this invocation. Generally this is the most descriptive name for the script or - command. For the toplevel script is is usually + command. For the toplevel script it is usually the name of the script, for commands below it it's the name of the script. :param obj: an arbitrary object of user data. @@ -126,6 +131,14 @@ class Context(object): inherit from parent context. If no context defines the terminal width then auto detection will be applied. + :param max_content_width: the maximum width for content rendered by + Click (this currently only affects help + pages). This defaults to 80 characters if + not overridden. In other words: even if the + terminal is larger than that, Click will not + format things wider than 80 characters by + default. In addition to that, formatters might + add some safety mapping on the right. :param resilient_parsing: if this flag is enabled then Click will parse without any interactivity or callback invocation. This is useful for implementing @@ -137,6 +150,9 @@ class Context(object): :param allow_interspersed_args: if this is set to `False` then options and arguments cannot be mixed. The default is to inherit from the command. + :param ignore_unknown_options: instructs click to ignore options it does + not know and keeps them for later + processing. :param help_option_names: optionally a list of strings that define how the default help parameter is named. The default is ``['--help']``. @@ -144,13 +160,20 @@ class Context(object): normalize tokens (options, choices, etc.). This for instance can be used to implement case insensitive behavior. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. This is only needed if ANSI + codes are used in texts that Click prints which is by + default not the case. This for instance would affect + help output. """ def __init__(self, command, parent=None, info_name=None, obj=None, auto_envvar_prefix=None, default_map=None, - terminal_width=None, resilient_parsing=False, - allow_extra_args=None, allow_interspersed_args=None, - help_option_names=None, token_normalize_func=None): + terminal_width=None, max_content_width=None, + resilient_parsing=False, allow_extra_args=None, + allow_interspersed_args=None, + ignore_unknown_options=None, help_option_names=None, + token_normalize_func=None, color=None): #: the parent context or `None` if none exists. self.parent = parent #: the :class:`Command` for this context. @@ -190,6 +213,12 @@ class Context(object): #: The width of the terminal (None is autodetection). self.terminal_width = terminal_width + if max_content_width is None and parent is not None: + max_content_width = parent.max_content_width + #: The maximum width of formatted content (None implies a sensible + #: default which is 80 for most things). + self.max_content_width = max_content_width + if allow_extra_args is None: allow_extra_args = command.allow_extra_args #: Indicates if the context allows extra args or if it should @@ -206,6 +235,18 @@ class Context(object): #: .. versionadded:: 3.0 self.allow_interspersed_args = allow_interspersed_args + if ignore_unknown_options is None: + ignore_unknown_options = command.ignore_unknown_options + #: Instructs click to ignore options that a command does not + #: understand and will store it on the context for later + #: processing. This is primarily useful for situations where you + #: want to call into external programs. Generally this pattern is + #: strongly discouraged because it's not possibly to losslessly + #: forward all arguments. + #: + #: .. versionadded:: 4.0 + self.ignore_unknown_options = ignore_unknown_options + if help_option_names is None: if parent is not None: help_option_names = parent.help_option_names @@ -239,6 +280,12 @@ class Context(object): self.auto_envvar_prefix = auto_envvar_prefix.upper() self.auto_envvar_prefix = auto_envvar_prefix + if color is None and parent is not None: + color = parent.color + + #: Controls if styling output is wanted or not. + self.color = color + self._close_callbacks = [] self._depth = 0 @@ -272,7 +319,8 @@ class Context(object): def make_formatter(self): """Creates the formatter for the help and usage output.""" - return HelpFormatter(width=self.terminal_width) + return HelpFormatter(width=self.terminal_width, + max_width=self.max_content_width) def call_on_close(self, f): """This decorator remembers a function as callback that should be @@ -466,8 +514,12 @@ class BaseCommand(object): :param context_settings: an optional dictionary with defaults that are passed to the context object. """ + #: the default for the :attr:`Context.allow_extra_args` flag. allow_extra_args = False + #: the default for the :attr:`Context.allow_interspersed_args` flag. allow_interspersed_args = True + #: the default for the :attr:`Context.ignore_unknown_options` flag. + ignore_unknown_options = False def __init__(self, name, context_settings=None): #: the name the command thinks it has. Upon registering a command @@ -475,6 +527,8 @@ class BaseCommand(object): #: with this information. You should instead use the #: :class:`Context`\'s :attr:`~Context.info_name` attribute. self.name = name + if context_settings is None: + context_settings = {} #: an optional dictionary with defaults passed to the context. self.context_settings = context_settings @@ -499,7 +553,7 @@ class BaseCommand(object): :param extra: extra keyword arguments forwarded to the context constructor. """ - for key, value in iteritems(self.context_settings or {}): + for key, value in iteritems(self.context_settings): if key not in extra: extra[key] = value ctx = Context(self, info_name=info_name, parent=parent, **extra) @@ -689,12 +743,12 @@ class Command(BaseCommand): def get_help_option(self, ctx): """Returns the help option object.""" help_options = self.get_help_option_names(ctx) - if not help_options: + if not help_options or not self.add_help_option: return def show_help(ctx, param, value): if value and not ctx.resilient_parsing: - echo(ctx.get_help()) + echo(ctx.get_help(), color=ctx.color) ctx.exit() return Option(help_options, is_flag=True, is_eager=True, expose_value=False, @@ -705,6 +759,7 @@ class Command(BaseCommand): """Creates the underlying option parser for this command.""" parser = OptionParser(ctx) parser.allow_interspersed_args = ctx.allow_interspersed_args + parser.ignore_unknown_options = ctx.ignore_unknown_options for param in self.get_params(ctx): param.add_to_parser(parser, ctx) return parser @@ -785,7 +840,7 @@ class Command(BaseCommand): class MultiCommand(Command): """A multi command is the basic implementation of a command that dispatches to subcommands. The most common version is the - :class:`Command`. + :class:`Group`. :param invoke_without_command: this controls how the multi command itself is invoked. By default it's only invoked @@ -893,7 +948,7 @@ class MultiCommand(Command): def parse_args(self, ctx, args): if not args and self.no_args_is_help and not ctx.resilient_parsing: - echo(ctx.get_help()) + echo(ctx.get_help(), color=ctx.color) ctx.exit() return Command.parse_args(self, ctx, args) @@ -1111,7 +1166,9 @@ class Parameter(object): value)`` and needs to return the value. Before Click 2.0, the signature was ``(ctx, value)``. :param nargs: the number of arguments to match. If not ``1`` the return - value is a tuple instead of single value. + value is a tuple instead of single value. The default for + nargs is ``1`` (except if the type is a tuple, then it's + the arity of the tuple). :param metavar: how the value is represented in the help page. :param expose_value: if this is `True` then the value is passed onwards to the command callback and stored on the context, @@ -1125,11 +1182,21 @@ class Parameter(object): param_type_name = 'parameter' def __init__(self, param_decls=None, type=None, required=False, - default=None, callback=None, nargs=1, metavar=None, + default=None, callback=None, nargs=None, metavar=None, expose_value=True, is_eager=False, envvar=None): self.name, self.opts, self.secondary_opts = \ self._parse_decls(param_decls or (), expose_value) + self.type = convert_type(type, default) + + # Default nargs to what the type tells us if we have that + # information available. + if nargs is None: + if self.type.is_composite: + nargs = self.type.arity + else: + nargs = 1 + self.required = required self.callback = callback self.nargs = nargs @@ -1140,6 +1207,13 @@ class Parameter(object): self.metavar = metavar self.envvar = envvar + @property + def human_readable_name(self): + """Returns the human readable name of this parameter. This is the + same as the name for options, but the metavar for arguments. + """ + return self.name + def make_metavar(self): if self.metavar is not None: return self.metavar @@ -1172,8 +1246,19 @@ class Parameter(object): def type_cast_value(self, ctx, value): """Given a value this runs it properly through the type system. - This automatically handles things like `nargs` and `multiple`. + This automatically handles things like `nargs` and `multiple` as + well as composite types. """ + if self.type.is_composite: + if self.nargs <= 1: + raise TypeError('Attempted to invoke composite type ' + 'but nargs has been set to %s. This is ' + 'not supported; nargs needs to be set to ' + 'a fixed value > 1.' % self.nargs) + if self.multiple: + return tuple(self.type(x or (), self, ctx) for x in value or ()) + return self.type(value or (), self, ctx) + def _convert(value, level): if level == 0: return self.type(value, self, ctx) @@ -1205,21 +1290,10 @@ class Parameter(object): value = self.get_default(ctx) if self.required and self.value_is_missing(value): - ctx.fail(self.get_missing_message(ctx)) + raise MissingParameter(ctx=ctx, param=self) return value - def get_missing_message(self, ctx): - rv = 'Missing %s %s.' % ( - self.param_type_name, - ' / '.join('"%s"' % x for x in chain( - self.opts, self.secondary_opts)), - ) - extra = self.type.get_missing_message(self) - if extra: - rv += ' ' + extra - return rv - def resolve_envvar_value(self, ctx): if self.envvar is None: return @@ -1351,6 +1425,8 @@ class Option(Parameter): # Sanity check for stuff we don't support if __debug__: + if self.nargs < 0: + raise TypeError('Options cannot have nargs < 0') if self.prompt and self.is_flag and not self.is_bool_flag: raise TypeError('Cannot prompt for flags that are not bools.') if not self.is_bool_flag and self.secondary_opts: @@ -1455,7 +1531,10 @@ class Option(Parameter): help = self.help or '' extra = [] if self.default is not None and self.show_default: - extra.append('default: %s' % self.default) + extra.append('default: %s' % ( + ', '.join('%s' % d for d in self.default) + if isinstance(self.default, (list, tuple)) + else self.default, )) if self.required: extra.append('required') if extra: @@ -1538,6 +1617,12 @@ class Argument(Parameter): required = attrs.get('nargs', 1) > 0 Parameter.__init__(self, param_decls, required=required, **attrs) + @property + def human_readable_name(self): + if self.metavar is not None: + return self.metavar + return self.name.upper() + def make_metavar(self): if self.metavar is not None: return self.metavar diff --git a/click/decorators.py b/click/decorators.py index 3811bff..e3184ba 100644 --- a/click/decorators.py +++ b/click/decorators.py @@ -134,14 +134,18 @@ def _param_memo(f, param): def argument(*param_decls, **attrs): - """Attaches an option to the command. All positional arguments are + """Attaches an argument to the command. All positional arguments are passed as parameter declarations to :class:`Argument`; all keyword - arguments are forwarded unchanged. This is equivalent to creating an - :class:`Option` instance manually and attaching it to the - :attr:`Command.params` list. + arguments are forwarded unchanged (except ``cls``). + This is equivalent to creating an :class:`Argument` instance manually + and attaching it to the :attr:`Command.params` list. + + :param cls: the argument class to instantiate. This defaults to + :class:`Argument`. """ def decorator(f): - _param_memo(f, Argument(param_decls, **attrs)) + ArgumentClass = attrs.pop('cls', Argument) + _param_memo(f, ArgumentClass(param_decls, **attrs)) return f return decorator @@ -149,14 +153,18 @@ def argument(*param_decls, **attrs): def option(*param_decls, **attrs): """Attaches an option to the command. All positional arguments are passed as parameter declarations to :class:`Option`; all keyword - arguments are forwarded unchanged. This is equivalent to creating an - :class:`Option` instance manually and attaching it to the - :attr:`Command.params` list. + arguments are forwarded unchanged (except ``cls``). + This is equivalent to creating an :class:`Option` instance manually + and attaching it to the :attr:`Command.params` list. + + :param cls: the option class to instantiate. This defaults to + :class:`Option`. """ def decorator(f): if 'help' in attrs: attrs['help'] = inspect.cleandoc(attrs['help']) - _param_memo(f, Option(param_decls, **attrs)) + OptionClass = attrs.pop('cls', Option) + _param_memo(f, OptionClass(param_decls, **attrs)) return f return decorator @@ -253,7 +261,7 @@ def version_option(version=None, *param_decls, **attrs): echo(message % { 'prog': prog, 'version': ver, - }) + }, color=ctx.color) ctx.exit() attrs.setdefault('is_flag', True) @@ -278,7 +286,7 @@ def help_option(*param_decls, **attrs): def decorator(f): def callback(ctx, param, value): if value and not ctx.resilient_parsing: - echo(ctx.get_help()) + echo(ctx.get_help(), color=ctx.color) ctx.exit() attrs.setdefault('is_flag', True) attrs.setdefault('expose_value', False) diff --git a/click/exceptions.py b/click/exceptions.py index 4e436bf..042433a 100644 --- a/click/exceptions.py +++ b/click/exceptions.py @@ -10,9 +10,9 @@ class ClickException(Exception): def __init__(self, message): if PY2: - Exception.__init__(self, message.encode('utf-8')) - else: - Exception.__init__(self, message) + if message is not None: + message = message.encode('utf-8') + Exception.__init__(self, message) self.message = message def format_message(self): @@ -41,9 +41,11 @@ class UsageError(ClickException): def show(self, file=None): if file is None: file = get_text_stderr() + color = None if self.ctx is not None: - echo(self.ctx.get_usage() + '\n', file=file) - echo('Error: %s' % self.format_message(), file=file) + color = self.ctx.color + echo(self.ctx.get_usage() + '\n', file=file, color=color) + echo('Error: %s' % self.format_message(), file=file, color=color) class BadParameter(UsageError): @@ -74,7 +76,7 @@ class BadParameter(UsageError): if self.param_hint is not None: param_hint = self.param_hint elif self.param is not None: - param_hint = self.param.opts or [self.param.name] + param_hint = self.param.opts or [self.param.human_readable_name] else: return 'Invalid value: %s' % self.message if isinstance(param_hint, (tuple, list)): @@ -82,6 +84,91 @@ class BadParameter(UsageError): return 'Invalid value for %s: %s' % (param_hint, self.message) +class MissingParameter(BadParameter): + """Raised if click required an option or argument but it was not + provided when invoking the script. + + .. versionadded:: 4.0 + + :param param_type: a string that indicates the type of the parameter. + The default is to inherit the parameter type from + the given `param`. Valid values are ``'parameter'``, + ``'option'`` or ``'argument'``. + """ + + def __init__(self, message=None, ctx=None, param=None, + param_hint=None, param_type=None): + BadParameter.__init__(self, message, ctx, param, param_hint) + self.param_type = param_type + + def format_message(self): + if self.param_hint is not None: + param_hint = self.param_hint + elif self.param is not None: + param_hint = self.param.opts or [self.param.human_readable_name] + else: + param_hint = None + if isinstance(param_hint, (tuple, list)): + param_hint = ' / '.join('"%s"' % x for x in param_hint) + + param_type = self.param_type + if param_type is None and self.param is not None: + param_type = self.param.param_type_name + + msg = self.message + msg_extra = self.param.type.get_missing_message(self.param) + if msg_extra: + if msg: + msg += '. ' + msg_extra + else: + msg = msg_extra + + return 'Missing %s%s%s%s' % ( + param_type, + param_hint and ' %s' % param_hint or '', + msg and '. ' or '.', + msg or '', + ) + + +class NoSuchOption(UsageError): + """Raised if click attempted to handle an option that does not + exist. + + .. versionadded:: 4.0 + """ + + def __init__(self, option_name, message=None, possibilities=None, + ctx=None): + if message is None: + message = 'no such option: %s' % option_name + UsageError.__init__(self, message, ctx) + self.option_name = option_name + self.possibilities = possibilities + + def format_message(self): + bits = [self.message] + if self.possibilities: + if len(self.possibilities) == 1: + bits.append('Did you mean %s?' % self.possibilities[0]) + else: + possibilities = sorted(self.possibilities) + bits.append('(Possible options: %s)' % ', '.join(possibilities)) + return ' '.join(bits) + + +class BadOptionUsage(UsageError): + """Raised if an option is generally supplied but the use of the option + was incorrect. This is for instance raised if the number of arguments + for an option is not correct. + + .. versionadded:: 4.0 + """ + + def __init__(self, option_name, message, ctx=None): + UsageError.__init__(self, message, ctx) + + class FileError(ClickException): """Raised if a file cannot be opened.""" diff --git a/click/formatting.py b/click/formatting.py index 204c5cf..a70a46f 100644 --- a/click/formatting.py +++ b/click/formatting.py @@ -18,6 +18,12 @@ def iter_rows(rows, col_count): yield row + ('',) * (col_count - len(row)) +def add_subsequent_indent(text, subsequent_indent): + lines = text.splitlines() + lines = lines[:1] + [subsequent_indent + line for line in lines[1:]] + return '\n'.join(lines) + + def wrap_text(text, width=78, initial_indent='', subsequent_indent='', preserve_paragraphs=False): """A helper function that intelligently wraps text. By default, it @@ -40,11 +46,13 @@ def wrap_text(text, width=78, initial_indent='', subsequent_indent='', """ from ._textwrap import TextWrapper text = text.expandtabs() + post_wrap_indent = subsequent_indent[:-1] + subsequent_indent = subsequent_indent[-1:] wrapper = TextWrapper(width, initial_indent=initial_indent, subsequent_indent=subsequent_indent, replace_whitespace=False) if not preserve_paragraphs: - return wrapper.fill(text) + return add_subsequent_indent(wrapper.fill(text), post_wrap_indent) p = [] buf = [] @@ -75,9 +83,11 @@ def wrap_text(text, width=78, initial_indent='', subsequent_indent='', for indent, raw, text in p: with wrapper.extra_indent(' ' * indent): if raw: - rv.append(wrapper.indent_only(text)) + rv.append(add_subsequent_indent(wrapper.indent_only(text), + post_wrap_indent)) else: - rv.append(wrapper.fill(text)) + rv.append(add_subsequent_indent(wrapper.fill(text), + post_wrap_indent)) return '\n\n'.join(rv) @@ -94,10 +104,12 @@ class HelpFormatter(object): width clamped to a maximum of 78. """ - def __init__(self, indent_increment=2, width=None): + def __init__(self, indent_increment=2, width=None, max_width=None): self.indent_increment = indent_increment + if max_width is None: + max_width = 80 if width is None: - width = max(min(get_terminal_size()[0], 80) - 2, 50) + width = max(min(get_terminal_size()[0], max_width) - 2, 50) self.width = width self.current_indent = 0 self.buffer = [] diff --git a/click/parser.py b/click/parser.py index 93b3887..426cde6 100644 --- a/click/parser.py +++ b/click/parser.py @@ -16,10 +16,16 @@ and might cause us issues. """ import re -from .exceptions import UsageError +from .exceptions import UsageError, NoSuchOption, BadOptionUsage from .utils import unpack_args +def _error_args(nargs, opt): + if nargs == 1: + raise BadOptionUsage(opt, '%s option requires an argument' % opt) + raise BadOptionUsage(opt, '%s option requires %d arguments' % (opt, nargs)) + + def split_opt(opt): first = opt[:1] if first.isalnum(): @@ -146,6 +152,14 @@ class OptionParser(object): #: non-option. Click uses this to implement nested subcommands #: safely. self.allow_interspersed_args = True + #: This tells the parser how to deal with unknown options. By + #: default it will error out (which is sensible), but there is a + #: second mode where it will ignore it and continue processing + #: after shifting all the unknown options into the resulting args. + self.ignore_unknown_options = False + if ctx is not None: + self.allow_interspersed_args = ctx.allow_interspersed_args + self.ignore_unknown_options = ctx.ignore_unknown_options self._short_opt = {} self._long_opt = {} self._opt_prefixes = set(['-', '--']) @@ -194,7 +208,7 @@ class OptionParser(object): self._process_args_for_options(state) self._process_args_for_args(state) except UsageError: - if not self.ctx.resilient_parsing: + if self.ctx is None or not self.ctx.resilient_parsing: raise return state.opts, state.largs, state.order @@ -244,77 +258,54 @@ class OptionParser(object): # *empty* -- still a subset of [arg0, ..., arg(i-1)], but # not a very interesting subset! - def _match_long_opt(self, opt): - # Is there an exact match? - if opt in self._long_opt: - return opt + def _match_long_opt(self, opt, explicit_value, state): + if opt not in self._long_opt: + possibilities = [word for word in self._long_opt + if word.startswith(opt)] + raise NoSuchOption(opt, possibilities=possibilities) - # Isolate all words with s as a prefix. - possibilities = [word for word in self._long_opt - if word.startswith(opt)] - - # No exact match, so there had better be just one possibility. - if not possibilities: - self._error('no such option: %s' % opt) - elif len(possibilities) == 1: - self._error('no such option: %s. Did you mean %s?' % - (opt, possibilities[0])) - return possibilities[0] - else: - # More than one possible completion: ambiguous prefix. - possibilities.sort() - self._error('no such option: %s. (Possible options: %s)' - % (opt, ', '.join(possibilities))) - - def _process_long_opt(self, arg, state): - # Value explicitly attached to arg? Pretend it's the next argument. - if '=' in arg: - opt, next_arg = arg.split('=', 1) - state.rargs.insert(0, next_arg) - had_explicit_value = True - else: - 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: + # At this point it's safe to modify rargs by injecting the + # explicit value, because no exception is raised in this + # branch. This means that the inserted value will be fully + # consumed. + if explicit_value is not None: + state.rargs.insert(0, explicit_value) + nargs = option.nargs if len(state.rargs) < nargs: - if nargs == 1: - self._error('%s option requires an argument' % opt) - else: - self._error('%s option requires %d arguments' % (opt, nargs)) + _error_args(nargs, opt) elif nargs == 1: value = state.rargs.pop(0) else: value = tuple(state.rargs[:nargs]) del state.rargs[:nargs] - elif had_explicit_value: - self._error('%s option does not take a value' % opt) + elif explicit_value is not None: + raise BadOptionUsage(opt, '%s option does not take a value' % opt) else: value = None option.process(value, state) - def _process_opts(self, arg, state): - if '=' in arg or normalize_opt(arg, self.ctx) in self._long_opt: - return self._process_long_opt(arg, state) - + def _match_short_opt(self, arg, state): stop = False i = 1 prefix = arg[0] + unknown_options = [] + for ch in arg[1:]: opt = normalize_opt(prefix + ch, self.ctx) option = self._short_opt.get(opt) i += 1 if not option: - self._error('no such option: %s' % (arg if arg.startswith('--') else opt)) + if self.ignore_unknown_options: + unknown_options.append(ch) + continue + raise NoSuchOption(opt) if option.takes_value: # Any characters left in arg? Pretend they're the # next arg, and stop consuming characters of arg. @@ -324,11 +315,7 @@ class OptionParser(object): nargs = option.nargs if len(state.rargs) < nargs: - if nargs == 1: - self._error('%s option requires an argument' % opt) - else: - self._error('%s option requires %d arguments' % - (opt, nargs)) + _error_args(nargs, opt) elif nargs == 1: value = state.rargs.pop(0) else: @@ -343,5 +330,38 @@ class OptionParser(object): if stop: break - def _error(self, msg): - raise UsageError(msg, self.ctx) + # If we got any unknown options we re-combinate the string of the + # remaining options and re-attach the prefix, then report that + # to the state as new larg. This way there is basic combinatorics + # that can be achieved while still ignoring unknown arguments. + if self.ignore_unknown_options and unknown_options: + state.largs.append(prefix + ''.join(unknown_options)) + + def _process_opts(self, arg, state): + explicit_value = None + # Long option handling happens in two parts. The first part is + # supporting explicitly attached values. In any case, we will try + # to long match the option first. + if '=' in arg: + long_opt, explicit_value = arg.split('=', 1) + else: + long_opt = arg + norm_long_opt = normalize_opt(long_opt, self.ctx) + + # At this point we will match the (assumed) long option through + # the long option matching code. Note that this allows options + # like "-foo" to be matched as long options. + try: + self._match_long_opt(norm_long_opt, explicit_value, state) + except NoSuchOption: + # At this point the long option matching failed, and we need + # to try with short options. However there is a special rule + # which says, that if we have a two character options prefix + # (applies to "--foo" for instance), we do not dispatch to the + # short option code and will instead raise the no option + # error. + if arg[:2] not in self._opt_prefixes: + return self._match_short_opt(arg, state) + if not self.ignore_unknown_options: + raise + state.largs.append(arg) diff --git a/click/termui.py b/click/termui.py index ce3b607..a39f2df 100644 --- a/click/termui.py +++ b/click/termui.py @@ -3,8 +3,7 @@ import sys import struct from ._compat import raw_input, text_type, string_types, \ - colorama, isatty, strip_ansi, get_winterm_size, \ - DEFAULT_COLUMNS, WIN + isatty, strip_ansi, get_winterm_size, DEFAULT_COLUMNS, WIN from .utils import echo from .exceptions import Abort, UsageError from .types import convert_type @@ -33,13 +32,17 @@ def _build_prompt(text, suffix, show_default=False, default=None): def prompt(text, default=None, hide_input=False, confirmation_prompt=False, type=None, - value_proc=None, prompt_suffix=': ', show_default=True): + value_proc=None, prompt_suffix=': ', + show_default=True, err=False): """Prompts a user for input. This is a convenience function that can be used to prompt a user for input later. If the user aborts the input by sending a interrupt signal, this function will catch it and raise a :exc:`Abort` exception. + .. versionadded:: 4.0 + Added the `err` parameter. + :param text: the text to show for the prompt. :param default: the default value to use if no input happens. If this is not given it will prompt until it's aborted. @@ -52,6 +55,8 @@ def prompt(text, default=None, hide_input=False, convert a value. :param prompt_suffix: a suffix that should be added to the prompt. :param show_default: shows or hides the default value in the prompt. + :param err: if set to true the file defaults to ``stderr`` instead of + ``stdout``, the same as with echo. """ result = None @@ -60,7 +65,7 @@ def prompt(text, default=None, hide_input=False, try: # Write the prompt separately so that we get nice # coloring through colorama on Windows - echo(text, nl=False) + echo(text, nl=False, err=err) return f('') except (KeyboardInterrupt, EOFError): raise Abort() @@ -83,7 +88,7 @@ def prompt(text, default=None, hide_input=False, try: result = value_proc(value) except UsageError as e: - echo('Error: %s' % e.message) + echo('Error: %s' % e.message, err=err) continue if not confirmation_prompt: return result @@ -93,22 +98,27 @@ def prompt(text, default=None, hide_input=False, break if value == value2: return result - echo('Error: the two entered values do not match') + echo('Error: the two entered values do not match', err=err) def confirm(text, default=False, abort=False, prompt_suffix=': ', - show_default=True): + show_default=True, err=False): """Prompts for confirmation (yes/no question). If the user aborts the input by sending a interrupt signal this function will catch it and raise a :exc:`Abort` exception. + .. versionadded:: 4.0 + Added the `err` parameter. + :param text: the question to ask. :param default: the default for the prompt. :param abort: if this is set to `True` a negative answer aborts the exception by raising :exc:`Abort`. :param prompt_suffix: a suffix that should be added to the prompt. :param show_default: shows or hides the default value in the prompt. + :param err: if set to true the file defaults to ``stderr`` instead of + ``stdout``, the same as with echo. """ prompt = _build_prompt(text, prompt_suffix, show_default, default and 'Y/n' or 'y/N') @@ -116,7 +126,7 @@ def confirm(text, default=False, abort=False, prompt_suffix=': ', try: # Write the prompt separately so that we get nice # coloring through colorama on Windows - echo(prompt, nl=False) + echo(prompt, nl=False, err=err) value = visible_prompt_func('').lower().strip() except (KeyboardInterrupt, EOFError): raise Abort() @@ -127,7 +137,7 @@ def confirm(text, default=False, abort=False, prompt_suffix=': ', elif value == '': rv = default else: - echo('Error: invalid input') + echo('Error: invalid input', err=err) continue break if abort and not rv: @@ -197,7 +207,7 @@ def progressbar(iterable=None, length=None, label=None, show_eta=True, show_percent=None, show_pos=False, item_show_func=None, fill_char='#', empty_char='-', bar_template='%(label)s [%(bar)s] %(info)s', - info_sep=' ', width=36, file=None): + info_sep=' ', width=36, file=None, color=None): """This function creates an iterable context manager that can be used to iterate over something while showing a progress bar. It will either iterate over the `iterable` or `length` items (that are counted @@ -221,8 +231,22 @@ def progressbar(iterable=None, length=None, label=None, show_eta=True, for item in bar: do_something_with(item) + Alternatively, if no iterable is specified, one can manually update the + progress bar through the `update()` method instead of directly + iterating over the progress bar. The update method accepts the number + of steps to increment the bar with:: + + with progressbar(length=chunks.total_bytes) as bar: + for chunk in chunks: + process_chunk(chunk) + bar.update(chunks.bytes) + .. versionadded:: 2.0 + .. versionadded:: 4.0 + Added the `color` parameter. Added a `update` method to the + progressbar object. + :param iterable: an iterable to iterate over. If not provided the length is required. :param length: the number of items to iterate over. By default the @@ -257,6 +281,10 @@ def progressbar(iterable=None, length=None, label=None, show_eta=True, terminal width :param file: the file to write to. If this is not a terminal then only the label is printed. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. This is only needed if ANSI + codes are included anywhere in the progress bar output + which is not the case by default. """ from ._termui_impl import ProgressBar return ProgressBar(iterable=iterable, length=length, show_eta=show_eta, @@ -264,7 +292,7 @@ def progressbar(iterable=None, length=None, label=None, show_eta=True, item_show_func=item_show_func, fill_char=fill_char, empty_char=empty_char, bar_template=bar_template, info_sep=info_sep, file=file, label=label, - width=width) + width=width, color=color) def clear(): @@ -279,7 +307,7 @@ def clear(): # If we're on Windows and we don't have colorama available, then we # clear the screen by shelling out. Otherwise we can use an escape # sequence. - if WIN and colorama is None: + if WIN: os.system('cls') else: sys.stdout.write('\033[2J\033[1;1H') @@ -366,7 +394,7 @@ def unstyle(text): return strip_ansi(text) -def secho(text, file=None, nl=True, err=False, **styles): +def secho(text, file=None, nl=True, err=False, color=None, **styles): """This function combines :func:`echo` and :func:`style` into one call. As such the following two calls are the same:: @@ -378,8 +406,7 @@ def secho(text, file=None, nl=True, err=False, **styles): .. versionadded:: 2.0 """ - text = style(text, **styles) - return echo(text, file=file, nl=nl, err=err) + return echo(style(text, **styles), file=file, nl=nl, err=err, color=color) def edit(text=None, editor=None, env=None, require_save=True, @@ -472,7 +499,7 @@ def getchar(echo=False): return f(echo) -def pause(info='Press any key to continue ...'): +def pause(info='Press any key to continue ...', err=False): """This command stops execution and waits for the user to press any key to continue. This is similar to the Windows batch "pause" command. If the program is not run through a terminal, this command @@ -480,17 +507,22 @@ def pause(info='Press any key to continue ...'): .. versionadded:: 2.0 + .. versionadded:: 4.0 + Added the `err` parameter. + :param info: the info string to print before pausing. + :param err: if set to message goes to ``stderr`` instead of + ``stdout``, the same as with echo. """ if not isatty(sys.stdin) or not isatty(sys.stdout): return try: if info: - echo(info, nl=False) + echo(info, nl=False, err=err) try: getchar() except (KeyboardInterrupt, EOFError): pass finally: if info: - echo() + echo(err=err) diff --git a/click/testing.py b/click/testing.py index 9527d8b..df07458 100644 --- a/click/testing.py +++ b/click/testing.py @@ -135,7 +135,7 @@ class CliRunner(object): return rv @contextlib.contextmanager - def isolation(self, input=None, env=None): + def isolation(self, input=None, env=None, color=False): """A context manager that sets up the isolation for invoking of a command line tool. This sets up stdin with the given input data and `os.environ` with the overrides from the given dictionary. @@ -144,8 +144,13 @@ class CliRunner(object): This is automatically done in the :meth:`invoke` method. + .. versionadded:: 4.0 + The ``color`` parameter was added. + :param input: the input stream to put into sys.stdin. :param env: the environment overrides as dictionary. + :param color: whether the output should contain color codes. The + application can still override this explicitly. """ input = make_input_stream(input, self.charset) @@ -188,12 +193,20 @@ class CliRunner(object): sys.stdout.flush() return char + default_color = color + def should_strip_ansi(stream=None, color=None): + if color is None: + return not default_color + return not color + old_visible_prompt_func = clickpkg.termui.visible_prompt_func old_hidden_prompt_func = clickpkg.termui.hidden_prompt_func old__getchar_func = clickpkg.termui._getchar + old_should_strip_ansi = clickpkg.utils.should_strip_ansi clickpkg.termui.visible_prompt_func = visible_input clickpkg.termui.hidden_prompt_func = hidden_input clickpkg.termui._getchar = _getchar + clickpkg.utils.should_strip_ansi = should_strip_ansi old_env = {} try: @@ -222,9 +235,10 @@ class CliRunner(object): clickpkg.termui.visible_prompt_func = old_visible_prompt_func clickpkg.termui.hidden_prompt_func = old_hidden_prompt_func clickpkg.termui._getchar = old__getchar_func + clickpkg.utils.should_strip_ansi = old_should_strip_ansi def invoke(self, cli, args=None, input=None, env=None, - catch_exceptions=True, **extra): + catch_exceptions=True, color=False, **extra): """Invokes a command in an isolated environment. The arguments are forwarded directly to the command line script, the `extra` keyword arguments are passed to the :meth:`~clickpkg.Command.main` function of @@ -239,6 +253,9 @@ class CliRunner(object): The result object now has an `exc_info` attribute with the traceback if available. + .. versionadded:: 4.0 + The ``color`` parameter was added. + :param cli: the command to invoke :param args: the arguments to invoke :param input: the input data for `sys.stdin`. @@ -246,9 +263,11 @@ class CliRunner(object): :param catch_exceptions: Whether to catch any other exceptions than ``SystemExit``. :param extra: the keyword arguments to pass to :meth:`main`. + :param color: whether the output should contain color codes. The + application can still override this explicitly. """ exc_info = None - with self.isolation(input=input, env=env) as out: + with self.isolation(input=input, env=env, color=color) as out: exception = None exit_code = 0 diff --git a/click/types.py b/click/types.py index 014a935..fd646f9 100644 --- a/click/types.py +++ b/click/types.py @@ -2,7 +2,8 @@ import os import sys import stat -from ._compat import open_stream, text_type, filename_to_ui, get_streerror +from ._compat import open_stream, text_type, filename_to_ui, \ + get_filesystem_encoding, get_streerror from .exceptions import BadParameter from .utils import safecall, LazyFile @@ -20,6 +21,7 @@ class ParamType(object): This can be the case when the object is used with prompt inputs. """ + is_composite = False #: the descriptive name of this type name = None @@ -67,6 +69,14 @@ class ParamType(object): raise BadParameter(message, ctx=ctx, param=param) +class CompositeParamType(ParamType): + is_composite = True + + @property + def arity(self): + raise NotImplementedError() + + class FuncParamType(ParamType): def __init__(self, func): @@ -84,6 +94,16 @@ class FuncParamType(ParamType): self.fail(value, param, ctx) +class UnprocessedParamType(ParamType): + name = 'text' + + def convert(self, value, param, ctx): + return value + + def __repr__(self): + return 'UNPROCESSED' + + class StringParamType(ParamType): name = 'text' @@ -95,7 +115,7 @@ class StringParamType(ParamType): value = value.decode(enc) except UnicodeError: try: - value = value.decode(sys.getfilesystemencoding()) + value = value.decode(get_filesystem_encoding()) except UnicodeError: value = value.decode('utf-8', 'replace') return value @@ -106,7 +126,7 @@ class StringParamType(ParamType): class Choice(ParamType): - """The choice type allows a value to checked against a fixed set of + """The choice type allows a value to be checked against a fixed set of supported values. All of these values have to be strings. See :ref:`choice-opts` for an example. @@ -319,7 +339,7 @@ class File(ParamType): class Path(ParamType): """The path type is similar to the :class:`File` type but it performs - different checks. First of all, instead of returning a open file + different checks. First of all, instead of returning an open file handle it returns just the filename. Secondly, it can perform various basic checks about what the file or directory should be. @@ -395,16 +415,54 @@ class Path(ParamType): return rv +class Tuple(CompositeParamType): + """The default behavior of Click is to apply a type on a value directly. + This works well in most cases, except for when `nargs` is set to a fixed + count and different types should be used for different items. In this + case the :class:`Tuple` type can be used. This type can only be used + if `nargs` is set to a fixed number. + + For more information see :ref:`tuple-type`. + + This can be selected by using a Python tuple literal as a type. + + :param types: a list of types that should be used for the tuple items. + """ + + def __init__(self, types): + self.types = [convert_type(ty) for ty in types] + + @property + def name(self): + return "<" + " ".join(ty.name for ty in self.types) + ">" + + @property + def arity(self): + return len(self.types) + + def convert(self, value, param, ctx): + if len(value) != len(self.types): + raise TypeError('It would appear that nargs is set to conflict ' + 'with the composite type arity.') + return tuple(ty(x, param, ctx) for ty, x in zip(self.types, value)) + + def convert_type(ty, default=None): """Converts a callable or python ty into the most appropriate param ty. """ - if isinstance(ty, ParamType): - return ty guessed_type = False if ty is None and default is not None: - ty = type(default) + if isinstance(default, tuple): + ty = tuple(map(type, default)) + else: + ty = type(default) guessed_type = True + + if isinstance(ty, tuple): + return Tuple(ty) + if isinstance(ty, ParamType): + return ty if ty is text_type or ty is str or ty is None: return STRING if ty is int: @@ -431,6 +489,20 @@ def convert_type(ty, default=None): return FuncParamType(ty) +#: A dummy parameter type that just does nothing. From a user's +#: perspective this appears to just be the same as `STRING` but internally +#: no string conversion takes place. This is necessary to achieve the +#: same bytes/unicode behavior on Python 2/3 in situations where you want +#: to not convert argument types. This is usually useful when working +#: with file paths as they can appear in bytes and unicode. +#: +#: For path related uses the :class:`Path` type is a better choice but +#: there are situations where an unprocessed type is useful which is why +#: it is is provided. +#: +#: .. versionadded:: 4.0 +UNPROCESSED = UnprocessedParamType() + #: A unicode string parameter type which is the implicit default. This #: can also be selected by using ``str`` as type. STRING = StringParamType() diff --git a/click/utils.py b/click/utils.py index f5068d4..7b3669b 100644 --- a/click/utils.py +++ b/click/utils.py @@ -2,10 +2,10 @@ import os import sys from collections import deque -from ._compat import text_type, open_stream, get_streerror, string_types, \ - PY2, binary_streams, text_streams, filename_to_ui, \ - auto_wrap_for_ansi, strip_ansi, isatty, _default_text_stdout, \ - _default_text_stderr, is_bytes, WIN +from ._compat import text_type, open_stream, get_filesystem_encoding, \ + get_streerror, string_types, PY2, binary_streams, text_streams, \ + filename_to_ui, auto_wrap_for_ansi, strip_ansi, should_strip_ansi, \ + _default_text_stdout, _default_text_stderr, is_bytes, WIN if not PY2: from ._compat import _find_binary_writer @@ -38,6 +38,8 @@ def unpack_args(args, nargs_spec): (((0, 1, 2, 3, 4, 5),), []) >>> unpack_args(range(6), [1, 1]) ((0, 1), [2, 3, 4, 5]) + >>> unpack_args(range(6), [-1,1,1,1,1]) + (((0, 1), 2, 3, 4, 5), []) """ args = deque(args) nargs_spec = deque(nargs_spec) @@ -46,7 +48,10 @@ def unpack_args(args, nargs_spec): def _fetch(c): try: - return (spos is not None and c.pop() or c.popleft()) + if spos is None: + return c.popleft() + else: + return c.pop() except IndexError: return None @@ -72,6 +77,7 @@ def unpack_args(args, nargs_spec): if spos is not None: rv[spos] = tuple(args) args = [] + rv[spos + 1:] = reversed(rv[spos + 1:]) return tuple(rv), list(args) @@ -90,7 +96,7 @@ def make_str(value): """Converts a value into a valid string.""" if isinstance(value, bytes): try: - return value.decode(sys.getfilesystemencoding()) + return value.decode(get_filesystem_encoding()) except UnicodeError: return value.decode('utf-8', 'replace') return text_type(value) @@ -210,7 +216,7 @@ class KeepOpenFile(object): return repr(self._file) -def echo(message=None, file=None, nl=True, err=False): +def echo(message=None, file=None, nl=True, err=False, color=None): """Prints a message plus a newline to the given file or stdout. On first sight, this looks like the print function, but it has improved support for handling Unicode and binary data that does not fail no @@ -238,12 +244,17 @@ def echo(message=None, file=None, nl=True, err=False): .. versionadded:: 3.0 The `err` parameter was added. + .. versionchanged:: 4.0 + Added the `color` flag. + :param message: the message to print :param file: the file to write to (defaults to ``stdout``) :param err: if set to true the file defaults to ``stderr`` instead of ``stdout``. This is faster and easier than calling :func:`get_text_stderr` yourself. :param nl: if set to `True` (the default) a newline is printed afterwards. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. """ if file is None: if err: @@ -276,18 +287,18 @@ def echo(message=None, file=None, nl=True, err=False): # to strip the color or we use the colorama support to translate the # ansi codes to API calls. if message and not is_bytes(message): - if not isatty(file): + if should_strip_ansi(file, color): message = strip_ansi(message) elif WIN: if auto_wrap_for_ansi is not None: file = auto_wrap_for_ansi(file) - else: + elif not color: message = strip_ansi(message) if message: file.write(message) if nl: - file.write('\n') + file.write(u'\n') file.flush() diff --git a/docs/advanced.rst b/docs/advanced.rst index 3154407..b84333c 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -243,3 +243,98 @@ Missing parameters: Most of the time you do not need to be concerned about any of this, but it is important to know how it works for some advanced cases. + +.. _forwarding-unknown-options: + +Forwarding Unknown Options +-------------------------- + +In some situations it is interesting to be able to accept all unknown +options for further manual processing. Click can generally do that as of +Click 4.0, but it has some limitations that lie in the nature of the +problem. The support for this is provided through a parser flag called +``ignore_unknown_options`` which will instruct the parser to collect all +unknown options and to put them to the leftover argument instead of +triggering a parsing error. + +This can generally be activated in two different ways: + +1. It can be enabled on custom :class:`Command` subclasses by changing + the :attr:`~BaseCommand.ignore_unknown_options` attribute. +2. It can be enabled by changing the attribute of the same name on the + context class (:attr:`Context.ignore_unknown_options`). This is best + changed through the ``context_settings`` dictionary on the command. + +For most situations the easiest solution is the second. Once the behavior +is changed something needs to pick up those leftover options (which at +this point are considered arguments). For this again you have two +options: + +1. You can use :func:`pass_context` to get the context passed. This will + only work if in addition to :attr:`~Context.ignore_unknown_options` + you also set :attr:`~Context.allow_extra_args` as otherwise the + command will abort with an error that there are leftover arguments. + If you go with this solution, the extra arguments will be collected in + :attr:`Context.args`. +2. You can attach a :func:`argument` with ``nargs`` set to `-1` which + will eat up all leftover arguments. In this case it's recommeded to + set the `type` to :data:`UNPROCESSED` to avoid any string processing + on those arguments as otherwise they are forced into unicode strings + automatically which is often not what you want. + +In the end you end up with something like this: + +.. click:example:: + + import sys + from subprocess import call + + @click.command(context_settings=dict( + ignore_unknown_options=True, + )) + @click.option('-v', '--verbose', is_flag=True, help='Enables verbose mode') + @click.argument('timeit_args', nargs=-1, type=click.UNPROCESSED) + def cli(verbose, timeit_args): + """A wrapper around Python's timeit.""" + cmdline = ['python', '-mtimeit'] + list(timeit_args) + if verbose: + click.echo('Invoking: %s' % ' '.join(cmdline)) + call(cmdline) + +And what it looks like: + +.. click:run:: + + invoke(cli, prog_name='cli', args=['--help']) + println() + invoke(cli, prog_name='cli', args=['-n', '100', 'a = 1; b = 2; a * b']) + println() + invoke(cli, prog_name='cli', args=['-v', 'a = 1; b = 2; a * b']) + +As you can see the verbosity flag is handled by Click, everything else +ends up in the `timeit_args` variable for further processing which then +for instance, allows invoking a subprocess. There are a few things that +are important to know about how this ignoring of unhandled flag happens: + +* Unknown long options are generally ignored and not processed at all. + So for instance if ``--foo=bar`` or ``--foo bar`` are passed they + generally end up like that. Note that because the parser cannot know + if an option will accept an argument or not, the ``bar`` part might be + handled as an argument. +* Unknown short options might be partially handled and reassmebled if + necessary. For instance in the above example there is an option + called ``-v`` which enables verbose mode. If the command would be + ignored with ``-va`` then the ``-v`` part would be handled by Click + (as it is known) and ``-a`` would end up in the leftover parameters + for further processing. +* Depending on what you plan on doing you might have some success by + disabling interspersed arguments + (:attr:`~Context.allow_interspersed_args`) which instructs the parser + to not allow arguments and options to be mixed. Depending on your + situation this might improve your results. + +Generally though the combinated handling of options and arguments from +your own commands and commands from another application are discouraged +and if you can avoid it, you should. It's a much better idea to have +everything below a subcommand be forwarded to another application than to +handle some arguments yourself. diff --git a/docs/api.rst b/docs/api.rst index addb1b0..b4304f1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -119,6 +119,8 @@ Types .. autodata:: UUID +.. autodata:: UNPROCESSED + .. autoclass:: File .. autoclass:: Path @@ -127,6 +129,8 @@ Types .. autoclass:: IntRange +.. autoclass:: Tuple + .. autoclass:: ParamType :members: @@ -143,6 +147,10 @@ Exceptions .. autoexception:: FileError +.. autoexception:: NoSuchOption + +.. autoexception:: BadOptionUsage + Formatting ---------- diff --git a/docs/arguments.rst b/docs/arguments.rst index e224d0a..1abd88b 100644 --- a/docs/arguments.rst +++ b/docs/arguments.rst @@ -216,7 +216,7 @@ where the first one is picked. Generally, this feature is not recommended because it can cause the user a lot of confusion. -Argument-Like Options +Option-Like Arguments --------------------- Sometimes, you want to process arguments that look like options. For diff --git a/docs/clickdoctools.py b/docs/clickdoctools.py index 5cb558b..36723fa 100644 --- a/docs/clickdoctools.py +++ b/docs/clickdoctools.py @@ -4,6 +4,7 @@ import click import shutil import tempfile import contextlib +import subprocess try: from StringIO import StringIO @@ -49,6 +50,24 @@ class EchoingStdin(object): return iter(self._echo(x) for x in self._input) +@contextlib.contextmanager +def fake_modules(): + old_call = subprocess.call + def dummy_call(*args, **kwargs): + with tempfile.TemporaryFile('wb+') as f: + kwargs['stdout'] = f + kwargs['stderr'] = f + rv = subprocess.Popen(*args, **kwargs).wait() + f.seek(0) + click.echo(f.read().decode('utf-8', 'replace').rstrip()) + return rv + subprocess.call = dummy_call + try: + yield + finally: + subprocess.call = old_call + + @contextlib.contextmanager def isolation(input=None, env=None): if isinstance(input, unicode): @@ -123,8 +142,9 @@ class ExampleRunner(object): } def declare(self, source): - code = compile(source, '', 'exec') - eval(code, self.namespace) + with fake_modules(): + code = compile(source, '', 'exec') + eval(code, self.namespace) def run(self, source): code = compile(source, '', 'exec') diff --git a/docs/commands.rst b/docs/commands.rst index e79dca7..eac64a7 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -135,9 +135,9 @@ For instance, the :func:`pass_obj` decorator can be implemented like this: The :meth:`Context.invoke` command will automatically invoke the function in the correct way, so the function will either be called with ``f(ctx, obj)`` or ``f(obj)`` depending on whether or not it itself is decorated with -:func:`with_context`. +:func:`pass_context`. -This is a very powerful context that can be used to build very complex +This is a very powerful concept that can be used to build very complex nested applications; see :ref:`complex-guide` for more information. @@ -273,6 +273,8 @@ And what it looks like: In case a command exists in more than one source, the first source wins. +.. _multi-command-chaining: + Multi Command Chaining ---------------------- @@ -280,7 +282,7 @@ Multi Command Chaining Sometimes it is useful to be allowed to invoke more than one subcommand in one go. For instance if you have installed a setuptools package before -ouy might be familiar with the ``setup.py sdist bdist_wheel upload`` +you might be familiar with the ``setup.py sdist bdist_wheel upload`` command chain which invokes ``dist`` before ``bdist_wheel`` before ``upload``. Starting with Click 3.0 this is very simple to implement. All you have to do is to pass ``chain=True`` to your multicommand: @@ -501,3 +503,50 @@ And again the example in action: .. click:run:: invoke(cli, prog_name='cli', args=['runserver']) + + +Command Return Values +--------------------- + +.. versionadded:: 3.0 + +One of the new introductions in Click 3.0 is the full support for return +values from command callbacks. This enables a whole range of features +that were previously hard to implement. + +In essence any command callback can now return a value. This return value +is bubbled to certain receivers. One usecase for this has already been +show in the example of :ref:`multi-command-chaining` where it has been +demonstrated that chained multi commands can have callbacks that process +all return values. + +When working with command return values in Click, this is what you need to +know: + +- The return value of a command callback is generally returned from the + :meth:`BaseCommand.invoke` method. The exception to this rule has to + do with :class:`Group`\s: + + * In a group the return value is generally the return value of the + subcommand invoked. The only exception to this rule is that the + return value is the return value of the group callback if it's + invoked without arguments and `invoke_without_command` is enabled. + * If a group is set up for chaining then the return value is a list + of all subcommands' results. + * Return values of groups can be processed through a + :attr:`MultiCommand.result_callback`. This is invoked with the + list of all return values in chain mode, or the single return + value in case of non chained commands. + +- The return value is bubbled through from the :meth:`Context.invoke` + and :meth:`Context.forward` methods. This is useful in situations + where you internally want to call into another command. + +- Click does not have any hard requirements for the return values and + does not use them itself. This allows return values to be used for + custom decorators or workflows (like in the multi command chaining + example). + +- When a Click script is invoked as command line application (through + :meth:`BaseCommand.main`) the return value is ignored unless the + `standalone_mode` is disabled in which case it's bubbled through. diff --git a/docs/conf.py b/docs/conf.py index 08c31b1..e726702 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,6 +12,7 @@ # serve to show the default. import sys, os +import datetime # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -44,7 +45,7 @@ master_doc = 'index' # General information about the project. project = u'click' -copyright = u'2014, Armin Ronacher' +copyright = u'%d, Armin Ronacher' % datetime.datetime.utcnow().year # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/docs/options.rst b/docs/options.rst index 7243610..bd534a8 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -55,6 +55,42 @@ And on the command line: invoke(findme, args=['--pos', '2.0', '3.0']) +.. _tuple-type: + +Tuples as Multi Value Options +----------------------------- + +.. versionadded:: 4.0 + +As you can see that by using `nargs` set to a specific number each item in +the resulting tuple is of the same type. This might not be what you want. +Commonly you might want to use different types for different indexes in +the tuple. For this you can directly specify a tuple as type: + +.. click:example:: + + @click.command() + @click.option('--item', type=(unicode, int)) + def putitem(item): + click.echo('name=%s id=%d' % item) + +And on the command line: + +.. click:run:: + + invoke(putitem, args=['--item', 'peter', '1338']) + +By using a tuple literal as type, `nargs` gets automatically set to the +length of the tuple and the :class:`click.Tuple` type is automatically +used. The above example is thus equivalent to this: + +.. click:example:: + + @click.command() + @click.option('--item', nargs=2, type=click.Tuple([unicode, int])) + def putitem(item): + click.echo('name=%s id=%d' % item) + Multiple Options ---------------- @@ -113,12 +149,12 @@ Example: .. click:example:: - import os + import sys @click.command() @click.option('--shout/--no-shout', default=False) def info(shout): - rv = os.uname()[0] + rv = sys.platform if shout: rv = rv.upper() + '!!!!111' click.echo(rv) @@ -135,12 +171,12 @@ manually inform Click that something is a flag: .. click:example:: - import os + import sys @click.command() @click.option('--shout', is_flag=True) def info(shout): - rv = os.uname()[0] + rv = sys.platform if shout: rv = rv.upper() + '!!!!111' click.echo(rv) @@ -178,14 +214,14 @@ the default. .. click:example:: - import os + import sys @click.command() @click.option('--upper', 'transformation', flag_value='upper', default=True) @click.option('--lower', 'transformation', flag_value='lower') def info(transformation): - click.echo(getattr(os.uname()[0], transformation)()) + click.echo(getattr(sys.platform, transformation)()) And on the command line: @@ -301,6 +337,10 @@ Sometimes, you want a parameter to completely change the execution flow. For instance, this is the case when you want to have a ``--version`` parameter that prints out the version and then exits the application. +Note: an actual implementation of a ``--version`` parameter that is +reusable is available in Click as :func:`click.version_option`. The code +here is merely an example of how to implement such a flag. + In such cases, you need two concepts: eager parameters and a callback. An eager parameter is a parameter that is handled before others, and a callback is what executes after the parameter is handled. The eagerness diff --git a/docs/parameters.rst b/docs/parameters.rst index 04d4d05..920b9be 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -26,7 +26,7 @@ available for options: (this is intentional as arguments might be too specific to be automatically documented) -On the other hand arguments unlike options can accept an arbitrary number +On the other hand arguments, unlike options, can accept an arbitrary number of arguments. Options can strictly ever only accept a fixed number of arguments (defaults to 1). diff --git a/docs/python3.rst b/docs/python3.rst index 7a3b97c..29c7771 100644 --- a/docs/python3.rst +++ b/docs/python3.rst @@ -18,9 +18,9 @@ Python 3 Limitations At the moment, Click suffers from a few problems with Python 3: -* The command line in Unix traditionally is in bytes, and not Unicode. - While there are encoding hints for all of this, there are generally - some situations where this can break. The most common one is SSH +* The command line in Unix traditionally is in bytes, not Unicode. While + there are encoding hints for all of this, there are generally some + situations where this can break. The most common one is SSH connections to machines with different locales. Misconfigured environments can currently cause a wide range of Unicode @@ -56,7 +56,7 @@ Python 2 and 3 Differences -------------------------- Click attempts to minimize the differences between Python 2 and Python 3 -by following the best practices for both languages. +by following best practices for both languages. In Python 2, the following is true: @@ -81,7 +81,7 @@ In Python 3, the following is true: * ``sys.argv`` is always Unicode-based. This also means that the native type for input values to the types in Click is Unicode, and not bytes. - This causes problems when the terminal is incorrectly set and Python + This causes problems if the terminal is incorrectly set and Python does not figure out the encoding. In that case, the Unicode string will contain error bytes encoded as surrogate escapes. * When dealing with files, Click will always use the Unicode file system @@ -100,9 +100,9 @@ and is subject to its behavior. In Python 2, Click does all the Unicode handling itself, which means there are differences in error behavior. The most glaring difference is that in Python 2, Unicode will "just work", -while in Python 3, it requires extra care. The reason for this is that on -Python 3, the encoding detection is done in the interpreter and on Linux -and certain other operating systems its encoding handling is problematic. +while in Python 3, it requires extra care. The reason for this is that in +Python 3, the encoding detection is done in the interpreter, and on Linux +and certain other operating systems, its encoding handling is problematic. The biggest source of frustration is that Click scripts invoked by init systems (sysvinit, upstart, systemd, etc.), deployment tools (salt, @@ -120,7 +120,7 @@ If you see something like this error in Python 3:: ... RuntimeError: Click will abort further execution because Python 3 was configured to use ASCII as encoding for the environment. Either switch - to Python 2 or consult for http://click.pocoo.org/python3/ + to Python 2 or consult http://click.pocoo.org/python3/ for mitigation steps. You are dealing with an environment where Python 3 thinks you are @@ -133,7 +133,7 @@ by exporting the locale to ``de_DE.utf-8``:: export LC_ALL=de_DE.utf-8 export LANG=de_DE.utf-8 -If you are on a US machine, ``en_EN.utf-8`` is the encoding of choice. On +If you are on a US machine, ``en_US.utf-8`` is the encoding of choice. On some newer Linux systems, you could also try ``C.UTF-8`` as the locale:: export LC_ALL=C.UTF-8 diff --git a/docs/quickstart.rst b/docs/quickstart.rst index bddbd16..e5ce571 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -81,7 +81,7 @@ And if you want to go back to the real world, use the following command:: After doing this, the prompt of your shell should be as familar as before. -Now, let's move on. Enter the following command to get Flask activated in your +Now, let's move on. Enter the following command to get Click activated in your virtualenv:: $ pip install Click diff --git a/docs/setuptools.rst b/docs/setuptools.rst index 7dfa591..645ec6f 100644 --- a/docs/setuptools.rst +++ b/docs/setuptools.rst @@ -108,9 +108,11 @@ contained in a Python package the changes necessary are minimal. Let's assume your directory structure changed to this:: yourpackage/ + __init__.py main.py utils.py scripts/ + __init__.py yourscript.py In this case instead of using ``py_modules`` in your ``setup.py`` file you diff --git a/docs/testing.rst b/docs/testing.rst index 62672fb..7719c29 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -58,7 +58,7 @@ Example:: runner = CliRunner() with runner.isolated_filesystem(): - with open('hello.txt') as f: + with open('hello.txt', 'w') as f: f.write('Hello World!') result = runner.invoke(cat, ['hello.txt']) diff --git a/docs/utils.rst b/docs/utils.rst index 04e4106..8c7308a 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -366,3 +366,14 @@ will be shown preceding the progress bar:: length=number_of_users) as bar: for user in bar: modify_the_user(user) + +Sometimes, one may need to iterate over an external iterator, and advance the +progress bar irregularly. To do so, you need to specify the length (and no +iterable), and use the update method on the context return value instead of +iterating directly over it:: + + with click.progressbar(length=total_size, + label='Unzipping archive') as bar: + for archive in zip_file: + archive.extract() + bar.update(archive.size) diff --git a/examples/.DS_Store b/examples/.DS_Store deleted file mode 100644 index 4aca92d..0000000 Binary files a/examples/.DS_Store and /dev/null differ diff --git a/examples/aliases/setup.py b/examples/aliases/setup.py index c725501..8d1d6a4 100644 --- a/examples/aliases/setup.py +++ b/examples/aliases/setup.py @@ -6,7 +6,7 @@ setup( py_modules=['aliases'], include_package_data=True, install_requires=[ - 'Click', + 'click', ], entry_points=''' [console_scripts] diff --git a/examples/colors/setup.py b/examples/colors/setup.py index 1a87b2d..3f8e105 100644 --- a/examples/colors/setup.py +++ b/examples/colors/setup.py @@ -6,7 +6,7 @@ setup( py_modules=['colors'], include_package_data=True, install_requires=[ - 'Click', + 'click', # Colorama is only required for Windows. 'colorama', ], diff --git a/examples/complex/setup.py b/examples/complex/setup.py index 3c9946c..dee002c 100644 --- a/examples/complex/setup.py +++ b/examples/complex/setup.py @@ -6,7 +6,7 @@ setup( packages=['complex', 'complex.commands'], include_package_data=True, install_requires=[ - 'Click', + 'click', ], entry_points=''' [console_scripts] diff --git a/examples/imagepipe/.DS_Store b/examples/imagepipe/.DS_Store deleted file mode 100644 index ea27d0d..0000000 Binary files a/examples/imagepipe/.DS_Store and /dev/null differ diff --git a/examples/imagepipe/click_example_imagepipe.egg-info/SOURCES.txt b/examples/imagepipe/click_example_imagepipe.egg-info/SOURCES.txt deleted file mode 100644 index 10372d6..0000000 --- a/examples/imagepipe/click_example_imagepipe.egg-info/SOURCES.txt +++ /dev/null @@ -1,8 +0,0 @@ -README -imagepipe.py -click_example_imagepipe.egg-info/PKG-INFO -click_example_imagepipe.egg-info/SOURCES.txt -click_example_imagepipe.egg-info/dependency_links.txt -click_example_imagepipe.egg-info/entry_points.txt -click_example_imagepipe.egg-info/requires.txt -click_example_imagepipe.egg-info/top_level.txt \ No newline at end of file diff --git a/examples/imagepipe/click_example_imagepipe.egg-info/entry_points.txt b/examples/imagepipe/click_example_imagepipe.egg-info/entry_points.txt deleted file mode 100644 index 2ed4423..0000000 --- a/examples/imagepipe/click_example_imagepipe.egg-info/entry_points.txt +++ /dev/null @@ -1,4 +0,0 @@ - - [console_scripts] - imagepipe=imagepipe:cli - \ No newline at end of file diff --git a/examples/imagepipe/click_example_imagepipe.egg-info/requires.txt b/examples/imagepipe/click_example_imagepipe.egg-info/requires.txt deleted file mode 100644 index 4d441ba..0000000 --- a/examples/imagepipe/click_example_imagepipe.egg-info/requires.txt +++ /dev/null @@ -1 +0,0 @@ -Click \ No newline at end of file diff --git a/examples/imagepipe/click_example_imagepipe.egg-info/top_level.txt b/examples/imagepipe/click_example_imagepipe.egg-info/top_level.txt deleted file mode 100644 index c7f8dcb..0000000 --- a/examples/imagepipe/click_example_imagepipe.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -imagepipe diff --git a/examples/imagepipe/setup.py b/examples/imagepipe/setup.py index 82d521c..d2d8d99 100644 --- a/examples/imagepipe/setup.py +++ b/examples/imagepipe/setup.py @@ -6,7 +6,7 @@ setup( py_modules=['imagepipe'], include_package_data=True, install_requires=[ - 'Click', + 'click', 'pillow', ], entry_points=''' diff --git a/examples/inout/setup.py b/examples/inout/setup.py index e976b3b..5c61364 100644 --- a/examples/inout/setup.py +++ b/examples/inout/setup.py @@ -6,7 +6,7 @@ setup( py_modules=['inout'], include_package_data=True, install_requires=[ - 'Click', + 'click', ], entry_points=''' [console_scripts] diff --git a/examples/naval/setup.py b/examples/naval/setup.py index 1998fb0..124addf 100644 --- a/examples/naval/setup.py +++ b/examples/naval/setup.py @@ -6,7 +6,7 @@ setup( py_modules=['naval'], include_package_data=True, install_requires=[ - 'Click', + 'click', ], entry_points=''' [console_scripts] diff --git a/examples/imagepipe/click_example_imagepipe.egg-info/PKG-INFO b/examples/plugins/BrokenPlugin/printer_bold.egg-info/PKG-INFO similarity index 77% rename from examples/imagepipe/click_example_imagepipe.egg-info/PKG-INFO rename to examples/plugins/BrokenPlugin/printer_bold.egg-info/PKG-INFO index 9cde7e6..97a4724 100644 --- a/examples/imagepipe/click_example_imagepipe.egg-info/PKG-INFO +++ b/examples/plugins/BrokenPlugin/printer_bold.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 1.0 -Name: click-example-imagepipe -Version: 1.0 +Name: printer-bold +Version: 0.1dev0 Summary: UNKNOWN Home-page: UNKNOWN Author: UNKNOWN diff --git a/examples/plugins/BrokenPlugin/printer_bold.egg-info/SOURCES.txt b/examples/plugins/BrokenPlugin/printer_bold.egg-info/SOURCES.txt new file mode 100644 index 0000000..34aba44 --- /dev/null +++ b/examples/plugins/BrokenPlugin/printer_bold.egg-info/SOURCES.txt @@ -0,0 +1,8 @@ +README.rst +printer_bold/__init__.py +printer_bold/core.py +printer_bold.egg-info/PKG-INFO +printer_bold.egg-info/SOURCES.txt +printer_bold.egg-info/dependency_links.txt +printer_bold.egg-info/entry_points.txt +printer_bold.egg-info/top_level.txt \ No newline at end of file diff --git a/examples/imagepipe/click_example_imagepipe.egg-info/dependency_links.txt b/examples/plugins/BrokenPlugin/printer_bold.egg-info/dependency_links.txt similarity index 100% rename from examples/imagepipe/click_example_imagepipe.egg-info/dependency_links.txt rename to examples/plugins/BrokenPlugin/printer_bold.egg-info/dependency_links.txt diff --git a/examples/plugins/BrokenPlugin/printer_bold.egg-info/entry_points.txt b/examples/plugins/BrokenPlugin/printer_bold.egg-info/entry_points.txt new file mode 100644 index 0000000..1635389 --- /dev/null +++ b/examples/plugins/BrokenPlugin/printer_bold.egg-info/entry_points.txt @@ -0,0 +1,4 @@ + + [printer.plugins] + bold=printer_bold.core:bolddddddddddd + \ No newline at end of file diff --git a/examples/plugins/BrokenPlugin/printer_bold.egg-info/top_level.txt b/examples/plugins/BrokenPlugin/printer_bold.egg-info/top_level.txt new file mode 100644 index 0000000..be45b4b --- /dev/null +++ b/examples/plugins/BrokenPlugin/printer_bold.egg-info/top_level.txt @@ -0,0 +1 @@ +printer_bold diff --git a/examples/plugins/printer.egg-info/PKG-INFO b/examples/plugins/printer.egg-info/PKG-INFO new file mode 100644 index 0000000..7f22902 --- /dev/null +++ b/examples/plugins/printer.egg-info/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 1.0 +Name: printer +Version: 0.1dev0 +Summary: UNKNOWN +Home-page: UNKNOWN +Author: UNKNOWN +Author-email: UNKNOWN +License: UNKNOWN +Description: UNKNOWN +Platform: UNKNOWN diff --git a/examples/plugins/printer.egg-info/SOURCES.txt b/examples/plugins/printer.egg-info/SOURCES.txt new file mode 100644 index 0000000..a1d305e --- /dev/null +++ b/examples/plugins/printer.egg-info/SOURCES.txt @@ -0,0 +1,8 @@ +README.rst +printer/__init__.py +printer/cli.py +printer.egg-info/PKG-INFO +printer.egg-info/SOURCES.txt +printer.egg-info/dependency_links.txt +printer.egg-info/entry_points.txt +printer.egg-info/top_level.txt \ No newline at end of file diff --git a/examples/plugins/printer.egg-info/dependency_links.txt b/examples/plugins/printer.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/plugins/printer.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/examples/plugins/printer.egg-info/entry_points.txt b/examples/plugins/printer.egg-info/entry_points.txt new file mode 100644 index 0000000..f2e6982 --- /dev/null +++ b/examples/plugins/printer.egg-info/entry_points.txt @@ -0,0 +1,4 @@ + + [console_scripts] + printer=printer.cli:cli + \ No newline at end of file diff --git a/examples/plugins/printer.egg-info/top_level.txt b/examples/plugins/printer.egg-info/top_level.txt new file mode 100644 index 0000000..24b8a4f --- /dev/null +++ b/examples/plugins/printer.egg-info/top_level.txt @@ -0,0 +1 @@ +printer diff --git a/examples/repo/setup.py b/examples/repo/setup.py index fab2829..19aab70 100644 --- a/examples/repo/setup.py +++ b/examples/repo/setup.py @@ -6,7 +6,7 @@ setup( py_modules=['repo'], include_package_data=True, install_requires=[ - 'Click', + 'click', ], entry_points=''' [console_scripts] diff --git a/examples/termui/build/lib/termui.py b/examples/termui/build/lib/termui.py new file mode 100644 index 0000000..007ac42 --- /dev/null +++ b/examples/termui/build/lib/termui.py @@ -0,0 +1,145 @@ +# coding: utf-8 +import click +import time +import random + +try: + range_type = xrange +except NameError: + range_type = range + + +@click.group() +def cli(): + """This script showcases different terminal UI helpers in Click.""" + pass + + +@cli.command() +def colordemo(): + """Demonstrates ANSI color support.""" + for color in 'red', 'green', 'blue': + click.echo(click.style('I am colored %s' % color, fg=color)) + click.echo(click.style('I am background colored %s' % color, bg=color)) + + +@cli.command() +def pager(): + """Demonstrates using the pager.""" + lines = [] + for x in range_type(200): + lines.append('%s. Hello World!' % click.style(str(x), fg='green')) + click.echo_via_pager('\n'.join(lines)) + + +@cli.command() +@click.option('--count', default=8000, type=click.IntRange(1, 100000), + help='The number of items to process.') +def progress(count): + """Demonstrates the progress bar.""" + items = range_type(count) + + def process_slowly(item): + time.sleep(0.002 * random.random()) + + def filter(items): + for item in items: + if random.random() > 0.3: + yield item + + with click.progressbar(items, label='Processing accounts', + fill_char=click.style('#', fg='green')) as bar: + for item in bar: + process_slowly(item) + + def show_item(item): + if item is not None: + return 'Item #%d' % item + + with click.progressbar(filter(items), label='Committing transaction', + fill_char=click.style('#', fg='yellow'), + item_show_func=show_item) as bar: + for item in bar: + process_slowly(item) + + with click.progressbar(length=count, label='Counting', + bar_template='%(label)s %(bar)s | %(info)s', + fill_char=click.style(u'█', fg='cyan'), + empty_char=' ') as bar: + for item in bar: + process_slowly(item) + + with click.progressbar(length=count, width=0, show_percent=False, + show_eta=False, + fill_char=click.style('#', fg='magenta')) as bar: + for item in bar: + process_slowly(item) + + +@cli.command() +@click.argument('url') +def open(url): + """Opens a file or URL In the default application.""" + click.launch(url) + + +@cli.command() +@click.argument('url') +def locate(url): + """Opens a file or URL In the default application.""" + click.launch(url, locate=True) + + +@cli.command() +def edit(): + """Opens an editor with some text in it.""" + MARKER = '# Everything below is ignored\n' + message = click.edit('\n\n' + MARKER) + if message is not None: + msg = message.split(MARKER, 1)[0].rstrip('\n') + if not msg: + click.echo('Empty message!') + else: + click.echo('Message:\n' + msg) + else: + click.echo('You did not enter anything!') + + +@cli.command() +def clear(): + """Clears the entire screen.""" + click.clear() + + +@cli.command() +def pause(): + """Waits for the user to press a button.""" + click.pause() + + +@cli.command() +def menu(): + """Shows a simple menu.""" + menu = 'main' + while 1: + if menu == 'main': + click.echo('Main menu:') + click.echo(' d: debug menu') + click.echo(' q: quit') + char = click.getchar() + if char == 'd': + menu = 'debug' + elif char == 'q': + menu = 'quit' + else: + click.echo('Invalid input') + elif menu == 'debug': + click.echo('Debug menu') + click.echo(' b: back') + char = click.getchar() + if char == 'b': + menu = 'main' + else: + click.echo('Invalid input') + elif menu == 'quit': + return diff --git a/examples/termui/dist/click_example_termui-1.0-py3.4.egg b/examples/termui/dist/click_example_termui-1.0-py3.4.egg new file mode 100644 index 0000000..4bb967c Binary files /dev/null and b/examples/termui/dist/click_example_termui-1.0-py3.4.egg differ diff --git a/examples/termui/setup.py b/examples/termui/setup.py index 8478cf8..14558e8 100644 --- a/examples/termui/setup.py +++ b/examples/termui/setup.py @@ -6,7 +6,7 @@ setup( py_modules=['termui'], include_package_data=True, install_requires=[ - 'Click', + 'click', # Colorama is only required for Windows. 'colorama', ], diff --git a/examples/termui/termui.py b/examples/termui/termui.py index 007ac42..793afa4 100644 --- a/examples/termui/termui.py +++ b/examples/termui/termui.py @@ -1,5 +1,6 @@ # coding: utf-8 import click +import math import time import random @@ -75,6 +76,16 @@ def progress(count): for item in bar: process_slowly(item) + # 'Non-linear progress bar' + steps = [math.exp( x * 1. / 20) - 1 for x in range(20)] + count = int(sum(steps)) + with click.progressbar(length=count, show_percent=False, + label='Slowing progress bar', + fill_char=click.style(u'█', fg='green')) as bar: + for item in steps: + time.sleep(item) + bar.update(item) + @cli.command() @click.argument('url') diff --git a/examples/validation/setup.py b/examples/validation/setup.py index e0bc889..9491f70 100644 --- a/examples/validation/setup.py +++ b/examples/validation/setup.py @@ -6,7 +6,7 @@ setup( py_modules=['validation'], include_package_data=True, install_requires=[ - 'Click', + 'click', ], entry_points=''' [console_scripts] diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 3f936ed..d1e0ec6 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -34,6 +34,27 @@ def test_nargs_tup(runner): ] +def test_nargs_tup_composite(runner): + variations = [ + dict(type=(str, int)), + dict(type=click.Tuple([str, int])), + dict(nargs=2, type=click.Tuple([str, int])), + dict(nargs=2, type=(str, int)), + ] + + for opts in variations: + @click.command() + @click.argument('item', **opts) + def copy(item): + click.echo('name=%s id=%d' % item) + + result = runner.invoke(copy, ['peter', '1']) + assert not result.exception + assert result.output.splitlines() == [ + 'name=peter id=1', + ] + + def test_nargs_err(runner): @click.command() @click.argument('x') @@ -106,7 +127,7 @@ def test_stdout_default(runner): def test_nargs_envvar(runner): @click.command() - @click.option('--arg', nargs=-1) + @click.option('--arg', nargs=2) def cmd(arg): click.echo('|'.join(arg)) @@ -116,7 +137,7 @@ def test_nargs_envvar(runner): assert result.output == 'foo|bar\n' @click.command() - @click.option('--arg', envvar='X', nargs=-1) + @click.option('--arg', envvar='X', nargs=2) def cmd(arg): click.echo('|'.join(arg)) @@ -189,3 +210,37 @@ def test_eat_options(runner): 'bar', '-x', ] + + +def test_nargs_star_ordering(runner): + @click.command() + @click.argument('a', nargs=-1) + @click.argument('b') + @click.argument('c') + def cmd(a, b, c): + for arg in (a, b, c): + click.echo(arg) + + result = runner.invoke(cmd, ['a', 'b', 'c']) + assert result.output.splitlines() == [ + "('a',)", + 'b', + 'c', + ] + + +def test_nargs_specified_plus_star_ordering(runner): + @click.command() + @click.argument('a', nargs=-1) + @click.argument('b') + @click.argument('c', nargs=2) + def cmd(a, b, c): + for arg in (a, b, c): + click.echo(arg) + + result = runner.invoke(cmd, ['a', 'b', 'c', 'd', 'e', 'f']) + assert result.output.splitlines() == [ + "('a', 'b', 'c')", + 'd', + "('e', 'f')", + ] diff --git a/tests/test_commands.py b/tests/test_commands.py index 7c8b453..ac9760f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -252,3 +252,21 @@ def test_invoked_subcommand(runner): result = runner.invoke(cli) assert not result.exception assert result.output == 'no subcommand, use default\nin subcommand\n' + + +def test_unprocessed_options(runner): + @click.command(context_settings=dict( + ignore_unknown_options=True + )) + @click.argument('args', nargs=-1, type=click.UNPROCESSED) + @click.option('--verbose', '-v', count=True) + def cli(verbose, args): + click.echo('Verbosity: %s' % verbose) + click.echo('Args: %s' % '|'.join(args)) + + result = runner.invoke(cli, ['-foo', '-vvvvx', '--muhaha', 'x', 'y', '-x']) + assert not result.exception + assert result.output.splitlines() == [ + 'Verbosity: 4', + 'Args: -foo|-x|--muhaha|x|y|-x', + ] diff --git a/tests/test_compat.py b/tests/test_compat.py index a46530c..e4ecdc8 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -15,3 +15,10 @@ if click.__version__ >= '3.0': assert result.exit_code == 0 assert 'WAT' in result.output assert 'Invoked legacy parameter callback' in result.output + + +def test_bash_func_name(): + from click._bashcomplete import get_completion_script + script = get_completion_script('foo-bar baz_blah', '_COMPLETE_VAR').strip() + assert script.startswith('_foo_barbaz_blah_completion()') + assert '_COMPLETE_VAR=complete $1' in script diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 30059aa..448f0d6 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -48,3 +48,63 @@ def test_basic_functionality(runner): 'Options:', ' --help Show this message and exit.', ] + + +def test_wrapping_long_options_strings(runner): + @click.group() + def cli(): + """Top level command + """ + + @cli.group() + def a_very_long(): + """Second level + """ + + @a_very_long.command() + @click.argument('first') + @click.argument('second') + @click.argument('third') + @click.argument('fourth') + @click.argument('fifth') + @click.argument('sixth') + def command(): + """A command. + """ + + # 54 is chosen as a lenthg where the second line is one character + # longer than the maximum length. + result = runner.invoke(cli, ['a_very_long', 'command', '--help'], + terminal_width=54) + assert not result.exception + assert result.output.splitlines() == [ + 'Usage: cli a_very_long command [OPTIONS] FIRST SECOND', + ' THIRD FOURTH FIFTH', + ' SIXTH', + '', + ' A command.', + '', + 'Options:', + ' --help Show this message and exit.', + ] + + +def test_formatting_empty_help_lines(runner): + @click.command() + def cli(): + """Top level command + + """ + + result = runner.invoke(cli, ['--help']) + assert not result.exception + assert result.output.splitlines() == [ + 'Usage: cli [OPTIONS]', + '', + ' Top level command', + '', + '', + '', + 'Options:', + ' --help Show this message and exit.', + ] diff --git a/tests/test_options.py b/tests/test_options.py index e60ed43..200cd2e 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -34,6 +34,33 @@ def test_invalid_option(runner): assert False, 'Expected a type error because of an invalid option.' +def test_invalid_nargs(runner): + try: + @click.command() + @click.option('--foo', nargs=-1) + def cli(foo): + pass + except TypeError as e: + assert 'Options cannot have nargs < 0' in str(e) + else: + assert False, 'Expected a type error because of an invalid option.' + + +def test_nargs_tup_composite_mult(runner): + @click.command() + @click.option('--item', type=(str, int), multiple=True) + def copy(item): + for item in item: + click.echo('name=%s id=%d' % item) + + result = runner.invoke(copy, ['--item', 'peter', '1', '--item', 'max', '2']) + assert not result.exception + assert result.output.splitlines() == [ + 'name=peter id=1', + 'name=max id=2', + ] + + def test_counting(runner): @click.command() @click.option('-v', count=True, help='Verbosity', @@ -115,6 +142,34 @@ def test_multiple_envvar(runner): assert result.output == 'foo|bar\n' +def test_multiple_default_help(runner): + @click.command() + @click.option("--arg1", multiple=True, default=('foo', 'bar'), + show_default=True) + @click.option("--arg2", multiple=True, default=(1, 2), type=int, + show_default=True) + def cmd(arg, arg2): + pass + + result = runner.invoke(cmd, ['--help']) + assert not result.exception + assert "foo, bar" in result.output + assert "1, 2" in result.output + +def test_multiple_default_type(runner): + @click.command() + @click.option("--arg1", multiple=True, default=('foo', 'bar')) + @click.option("--arg2", multiple=True, default=(1, "a")) + def cmd(arg1, arg2): + assert all(isinstance(e[0],str) for e in arg1) + assert all(isinstance(e[1],str) for e in arg1) + + assert all(isinstance(e[0],int) for e in arg2) + assert all(isinstance(e[1],str) for e in arg2) + + result = runner.invoke(cmd, "--arg1 a b --arg1 test 1 --arg2 2 two --arg2 4 four".split()) + assert not result.exception + def test_nargs_envvar(runner): @click.command() @click.option('--arg', nargs=2) @@ -217,3 +272,35 @@ def test_multiline_help(runner): assert ' --foo TEXT hello' in out assert ' i am' in out assert ' multiline' in out + + +def test_argument_custom_class(runner): + class CustomArgument(click.Argument): + def get_default(self, ctx): + '''a dumb override of a default value for testing''' + return 'I am a default' + + @click.command() + @click.argument('testarg', cls=CustomArgument, default='you wont see me') + def cmd(testarg): + click.echo(testarg) + + result = runner.invoke(cmd) + assert 'I am a default' in result.output + assert 'you wont see me' not in result.output + + +def test_option_custom_class(runner): + class CustomOption(click.Option): + def get_help_record(self, ctx): + '''a dumb override of a help text for testing''' + return ('--help', 'I am a help text') + + @click.command() + @click.option('--testoption', cls=CustomOption, help='you wont see me') + def cmd(testoption): + click.echo(testoption) + + result = runner.invoke(cmd, ['--help']) + assert 'I am a help text' in result.output + assert 'you wont see me' not in result.output diff --git a/tests/test_testing.py b/tests/test_testing.py index 7903b37..590248d 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -113,3 +113,30 @@ def test_catch_exceptions(): result = runner.invoke(cli) assert result.exit_code == 1 + + +def test_with_color(): + @click.command() + def cli(): + click.secho('hello world', fg='blue') + + runner = CliRunner() + + result = runner.invoke(cli) + assert result.output == 'hello world\n' + assert not result.exception + + result = runner.invoke(cli, color=True) + assert result.output == click.style('hello world', fg='blue') + '\n' + assert not result.exception + + +def test_with_color_but_pause_not_blocking(): + @click.command() + def cli(): + click.pause() + + runner = CliRunner() + result = runner.invoke(cli, color=True) + assert not result.exception + assert result.output == '' diff --git a/tests/test_utils.py b/tests/test_utils.py index 45ef6dc..c31f0b9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -39,6 +39,13 @@ def test_echo(runner): assert out.getvalue() == b'\x1b[31mx\x1b[39m' +def test_echo_custom_file(): + import io + f = io.StringIO() + click.echo(u'hello', file=f) + assert f.getvalue() == u'hello\n' + + def test_styling(): examples = [ ('x', dict(fg='black'), '\x1b[30mx\x1b[0m'), @@ -124,6 +131,90 @@ def test_echo_via_pager(monkeypatch, capfd): assert out == 'haha\n' +def test_echo_color_flag(monkeypatch, capfd): + isatty = True + monkeypatch.setattr(click._compat, 'isatty', lambda x: isatty) + + text = 'foo' + styled_text = click.style(text, fg='red') + assert styled_text == '\x1b[31mfoo\x1b[0m' + + click.echo(styled_text, color=False) + out, err = capfd.readouterr() + assert out == text + '\n' + + click.echo(styled_text, color=True) + out, err = capfd.readouterr() + assert out == styled_text + '\n' + + isatty = True + click.echo(styled_text) + out, err = capfd.readouterr() + assert out == styled_text + '\n' + + isatty = False + click.echo(styled_text) + out, err = capfd.readouterr() + assert out == text + '\n' + + +def test_echo_writing_to_standard_error(capfd, monkeypatch): + def emulate_input(text): + """Emulate keyboard input.""" + if sys.version_info[0] == 2: + from StringIO import StringIO + else: + from io import StringIO + monkeypatch.setattr(sys, 'stdin', StringIO(text)) + + click.echo('Echo to standard output') + out, err = capfd.readouterr() + assert out == 'Echo to standard output\n' + assert err == '' + + click.echo('Echo to standard error', err=True) + out, err = capfd.readouterr() + assert out == '' + assert err == 'Echo to standard error\n' + + emulate_input('asdlkj\n') + click.prompt('Prompt to stdin') + out, err = capfd.readouterr() + assert out == 'Prompt to stdin: ' + assert err == '' + + emulate_input('asdlkj\n') + click.prompt('Prompt to stderr', err=True) + out, err = capfd.readouterr() + assert out == '' + assert err == 'Prompt to stderr: ' + + emulate_input('y\n') + click.confirm('Prompt to stdin') + out, err = capfd.readouterr() + assert out == 'Prompt to stdin [y/N]: ' + assert err == '' + + emulate_input('y\n') + click.confirm('Prompt to stderr', err=True) + out, err = capfd.readouterr() + assert out == '' + assert err == 'Prompt to stderr [y/N]: ' + + monkeypatch.setattr(click.termui, 'isatty', lambda x: True) + monkeypatch.setattr(click.termui, 'getchar', lambda: ' ') + + click.pause('Pause to stdout') + out, err = capfd.readouterr() + assert out == 'Pause to stdout\n' + assert err == '' + + click.pause('Pause to stderr', err=True) + out, err = capfd.readouterr() + assert out == '' + assert err == 'Pause to stderr\n' + + def test_open_file(runner): with runner.isolated_filesystem(): with open('hello.txt', 'w') as f: