From 61266619cb201accdba6392a4809d3e70cac2b5c Mon Sep 17 00:00:00 2001 From: con-f-use Date: Sun, 13 Dec 2015 13:39:28 +0100 Subject: [PATCH 1/3] Progress bars no longer displayed if total time since start < 0.5s --- click/_termui_impl.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/click/_termui_impl.py b/click/_termui_impl.py index 9796a9f79..000df143f 100644 --- a/click/_termui_impl.py +++ b/click/_termui_impl.py @@ -88,6 +88,7 @@ def __init__(self, iterable, length=None, fill_char='#', empty_char=' ', self.current_item = None self.is_hidden = not isatty(self.file) self._last_line = None + self.short_limit = 0.5 def __enter__(self): self.entered = True @@ -103,8 +104,11 @@ def __iter__(self): self.render_progress() return self + def is_fast(self): + return time.time() - self.start <= self.short_limit + def render_finish(self): - if self.is_hidden: + if self.is_hidden or self.is_fast(): return self.file.write(AFTER_BAR) self.file.flush() @@ -225,7 +229,8 @@ def render_progress(self): line = ''.join(buf) # Render the line only if it changed. - if line != self._last_line: + + if line != self._last_line and not self.is_fast(): self._last_line = line echo(line, file=self.file, color=self.color, nl=nl) self.file.flush() From 82f9c626ffedb284eafe67235b5592be6919106d Mon Sep 17 00:00:00 2001 From: Jan Christoph Bischko Date: Tue, 15 May 2018 12:25:36 +0200 Subject: [PATCH 2/3] Fix test for delayed progress bar display --- CHANGES | 76 +++++- CONTRIBUTING.rst | 7 +- Makefile | 2 +- README.rst | 92 +++++++ click/__init__.py | 4 +- click/_bashcomplete.py | 187 ++++++++++++-- click/_compat.py | 92 +++++-- click/_termui_impl.py | 158 ++++++------ click/_unicodefun.py | 16 +- click/_winconsole.py | 39 +-- click/core.py | 94 +++++-- click/decorators.py | 23 +- click/exceptions.py | 34 ++- click/globals.py | 2 +- click/termui.py | 99 ++++++-- click/testing.py | 102 ++++++-- click/types.py | 130 ++++++---- click/utils.py | 5 +- docs/_templates/sidebarintro.html | 6 +- docs/advanced.rst | 10 +- docs/arguments.rst | 18 ++ docs/bashcomplete.rst | 51 +++- docs/clickdoctools.py | 11 +- docs/commands.rst | 6 +- docs/complex.rst | 10 +- docs/options.rst | 19 +- docs/parameters.rst | 7 +- docs/python3.rst | 4 +- docs/quickstart.rst | 22 +- docs/upgrading.rst | 2 +- docs/utils.rst | 15 +- examples/bashcompletion/README | 12 + examples/bashcompletion/bashcompletion.py | 41 ++++ examples/bashcompletion/setup.py | 15 ++ examples/colors/colors.py | 4 +- examples/validation/validation.py | 2 +- setup.cfg | 8 +- setup.py | 33 ++- tests/test_arguments.py | 15 +- tests/test_bashcomplete.py | 286 ++++++++++++++++++++++ tests/test_basic.py | 101 +++++++- tests/test_commands.py | 66 ++++- tests/test_compat.py | 9 +- tests/test_formatting.py | 111 ++++++++- tests/test_imports.py | 3 +- tests/test_options.py | 83 ++++++- tests/test_termui.py | 85 ++++++- tests/test_testing.py | 74 ++++++ tests/test_utils.py | 51 ++-- tox.ini | 11 +- 50 files changed, 1986 insertions(+), 367 deletions(-) create mode 100644 README.rst create mode 100644 examples/bashcompletion/README create mode 100644 examples/bashcompletion/bashcompletion.py create mode 100644 examples/bashcompletion/setup.py create mode 100644 tests/test_bashcomplete.py diff --git a/CHANGES b/CHANGES index a270cfbfe..cb2d35ae8 100644 --- a/CHANGES +++ b/CHANGES @@ -7,13 +7,85 @@ Version 7.0 ----------- (upcoming release with new features, release date to be decided) - +- Added support for bash completions containing spaces. See #773. +- Added support for dynamic bash completion from a user-supplied callback. + See #755. +- Added support for bash completion of type=click.Choice for Options and + Arguments. See #535. +- The user is now presented with the available choices if prompt=True and + type=click.Choice in a click.option. The choices are listed within + parenthesis like 'Choose fruit (apple, orange): '. - The exception objects now store unicode properly. +- Added the ability to hide commands and options from help. +- Added Float Range in Types. +- `secho`'s first argument can now be `None`, like in `echo`. +- Usage errors now hint at the `--help` option. +- ``launch`` now works properly under Cygwin. See #650. +- `CliRunner.invoke` now may receive `args` as a string representing + a Unix shell command. See #664. +- Fix bug that caused bashcompletion to give improper completions on + chained commands. See #774. +- Add support for bright colors. +- 't' and 'f' are now converted to True and False. +- Fix bug that caused bashcompletion to give improper completions on + chained commands when a required option/argument was being completed. + See #790. +- Allow autocompletion function to determine whether or not to return + completions that start with the incomplete argument. +- Subcommands that are named by the function now automatically have the + underscore replaced with a dash. So if you register a function named + `my_command` it becomes `my-command` in the command line interface. +- Stdout is now automatically set to non blocking. +- Use realpath to convert atomic file internally into its full canonical + path so that changing working directories does not harm it. +- Force stdout/stderr writable. This works around issues with badly patched + standard streams like those from jupyter. + +Version 6.8 +----------- + +(bugfix release; yet to be released) + +- Disabled sys._getframes() on Python interpreters that don't support it. See + #728. +- Fix bug in test runner when calling ``sys.exit`` with ``None``. See #739. +- Fix crash on Windows console, see #744. +- Fix bashcompletion on chained commands. See #754. +- Fix option naming routine to match documentation. See #793 +- Fixed the behavior of click error messages with regards to unicode on 2.x + and 3.x respectively. Message is now always unicode and the str and unicode + special methods work as you expect on that platform. + +Version 6.7 +----------- + +(bugfix release; released on January 6th 2017) + +- Make `click.progressbar` work with `codecs.open` files. See #637. +- Fix bug in bash completion with nested subcommands. See #639. +- Fix test runner not saving caller env correctly. See #644. +- Fix handling of SIGPIPE. See #626 +- Deal with broken Windows environments such as Google App Engine's. See #711. + +Version 6.6 +----------- + +(bugfix release; released on April 4th 2016) + +- Fix bug in `click.Path` where it would crash when passed a `-`. See #551. + +Version 6.4 +----------- + +(bugfix release; released on March 24th 2016) + +- Fix bug in bash completion where click would discard one or more trailing + arguments. See #471. Version 6.3 ----------- -(bugfix release; unreleased) +(bugfix release; released on February 22 2016) - Fix argument checks for interpreter invoke with `-m` and `-c` on Windows. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e16e50b2d..99b493ad7 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -32,11 +32,16 @@ Submitting patches - Try to follow `PEP8 `_, but you may ignore the line-length-limit if following it would make the code uglier. +- For features: Consider whether your feature would be a better fit for an + `external package `_ + +- For bugfixes: Submit against the latest maintenance branch instead of master! + Running the testsuite --------------------- You probably want to set up a `virtualenv -`_. +`_. The minimal requirement for running the testsuite is ``py.test``. You can install it with:: diff --git a/Makefile b/Makefile index 6927e4c93..971e401dd 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ test: - @cd tests; PYTHONPATH=.. py.test --tb=short + @cd tests; PYTHONPATH=.. pytest --tb=short upload-docs: $(MAKE) -C docs dirhtml diff --git a/README.rst b/README.rst new file mode 100644 index 000000000..07352ad18 --- /dev/null +++ b/README.rst @@ -0,0 +1,92 @@ +\$ click\_ +========== + +What's Click? +------------- + +Click is a Python package for creating beautiful command line interfaces +in a composable way with as little code as necessary. It's the "Command +Line Interface Creation Kit". It's highly configurable but comes with +sensible defaults out of the box. + +It aims to make the process of writing command line tools quick and fun +while also preventing any frustration caused by the inability to implement +an intended CLI API. + +Click in three points: + - arbitrary nesting of commands + - automatic help page generation + - supports lazy loading of subcommands at runtime + + +Installing +---------- + +Install and update using `pip`_: + +.. code-block:: text + + $ pip install click + +A Simple Example +---------------- + +What does it look like? Here is an example of a simple Click program: + +.. code-block:: python + + import click + + @click.command() + @click.option('--count', default=1, help='Number of greetings.') + @click.option('--name', prompt='Your name', + help='The person to greet.') + def hello(count, name): + """Simple program that greets NAME for a total of COUNT times.""" + for x in range(count): + click.echo('Hello %s!' % name) + + if __name__ == '__main__': + hello() + +And what it looks like when run: + +.. code-block:: text + + $ python hello.py --count=3 + Your name: John + Hello John! + Hello John! + Hello John! + +Donate +------ + +The Pallets organization develops and supports Flask and the libraries +it uses. In order to grow the community of contributors and users, and +allow the maintainers to devote more time to the projects, `please +donate today`_. + +.. _please donate today: https://psfmember.org/civicrm/contribute/transact?reset=1&id=20 + + +Links +----- + +* Website: https://www.palletsprojects.com/p/click/ +* Documentation: http://click.pocoo.org/ +* License: `BSD `_ +* Releases: https://pypi.org/project/click/ +* Code: https://github.com/pallets/click +* Issue tracker: https://github.com/pallets/click/issues +* Test status: + + * Linux, Mac: https://travis-ci.org/pallets/click + * Windows: https://ci.appveyor.com/project/pallets/click + +* Test coverage: https://codecov.io/gh/pallets/click + +.. _WSGI: https://wsgi.readthedocs.io +.. _Werkzeug: https://www.palletsprojects.com/p/werkzeug/ +.. _Jinja: https://www.palletsprojects.com/p/jinja/ +.. _pip: https://pip.pypa.io/en/stable/quickstart/ diff --git a/click/__init__.py b/click/__init__.py index 774d98822..6de314b31 100644 --- a/click/__init__.py +++ b/click/__init__.py @@ -28,7 +28,7 @@ # Types from .types import ParamType, File, Path, Choice, IntRange, Tuple, \ - STRING, INT, FLOAT, BOOL, UUID, UNPROCESSED + STRING, INT, FLOAT, BOOL, UUID, UNPROCESSED, FloatRange # Utilities from .utils import echo, get_binary_stream, get_text_stream, open_file, \ @@ -66,7 +66,7 @@ # Types 'ParamType', 'File', 'Path', 'Choice', 'IntRange', 'Tuple', 'STRING', - 'INT', 'FLOAT', 'BOOL', 'UUID', 'UNPROCESSED', + 'INT', 'FLOAT', 'BOOL', 'UUID', 'UNPROCESSED', 'FloatRange', # Utilities 'echo', 'get_binary_stream', 'get_text_stream', 'open_file', diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py index 43feffbc0..e0e739579 100644 --- a/click/_bashcomplete.py +++ b/click/_bashcomplete.py @@ -1,12 +1,18 @@ +import collections +import copy import os import re + from .utils import echo from .parser import split_arg_string -from .core import MultiCommand, Option +from .core import MultiCommand, Option, Argument +from .types import Choice +WORDBREAK = '=' COMPLETION_SCRIPT = ''' %(complete_func)s() { + local IFS=$'\n' COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ COMP_CWORD=$COMP_CWORD \\ %(autocomplete_var)s=complete $1 ) ) @@ -29,16 +35,164 @@ def get_completion_script(prog_name, complete_var): def resolve_ctx(cli, prog_name, args): + """ + Parse into a hierarchy of contexts. Contexts are connected through the parent variable. + :param cli: command definition + :param prog_name: the program that is running + :param args: full list of args + :return: the final context/command parsed + """ ctx = cli.make_context(prog_name, args, resilient_parsing=True) - while ctx.args and isinstance(ctx.command, MultiCommand): - cmd = ctx.command.get_command(ctx, ctx.args[0]) - if cmd is None: - return None - ctx = cmd.make_context(ctx.args[0], ctx.args[1:], parent=ctx, - resilient_parsing=True) + args_remaining = ctx.protected_args + ctx.args + while ctx is not None and args_remaining: + if isinstance(ctx.command, MultiCommand): + cmd = ctx.command.get_command(ctx, args_remaining[0]) + if cmd is None: + return None + ctx = cmd.make_context( + args_remaining[0], args_remaining[1:], parent=ctx, resilient_parsing=True) + args_remaining = ctx.protected_args + ctx.args + else: + ctx = ctx.parent + return ctx +def start_of_option(param_str): + """ + :param param_str: param_str to check + :return: whether or not this is the start of an option declaration (i.e. starts "-" or "--") + """ + return param_str and param_str[:1] == '-' + + +def is_incomplete_option(all_args, cmd_param): + """ + :param all_args: the full original list of args supplied + :param cmd_param: the current command paramter + :return: whether or not the last option declaration (i.e. starts "-" or "--") is incomplete and + corresponds to this cmd_param. In other words whether this cmd_param option can still accept + values + """ + if not isinstance(cmd_param, Option): + return False + if cmd_param.is_flag: + return False + last_option = None + for index, arg_str in enumerate(reversed([arg for arg in all_args if arg != WORDBREAK])): + if index + 1 > cmd_param.nargs: + break + if start_of_option(arg_str): + last_option = arg_str + + return True if last_option and last_option in cmd_param.opts else False + + +def is_incomplete_argument(current_params, cmd_param): + """ + :param current_params: the current params and values for this argument as already entered + :param cmd_param: the current command parameter + :return: whether or not the last argument is incomplete and corresponds to this cmd_param. In + other words whether or not the this cmd_param argument can still accept values + """ + if not isinstance(cmd_param, Argument): + return False + current_param_values = current_params[cmd_param.name] + if current_param_values is None: + return True + if cmd_param.nargs == -1: + return True + if isinstance(current_param_values, collections.Iterable) \ + and cmd_param.nargs > 1 and len(current_param_values) < cmd_param.nargs: + return True + return False + + +def get_user_autocompletions(ctx, args, incomplete, cmd_param): + """ + :param ctx: context associated with the parsed command + :param args: full list of args + :param incomplete: the incomplete text to autocomplete + :param cmd_param: command definition + :return: all the possible user-specified completions for the param + """ + if isinstance(cmd_param.type, Choice): + return [c for c in cmd_param.type.choices if c.startswith(incomplete)] + elif cmd_param.autocompletion is not None: + return cmd_param.autocompletion(ctx=ctx, + args=args, + incomplete=incomplete) + else: + return [] + + +def add_subcommand_completions(ctx, incomplete, completions_out): + # Add subcommand completions. + if isinstance(ctx.command, MultiCommand): + completions_out.extend( + [c for c in ctx.command.list_commands(ctx) if c.startswith(incomplete)]) + + # Walk up the context list and add any other completion possibilities from chained commands + while ctx.parent is not None: + ctx = ctx.parent + if isinstance(ctx.command, MultiCommand) and ctx.command.chain: + remaining_commands = sorted( + set(ctx.command.list_commands(ctx)) - set(ctx.protected_args)) + completions_out.extend( + [c for c in remaining_commands if c.startswith(incomplete)]) + + +def get_choices(cli, prog_name, args, incomplete): + """ + :param cli: command definition + :param prog_name: the program that is running + :param args: full list of args + :param incomplete: the incomplete text to autocomplete + :return: all the possible completions for the incomplete + """ + all_args = copy.deepcopy(args) + + ctx = resolve_ctx(cli, prog_name, args) + if ctx is None: + return [] + + # In newer versions of bash long opts with '='s are partitioned, but it's easier to parse + # without the '=' + if start_of_option(incomplete) and WORDBREAK in incomplete: + partition_incomplete = incomplete.partition(WORDBREAK) + all_args.append(partition_incomplete[0]) + incomplete = partition_incomplete[2] + elif incomplete == WORDBREAK: + incomplete = '' + + completions = [] + if start_of_option(incomplete): + # completions for partial options + for param in ctx.command.params: + if isinstance(param, Option): + param_opts = [param_opt for param_opt in param.opts + + param.secondary_opts if param_opt not in all_args or param.multiple] + completions.extend( + [c for c in param_opts if c.startswith(incomplete)]) + return completions + # completion for option values from user supplied values + for param in ctx.command.params: + if is_incomplete_option(all_args, param): + return get_user_autocompletions(ctx, all_args, incomplete, param) + # completion for argument values from user supplied values + for param in ctx.command.params: + if is_incomplete_argument(ctx.params, param): + completions.extend(get_user_autocompletions( + ctx, all_args, incomplete, param)) + # Stop looking for other completions only if this argument is required. + if param.required: + return completions + break + + add_subcommand_completions(ctx, incomplete, completions) + return completions + + def do_complete(cli, prog_name): cwords = split_arg_string(os.environ['COMP_WORDS']) cword = int(os.environ['COMP_CWORD']) @@ -48,23 +202,8 @@ def do_complete(cli, prog_name): except IndexError: incomplete = '' - ctx = resolve_ctx(cli, prog_name, args) - if ctx is None: - return True - - choices = [] - if incomplete and not incomplete[:1].isalnum(): - for param in ctx.command.params: - if not isinstance(param, Option): - continue - choices.extend(param.opts) - choices.extend(param.secondary_opts) - elif isinstance(ctx.command, MultiCommand): - choices.extend(ctx.command.list_commands(ctx)) - - for item in choices: - if item.startswith(incomplete): - echo(item) + for item in get_choices(cli, prog_name, args, incomplete): + echo(item) return True diff --git a/click/_compat.py b/click/_compat.py index 3e24a4eec..eeacc6347 100644 --- a/click/_compat.py +++ b/click/_compat.py @@ -8,23 +8,27 @@ PY2 = sys.version_info[0] == 2 WIN = sys.platform.startswith('win') +CYGWIN = sys.platform.startswith('cygwin') DEFAULT_COLUMNS = 80 -_ansi_re = re.compile('\033\[((?:\d|;)*)([a-zA-Z])') +_ansi_re = re.compile(r'\033\[((?:\d|;)*)([a-zA-Z])') def get_filesystem_encoding(): return sys.getfilesystemencoding() or sys.getdefaultencoding() -def _make_text_stream(stream, encoding, errors): +def _make_text_stream(stream, encoding, errors, + force_readable=False, force_writable=False): if encoding is None: encoding = get_best_encoding(stream) if errors is None: errors = 'replace' return _NonClosingTextIOWrapper(stream, encoding, errors, - line_buffering=True) + line_buffering=True, + force_readable=force_readable, + force_writable=force_writable) def is_ascii_encoding(encoding): @@ -45,8 +49,10 @@ def get_best_encoding(stream): class _NonClosingTextIOWrapper(io.TextIOWrapper): - def __init__(self, stream, encoding, errors, **extra): - self._stream = stream = _FixupStream(stream) + def __init__(self, stream, encoding, errors, + force_readable=False, force_writable=False, **extra): + self._stream = stream = _FixupStream(stream, force_readable, + force_writable) io.TextIOWrapper.__init__(self, stream, encoding, errors, **extra) # The io module is a place where the Python 3 text behavior @@ -81,10 +87,16 @@ class _FixupStream(object): """The new io interface needs more from streams than streams traditionally implement. As such, this fix-up code is necessary in some circumstances. + + The forcing of readable and writable flags are there because some tools + put badly patched objects on sys (one such offender are certain version + of jupyter notebook). """ - def __init__(self, stream): + def __init__(self, stream, force_readable=False, force_writable=False): self._stream = stream + self._force_readable = force_readable + self._force_writable = force_writable def __getattr__(self, name): return getattr(self._stream, name) @@ -101,6 +113,8 @@ def read1(self, size): return self._stream.read(size) def readable(self): + if self._force_readable: + return True x = getattr(self._stream, 'readable', None) if x is not None: return x() @@ -111,6 +125,8 @@ def readable(self): return True def writable(self): + if self._force_writable: + return True x = getattr(self._stream, 'writable', None) if x is not None: return x() @@ -161,8 +177,17 @@ def is_bytes(x): # # This code also lives in _winconsole for the fallback to the console # emulation stream. - if WIN: + # + # There are also Windows environments where the `msvcrt` module is not + # available (which is why we use try-catch instead of the WIN variable + # here), such as the Google App Engine development server on Windows. In + # those cases there is just nothing we can do. + def set_binary_mode(f): + return f + + try: import msvcrt + def set_binary_mode(f): try: fileno = f.fileno() @@ -171,8 +196,23 @@ def set_binary_mode(f): else: msvcrt.setmode(fileno, os.O_BINARY) return f - else: - set_binary_mode = lambda x: x + except ImportError: + pass + + try: + import fcntl + + def set_binary_mode(f): + try: + fileno = f.fileno() + except Exception: + pass + else: + flags = fcntl.fcntl(fileno, fcntl.F_GETFL) + fcntl.fcntl(fileno, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) + return f + except ImportError: + pass def isidentifier(x): return _identifier_re.search(x) is not None @@ -190,19 +230,22 @@ def get_text_stdin(encoding=None, errors=None): rv = _get_windows_console_stream(sys.stdin, encoding, errors) if rv is not None: return rv - return _make_text_stream(sys.stdin, encoding, errors) + return _make_text_stream(sys.stdin, encoding, errors, + force_readable=True) def get_text_stdout(encoding=None, errors=None): rv = _get_windows_console_stream(sys.stdout, encoding, errors) if rv is not None: return rv - return _make_text_stream(sys.stdout, encoding, errors) + return _make_text_stream(sys.stdout, encoding, errors, + force_writable=True) def get_text_stderr(encoding=None, errors=None): rv = _get_windows_console_stream(sys.stderr, encoding, errors) if rv is not None: return rv - return _make_text_stream(sys.stderr, encoding, errors) + return _make_text_stream(sys.stderr, encoding, errors, + force_writable=True) def filename_to_ui(value): if isinstance(value, bytes): @@ -294,7 +337,8 @@ def _is_compatible_text_stream(stream, encoding, errors): return False - def _force_correct_text_reader(text_reader, encoding, errors): + def _force_correct_text_reader(text_reader, encoding, errors, + force_readable=False): if _is_binary_reader(text_reader, False): binary_reader = text_reader else: @@ -320,9 +364,11 @@ def _force_correct_text_reader(text_reader, encoding, errors): # we're so fundamentally fucked that nothing can repair it. if errors is None: errors = 'replace' - return _make_text_stream(binary_reader, encoding, errors) + return _make_text_stream(binary_reader, encoding, errors, + force_readable=force_readable) - def _force_correct_text_writer(text_writer, encoding, errors): + def _force_correct_text_writer(text_writer, encoding, errors, + force_writable=False): if _is_binary_writer(text_writer, False): binary_writer = text_writer else: @@ -348,7 +394,8 @@ def _force_correct_text_writer(text_writer, encoding, errors): # we're so fundamentally fucked that nothing can repair it. if errors is None: errors = 'replace' - return _make_text_stream(binary_writer, encoding, errors) + return _make_text_stream(binary_writer, encoding, errors, + force_writable=force_writable) def get_binary_stdin(): reader = _find_binary_reader(sys.stdin) @@ -375,19 +422,22 @@ def get_text_stdin(encoding=None, errors=None): rv = _get_windows_console_stream(sys.stdin, encoding, errors) if rv is not None: return rv - return _force_correct_text_reader(sys.stdin, encoding, errors) + return _force_correct_text_reader(sys.stdin, encoding, errors, + force_readable=True) def get_text_stdout(encoding=None, errors=None): rv = _get_windows_console_stream(sys.stdout, encoding, errors) if rv is not None: return rv - return _force_correct_text_writer(sys.stdout, encoding, errors) + return _force_correct_text_writer(sys.stdout, encoding, errors, + force_writable=True) def get_text_stderr(encoding=None, errors=None): rv = _get_windows_console_stream(sys.stderr, encoding, errors) if rv is not None: return rv - return _force_correct_text_writer(sys.stderr, encoding, errors) + return _force_correct_text_writer(sys.stderr, encoding, errors, + force_writable=True) def filename_to_ui(value): if isinstance(value, bytes): @@ -416,7 +466,7 @@ def open_stream(filename, mode='r', encoding=None, errors='strict', # Standard streams first. These are simple because they don't need # special handling for the atomic flag. It's entirely ignored. if filename == '-': - if 'w' in mode: + if any(m in mode for m in ['w', 'a', 'x']): if 'b' in mode: return get_binary_stdout(), False return get_text_stdout(encoding=encoding, errors=errors), False @@ -456,7 +506,7 @@ def open_stream(filename, mode='r', encoding=None, errors='strict', else: f = os.fdopen(fd, mode) - return _AtomicFile(f, tmp_filename, filename), True + return _AtomicFile(f, tmp_filename, os.path.realpath(filename)), True # Used in a destructor call, needs extra protection from interpreter cleanup. diff --git a/click/_termui_impl.py b/click/_termui_impl.py index 000df143f..a4e421ecb 100644 --- a/click/_termui_impl.py +++ b/click/_termui_impl.py @@ -13,8 +13,10 @@ import sys import time import math + from ._compat import _default_text_stdout, range_type, PY2, isatty, \ - open_stream, strip_ansi, term_len, get_best_encoding, WIN, int_types + open_stream, strip_ansi, term_len, get_best_encoding, WIN, int_types, \ + CYGWIN from .utils import echo from .exceptions import ClickException @@ -31,7 +33,7 @@ def _length_hint(obj): """Returns the length hint of an object.""" try: return len(obj) - except TypeError: + except (AttributeError, TypeError): try: get_hint = type(obj).__length_hint__ except AttributeError: @@ -102,7 +104,7 @@ def __iter__(self): if not self.entered: raise RuntimeError('You need to use progress bars in a with block.') self.render_progress() - return self + return self.generator() def is_fast(self): return time.time() - self.start <= self.short_limit @@ -133,13 +135,13 @@ def eta(self): def format_eta(self): if self.eta_known: - t = self.eta + 1 + t = int(self.eta) seconds = t % 60 - t /= 60 + t //= 60 minutes = t % 60 - t /= 60 + t //= 60 hours = t % 24 - t /= 24 + t //= 24 if t > 0: days = t return '%dd %02d:%02d:%02d' % (days, hours, minutes, seconds) @@ -195,44 +197,41 @@ def format_progress_line(self): def render_progress(self): from .termui import get_terminal_size - nl = False if self.is_hidden: - buf = [self.label] - nl = True - else: - buf = [] - # Update width in case the terminal has been resized - if self.autowidth: - old_width = self.width - self.width = 0 - clutter_length = term_len(self.format_progress_line()) - new_width = max(0, get_terminal_size()[0] - clutter_length) - if new_width < old_width: - buf.append(BEFORE_BAR) - buf.append(' ' * self.max_width) - self.max_width = new_width - self.width = new_width - - clear_width = self.width - if self.max_width is not None: - clear_width = self.max_width - - buf.append(BEFORE_BAR) - line = self.format_progress_line() - line_len = term_len(line) - if self.max_width is None or self.max_width < line_len: - self.max_width = line_len - buf.append(line) - - buf.append(' ' * (clear_width - line_len)) - line = ''.join(buf) + return + buf = [] + # Update width in case the terminal has been resized + if self.autowidth: + old_width = self.width + self.width = 0 + clutter_length = term_len(self.format_progress_line()) + new_width = max(0, get_terminal_size()[0] - clutter_length) + if new_width < old_width: + buf.append(BEFORE_BAR) + buf.append(' ' * self.max_width) + self.max_width = new_width + self.width = new_width + + clear_width = self.width + if self.max_width is not None: + clear_width = self.max_width + + buf.append(BEFORE_BAR) + line = self.format_progress_line() + line_len = term_len(line) + if self.max_width is None or self.max_width < line_len: + self.max_width = line_len + + buf.append(line) + buf.append(' ' * (clear_width - line_len)) + line = ''.join(buf) # Render the line only if it changed. if line != self._last_line and not self.is_fast(): self._last_line = line - echo(line, file=self.file, color=self.color, nl=nl) + echo(line, file=self.file, color=self.color, nl=True) self.file.flush() def make_step(self, n_steps): @@ -257,54 +256,56 @@ def finish(self): self.current_item = None self.finished = True - def next(self): + def generator(self): + """ + Returns a generator which yields the items added to the bar during + construction, and updates the progress bar *after* the yielded block + returns. + """ + if not self.entered: + raise RuntimeError('You need to use progress bars in a with block.') + if self.is_hidden: - return next(self.iter) - try: - rv = next(self.iter) - self.current_item = rv - except StopIteration: + for rv in self.iter: + yield rv + else: + for rv in self.iter: + self.current_item = rv + yield rv + self.update(1) self.finish() self.render_progress() - raise StopIteration() - else: - self.update(1) - return rv - - if not PY2: - __next__ = next - del next -def pager(text, color=None): +def pager(generator, color=None): """Decide what method to use for paging through text.""" stdout = _default_text_stdout() if not isatty(sys.stdin) or not isatty(stdout): - return _nullpager(stdout, text, color) + return _nullpager(stdout, generator, color) pager_cmd = (os.environ.get('PAGER', None) or '').strip() if pager_cmd: if WIN: - return _tempfilepager(text, pager_cmd, color) - return _pipepager(text, pager_cmd, color) + return _tempfilepager(generator, pager_cmd, color) + return _pipepager(generator, pager_cmd, color) if os.environ.get('TERM') in ('dumb', 'emacs'): - return _nullpager(stdout, text, color) + return _nullpager(stdout, generator, color) if WIN or sys.platform.startswith('os2'): - return _tempfilepager(text, 'more <', color) + return _tempfilepager(generator, 'more <', color) if hasattr(os, 'system') and os.system('(less) 2>/dev/null') == 0: - return _pipepager(text, 'less', color) + return _pipepager(generator, 'less', color) import tempfile fd, filename = tempfile.mkstemp() os.close(fd) try: if hasattr(os, 'system') and os.system('more "%s"' % filename) == 0: - return _pipepager(text, 'more', color) - return _nullpager(stdout, text, color) + return _pipepager(generator, 'more', color) + return _nullpager(stdout, generator, color) finally: os.unlink(filename) -def _pipepager(text, cmd, color): +def _pipepager(generator, cmd, color): """Page through text by feeding it to another program. Invoking a pager through this might support colors. """ @@ -322,17 +323,19 @@ def _pipepager(text, cmd, color): elif 'r' in less_flags or 'R' in less_flags: color = True - if not color: - text = strip_ansi(text) - c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env) encoding = get_best_encoding(c.stdin) try: - c.stdin.write(text.encode(encoding, 'replace')) - c.stdin.close() + for text in generator: + if not color: + text = strip_ansi(text) + + c.stdin.write(text.encode(encoding, 'replace')) except (IOError, KeyboardInterrupt): pass + else: + c.stdin.close() # Less doesn't respect ^C, but catches it for its own UI purposes (aborting # search or other commands inside less). @@ -351,10 +354,12 @@ def _pipepager(text, cmd, color): break -def _tempfilepager(text, cmd, color): +def _tempfilepager(generator, cmd, color): """Page through text by invoking a program on a temporary file.""" import tempfile filename = tempfile.mktemp() + # TODO: This never terminates if the passed generator never terminates. + text = "".join(generator) if not color: text = strip_ansi(text) encoding = get_best_encoding(sys.stdout) @@ -366,11 +371,12 @@ def _tempfilepager(text, cmd, color): os.unlink(filename) -def _nullpager(stream, text, color): +def _nullpager(stream, generator, color): """Simply print unformatted text. This is the ultimate fallback.""" - if not color: - text = strip_ansi(text) - stream.write(text) + for text in generator: + if not color: + text = strip_ansi(text) + stream.write(text) class Editor(object): @@ -483,6 +489,14 @@ def _unquote_file(url): args = 'start %s "" "%s"' % ( wait and '/WAIT' or '', url.replace('"', '')) return os.system(args) + elif CYGWIN: + if locate: + url = _unquote_file(url) + args = 'cygstart "%s"' % (os.path.dirname(url).replace('"', '')) + else: + args = 'cygstart %s "%s"' % ( + wait and '-w' or '', url.replace('"', '')) + return os.system(args) try: if locate: diff --git a/click/_unicodefun.py b/click/_unicodefun.py index 24b703102..638341511 100644 --- a/click/_unicodefun.py +++ b/click/_unicodefun.py @@ -14,6 +14,8 @@ def _find_unicode_literals_frame(): import __future__ + if not hasattr(sys, '_getframe'): # not all Python implementations have it + return 0 frm = sys._getframe(1) idx = 1 while frm is not None: @@ -60,8 +62,11 @@ def _verify_python3_env(): extra = '' if os.name == 'posix': import subprocess - rv = subprocess.Popen(['locale', '-a'], stdout=subprocess.PIPE, - stderr=subprocess.PIPE).communicate()[0] + try: + rv = subprocess.Popen(['locale', '-a'], stdout=subprocess.PIPE, + stderr=subprocess.PIPE).communicate()[0] + except OSError: + rv = b'' good_locales = set() has_c_utf8 = False @@ -94,7 +99,7 @@ def _verify_python3_env(): else: extra += ( 'This system lists a couple of UTF-8 supporting locales that\n' - 'you can pick from. The following suitable locales where\n' + 'you can pick from. The following suitable locales were\n' 'discovered: %s' ) % ', '.join(sorted(good_locales)) @@ -114,6 +119,5 @@ def _verify_python3_env(): raise RuntimeError('Click will abort further execution because Python 3 ' 'was configured to use ASCII as encoding for the ' - 'environment. Either run this under Python 2 or ' - 'consult http://click.pocoo.org/python3/ for ' - 'mitigation steps.' + extra) + 'environment. Consult http://click.pocoo.org/python3/ ' + 'for mitigation steps.' + extra) diff --git a/click/_winconsole.py b/click/_winconsole.py index 4695be361..f1d5e28ca 100644 --- a/click/_winconsole.py +++ b/click/_winconsole.py @@ -16,16 +16,19 @@ import ctypes import msvcrt from click._compat import _NonClosingTextIOWrapper, text_type, PY2 -from ctypes import byref, POINTER, pythonapi, c_int, c_char, c_char_p, \ +from ctypes import byref, POINTER, c_int, c_char, c_char_p, \ c_void_p, py_object, c_ssize_t, c_ulong, windll, WINFUNCTYPE +try: + from ctypes import pythonapi + PyObject_GetBuffer = pythonapi.PyObject_GetBuffer + PyBuffer_Release = pythonapi.PyBuffer_Release +except ImportError: + pythonapi = None from ctypes.wintypes import LPWSTR, LPCWSTR c_ssize_p = POINTER(c_ssize_t) -PyObject_GetBuffer = pythonapi.PyObject_GetBuffer -PyBuffer_Release = pythonapi.PyBuffer_Release - kernel32 = windll.kernel32 GetStdHandle = kernel32.GetStdHandle ReadConsoleW = kernel32.ReadConsoleW @@ -77,15 +80,20 @@ class Py_buffer(ctypes.Structure): _fields_.insert(-1, ('smalltable', c_ssize_t * 2)) -def get_buffer(obj, writable=False): - buf = Py_buffer() - flags = PyBUF_WRITABLE if writable else PyBUF_SIMPLE - PyObject_GetBuffer(py_object(obj), byref(buf), flags) - try: - buffer_type = c_char * buf.len - return buffer_type.from_address(buf.buf) - finally: - PyBuffer_Release(byref(buf)) +# On PyPy we cannot get buffers so our ability to operate here is +# serverly limited. +if pythonapi is None: + get_buffer = None +else: + def get_buffer(obj, writable=False): + buf = Py_buffer() + flags = PyBUF_WRITABLE if writable else PyBUF_SIMPLE + PyObject_GetBuffer(py_object(obj), byref(buf), flags) + try: + buffer_type = c_char * buf.len + return buffer_type.from_address(buf.buf) + finally: + PyBuffer_Release(byref(buf)) class _WindowsConsoleRawIOBase(io.RawIOBase): @@ -246,13 +254,14 @@ def _get_windows_argv(): def _get_windows_console_stream(f, encoding, errors): - if encoding in ('utf-16-le', None) \ + if get_buffer is not None and \ + encoding in ('utf-16-le', None) \ and errors in ('strict', None) and \ hasattr(f, 'isatty') and f.isatty(): func = _stream_factories.get(f.fileno()) if func is not None: if not PY2: - f = getattr(f, 'buffer') + f = getattr(f, 'buffer', None) if f is None: return None else: diff --git a/click/core.py b/click/core.py index 33a527aac..1b7ef792e 100644 --- a/click/core.py +++ b/click/core.py @@ -1,3 +1,4 @@ +import errno import os import sys from contextlib import contextmanager @@ -24,6 +25,15 @@ SUBCOMMANDS_METAVAR = 'COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...' +def fast_exit(code): + """Exit without garbage collection, this speeds up exit by about 10ms for + things like bash completion. + """ + sys.stdout.flush() + sys.stderr.flush() + os._exit(code) + + def _bashcomplete(cmd, prog_name, complete_var=None): """Internal handler for the bash completion support.""" if complete_var is None: @@ -34,7 +44,7 @@ def _bashcomplete(cmd, prog_name, complete_var=None): from ._bashcomplete import bashcomplete if bashcomplete(cmd, prog_name, complete_var, complete_instr): - sys.exit(1) + fast_exit(1) def _check_multicommand(base_command, cmd_name, cmd, register=False): @@ -49,9 +59,7 @@ def _check_multicommand(base_command, cmd_name, cmd, register=False): raise RuntimeError('%s. Command "%s" is set to chain and "%s" was ' 'added as subcommand but it in itself is a ' 'multi command. ("%s" is a %s within a chained ' - '%s named "%s"). This restriction was supposed to ' - 'be lifted in 6.0 but the fix was flawed. This ' - 'will be fixed in Click 7.0' % ( + '%s named "%s").' % ( hint, base_command.name, cmd_name, cmd_name, cmd.__class__.__name__, base_command.__class__.__name__, @@ -371,7 +379,7 @@ def scope(self, cleanup=True): @property def meta(self): """This is a dictionary which is shared with all the contexts - that are nested. It exists so that click utiltiies can store some + that are nested. It exists so that click utilities can store some state here if they need to. It is however the responsibility of that code to manage this dictionary well. @@ -654,7 +662,7 @@ def main(self, args=None, prog_name=None, complete_var=None, name from ``sys.argv[0]``. :param complete_var: the environment variable that controls the bash completion support. The default is - ``"__COMPLETE"`` with prog name in + ``"__COMPLETE"`` with prog_name in uppercase. :param standalone_mode: the default behavior is to invoke the script in standalone mode. Click will then @@ -669,7 +677,7 @@ def main(self, args=None, prog_name=None, complete_var=None, constructor. See :class:`Context` for more information. """ # If we are in Python 3, we will verify that the environment is - # sane at this point of reject further execution to avoid a + # sane at this point or reject further execution to avoid a # broken script. if not PY2: _verify_python3_env() @@ -705,6 +713,11 @@ def main(self, args=None, prog_name=None, complete_var=None, raise e.show() sys.exit(e.exit_code) + except IOError as e: + if e.errno == errno.EPIPE: + sys.exit(1) + else: + raise except Abort: if not standalone_mode: raise @@ -737,11 +750,13 @@ class Command(BaseCommand): shown on the command listing of the parent command. :param add_help_option: by default each command registers a ``--help`` option. This can be disabled by this parameter. + :param hidden: hide this command from help outputs. """ def __init__(self, name, context_settings=None, callback=None, params=None, help=None, epilog=None, short_help=None, - options_metavar='[OPTIONS]', add_help_option=True): + options_metavar='[OPTIONS]', add_help_option=True, + hidden=False): BaseCommand.__init__(self, name, context_settings) #: the callback to execute when the command fires. This might be #: `None` in which case nothing happens. @@ -757,6 +772,7 @@ def __init__(self, name, context_settings=None, callback=None, short_help = make_default_short_help(help) self.short_help = short_help self.add_help_option = add_help_option + self.hidden = hidden def get_usage(self, ctx): formatter = ctx.make_formatter() @@ -810,8 +826,6 @@ def show_help(ctx, param, value): def make_parser(self, ctx): """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 @@ -996,6 +1010,8 @@ def format_commands(self, ctx, formatter): # What is this, the tool lied about a command. Ignore it if cmd is None: continue + if cmd.hidden: + continue help = cmd.short_help or '' rows.append((subcommand, help)) @@ -1210,7 +1226,7 @@ def list_commands(self, ctx): class Parameter(object): - """A parameter to a command comes in two versions: they are either + r"""A parameter to a command comes in two versions: they are either :class:`Option`\s or :class:`Argument`\s. Other subclasses are currently not supported by design as some of the internals for parsing are intentionally not finalized. @@ -1255,7 +1271,8 @@ class Parameter(object): def __init__(self, param_decls=None, type=None, required=False, default=None, callback=None, nargs=None, metavar=None, - expose_value=True, is_eager=False, envvar=None): + expose_value=True, is_eager=False, envvar=None, + autocompletion=None): self.name, self.opts, self.secondary_opts = \ self._parse_decls(param_decls or (), expose_value) @@ -1278,6 +1295,7 @@ def __init__(self, param_decls=None, type=None, required=False, self.is_eager = is_eager self.metavar = metavar self.envvar = envvar + self.autocompletion = autocompletion @property def human_readable_name(self): @@ -1308,12 +1326,13 @@ def get_default(self, ctx): def add_to_parser(self, parser, ctx): pass + def consume_value(self, ctx, opts): value = opts.get(self.name) - if value is None: - value = ctx.lookup_default(self.name) if value is None: value = self.value_from_envvar(ctx) + if value is None: + value = ctx.lookup_default(self.name) return value def type_cast_value(self, ctx, value): @@ -1410,6 +1429,13 @@ def get_help_record(self, ctx): def get_usage_pieces(self, ctx): return [] + def get_error_hint(self, ctx): + """Get a stringified version of the param for use in error messages to + indicate which param caused the error. + """ + hint_list = self.opts or [self.human_readable_name] + return ' / '.join('"%s"' % x for x in hint_list) + class Option(Parameter): """Options are usually optional values on the command line and @@ -1419,9 +1445,12 @@ class Option(Parameter): :param show_default: controls if the default value should be shown on the help page. Normally, defaults are not shown. - :param prompt: if set to `True` or a non empty string then the user will - be prompted for input if not set. If set to `True` the - prompt will be the option name capitalized. + :param show_envvar: controls if an environment variable should be shown on + the help page. Normally, environment variables + are not shown. + :param prompt: if set to `True` or a non empty string then the user will be + prompted for input. If set to `True` the prompt will be the + option name capitalized. :param confirmation_prompt: if set then the value will need to be confirmed if it was prompted for. :param hide_input: if this is `True` then the input on the prompt will be @@ -1442,6 +1471,7 @@ class Option(Parameter): variable in case a prefix is defined on the context. :param help: the help string. + :param hidden: hide this option from help outputs. """ param_type_name = 'option' @@ -1449,7 +1479,8 @@ def __init__(self, param_decls=None, show_default=False, prompt=False, confirmation_prompt=False, hide_input=False, is_flag=None, flag_value=None, multiple=False, count=False, allow_from_autoenv=True, - type=None, help=None, **attrs): + type=None, help=None, hidden=False, show_choices=True, + show_envvar=False, **attrs): default_is_missing = attrs.get('default', _missing) is _missing Parameter.__init__(self, param_decls, type=type, **attrs) @@ -1462,6 +1493,7 @@ def __init__(self, param_decls=None, show_default=False, self.prompt = prompt_text self.confirmation_prompt = confirmation_prompt self.hide_input = hide_input + self.hidden = hidden # Flags if is_flag is None: @@ -1494,6 +1526,8 @@ def __init__(self, param_decls=None, show_default=False, self.allow_from_autoenv = allow_from_autoenv self.help = help self.show_default = show_default + self.show_choices = show_choices + self.show_envvar = show_envvar # Sanity check for stuff we don't support if __debug__: @@ -1542,8 +1576,8 @@ def _parse_decls(self, decls, expose_value): opts.append(decl) if name is None and possible_names: - possible_names.sort(key=lambda x: len(x[0])) - name = possible_names[-1][1].replace('-', '_').lower() + possible_names.sort(key=lambda x: -len(x[0])) # group long options first + name = possible_names[0][1].replace('-', '_').lower() if not isidentifier(name): name = None @@ -1589,6 +1623,8 @@ def add_to_parser(self, parser, ctx): parser.add_option(self.opts, **kwargs) def get_help_record(self, ctx): + if self.hidden: + return any_prefix_is_slash = [] def _write_opts(opts): @@ -1605,6 +1641,17 @@ def _write_opts(opts): help = self.help or '' extra = [] + if self.show_envvar: + envvar = self.envvar + if envvar is None: + if self.allow_from_autoenv and \ + ctx.auto_envvar_prefix is not None: + envvar = '%s_%s' % (ctx.auto_envvar_prefix, self.name.upper()) + if envvar is not None: + extra.append('env var: %s' % ( + ', '.join('%s' % d for d in envvar) + if isinstance(envvar, (list, tuple)) + else envvar, )) if self.default is not None and self.show_default: extra.append('default: %s' % ( ', '.join('%s' % d for d in self.default) @@ -1643,8 +1690,8 @@ def prompt_for_value(self, ctx): if self.is_bool_flag: return confirm(self.prompt, default) - return prompt(self.prompt, default=default, - hide_input=self.hide_input, + return prompt(self.prompt, default=default, type=self.type, + hide_input=self.hide_input, show_choices=self.show_choices, confirmation_prompt=self.confirmation_prompt, value_proc=lambda x: self.process_value(ctx, x)) @@ -1729,6 +1776,9 @@ def _parse_decls(self, decls, expose_value): def get_usage_pieces(self, ctx): return [self.make_metavar()] + def get_error_hint(self, ctx): + return '"%s"' % self.make_metavar() + def add_to_parser(self, parser, ctx): parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) diff --git a/click/decorators.py b/click/decorators.py index 989345265..bfc7a058a 100644 --- a/click/decorators.py +++ b/click/decorators.py @@ -85,12 +85,12 @@ def _make_command(f, name, attrs, cls): help = inspect.cleandoc(help) attrs['help'] = help _check_for_unicode_literals() - return cls(name=name or f.__name__.lower(), + return cls(name=name or f.__name__.lower().replace('_', '-'), callback=f, params=params, **attrs) def command(name=None, cls=None, **attrs): - """Creates a new :class:`Command` and uses the decorated function as + r"""Creates a new :class:`Command` and uses the decorated function as callback. This will also automatically attach all decorated :func:`option`\s and :func:`argument`\s as parameters to the command. @@ -105,7 +105,7 @@ def command(name=None, cls=None, **attrs): command :class:`Group`. :param name: the name of the command. This defaults to the function - name. + name with underscores replaced by dashes. :param cls: the command class to instantiate. This defaults to :class:`Command`. """ @@ -164,10 +164,13 @@ def option(*param_decls, **attrs): :class:`Option`. """ def decorator(f): - if 'help' in attrs: - attrs['help'] = inspect.cleandoc(attrs['help']) - OptionClass = attrs.pop('cls', Option) - _param_memo(f, OptionClass(param_decls, **attrs)) + # Issue 926, copy attrs, so pre-defined options can re-use the same cls= + option_attrs = attrs.copy() + + if 'help' in option_attrs: + option_attrs['help'] = inspect.cleandoc(option_attrs['help']) + OptionClass = option_attrs.pop('cls', Option) + _param_memo(f, OptionClass(param_decls, **option_attrs)) return f return decorator @@ -235,7 +238,11 @@ def version_option(version=None, *param_decls, **attrs): :param others: everything else is forwarded to :func:`option`. """ if version is None: - module = sys._getframe(1).f_globals.get('__name__') + if hasattr(sys, '_getframe'): + module = sys._getframe(1).f_globals.get('__name__') + else: + module = '' + def decorator(f): prog_name = attrs.pop('prog_name', None) message = attrs.pop('message', '%(prog)s, version %(version)s') diff --git a/click/exceptions.py b/click/exceptions.py index e2c0bcd9c..b5641343d 100644 --- a/click/exceptions.py +++ b/click/exceptions.py @@ -2,6 +2,12 @@ from .utils import echo +def _join_param_hints(param_hint): + if isinstance(param_hint, (tuple, list)): + return ' / '.join('"%s"' % x for x in param_hint) + return param_hint + + class ClickException(Exception): """An exception that Click can handle and show to the user.""" @@ -19,11 +25,14 @@ def __init__(self, message): def format_message(self): return self.message - def __unicode__(self): + def __str__(self): return self.message - def __str__(self): - return self.message.encode('utf-8') + if PY2: + __unicode__ = __str__ + + def __str__(self): + return self.message.encode('utf-8') def show(self, file=None): if file is None: @@ -44,14 +53,20 @@ class UsageError(ClickException): def __init__(self, message, ctx=None): ClickException.__init__(self, message) self.ctx = ctx + self.cmd = self.ctx and self.ctx.command or None def show(self, file=None): if file is None: file = get_text_stderr() color = None + hint = '' + if (self.cmd is not None and + self.cmd.get_help_option(self.ctx) is not None): + hint = ('Try "%s %s" for help.\n' + % (self.ctx.command_path, self.ctx.help_option_names[0])) if self.ctx is not None: color = self.ctx.color - echo(self.ctx.get_usage() + '\n', file=file, color=color) + echo(self.ctx.get_usage() + '\n%s' % hint, file=file, color=color) echo('Error: %s' % self.format_message(), file=file, color=color) @@ -83,11 +98,11 @@ 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] + param_hint = self.param.get_error_hint(self.ctx) else: return 'Invalid value: %s' % self.message - if isinstance(param_hint, (tuple, list)): - param_hint = ' / '.join('"%s"' % x for x in param_hint) + param_hint = _join_param_hints(param_hint) + return 'Invalid value for %s: %s' % (param_hint, self.message) @@ -112,11 +127,10 @@ 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] + param_hint = self.param.get_error_hint(self.ctx) else: param_hint = None - if isinstance(param_hint, (tuple, list)): - param_hint = ' / '.join('"%s"' % x for x in param_hint) + param_hint = _join_param_hints(param_hint) param_type = self.param_type if param_type is None and self.param is not None: diff --git a/click/globals.py b/click/globals.py index 14338e6bb..843b594ab 100644 --- a/click/globals.py +++ b/click/globals.py @@ -9,7 +9,7 @@ def get_current_context(silent=False): access the current context object from anywhere. This is a more implicit alternative to the :func:`pass_context` decorator. This function is primarily useful for helpers such as :func:`echo` which might be - interested in changing it's behavior based on the current context. + interested in changing its behavior based on the current context. To push the current context, :meth:`Context.scope` can be used. diff --git a/click/termui.py b/click/termui.py index d9fba5232..f3dee0bdb 100644 --- a/click/termui.py +++ b/click/termui.py @@ -1,12 +1,14 @@ import os import sys import struct +import inspect +import itertools from ._compat import raw_input, text_type, string_types, \ isatty, strip_ansi, get_winterm_size, DEFAULT_COLUMNS, WIN from .utils import echo from .exceptions import Abort, UsageError -from .types import convert_type +from .types import convert_type, Choice from .globals import resolve_color_default @@ -14,8 +16,25 @@ # functions to customize how they work. visible_prompt_func = raw_input -_ansi_colors = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', - 'cyan', 'white', 'reset') +_ansi_colors = { + 'black': 30, + 'red': 31, + 'green': 32, + 'yellow': 33, + 'blue': 34, + 'magenta': 35, + 'cyan': 36, + 'white': 37, + 'reset': 39, + 'bright_black': 90, + 'bright_red': 91, + 'bright_green': 92, + 'bright_yellow': 93, + 'bright_blue': 94, + 'bright_magenta': 95, + 'bright_cyan': 96, + 'bright_white': 97, +} _ansi_reset_all = '\033[0m' @@ -24,23 +43,27 @@ def hidden_prompt_func(prompt): return getpass.getpass(prompt) -def _build_prompt(text, suffix, show_default=False, default=None): +def _build_prompt(text, suffix, show_default=False, default=None, show_choices=True, type=None): prompt = text + if type is not None and show_choices and isinstance(type, Choice): + prompt += ' (' + ", ".join(map(str, type.choices)) + ')' if default is not None and show_default: prompt = '%s [%s]' % (prompt, default) return prompt + suffix -def prompt(text, default=None, hide_input=False, - confirmation_prompt=False, type=None, - value_proc=None, prompt_suffix=': ', - show_default=True, err=False): +def prompt(text, default=None, hide_input=False, confirmation_prompt=False, + type=None, value_proc=None, prompt_suffix=': ', show_default=True, + err=False, show_choices=True): """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:: 7.0 + Added the show_choices parameter. + .. versionadded:: 6.0 Added unicode support for cmd.exe on Windows. @@ -61,6 +84,10 @@ def prompt(text, default=None, hide_input=False, :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. + :param show_choices: Show or hide choices if the passed type is a Choice. + For example if type is a Choice of either day or week, + show_choices is true and text is "Group by" then the + prompt will be "Group by (day, week): ". """ result = None @@ -82,7 +109,7 @@ def prompt_func(text): if value_proc is None: value_proc = convert_type(type, default) - prompt = _build_prompt(text, prompt_suffix, show_default, default) + prompt = _build_prompt(text, prompt_suffix, show_default, default, show_choices, type) while 1: while 1: @@ -166,8 +193,14 @@ def get_terminal_size(): sz = shutil_get_terminal_size() return sz.columns, sz.lines + # We provide a sensible default for get_winterm_size() when being invoked + # inside a subprocess. Without this, it would not provide a useful input. if get_winterm_size is not None: - return get_winterm_size() + size = get_winterm_size() + if size == (0, 0): + return (79, 24) + else: + return size def ioctl_gwinsz(fd): try: @@ -195,22 +228,33 @@ def ioctl_gwinsz(fd): return int(cr[1]), int(cr[0]) -def echo_via_pager(text, color=None): +def echo_via_pager(text_or_generator, color=None): """This function takes a text and shows it via an environment specific pager on stdout. .. versionchanged:: 3.0 Added the `color` flag. - :param text: the text to page. + :param text_or_generator: the text to page, or alternatively, a + generator emitting the text to page. :param color: controls if the pager supports ANSI colors or not. The default is autodetection. """ color = resolve_color_default(color) - if not isinstance(text, string_types): - text = text_type(text) + + if inspect.isgeneratorfunction(text_or_generator): + i = text_or_generator() + elif isinstance(text_or_generator, string_types): + i = [text_or_generator] + else: + i = iter(text_or_generator) + + # convert every element of i to a text type if necessary + text_generator = (el if isinstance(el, string_types) else text_type(el) + for el in i) + from ._termui_impl import pager - return pager(text + '\n', color) + return pager(itertools.chain(text_generator, "\n"), color) def progressbar(iterable=None, length=None, label=None, show_eta=True, @@ -347,10 +391,21 @@ def style(text, fg=None, bg=None, bold=None, dim=None, underline=None, * ``magenta`` * ``cyan`` * ``white`` (might be light gray) + * ``bright_black`` + * ``bright_red`` + * ``bright_green`` + * ``bright_yellow`` + * ``bright_blue`` + * ``bright_magenta`` + * ``bright_cyan`` + * ``bright_white`` * ``reset`` (reset the color code only) .. versionadded:: 2.0 + .. versionadded:: 7.0 + Added support for bright colors. + :param text: the string to style with ansi codes. :param fg: if provided this will become the foreground color. :param bg: if provided this will become the background color. @@ -369,13 +424,13 @@ def style(text, fg=None, bg=None, bold=None, dim=None, underline=None, bits = [] if fg: try: - bits.append('\033[%dm' % (_ansi_colors.index(fg) + 30)) - except ValueError: + bits.append('\033[%dm' % (_ansi_colors[fg])) + except KeyError: raise TypeError('Unknown color %r' % fg) if bg: try: - bits.append('\033[%dm' % (_ansi_colors.index(bg) + 40)) - except ValueError: + bits.append('\033[%dm' % (_ansi_colors[bg] + 10)) + except KeyError: raise TypeError('Unknown color %r' % bg) if bold is not None: bits.append('\033[%dm' % (1 if bold else 22)) @@ -405,7 +460,7 @@ def unstyle(text): return strip_ansi(text) -def secho(text, file=None, nl=True, err=False, color=None, **styles): +def secho(message=None, 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:: @@ -417,7 +472,9 @@ def secho(text, file=None, nl=True, err=False, color=None, **styles): .. versionadded:: 2.0 """ - return echo(style(text, **styles), file=file, nl=nl, err=err, color=color) + if message is not None: + message = style(message, **styles) + return echo(message, file=file, nl=nl, err=err, color=color) def edit(text=None, editor=None, env=None, require_save=True, diff --git a/click/testing.py b/click/testing.py index d43581ff2..bfa67fb7d 100644 --- a/click/testing.py +++ b/click/testing.py @@ -3,8 +3,9 @@ import shutil import tempfile import contextlib +import shlex -from ._compat import iteritems, PY2 +from ._compat import iteritems, PY2, string_types # If someone wants to vendor click, we want to ensure the @@ -72,12 +73,14 @@ def make_input_stream(input, charset): class Result(object): """Holds the captured result of an invoked CLI script.""" - def __init__(self, runner, output_bytes, exit_code, exception, - exc_info=None): + def __init__(self, runner, stdout_bytes, stderr_bytes, exit_code, + exception, exc_info=None): #: The runner that created the result self.runner = runner - #: The output as bytes. - self.output_bytes = output_bytes + #: The standard output as bytes. + self.stdout_bytes = stdout_bytes + #: The standard error as bytes, or False(y) if not available + self.stderr_bytes = stderr_bytes #: The exit code as integer. self.exit_code = exit_code #: The exception that happend if one did. @@ -87,12 +90,27 @@ def __init__(self, runner, output_bytes, exit_code, exception, @property def output(self): - """The output as unicode string.""" - return self.output_bytes.decode(self.runner.charset, 'replace') \ + """The (standard) output as unicode string.""" + return self.stdout + + @property + def stdout(self): + """The standard output as unicode string.""" + return self.stdout_bytes.decode(self.runner.charset, 'replace') \ + .replace('\r\n', '\n') + + @property + def stderr(self): + """The standard error as unicode string.""" + if not self.stderr_bytes: + raise ValueError("stderr not separately captured") + return self.stderr_bytes.decode(self.runner.charset, 'replace') \ .replace('\r\n', '\n') + def __repr__(self): - return '' % ( + return '<%s %s>' % ( + type(self).__name__, self.exception and repr(self.exception) or 'okay', ) @@ -111,14 +129,21 @@ class CliRunner(object): to stdout. This is useful for showing examples in some circumstances. Note that regular prompts will automatically echo the input. + :param mix_stderr: if this is set to `False`, then stdout and stderr are + preserved as independent streams. This is useful for + Unix-philosophy apps that have predictable stdout and + noisy stderr, such that each may be measured + independently """ - def __init__(self, charset=None, env=None, echo_stdin=False): + def __init__(self, charset=None, env=None, echo_stdin=False, + mix_stderr=True): if charset is None: charset = 'utf-8' self.charset = charset self.env = env or {} self.echo_stdin = echo_stdin + self.mix_stderr = mix_stderr def get_default_prog_name(self, cli): """Given a command object it will return the default program name @@ -163,16 +188,27 @@ def isolation(self, input=None, env=None, color=False): env = self.make_env(env) if PY2: - sys.stdout = sys.stderr = bytes_output = StringIO() + bytes_output = StringIO() if self.echo_stdin: input = EchoingStdin(input, bytes_output) + sys.stdout = bytes_output + if not self.mix_stderr: + bytes_error = StringIO() + sys.stderr = bytes_error else: bytes_output = io.BytesIO() if self.echo_stdin: input = EchoingStdin(input, bytes_output) input = io.TextIOWrapper(input, encoding=self.charset) - sys.stdout = sys.stderr = io.TextIOWrapper( + sys.stdout = io.TextIOWrapper( bytes_output, encoding=self.charset) + if not self.mix_stderr: + bytes_error = io.BytesIO() + sys.stderr = io.TextIOWrapper( + bytes_error, encoding=self.charset) + + if self.mix_stderr: + sys.stderr = sys.stdout sys.stdin = input @@ -196,6 +232,7 @@ def _getchar(echo): return char default_color = color + def should_strip_ansi(stream=None, color=None): if color is None: return not default_color @@ -213,7 +250,7 @@ def should_strip_ansi(stream=None, color=None): old_env = {} try: for key, value in iteritems(env): - old_env[key] = os.environ.get(value) + old_env[key] = os.environ.get(key) if value is None: try: del os.environ[key] @@ -221,7 +258,7 @@ def should_strip_ansi(stream=None, color=None): pass else: os.environ[key] = value - yield bytes_output + yield (bytes_output, not self.mix_stderr and bytes_error) finally: for key, value in iteritems(old_env): if value is None: @@ -241,7 +278,7 @@ def should_strip_ansi(stream=None, color=None): clickpkg.formatting.FORCED_WIDTH = old_forced_width def invoke(self, cli, args=None, input=None, env=None, - catch_exceptions=True, color=False, **extra): + catch_exceptions=True, color=False, mix_stderr=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 @@ -260,7 +297,10 @@ def invoke(self, cli, args=None, input=None, env=None, The ``color`` parameter was added. :param cli: the command to invoke - :param args: the arguments to invoke + :param args: the arguments to invoke. It may be given as an iterable + or a string. When given as string it will be interpreted + as a Unix shell command. More details at + :func:`shlex.split`. :param input: the input data for `sys.stdin`. :param env: the environment overrides. :param catch_exceptions: Whether to catch any other exceptions than @@ -270,36 +310,48 @@ def invoke(self, cli, args=None, input=None, env=None, application can still override this explicitly. """ exc_info = None - with self.isolation(input=input, env=env, color=color) as out: + with self.isolation(input=input, env=env, color=color) as outstreams: exception = None exit_code = 0 + if isinstance(args, string_types): + args = shlex.split(args) + try: - cli.main(args=args or (), - prog_name=self.get_default_prog_name(cli), **extra) - except SystemExit as e: - if e.code != 0: - exception = e + prog_name = extra.pop("prog_name") + except KeyError: + prog_name = self.get_default_prog_name(cli) + try: + cli.main(args=args or (), prog_name=prog_name, **extra) + except SystemExit as e: exc_info = sys.exc_info() - exit_code = e.code + if exit_code is None: + exit_code = 0 + + if exit_code != 0: + exception = e + if not isinstance(exit_code, int): sys.stdout.write(str(exit_code)) sys.stdout.write('\n') exit_code = 1 + except Exception as e: if not catch_exceptions: raise exception = e - exit_code = -1 + exit_code = 1 exc_info = sys.exc_info() finally: sys.stdout.flush() - output = out.getvalue() + stdout = outstreams[0].getvalue() + stderr = outstreams[1] and outstreams[1].getvalue() return Result(runner=self, - output_bytes=output, + stdout_bytes=stdout, + stderr_bytes=stderr, exit_code=exit_code, exception=exception, exc_info=exc_info) diff --git a/click/types.py b/click/types.py index 7a247471d..f59400529 100644 --- a/click/types.py +++ b/click/types.py @@ -129,6 +129,9 @@ class Choice(ParamType): """The choice type allows a value to be checked against a fixed set of supported values. All of these values have to be strings. + You should only pass *choices* as list or tuple. Other iterables (like + generators) may lead to surprising results. + See :ref:`choice-opts` for an example. """ name = 'choice' @@ -214,6 +217,59 @@ def __repr__(self): return 'IntRange(%r, %r)' % (self.min, self.max) +class FloatParamType(ParamType): + name = 'float' + + def convert(self, value, param, ctx): + try: + return float(value) + except (UnicodeError, ValueError): + self.fail('%s is not a valid floating point value' % + value, param, ctx) + + def __repr__(self): + return 'FLOAT' + + +class FloatRange(FloatParamType): + """A parameter that works similar to :data:`click.FLOAT` but restricts + the value to fit into a range. The default behavior is to fail if the + value falls outside the range, but it can also be silently clamped + between the two edges. + + See :ref:`ranges` for an example. + """ + name = 'float range' + + def __init__(self, min=None, max=None, clamp=False): + self.min = min + self.max = max + self.clamp = clamp + + def convert(self, value, param, ctx): + rv = FloatParamType.convert(self, value, param, ctx) + if self.clamp: + if self.min is not None and rv < self.min: + return self.min + if self.max is not None and rv > self.max: + return self.max + if self.min is not None and rv < self.min or \ + self.max is not None and rv > self.max: + if self.min is None: + self.fail('%s is bigger than the maximum valid value ' + '%s.' % (rv, self.max), param, ctx) + elif self.max is None: + self.fail('%s is smaller than the minimum valid value ' + '%s.' % (rv, self.min), param, ctx) + else: + self.fail('%s is not in the valid range of %s to %s.' + % (rv, self.min, self.max), param, ctx) + return rv + + def __repr__(self): + return 'FloatRange(%r, %r)' % (self.min, self.max) + + class BoolParamType(ParamType): name = 'boolean' @@ -221,9 +277,9 @@ def convert(self, value, param, ctx): if isinstance(value, bool): return bool(value) value = value.lower() - if value in ('true', '1', 'yes', 'y'): + if value in ('true', 't', '1', 'yes', 'y'): return True - elif value in ('false', '0', 'no', 'n'): + elif value in ('false', 'f', '0', 'no', 'n'): return False self.fail('%s is not a valid boolean' % value, param, ctx) @@ -231,20 +287,6 @@ def __repr__(self): return 'BOOL' -class FloatParamType(ParamType): - name = 'float' - - def convert(self, value, param, ctx): - try: - return float(value) - except (UnicodeError, ValueError): - self.fail('%s is not a valid floating point value' % - value, param, ctx) - - def __repr__(self): - return 'FLOAT' - - class UUIDParameterType(ParamType): name = 'uuid' @@ -358,14 +400,16 @@ class Path(ParamType): :param readable: if true, a readable check is performed. :param resolve_path: if this is true, then the path is fully resolved before the value is passed onwards. This means - that it's absolute and symlinks are resolved. + that it's absolute and symlinks are resolved. It + will not expand a tilde-prefix, as this is + supposed to be done by the shell only. :param allow_dash: If this is set to `True`, a single dash to indicate standard streams is permitted. - :param type: optionally a string type that should be used to - represent the path. The default is `None` which - means the return value will be either bytes or - unicode depending on what makes most sense given the - input data Click deals with. + :param path_type: optionally a string type that should be used to + represent the path. The default is `None` which + means the return value will be either bytes or + unicode depending on what makes most sense given the + input data Click deals with. """ envvar_list_splitter = os.path.pathsep @@ -384,7 +428,7 @@ def __init__(self, exists=False, file_okay=True, dir_okay=True, if self.file_okay and not self.dir_okay: self.name = 'file' self.path_type = 'File' - if self.dir_okay and not self.file_okay: + elif self.dir_okay and not self.file_okay: self.name = 'directory' self.path_type = 'Directory' else: @@ -418,26 +462,26 @@ def convert(self, value, param, ctx): filename_to_ui(value) ), param, ctx) - if not self.file_okay and stat.S_ISREG(st.st_mode): - self.fail('%s "%s" is a file.' % ( - self.path_type, - filename_to_ui(value) - ), param, ctx) - if not self.dir_okay and stat.S_ISDIR(st.st_mode): - self.fail('%s "%s" is a directory.' % ( - self.path_type, - filename_to_ui(value) - ), param, ctx) - if self.writable and not os.access(value, os.W_OK): - self.fail('%s "%s" is not writable.' % ( - self.path_type, - filename_to_ui(value) - ), param, ctx) - if self.readable and not os.access(value, os.R_OK): - self.fail('%s "%s" is not readable.' % ( - self.path_type, - filename_to_ui(value) - ), param, ctx) + if not self.file_okay and stat.S_ISREG(st.st_mode): + self.fail('%s "%s" is a file.' % ( + self.path_type, + filename_to_ui(value) + ), param, ctx) + if not self.dir_okay and stat.S_ISDIR(st.st_mode): + self.fail('%s "%s" is a directory.' % ( + self.path_type, + filename_to_ui(value) + ), param, ctx) + if self.writable and not os.access(value, os.W_OK): + self.fail('%s "%s" is not writable.' % ( + self.path_type, + filename_to_ui(value) + ), param, ctx) + if self.readable and not os.access(value, os.R_OK): + self.fail('%s "%s" is not readable.' % ( + self.path_type, + filename_to_ui(value) + ), param, ctx) return self.coerce_path_result(rv) diff --git a/click/utils.py b/click/utils.py index eee626d3f..9f175eb27 100644 --- a/click/utils.py +++ b/click/utils.py @@ -43,6 +43,7 @@ def make_str(value): def make_default_short_help(help, max_length=45): + """Return a condensed version of help string.""" words = help.split() total_length = 0 result = [] @@ -171,7 +172,7 @@ def echo(message=None, file=None, nl=True, err=False, color=None): Primarily it means that you can print binary data as well as Unicode data on both 2.x and 3.x to the given file in the most appropriate way - possible. This is a very carefree function as in that it will try its + possible. This is a very carefree function in that it will try its best to not fail. As of Click 6.0 this includes support for unicode output on the Windows console. @@ -183,7 +184,7 @@ def echo(message=None, file=None, nl=True, err=False, color=None): - hide ANSI codes automatically if the destination file is not a terminal. - .. _colorama: http://pypi.python.org/pypi/colorama + .. _colorama: https://pypi.org/project/colorama/ .. versionchanged:: 6.0 As of Click 6.0 the echo function will properly support unicode diff --git a/docs/_templates/sidebarintro.html b/docs/_templates/sidebarintro.html index 0041190fb..4e49aba4b 100644 --- a/docs/_templates/sidebarintro.html +++ b/docs/_templates/sidebarintro.html @@ -7,7 +7,7 @@

About

Useful Links

diff --git a/docs/advanced.rst b/docs/advanced.rst index f2b83dd1c..f8c0ed8e0 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -277,7 +277,7 @@ options: 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 + will eat up all leftover arguments. In this case it's recommended 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. @@ -295,8 +295,8 @@ In the end you end up with something like this: @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) + """A fake wrapper around Python's timeit.""" + cmdline = ['echo', 'python', '-mtimeit'] + list(timeit_args) if verbose: click.echo('Invoking: %s' % ' '.join(cmdline)) call(cmdline) @@ -321,7 +321,7 @@ are important to know about how this ignoring of unhandled flag happens: 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 +* Unknown short options might be partially handled and reassembled 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 @@ -346,7 +346,7 @@ Global Context Access .. versionadded:: 5.0 Starting with Click 5.0 it is possible to access the current context from -anywhere within the same through through the use of the +anywhere within the same thread through the use of the :func:`get_current_context` function which returns it. This is primarily useful for accessing the context bound object as well as some flags that are stored on it to customize the runtime behavior. For instance the diff --git a/docs/arguments.rst b/docs/arguments.rst index 1abd88bc6..b2e61e9ae 100644 --- a/docs/arguments.rst +++ b/docs/arguments.rst @@ -243,3 +243,21 @@ And from the command line: .. click:run:: invoke(touch, ['--', '-foo.txt', 'bar.txt']) + +If you don't like the ``--`` marker, you can set ignore_unknown_options to +True to avoid checking unknown options: + +.. click:example:: + + @click.command(context_settings={"ignore_unknown_options": True}) + @click.argument('files', nargs=-1, type=click.Path()) + def touch(files): + for filename in files: + click.echo(filename) + +And from the command line: + +.. click:run:: + + invoke(touch, ['-foo.txt', 'bar.txt']) + diff --git a/docs/bashcomplete.rst b/docs/bashcomplete.rst index 9154dfb01..e2e2d4948 100644 --- a/docs/bashcomplete.rst +++ b/docs/bashcomplete.rst @@ -13,16 +13,15 @@ Limitations Bash completion is only available if a script has been installed properly, and not executed through the ``python`` command. For information about how to do that, see :ref:`setuptools-integration`. Also, Click currently -only supports completion for Bash. - -Currently, Bash completion is an internal feature that is not customizable. -This might be relaxed in future versions. +only supports completion for Bash. Zsh support is available through Zsh's +bash completion compatibility mode. What it Completes ----------------- -Generally, the Bash completion support will complete subcommands and -parameters. Subcommands are always listed whereas parameters only if at +Generally, the Bash completion support will complete subcommands, options +and any option or argument values where the type is click.Choice. +Subcommands and choices are always listed whereas options only if at least a dash has been provided. Example:: $ repo @@ -30,6 +29,33 @@ least a dash has been provided. Example:: $ repo clone - --deep --help --rev --shallow -r +Additionally, custom suggestions can be provided for arguments and options with +the ``autocompletion`` parameter. ``autocompletion`` should a callback function +that returns a list of strings. This is useful when the suggestions need to be +dynamically generated at bash completion time. The callback function will be +passed 3 keyword arguments: + +- ``ctx`` - The current click context. +- ``args`` - The list of arguments passed in. +- ``incomplete`` - The partial word that is being completed, as a string. May + be an empty string ``''`` if no characters have been entered yet. + +Here is an example of using a callback function to generate dynamic suggestions: + +.. click:example:: + + import os + + def get_env_vars(ctx, args, incomplete): + return os.environ.keys() + + @click.command() + @click.argument("envvar", type=click.STRING, autocompletion=get_env_vars) + def cmd1(envvar): + click.echo('Environment variable: %s' % envvar) + click.echo('Value: %s' % os.environ[envvar]) + + Activation ---------- @@ -42,7 +68,7 @@ with dashes replaced by underscores. If your tool is called ``foo-bar``, then the magic variable is called ``_FOO_BAR_COMPLETE``. By exporting it with the ``source`` value it will -spit out the activation script which can be trivally activated. +spit out the activation script which can be trivially activated. For instance, to enable Bash completion for your ``foo-bar`` script, this is what you would need to put into your ``.bashrc``:: @@ -67,3 +93,14 @@ This can be easily accomplished:: And then you would put this into your bashrc instead:: . /path/to/foo-bar-complete.sh + +Zsh Compatibility +---------------- + +To enable Bash completion in Zsh, add the following lines to your .zshrc: + + autoload bashcompinit + bashcompinit + +See https://github.com/pallets/click/issues/323 for more information on +this issue. diff --git a/docs/clickdoctools.py b/docs/clickdoctools.py index 36723fa22..1dd47d3d1 100644 --- a/docs/clickdoctools.py +++ b/docs/clickdoctools.py @@ -15,8 +15,15 @@ from docutils.statemachine import ViewList from sphinx.domains import Domain -from sphinx.util.compat import Directive +from docutils.parsers.rst import Directive +PY2 = sys.version_info[0] == 2 + +if PY2: + text_type = unicode +else: + text_type = str + class EchoingStdin(object): @@ -70,7 +77,7 @@ def dummy_call(*args, **kwargs): @contextlib.contextmanager def isolation(input=None, env=None): - if isinstance(input, unicode): + if isinstance(input, text_type): input = input.encode('utf-8') input = StringIO(input or '') output = StringIO() diff --git a/docs/commands.rst b/docs/commands.rst index 95d4fa800..2320432c1 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -29,7 +29,7 @@ when an inner command runs: @cli.command() def sync(): - click.echo('Synching') + click.echo('Syncing') Here is what this looks like: @@ -417,7 +417,7 @@ to not use the file type and manually open the file through For a more complex example that also improves upon handling of the pipelines have a look at the `imagepipe multi command chaining demo -`__ in +`__ in the Click repository. It implements a pipeline based image editing tool that has a nice internal structure for the pipelines. @@ -437,7 +437,7 @@ you're not satisfied with the defaults. The default map can be nested arbitrarily for each subcommand and provided when the script is invoked. Alternatively, it can also be -overriden at any point by commands. For instance, a top-level command could +overridden at any point by commands. For instance, a top-level command could load the defaults from a configuration file. Example usage: diff --git a/docs/complex.rst b/docs/complex.rst index 794de2df1..9907606f4 100644 --- a/docs/complex.rst +++ b/docs/complex.rst @@ -153,10 +153,10 @@ One obvious way to remedy this is to store a reference to the repo in the plugin, but then a command needs to be aware that it's attached below such a plugin. -There is a much better system that can built by taking advantage of the linked -nature of contexts. We know that the plugin context is linked to the context -that created our repo. Because of that, we can start a search for the last -level where the object stored by the context was a repo. +There is a much better system that can be built by taking advantage of the +linked nature of contexts. We know that the plugin context is linked to the +context that created our repo. Because of that, we can start a search for +the last level where the object stored by the context was a repo. Built-in support for this is provided by the :func:`make_pass_decorator` factory, which will create decorators for us that find objects (it @@ -210,7 +210,7 @@ As such it runs standalone: @click.command() @pass_repo def cp(repo): - click.echo(repo) + click.echo(isinstance(repo, Repo)) As you can see: diff --git a/docs/options.rst b/docs/options.rst index 2a018027d..bb29fcdfa 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -16,8 +16,9 @@ Basic Value Options The most basic option is a value option. These options accept one argument which is a value. If no type is provided, the type of the default value is used. If no default value is provided, the type is assumed to be -:data:`STRING`. By default, the name of the parameter is the first long -option defined; otherwise the first short one is used. +:data:`STRING`. Unless a name is explicitly specified, the name of the +parameter is the first long option defined; otherwise the first short one is +used. .. click:example:: @@ -26,6 +27,15 @@ option defined; otherwise the first short one is used. def dots(n): click.echo('.' * n) +.. click:example:: + + # How to use a Python reserved word such as `from` as a parameter + @click.command() + @click.option('--from', '-f', '_from') + @click.option('--to', '-t') + def reserved_param_name(_from, to): + click.echo('from %s to %s' % (_from, to)) + And on the command line: .. click:run:: @@ -282,6 +292,11 @@ What it looks like: println() invoke(digest, args=['--help']) +.. note:: + + You should only pass the choices as list or tuple. Other iterables (like + generators) may lead to surprising results. + .. _option-prompting: Prompting diff --git a/docs/parameters.rst b/docs/parameters.rst index 920b9beb6..9e5587e40 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -47,8 +47,8 @@ different behavior and some are supported out of the box: ``bool`` / :data:`click.BOOL`: A parameter that accepts boolean values. This is automatically used - for boolean flags. If used with string values ``1``, ``yes``, ``y`` - and ``true`` convert to `True` and ``0``, ``no``, ``n`` and ``false`` + for boolean flags. If used with string values ``1``, ``yes``, ``y``, ``t`` + and ``true`` convert to `True` and ``0``, ``no``, ``n``, ``f`` and ``false`` convert to `False`. :data:`click.UUID`: @@ -67,6 +67,9 @@ different behavior and some are supported out of the box: .. autoclass:: IntRange :noindex: +.. autoclass:: FloatRange + :noindex: + Custom parameter types can be implemented by subclassing :class:`click.ParamType`. For simple cases, passing a Python function that fails with a `ValueError` is also supported, though discouraged. diff --git a/docs/python3.rst b/docs/python3.rst index e148ba62f..65e1b50fa 100644 --- a/docs/python3.rst +++ b/docs/python3.rst @@ -6,9 +6,9 @@ Python 3 Support Click supports Python 3, but like all other command line utility libraries, it suffers from the Unicode text model in Python 3. All examples in the documentation were written so that they could run on both Python 2.x and -Python 3.3 or higher. +Python 3.4 or higher. -At the moment, it is strongly recommended is to use Python 2 for Click +At the moment, it is strongly recommended to use Python 2 for Click utilities unless Python 3 is a hard requirement. .. _python3-limitations: diff --git a/docs/quickstart.rst b/docs/quickstart.rst index e5ce571a0..234f8098b 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -51,7 +51,7 @@ If you are on Windows (or none of the above methods worked) you must install Once you have it installed, run the ``pip`` command from above, but without the `sudo` prefix. -.. _installing pip: http://pip.readthedocs.org/en/latest/installing.html +.. _installing pip: https://pip.readthedocs.io/en/latest/installing.html Once you have virtualenv installed, just fire up a shell and create your own environment. I usually create a project folder and a `venv` @@ -79,7 +79,7 @@ And if you want to go back to the real world, use the following command:: $ deactivate -After doing this, the prompt of your shell should be as familar as before. +After doing this, the prompt of your shell should be as familiar as before. Now, let's move on. Enter the following command to get Click activated in your virtualenv:: @@ -102,23 +102,23 @@ Examples of Click applications can be found in the documentation as well as in the GitHub repository together with readme files: * ``inout``: `File input and output - `_ + `_ * ``naval``: `Port of docopt naval example - `_ + `_ * ``aliases``: `Command alias example - `_ + `_ * ``repo``: `Git-/Mercurial-like command line interface - `_ + `_ * ``complex``: `Complex example with plugin loading - `_ + `_ * ``validation``: `Custom parameter validation example - `_ + `_ * ``colors``: `Colorama ANSI color support - `_ + `_ * ``termui``: `Terminal UI functions demo - `_ + `_ * ``imagepipe``: `Multi command chaining demo - `_ + `_ Basic Concepts -------------- diff --git a/docs/upgrading.rst b/docs/upgrading.rst index b682d2151..7c4cccde8 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -44,7 +44,7 @@ properly by this function. Multicommand Chaining API ````````````````````````` -Click 3 introduced multicommand chaning. This required a change in how +Click 3 introduced multicommand chaining. This required a change in how Click internally dispatches. Unfortunately this change was not correctly implemented and it appeared that it was possible to provide an API that can inform the super command about all the subcommands that will be diff --git a/docs/utils.rst b/docs/utils.rst index 2ecbe8873..dc25c794b 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -95,7 +95,7 @@ a single function called :func:`secho`:: click.secho('ATTENTION', blink=True, bold=True) -.. _colorama: https://pypi.python.org/pypi/colorama +.. _colorama: https://pypi.org/project/colorama/ Pager Support ------------- @@ -114,6 +114,17 @@ Example: click.echo_via_pager('\n'.join('Line %d' % idx for idx in range(200))) +If you want to use the pager for a lot of text, especially if generating everything in advance would take a lot of time, you can pass a generator (or generator function) instead of a string: + +.. click:example:: + def _generate_output(): + for idx in range(50000): + yield "Line %d\n" % idx + + @click.command() + def less(): + click.echo_via_pager(_generate_output()) + Screen Clearing --------------- @@ -226,7 +237,7 @@ Launching Applications .. versionadded:: 2.0 Click supports launching applications through :func:`launch`. This can be -used to open the default application assocated with a URL or filetype. +used to open the default application associated with a URL or filetype. This can be used to launch web browsers or picture viewers, for instance. In addition to this, it can also launch the file manager and automatically select the provided file. diff --git a/examples/bashcompletion/README b/examples/bashcompletion/README new file mode 100644 index 000000000..f8a0d51ef --- /dev/null +++ b/examples/bashcompletion/README @@ -0,0 +1,12 @@ +$ bashcompletion + + bashcompletion is a simple example of an application that + tries to autocomplete commands, arguments and options. + + This example requires Click 2.0 or higher. + +Usage: + + $ pip install --editable . + $ eval "$(_BASHCOMPLETION_COMPLETE=source bashcompletion)" + $ bashcompletion --help diff --git a/examples/bashcompletion/bashcompletion.py b/examples/bashcompletion/bashcompletion.py new file mode 100644 index 000000000..c483d79e4 --- /dev/null +++ b/examples/bashcompletion/bashcompletion.py @@ -0,0 +1,41 @@ +import click +import os + + +@click.group() +def cli(): + pass + + +def get_env_vars(ctx, args, incomplete): + for key in os.environ.keys(): + if incomplete in key: + yield key + + +@cli.command() +@click.argument("envvar", type=click.STRING, autocompletion=get_env_vars) +def cmd1(envvar): + click.echo('Environment variable: %s' % envvar) + click.echo('Value: %s' % os.environ[envvar]) + + +@click.group() +def group(): + pass + + +def list_users(ctx, args, incomplete): + # Here you can generate completions dynamically + users = ['bob', 'alice'] + for user in users: + if user.startswith(incomplete): + yield user + + +@group.command() +@click.argument("user", type=click.STRING, autocompletion=list_users) +def subcmd(user): + click.echo('Chosen user is %s' % user) + +cli.add_command(group) diff --git a/examples/bashcompletion/setup.py b/examples/bashcompletion/setup.py new file mode 100644 index 000000000..ad200818c --- /dev/null +++ b/examples/bashcompletion/setup.py @@ -0,0 +1,15 @@ +from setuptools import setup + +setup( + name='click-example-bashcompletion', + version='1.0', + py_modules=['bashcompletion'], + include_package_data=True, + install_requires=[ + 'click', + ], + entry_points=''' + [console_scripts] + bashcompletion=bashcompletion:cli + ''', +) diff --git a/examples/colors/colors.py b/examples/colors/colors.py index 1e365bd22..193b92712 100644 --- a/examples/colors/colors.py +++ b/examples/colors/colors.py @@ -2,7 +2,9 @@ all_colors = 'black', 'red', 'green', 'yellow', 'blue', 'magenta', \ - 'cyan', 'white' + 'cyan', 'white', 'bright_black', 'bright_red', \ + 'bright_green', 'bright_yellow', 'bright_blue', \ + 'bright_magenta', 'bright_cyan', 'bright_white' @click.command() diff --git a/examples/validation/validation.py b/examples/validation/validation.py index 4b95091c0..00fa0a600 100644 --- a/examples/validation/validation.py +++ b/examples/validation/validation.py @@ -1,6 +1,6 @@ import click try: - from urllib import parser as urlparse + from urllib import parse as urlparse except ImportError: import urlparse diff --git a/setup.cfg b/setup.cfg index 7c964b49e..3b0846a1b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,8 @@ -[wheel] +[bdist_wheel] universal=1 + +[metadata] +license_file = LICENSE + +[tool:pytest] +addopts = -p no:warnings --tb=short diff --git a/setup.py b/setup.py index 8bacd6a76..f1bcdcf9b 100644 --- a/setup.py +++ b/setup.py @@ -1,28 +1,49 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import io import re -import ast from setuptools import setup _version_re = re.compile(r'__version__\s+=\s+(.*)') -with open('click/__init__.py', 'rb') as f: - version = str(ast.literal_eval(_version_re.search( - f.read().decode('utf-8')).group(1))) +with io.open('README.rst', 'rt', encoding='utf8') as f: + readme = f.read() +with io.open('click/__init__.py', 'rt', encoding='utf8') as f: + version = re.search(r'__version__ = \'(.*?)\'', f.read()).group(1) setup( name='click', + version=version, + url='https://www.palletsprojects.com/p/click/', author='Armin Ronacher', author_email='armin.ronacher@active-4.com', - version=version, - url='http://github.com/mitsuhiko/click', + maintainer='Pallets team', + maintainer_email='contact@palletsprojects.com', + long_description=readme, packages=['click'], + extras_require={ + 'docs': [ + 'sphinx', + ], + }, description='A simple wrapper around optparse for ' 'powerful command line utilities.', + license='BSD', classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent' 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', ], + python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", ) diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 751ca695d..a6a325878 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -106,6 +106,17 @@ def inout(input, output): assert result.exit_code == 0 +def test_path_args(runner): + @click.command() + @click.argument('input', type=click.Path(dir_okay=False, allow_dash=True)) + def foo(input): + click.echo(input) + + result = runner.invoke(foo, ['-']) + assert result.output == '-\n' + assert result.exit_code == 0 + + def test_file_atomics(runner): @click.command() @click.argument('output', type=click.File('wb', atomic=True)) @@ -177,7 +188,7 @@ def cmd2(arg): result = runner.invoke(cmd2, []) assert result.exit_code == 2 - assert 'Missing argument "arg"' in result.output + assert 'Missing argument "ARG..."' in result.output def test_missing_arg(runner): @@ -188,7 +199,7 @@ def cmd(arg): result = runner.invoke(cmd, []) assert result.exit_code == 2 - assert 'Missing argument "arg".' in result.output + assert 'Missing argument "ARG".' in result.output def test_implicit_non_required(runner): diff --git a/tests/test_bashcomplete.py b/tests/test_bashcomplete.py new file mode 100644 index 000000000..69448e44c --- /dev/null +++ b/tests/test_bashcomplete.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- + +import click +from click._bashcomplete import get_choices + + +def test_single_command(): + @click.command() + @click.option('--local-opt') + def cli(local_opt): + pass + + assert list(get_choices(cli, 'lol', [], '-')) == ['--local-opt'] + assert list(get_choices(cli, 'lol', [], '')) == [] + + +def test_boolean_flag(): + @click.command() + @click.option('--shout/--no-shout', default=False) + def cli(local_opt): + pass + + assert list(get_choices(cli, 'lol', [], '-')) == ['--shout', '--no-shout'] + + +def test_multi_value_option(): + @click.group() + @click.option('--pos', nargs=2, type=float) + def cli(local_opt): + pass + + @cli.command() + @click.option('--local-opt') + def sub(local_opt): + pass + + assert list(get_choices(cli, 'lol', [], '-')) == ['--pos'] + assert list(get_choices(cli, 'lol', ['--pos'], '')) == [] + assert list(get_choices(cli, 'lol', ['--pos', '1.0'], '')) == [] + assert list(get_choices(cli, 'lol', ['--pos', '1.0', '1.0'], '')) == ['sub'] + + +def test_multi_option(): + @click.command() + @click.option('--message', '-m', multiple=True) + def cli(local_opt): + pass + + assert list(get_choices(cli, 'lol', [], '-')) == ['--message', '-m'] + assert list(get_choices(cli, 'lol', ['-m'], '')) == [] + + +def test_small_chain(): + @click.group() + @click.option('--global-opt') + def cli(global_opt): + pass + + @cli.command() + @click.option('--local-opt') + def sub(local_opt): + pass + + assert list(get_choices(cli, 'lol', [], '')) == ['sub'] + assert list(get_choices(cli, 'lol', [], '-')) == ['--global-opt'] + assert list(get_choices(cli, 'lol', ['sub'], '')) == [] + assert list(get_choices(cli, 'lol', ['sub'], '-')) == ['--local-opt'] + + +def test_long_chain(): + @click.group('cli') + @click.option('--cli-opt') + def cli(cli_opt): + pass + + @cli.group('asub') + @click.option('--asub-opt') + def asub(asub_opt): + pass + + @asub.group('bsub') + @click.option('--bsub-opt') + def bsub(bsub_opt): + pass + + COLORS = ['red', 'green', 'blue'] + def get_colors(ctx, args, incomplete): + for c in COLORS: + if c.startswith(incomplete): + yield c + + def search_colors(ctx, args, incomplete): + for c in COLORS: + if incomplete in c: + yield c + + CSUB_OPT_CHOICES = ['foo', 'bar'] + CSUB_CHOICES = ['bar', 'baz'] + @bsub.command('csub') + @click.option('--csub-opt', type=click.Choice(CSUB_OPT_CHOICES)) + @click.option('--csub', type=click.Choice(CSUB_CHOICES)) + @click.option('--search-color', autocompletion=search_colors) + @click.argument('color', autocompletion=get_colors) + def csub(csub_opt, color): + pass + + assert list(get_choices(cli, 'lol', [], '-')) == ['--cli-opt'] + assert list(get_choices(cli, 'lol', [], '')) == ['asub'] + assert list(get_choices(cli, 'lol', ['asub'], '-')) == ['--asub-opt'] + assert list(get_choices(cli, 'lol', ['asub'], '')) == ['bsub'] + assert list(get_choices(cli, 'lol', ['asub', 'bsub'], '-')) == ['--bsub-opt'] + assert list(get_choices(cli, 'lol', ['asub', 'bsub'], '')) == ['csub'] + assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], '-')) == ['--csub-opt', '--csub', '--search-color'] + assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub', '--csub-opt'], '')) == CSUB_OPT_CHOICES + assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], '--csub')) == ['--csub-opt', '--csub'] + assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub', '--csub'], '')) == CSUB_CHOICES + assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub', '--csub-opt'], 'f')) == ['foo'] + assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], '')) == COLORS + assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub'], 'b')) == ['blue'] + assert list(get_choices(cli, 'lol', ['asub', 'bsub', 'csub', '--search-color'], 'een')) == ['green'] + + +def test_chaining(): + @click.group('cli', chain=True) + @click.option('--cli-opt') + def cli(cli_opt): + pass + + @cli.command('asub') + @click.option('--asub-opt') + def asub(asub_opt): + pass + + @cli.command('bsub') + @click.option('--bsub-opt') + @click.argument('arg', type=click.Choice(['arg1', 'arg2']), required=True) + def bsub(bsub_opt, arg): + pass + + @cli.command('csub') + @click.option('--csub-opt') + @click.argument('arg', type=click.Choice(['carg1', 'carg2']), required=False) + def csub(csub_opt, arg): + pass + + assert list(get_choices(cli, 'lol', [], '-')) == ['--cli-opt'] + assert list(get_choices(cli, 'lol', [], '')) == ['asub', 'bsub', 'csub'] + assert list(get_choices(cli, 'lol', ['asub'], '-')) == ['--asub-opt'] + assert list(get_choices(cli, 'lol', ['asub'], '')) == ['bsub', 'csub'] + assert list(get_choices(cli, 'lol', ['bsub'], '')) == ['arg1', 'arg2'] + assert list(get_choices(cli, 'lol', ['asub', '--asub-opt'], '')) == [] + assert list(get_choices(cli, 'lol', ['asub', '--asub-opt', '5', 'bsub'], '-')) == ['--bsub-opt'] + assert list(get_choices(cli, 'lol', ['asub', 'bsub'], '-')) == ['--bsub-opt'] + assert list(get_choices(cli, 'lol', ['asub', 'csub'], '')) == ['carg1', 'carg2', 'bsub'] + assert list(get_choices(cli, 'lol', ['asub', 'csub'], '-')) == ['--csub-opt'] + + +def test_argument_choice(): + @click.command() + @click.argument('arg1', required=False, type=click.Choice(['arg11', 'arg12'])) + @click.argument('arg2', required=False, type=click.Choice(['arg21', 'arg22'])) + @click.argument('arg3', required=False, type=click.Choice(['arg', 'argument'])) + def cli(): + pass + + assert list(get_choices(cli, 'lol', [], '')) == ['arg11', 'arg12'] + assert list(get_choices(cli, 'lol', [], 'arg')) == ['arg11', 'arg12'] + assert list(get_choices(cli, 'lol', ['arg11'], '')) == ['arg21', 'arg22'] + assert list(get_choices(cli, 'lol', ['arg12', 'arg21'], '')) == ['arg', 'argument'] + assert list(get_choices(cli, 'lol', ['arg12', 'arg21'], 'argu')) == ['argument'] + + +def test_option_choice(): + @click.command() + @click.option('--opt1', type=click.Choice(['opt11', 'opt12'])) + @click.option('--opt2', type=click.Choice(['opt21', 'opt22'])) + @click.option('--opt3', type=click.Choice(['opt', 'option'])) + def cli(): + pass + + assert list(get_choices(cli, 'lol', [], '-')) == ['--opt1', '--opt2', '--opt3'] + assert list(get_choices(cli, 'lol', [], '--opt')) == ['--opt1', '--opt2', '--opt3'] + assert list(get_choices(cli, 'lol', [], '--opt1=')) == ['opt11', 'opt12'] + assert list(get_choices(cli, 'lol', [], '--opt2=')) == ['opt21', 'opt22'] + assert list(get_choices(cli, 'lol', ['--opt2'], '=')) == ['opt21', 'opt22'] + assert list(get_choices(cli, 'lol', ['--opt2', '='], 'opt')) == ['opt21', 'opt22'] + assert list(get_choices(cli, 'lol', ['--opt1'], '')) == ['opt11', 'opt12'] + assert list(get_choices(cli, 'lol', ['--opt2'], '')) == ['opt21', 'opt22'] + assert list(get_choices(cli, 'lol', ['--opt1', 'opt11', '--opt2'], '')) == ['opt21', 'opt22'] + assert list(get_choices(cli, 'lol', ['--opt2', 'opt21'], '-')) == ['--opt1', '--opt3'] + assert list(get_choices(cli, 'lol', ['--opt1', 'opt11'], '-')) == ['--opt2', '--opt3'] + assert list(get_choices(cli, 'lol', ['--opt1'], 'opt')) == ['opt11', 'opt12'] + assert list(get_choices(cli, 'lol', ['--opt3'], 'opti')) == ['option'] + + assert list(get_choices(cli, 'lol', ['--opt1', 'invalid_opt'], '-')) == ['--opt2', '--opt3'] + + +def test_option_and_arg_choice(): + @click.command() + @click.option('--opt1', type=click.Choice(['opt11', 'opt12'])) + @click.argument('arg1', required=False, type=click.Choice(['arg11', 'arg12'])) + @click.option('--opt2', type=click.Choice(['opt21', 'opt22'])) + def cli(): + pass + + assert list(get_choices(cli, 'lol', ['--opt1'], '')) == ['opt11', 'opt12'] + assert list(get_choices(cli, 'lol', [''], '--opt1=')) == ['opt11', 'opt12'] + assert list(get_choices(cli, 'lol', [], '')) == ['arg11', 'arg12'] + assert list(get_choices(cli, 'lol', ['--opt2'], '')) == ['opt21', 'opt22'] + + +def test_boolean_flag_choice(): + @click.command() + @click.option('--shout/--no-shout', default=False) + @click.argument('arg', required=False, type=click.Choice(['arg1', 'arg2'])) + def cli(local_opt): + pass + + assert list(get_choices(cli, 'lol', [], '-')) == ['--shout', '--no-shout'] + assert list(get_choices(cli, 'lol', ['--shout'], '')) == ['arg1', 'arg2'] + + +def test_multi_value_option_choice(): + @click.command() + @click.option('--pos', nargs=2, type=click.Choice(['pos1', 'pos2'])) + @click.argument('arg', required=False, type=click.Choice(['arg1', 'arg2'])) + def cli(local_opt): + pass + + assert list(get_choices(cli, 'lol', ['--pos'], '')) == ['pos1', 'pos2'] + assert list(get_choices(cli, 'lol', ['--pos', 'pos1'], '')) == ['pos1', 'pos2'] + assert list(get_choices(cli, 'lol', ['--pos', 'pos1', 'pos2'], '')) == ['arg1', 'arg2'] + assert list(get_choices(cli, 'lol', ['--pos', 'pos1', 'pos2', 'arg1'], '')) == [] + + +def test_multi_option_choice(): + @click.command() + @click.option('--message', '-m', multiple=True, type=click.Choice(['m1', 'm2'])) + @click.argument('arg', required=False, type=click.Choice(['arg1', 'arg2'])) + def cli(local_opt): + pass + + assert list(get_choices(cli, 'lol', ['-m'], '')) == ['m1', 'm2'] + assert list(get_choices(cli, 'lol', ['-m', 'm1', '-m'], '')) == ['m1', 'm2'] + assert list(get_choices(cli, 'lol', ['-m', 'm1'], '')) == ['arg1', 'arg2'] + + +def test_variadic_argument_choice(): + @click.command() + @click.argument('src', nargs=-1, type=click.Choice(['src1', 'src2'])) + def cli(local_opt): + pass + + assert list(get_choices(cli, 'lol', ['src1', 'src2'], '')) == ['src1', 'src2'] + + +def test_long_chain_choice(): + @click.group() + def cli(): + pass + + @cli.group('sub') + @click.option('--sub-opt', type=click.Choice(['subopt1', 'subopt2'])) + @click.argument('sub-arg', required=False, type=click.Choice(['subarg1', 'subarg2'])) + def sub(sub_opt): + pass + + @sub.command('bsub') + @click.option('--bsub-opt', type=click.Choice(['bsubopt1', 'bsubopt2'])) + @click.argument('bsub-arg1', required=False, type=click.Choice(['bsubarg1', 'bsubarg2'])) + @click.argument('bbsub-arg2', required=False, type=click.Choice(['bbsubarg1', 'bbsubarg2'])) + def bsub(bsub_opt): + pass + + assert list(get_choices(cli, 'lol', ['sub'], '')) == ['subarg1', 'subarg2', 'bsub'] + assert list(get_choices(cli, 'lol', ['sub', '--sub-opt'], '')) == ['subopt1', 'subopt2'] + assert list(get_choices(cli, 'lol', ['sub', '--sub-opt', 'subopt1'], '')) == \ + ['subarg1', 'subarg2', 'bsub'] + assert list(get_choices(cli, 'lol', + ['sub', '--sub-opt', 'subopt1', 'subarg1', 'bsub'], '-')) == ['--bsub-opt'] + assert list(get_choices(cli, 'lol', + ['sub', '--sub-opt', 'subopt1', 'subarg1', 'bsub', '--bsub-opt'], '')) == \ + ['bsubopt1', 'bsubopt2'] + assert list(get_choices(cli, 'lol', + ['sub', '--sub-opt', 'subopt1', 'subarg1', 'bsub', '--bsub-opt', 'bsubopt1', 'bsubarg1'], + '')) == ['bbsubarg1', 'bbsubarg2'] diff --git a/tests/test_basic.py b/tests/test_basic.py index 045f60853..8ba251fa1 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -180,6 +180,28 @@ def cli(flag): assert result.output == '%s\n' % (default) +def test_boolean_conversion(runner): + for default in True, False: + @click.command() + @click.option('--flag', default=default, type=bool) + def cli(flag): + click.echo(flag) + + for value in 'true', 't', '1', 'yes', 'y': + result = runner.invoke(cli, ['--flag', value]) + assert not result.exception + assert result.output == 'True\n' + + for value in 'false', 'f', '0', 'no', 'n': + result = runner.invoke(cli, ['--flag', value]) + assert not result.exception + assert result.output == 'False\n' + + result = runner.invoke(cli, []) + assert not result.exception + assert result.output == '%s\n' % default + + def test_file_option(runner): @click.command() @click.option('--file', type=click.File('w')) @@ -343,6 +365,39 @@ def clamp(x): assert result.output == '0\n' +def test_float_range_option(runner): + @click.command() + @click.option('--x', type=click.FloatRange(0, 5)) + def cli(x): + click.echo(x) + + result = runner.invoke(cli, ['--x=5.0']) + assert not result.exception + assert result.output == '5.0\n' + + result = runner.invoke(cli, ['--x=6.0']) + assert result.exit_code == 2 + assert 'Invalid value for "--x": 6.0 is not in the valid range of 0 to 5.\n' \ + in result.output + + @click.command() + @click.option('--x', type=click.FloatRange(0, 5, clamp=True)) + def clamp(x): + click.echo(x) + + result = runner.invoke(clamp, ['--x=5.0']) + assert not result.exception + assert result.output == '5.0\n' + + result = runner.invoke(clamp, ['--x=6.0']) + assert not result.exception + assert result.output == '5\n' + + result = runner.invoke(clamp, ['--x=-1.0']) + assert not result.exception + assert result.output == '0\n' + + def test_required_option(runner): @click.command() @click.option('--foo', required=True) @@ -357,7 +412,7 @@ def cli(foo): def test_evaluation_order(runner): called = [] - def memo(ctx, value): + def memo(ctx, param, value): called.append(value) return value @@ -397,3 +452,47 @@ def cli(**x): 'normal1', 'missing', ] + + +def test_hidden_option(runner): + @click.command() + @click.option('--nope', hidden=True) + def cli(nope): + click.echo(nope) + + result = runner.invoke(cli, ['--help']) + assert result.exit_code == 0 + assert '--nope' not in result.output + + +def test_hidden_command(runner): + @click.group() + def cli(): + pass + + @cli.command(hidden=True) + def nope(): + pass + + result = runner.invoke(cli, ['--help']) + assert result.exit_code == 0 + assert 'nope' not in result.output + + +def test_hidden_group(runner): + @click.group() + def cli(): + pass + + @cli.group(hidden=True) + def subgroup(): + pass + + @subgroup.command() + def nope(): + pass + + result = runner.invoke(cli, ['--help']) + assert result.exit_code == 0 + assert 'subgroup' not in result.output + assert 'nope' not in result.output diff --git a/tests/test_commands.py b/tests/test_commands.py index 9b6a6fbca..e8a95351b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- import re + import click +import pytest def test_other_command_invoke(runner): @@ -62,7 +64,7 @@ def long(): r'Commands:\n\s+' r'long\s+This is a long text that is too long to show\.\.\.\n\s+' r'short\s+This is a short text\.\n\s+' - r'special_chars\s+Login and store the token in ~/.netrc\.\s*', + r'special-chars\s+Login and store the token in ~/.netrc\.\s*', result.output) is not None @@ -206,7 +208,7 @@ def cli(ctx): @click.option('--foo', type=click.INT, default=42) @click.pass_context def other_cmd(ctx, foo): - assert ctx.info_name == 'other_cmd' + assert ctx.info_name == 'other-cmd' click.echo(foo) result = runner.invoke(cli, []) @@ -253,3 +255,63 @@ def cli(verbose, args): 'Verbosity: 4', 'Args: -foo|-x|--muhaha|x|y|-x', ] + + +def test_subcommand_naming(runner): + @click.group() + def cli(): + pass + + @cli.command() + def foo_bar(): + click.echo('foo-bar') + + result = runner.invoke(cli, ['foo-bar']) + assert not result.exception + assert result.output.splitlines() == ['foo-bar'] + + +def test_environment_variables(runner): + @click.group() + def cli(): + pass + + @cli.command() + @click.option('--name', envvar='CLICK_NAME') + def foo(name): + click.echo(name) + + result = runner.invoke(cli, ['foo'], env={'CLICK_NAME': 'environment'}) + + assert not result.exception + assert result.output == 'environment\n' + + +# Ensures the variables are read in the following order: +# 1. CLI +# 2. Environment +# 3. Defaults +variable_precedence_testdata = [ + (['foo', '--name=cli'], {'CLICK_NAME': 'environment'}, 'cli\n'), + (['foo'], {'CLICK_NAME': 'environment'}, 'environment\n'), + (['foo'], None, 'defaults\n'), +] + + +@pytest.mark.parametrize("command,environment,expected", + variable_precedence_testdata) +def test_variable_precendence_00(runner, command, environment, expected): + @click.group() + def cli(): + pass + + @cli.command() + @click.option('--name', envvar='CLICK_NAME') + def foo(name): + click.echo(name) + + defaults = {'foo': {'name': 'defaults'}} + result = runner.invoke(cli, command, default_map=defaults, env=environment) + + assert not result.exception + assert result.output == expected diff --git a/tests/test_compat.py b/tests/test_compat.py index e4ecdc81b..9dacc21d6 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1,4 +1,5 @@ import click +import pytest if click.__version__ >= '3.0': @@ -11,10 +12,10 @@ def legacy_callback(ctx, value): def cli(foo): click.echo(foo) - result = runner.invoke(cli, ['--foo', 'wat']) - assert result.exit_code == 0 - assert 'WAT' in result.output - assert 'Invoked legacy parameter callback' in result.output + with pytest.warns(Warning, match='Invoked legacy parameter callback'): + result = runner.invoke(cli, ['--foo', 'wat']) + assert result.exit_code == 0 + assert 'WAT' in result.output def test_bash_func_name(): diff --git a/tests/test_formatting.py b/tests/test_formatting.py index e2f550e1b..d2d54db50 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -74,11 +74,11 @@ def command(): # 54 is chosen as a length where the second line is one character # longer than the maximum length. - result = runner.invoke(cli, ['a_very_long', 'command', '--help'], + 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', + 'Usage: cli a-very-long command [OPTIONS] FIRST SECOND', ' THIRD FOURTH FIFTH', ' SIXTH', '', @@ -111,11 +111,11 @@ def command(): """A command. """ - result = runner.invoke(cli, ['a_very_very_very_long', 'command', '--help'], + result = runner.invoke(cli, ['a-very-very-very-long', 'command', '--help'], terminal_width=54) assert not result.exception assert result.output.splitlines() == [ - 'Usage: cli a_very_very_very_long command ', + 'Usage: cli a-very-very-very-long command ', ' [OPTIONS] FIRST SECOND THIRD FOURTH FIFTH', ' SIXTH', '', @@ -145,3 +145,106 @@ def cli(): 'Options:', ' --help Show this message and exit.', ] + + +def test_formatting_usage_error(runner): + @click.command() + @click.argument('arg') + def cmd(arg): + click.echo('arg:' + arg) + + result = runner.invoke(cmd, []) + assert result.exit_code == 2 + assert result.output.splitlines() == [ + 'Usage: cmd [OPTIONS] ARG', + 'Try "cmd --help" for help.', + '', + 'Error: Missing argument "ARG".' + ] + + +def test_formatting_usage_error_metavar_missing_arg(runner): + """ + :author: @r-m-n + Including attribution to #612 + """ + @click.command() + @click.argument('arg', metavar='metavar') + def cmd(arg): + pass + + result = runner.invoke(cmd, []) + assert result.exit_code == 2 + assert result.output.splitlines() == [ + 'Usage: cmd [OPTIONS] metavar', + 'Try "cmd --help" for help.', + '', + 'Error: Missing argument "metavar".' + ] + + +def test_formatting_usage_error_metavar_bad_arg(runner): + @click.command() + @click.argument('arg', type=click.INT, metavar='metavar') + def cmd(arg): + pass + + result = runner.invoke(cmd, ['3.14']) + assert result.exit_code == 2 + assert result.output.splitlines() == [ + 'Usage: cmd [OPTIONS] metavar', + 'Try "cmd --help" for help.', + '', + 'Error: Invalid value for "metavar": 3.14 is not a valid integer' + ] + + +def test_formatting_usage_error_nested(runner): + @click.group() + def cmd(): + pass + + @cmd.command() + @click.argument('bar') + def foo(bar): + click.echo('foo:' + bar) + + result = runner.invoke(cmd, ['foo']) + assert result.exit_code == 2 + assert result.output.splitlines() == [ + 'Usage: cmd foo [OPTIONS] BAR', + 'Try "cmd foo --help" for help.', + '', + 'Error: Missing argument "BAR".' + ] + + +def test_formatting_usage_error_no_help(runner): + @click.command(add_help_option=False) + @click.argument('arg') + def cmd(arg): + click.echo('arg:' + arg) + + result = runner.invoke(cmd, []) + assert result.exit_code == 2 + assert result.output.splitlines() == [ + 'Usage: cmd [OPTIONS] ARG', + '', + 'Error: Missing argument "ARG".' + ] + + +def test_formatting_usage_custom_help(runner): + @click.command(context_settings=dict(help_option_names=['--man'])) + @click.argument('arg') + def cmd(arg): + click.echo('arg:' + arg) + + result = runner.invoke(cmd, []) + assert result.exit_code == 2 + assert result.output.splitlines() == [ + 'Usage: cmd [OPTIONS] ARG', + 'Try "cmd --man" for help.', + '', + 'Error: Missing argument "ARG".' + ] diff --git a/tests/test_imports.py b/tests/test_imports.py index 6a8a12262..f400fa854 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -32,7 +32,7 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, ALLOWED_IMPORTS = set([ 'weakref', 'os', 'struct', 'collections', 'sys', 'contextlib', 'functools', 'stat', 're', 'codecs', 'inspect', 'itertools', 'io', - 'threading', 'colorama' + 'threading', 'colorama', 'errno', 'fcntl' ]) if WIN: @@ -48,7 +48,6 @@ def test_light_imports(): if sys.version_info[0] != 2: rv = rv.decode('utf-8') imported = json.loads(rv) - print(imported) for module in imported: if module == 'click' or module.startswith('click.'): diff --git a/tests/test_options.py b/tests/test_options.py index d196fe279..97b8d90e9 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -84,7 +84,7 @@ def cli(v): assert result.output == 'verbosity=0\n' result = runner.invoke(cli, ['--help']) - assert re.search('-v\s+Verbosity', result.output) is not None + assert re.search(r'-v\s+Verbosity', result.output) is not None @pytest.mark.parametrize('unknown_flag', ['--foo', '-f']) @@ -198,8 +198,32 @@ def cmd(arg): assert result.output == 'x|1\ny|2\n' +def test_show_envvar(runner): + @click.command() + @click.option('--arg1', envvar='ARG1', + show_envvar=True) + def cmd(arg): + pass + + result = runner.invoke(cmd, ['--help']) + assert not result.exception + assert 'ARG1' in result.output + + +def test_show_envvar_auto_prefix(runner): + @click.command() + @click.option('--arg1', show_envvar=True) + def cmd(arg): + pass + + result = runner.invoke(cmd, ['--help'], + auto_envvar_prefix='TEST') + assert not result.exception + assert 'TEST_ARG1' in result.output + + def test_custom_validation(runner): - def validate_pos_int(ctx, value): + def validate_pos_int(ctx, param, value): if value < 0: raise click.BadParameter('Value needs to be positive') return value @@ -311,6 +335,35 @@ def cmd(testoption): assert 'you wont see me' not in result.output +def test_option_custom_class_reusable(runner): + """Ensure we can reuse a custom class option. See Issue #926""" + + 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') + + # Assign to a variable to re-use the decorator. + testoption = click.option('--testoption', cls=CustomOption, help='you wont see me') + + @click.command() + @testoption + def cmd1(testoption): + click.echo(testoption) + + @click.command() + @testoption + def cmd2(testoption): + click.echo(testoption) + + # Both of the commands should have the --help option now. + for cmd in (cmd1, cmd2): + + result = runner.invoke(cmd, ['--help']) + assert 'I am a help text' in result.output + assert 'you wont see me' not in result.output + + def test_aliases_for_flags(runner): @click.command() @click.option('--warnings/--no-warnings', ' /-W', default=True) @@ -335,3 +388,29 @@ def cli_alt(warnings): assert result.output == 'False\n' result = runner.invoke(cli_alt, ['-w']) assert result.output == 'True\n' + +@pytest.mark.parametrize('option_args,expected', [ + (['--aggressive', '--all', '-a'], 'aggressive'), + (['--first', '--second', '--third', '-a', '-b', '-c'], 'first'), + (['--apple', '--banana', '--cantaloupe', '-a', '-b', '-c'], 'apple'), + (['--cantaloupe', '--banana', '--apple', '-c', '-b', '-a'], 'cantaloupe'), + (['-a', '-b', '-c'], 'a'), + (['-c', '-b', '-a'], 'c'), + (['-a', '--apple', '-b', '--banana', '-c', '--cantaloupe'], 'apple'), + (['-c', '-a', '--cantaloupe', '-b', '--banana', '--apple'], 'cantaloupe'), + (['--from', '-f', '_from'], '_from'), + (['--return', '-r', '_ret'], '_ret'), +]) +def test_option_names(runner, option_args, expected): + + @click.command() + @click.option(*option_args, is_flag=True) + def cmd(**kwargs): + click.echo(str(kwargs[expected])) + + assert cmd.params[0].name == expected + + for form in option_args: + if form.startswith('-'): + result = runner.invoke(cmd, [form]) + assert result.output == 'True\n' diff --git a/tests/test_termui.py b/tests/test_termui.py index 762163adb..97bf28168 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -1,15 +1,29 @@ import click +import time + + +class FakeClock(object): + def __init__(self): + self.now = time.time() + + def advance_time(self, seconds=1): + self.now += seconds + + def time(self): + return self.now def test_progressbar_strip_regression(runner, monkeypatch): + fake_clock = FakeClock() label = ' padded line' @click.command() def cli(): with click.progressbar(tuple(range(10)), label=label) as progress: for thing in progress: - pass + fake_clock.advance_time() + monkeypatch.setattr(time, 'time', fake_clock.time) monkeypatch.setattr(click._termui_impl, 'isatty', lambda _: True) assert label in runner.invoke(cli, []).output @@ -37,8 +51,75 @@ def __next__(self): def cli(): with click.progressbar(Hinted(10), label='test') as progress: for thing in progress: - pass + time.sleep(.5) monkeypatch.setattr(click._termui_impl, 'isatty', lambda _: True) result = runner.invoke(cli, []) assert result.exception is None + + +def test_progressbar_hidden(runner, monkeypatch): + label = 'whatever' + + @click.command() + def cli(): + with click.progressbar(tuple(range(10)), label=label) as progress: + for thing in progress: + pass + + monkeypatch.setattr(click._termui_impl, 'isatty', lambda _: False) + assert runner.invoke(cli, []).output == '' + + +def test_choices_list_in_prompt(runner, monkeypatch): + @click.command() + @click.option('-g', type=click.Choice(['none', 'day', 'week', 'month']), + prompt=True) + def cli_with_choices(g): + pass + + @click.command() + @click.option('-g', type=click.Choice(['none', 'day', 'week', 'month']), + prompt=True, show_choices=False) + def cli_without_choices(g): + pass + + result = runner.invoke(cli_with_choices, [], input='none') + assert '(none, day, week, month)' in result.output + + result = runner.invoke(cli_without_choices, [], input='none') + assert '(none, day, week, month)' not in result.output + + +def test_secho(runner): + with runner.isolation() as outstreams: + click.secho(None, nl=False) + bytes = outstreams[0].getvalue() + assert bytes == b'' + + +def test_progressbar_yields_all_items(runner): + with click.progressbar(range(3)) as progress: + assert len(list(progress)) == 3 + + +def test_progressbar_update(runner, monkeypatch): + fake_clock = FakeClock() + + @click.command() + def cli(): + with click.progressbar(range(4)) as progress: + for _ in progress: + fake_clock.advance_time() + print("") + + monkeypatch.setattr(time, 'time', fake_clock.time) + monkeypatch.setattr(click._termui_impl, 'isatty', lambda _: True) + output = runner.invoke(cli, []).output + + lines = [line for line in output.split('\n') if '[' in line] + + assert ' 25% 00:00:03' in lines[0] + assert ' 50% 00:00:02' in lines[1] + assert ' 75% 00:00:01' in lines[2] + assert '100% ' in lines[3] diff --git a/tests/test_testing.py b/tests/test_testing.py index fab2875dc..12f57de39 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,3 +1,4 @@ +import os import sys import pytest @@ -183,3 +184,76 @@ def cli_no_error(): result = runner.invoke(cli_no_error) assert result.exit_code == 0 assert result.output == 'hello world\n' + + +def test_env(): + @click.command() + def cli_env(): + click.echo('ENV=%s' % os.environ['TEST_CLICK_ENV']) + + runner = CliRunner() + + env_orig = dict(os.environ) + env = dict(env_orig) + assert 'TEST_CLICK_ENV' not in env + env['TEST_CLICK_ENV'] = 'some_value' + result = runner.invoke(cli_env, env=env) + assert result.exit_code == 0 + assert result.output == 'ENV=some_value\n' + + assert os.environ == env_orig + + +def test_stderr(): + @click.command() + def cli_stderr(): + click.echo("stdout") + click.echo("stderr", err=True) + + runner = CliRunner(mix_stderr=False) + + result = runner.invoke(cli_stderr) + + assert result.output == 'stdout\n' + assert result.stdout == 'stdout\n' + assert result.stderr == 'stderr\n' + + runner_mix = CliRunner(mix_stderr=True) + result_mix = runner_mix.invoke(cli_stderr) + + assert result_mix.output == 'stdout\nstderr\n' + assert result_mix.stdout == 'stdout\nstderr\n' + + with pytest.raises(ValueError): + result_mix.stderr + + +@pytest.mark.parametrize('args, expected_output', [ + (None, 'bar\n'), + ([], 'bar\n'), + ('', 'bar\n'), + (['--foo', 'one two'], 'one two\n'), + ('--foo "one two"', 'one two\n'), +]) +def test_args(args, expected_output): + + @click.command() + @click.option('--foo', default='bar') + def cli_args(foo): + click.echo(foo) + + runner = CliRunner() + result = runner.invoke(cli_args, args=args) + assert result.exit_code == 0 + assert result.output == expected_output + + +def test_setting_prog_name_in_extra(): + @click.command() + def cli(): + click.echo("ok") + + runner = CliRunner() + result = runner.invoke(cli, prog_name="foobar") + assert not result.exception + assert result.output == 'ok\n' diff --git a/tests/test_utils.py b/tests/test_utils.py index 88923adbc..4fd7cbbc3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,13 +10,13 @@ def test_echo(runner): - with runner.isolation() as out: + with runner.isolation() as outstreams: click.echo(u'\N{SNOWMAN}') click.echo(b'\x44\x44') click.echo(42, nl=False) click.echo(b'a', nl=False) click.echo('\x1b[31mx\x1b[39m', nl=False) - bytes = out.getvalue().replace(b'\r\n', b'\n') + bytes = outstreams[0].getvalue().replace(b'\r\n', b'\n') assert bytes == b'\xe2\x98\x83\nDD\n42ax' # If we are in Python 2, we expect that writing bytes into a string io @@ -35,12 +35,12 @@ def test_echo(runner): def cli(): click.echo(b'\xf6') result = runner.invoke(cli, []) - assert result.output_bytes == b'\xf6\n' + assert result.stdout_bytes == b'\xf6\n' # Ensure we do not strip for bytes. - with runner.isolation() as out: + with runner.isolation() as outstreams: click.echo(bytearray(b'\x1b[31mx\x1b[39m'), nl=False) - assert out.getvalue() == b'\x1b[31mx\x1b[39m' + assert outstreams[0].getvalue() == b'\x1b[31mx\x1b[39m' def test_echo_custom_file(): @@ -146,14 +146,36 @@ def f(_): assert out == 'Password: \nScrew you.\n' +def _test_gen_func(): + yield 'a' + yield 'b' + yield 'c' + yield 'abc' + + @pytest.mark.skipif(WIN, reason='Different behavior on windows.') @pytest.mark.parametrize('cat', ['cat', 'cat ', 'cat ']) -def test_echo_via_pager(monkeypatch, capfd, cat): +@pytest.mark.parametrize('test', [ + # We need lambda here, because pytest will + # reuse the parameters, and then the generators + # are already used and will not yield anymore + ('just text\n', lambda: 'just text'), + ('iterable\n', lambda: ["itera", "ble"]), + ('abcabc\n', lambda: _test_gen_func), + ('abcabc\n', lambda: _test_gen_func()), + ('012345\n', lambda: (c for c in range(6))), +]) +def test_echo_via_pager(monkeypatch, capfd, cat, test): monkeypatch.setitem(os.environ, 'PAGER', cat) monkeypatch.setattr(click._termui_impl, 'isatty', lambda x: True) - click.echo_via_pager('haha') + + expected_output = test[0] + test_input = test[1]() + + click.echo_via_pager(test_input) + out, err = capfd.readouterr() - assert out == 'haha\n' + assert out == expected_output @pytest.mark.skipif(WIN, reason='Test does not make sense on Windows.') @@ -268,9 +290,9 @@ def test_iter_keepopenfile(tmpdir): expected = list(map(str, range(10))) p = tmpdir.mkdir('testdir').join('testfile') p.write(os.linesep.join(expected)) - f = p.open() - for e_line, a_line in zip(expected, click.utils.KeepOpenFile(f)): - assert e_line == a_line.strip() + with p.open() as f: + for e_line, a_line in zip(expected, click.utils.KeepOpenFile(f)): + assert e_line == a_line.strip() @pytest.mark.xfail(WIN and not PY2, reason='God knows ...') @@ -278,6 +300,7 @@ def test_iter_lazyfile(tmpdir): expected = list(map(str, range(10))) p = tmpdir.mkdir('testdir').join('testfile') p.write(os.linesep.join(expected)) - f = p.open() - for e_line, a_line in zip(expected, click.utils.LazyFile(f.name)): - assert e_line == a_line.strip() + with p.open() as f: + with click.utils.LazyFile(f.name) as lf: + for e_line, a_line in zip(expected, lf): + assert e_line == a_line.strip() diff --git a/tox.ini b/tox.ini index 91bef64ea..ef4a70366 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,13 @@ [tox] -envlist = py26,py27,py33,py34,pypy +envlist = py27,py34,py35,py36,pypy +skip_missing_interpreters = true [testenv] passenv = LANG -commands = make test +commands = {env:TEST_RUNNER:pytest} {posargs} deps = - colorama pytest -whitelist_externals = make + colorama: colorama + coverage: coverage +setenv = + coverage: TEST_RUNNER=coverage run -m pytest From d2d80cc1ab22babafa568cd3d707cd759efc2b4c Mon Sep 17 00:00:00 2001 From: Jan Christoph Bischko Date: Tue, 15 May 2018 12:43:15 +0200 Subject: [PATCH 3/3] Add missing fake clock in termui tests --- tests/test_termui.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_termui.py b/tests/test_termui.py index 97bf28168..9e855d303 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -47,26 +47,31 @@ def __next__(self): next = __next__ + fake_clock = FakeClock() + @click.command() def cli(): with click.progressbar(Hinted(10), label='test') as progress: for thing in progress: - time.sleep(.5) + fake_clock.advance_time() + monkeypatch.setattr(time, 'time', fake_clock.time) monkeypatch.setattr(click._termui_impl, 'isatty', lambda _: True) result = runner.invoke(cli, []) assert result.exception is None def test_progressbar_hidden(runner, monkeypatch): + fake_clock = FakeClock() label = 'whatever' @click.command() def cli(): with click.progressbar(tuple(range(10)), label=label) as progress: for thing in progress: - pass + fake_clock.advance_time() + monkeypatch.setattr(time, 'time', fake_clock.time) monkeypatch.setattr(click._termui_impl, 'isatty', lambda _: False) assert runner.invoke(cli, []).output == ''