diff --git a/CHANGES b/CHANGES index 83c831e..5ebb65a 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,36 @@ Click Changelog This contains all major version changes between Click releases. +Version 5.1 +----------- + +(bugfix release, released on 17th August 2015) + +- Fix a bug in `pass_obj` that would accidentally pass the context too. + +Version 5.0 +----------- + +(codename "tok tok", released on 16th August 2015) + +- Removed various deprecated functionality. +- Atomic files now only accept the `w` mode. +- Change the usage part of help output for very long commands to wrap + their arguments onto the next line, indented by 4 spaces. +- Fix a bug where return code and error messages were incorrect when + using ``CliRunner``. +- added `get_current_context`. +- added a `meta` dictionary to the context which is shared across the + linked list of contexts to allow click utilities to place state there. +- introduced `Context.scope`. +- The `echo` function is now threadsafe: It calls the `write` method of the + underlying object only once. +- `prompt(hide_input=True)` now prints a newline on `^C`. +- Click will now warn if users are using ``unicode_literals``. +- Click will now ignore the ``PAGER`` environment variable if it is empty or + contains only whitespace. +- The `click-contrib` GitHub organization was created. + Version 4.1 ----------- diff --git a/PKG-INFO b/PKG-INFO index 8477435..a1a4aa5 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 1.1 Name: click -Version: 4.1 +Version: 5.1 Summary: A simple wrapper around optparse for powerful command line utilities. Home-page: http://github.com/mitsuhiko/click Author: Armin Ronacher diff --git a/README b/README index 7f4c97c..02e65f7 100644 --- a/README +++ b/README @@ -17,4 +17,4 @@ $ click_ Read the docs at http://click.pocoo.org/ - This library is a work in progress. Please give feedback! + This library is stable and active. Feedback is always welcome! diff --git a/click.egg-info/PKG-INFO b/click.egg-info/PKG-INFO index 8477435..a1a4aa5 100644 --- a/click.egg-info/PKG-INFO +++ b/click.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 1.1 Name: click -Version: 4.1 +Version: 5.1 Summary: A simple wrapper around optparse for powerful command line utilities. Home-page: http://github.com/mitsuhiko/click Author: Armin Ronacher diff --git a/click.egg-info/SOURCES.txt b/click.egg-info/SOURCES.txt index e00bc4b..160e199 100644 --- a/click.egg-info/SOURCES.txt +++ b/click.egg-info/SOURCES.txt @@ -15,6 +15,7 @@ click/core.py click/decorators.py click/exceptions.py click/formatting.py +click/globals.py click/parser.py click/termui.py click/testing.py @@ -34,6 +35,7 @@ docs/clickdoctools.py docs/commands.rst docs/complex.rst docs/conf.py +docs/contrib.rst docs/documentation.rst docs/exceptions.rst docs/index.rst @@ -55,12 +57,6 @@ docs/_static/click.png docs/_static/click@2x.png docs/_templates/sidebarintro.html docs/_templates/sidebarlogo.html -docs/_themes/LICENSE -docs/_themes/README -docs/_themes/click/layout.html -docs/_themes/click/relations.html -docs/_themes/click/theme.conf -docs/_themes/click/static/click.css_t examples/README examples/aliases/README examples/aliases/aliases.ini diff --git a/click/__init__.py b/click/__init__.py index 8d9b14c..0ed1235 100644 --- a/click/__init__.py +++ b/click/__init__.py @@ -18,6 +18,9 @@ from .core import Context, BaseCommand, Command, MultiCommand, Group, \ CommandCollection, Parameter, Option, Argument +# Globals +from .globals import get_current_context + # Decorators from .decorators import pass_context, pass_obj, make_pass_decorator, \ command, group, argument, option, confirmation_option, \ @@ -52,6 +55,9 @@ __all__ = [ 'Context', 'BaseCommand', 'Command', 'MultiCommand', 'Group', 'CommandCollection', 'Parameter', 'Option', 'Argument', + # Globals + 'get_current_context', + # Decorators 'pass_context', 'pass_obj', 'make_pass_decorator', 'command', 'group', 'argument', 'option', 'confirmation_option', 'password_option', @@ -82,4 +88,9 @@ __all__ = [ ] -__version__ = '4.1' +# Controls if click should emit the warning about the use of unicode +# literals. +disable_unicode_literals_warning = False + + +__version__ = '5.1' diff --git a/click/_compat.py b/click/_compat.py index 6e90408..d7a6e16 100644 --- a/click/_compat.py +++ b/click/_compat.py @@ -5,6 +5,8 @@ import sys import codecs from weakref import WeakKeyDictionary +import click + PY2 = sys.version_info[0] == 2 WIN = sys.platform.startswith('win') @@ -14,6 +16,39 @@ DEFAULT_COLUMNS = 80 _ansi_re = re.compile('\033\[((?:\d|;)*)([a-zA-Z])') +def _find_unicode_literals_frame(): + import __future__ + frm = sys._getframe(1) + idx = 1 + while frm is not None: + if frm.f_globals.get('__name__', '').startswith('click.'): + frm = frm.f_back + idx += 1 + elif frm.f_code.co_flags & __future__.unicode_literals.compiler_flag: + return idx + else: + break + return 0 + + +def _check_for_unicode_literals(): + if not __debug__: + return + if not PY2 or click.disable_unicode_literals_warning: + return + bad_frame = _find_unicode_literals_frame() + if bad_frame <= 0: + return + from warnings import warn + warn(Warning('Click detected the use of the unicode_literals ' + '__future__ import. This is heavily discouraged ' + 'because it can introduce subtle bugs in your ' + 'code. You should instead use explicit u"" literals ' + 'for your unicode strings. For more information see ' + 'http://click.pocoo.org/python3/'), + stacklevel=bad_frame) + + def get_filesystem_encoding(): return sys.getfilesystemencoding() or sys.getdefaultencoding() @@ -407,6 +442,19 @@ def open_stream(filename, mode='r', encoding=None, errors='strict', return open(filename, mode), True return io.open(filename, mode, encoding=encoding, errors=errors), True + # Some usability stuff for atomic writes + if 'a' in mode: + raise ValueError( + 'Appending to an existing file is not supported, because that ' + 'would involve an expensive `copy`-operation to a temporary ' + 'file. Open the file in normal `w`-mode and copy explicitly ' + 'if that\'s what you\'re after.' + ) + if 'x' in mode: + raise ValueError('Use the `overwrite`-parameter instead.') + if 'w' not in mode: + raise ValueError('Atomic writes only make sense with `w`-mode.') + # Atomic writes are more complicated. They work by opening a file # as a proxy in the same folder and then using the fdopen # functionality to wrap it in a Python file. Then we wrap it in an diff --git a/click/_termui_impl.py b/click/_termui_impl.py index a5ddc5d..e86b1bc 100644 --- a/click/_termui_impl.py +++ b/click/_termui_impl.py @@ -257,10 +257,11 @@ def pager(text, color=None): stdout = _default_text_stdout() if not isatty(sys.stdin) or not isatty(stdout): return _nullpager(stdout, text, color) - if 'PAGER' in os.environ: + pager_cmd = (os.environ.get('PAGER', None) or '').strip() + if pager_cmd: if WIN: - return _tempfilepager(text, os.environ['PAGER'], color) - return _pipepager(text, os.environ['PAGER'], color) + return _tempfilepager(text, pager_cmd, color) + return _pipepager(text, pager_cmd, color) if os.environ.get('TERM') in ('dumb', 'emacs'): return _nullpager(stdout, text, color) if WIN or sys.platform.startswith('os2'): diff --git a/click/core.py b/click/core.py index 3035931..b3b51c4 100644 --- a/click/core.py +++ b/click/core.py @@ -12,8 +12,10 @@ from .exceptions import ClickException, UsageError, BadParameter, Abort, \ from .termui import prompt, confirm from .formatting import HelpFormatter, join_options from .parser import OptionParser, split_opt +from .globals import push_context, pop_context + +from ._compat import PY2, isidentifier, iteritems, _check_for_unicode_literals -from ._compat import PY2, isidentifier, iteritems _missing = object() @@ -189,6 +191,8 @@ class Context(object): obj = parent.obj #: the user object stored. self.obj = obj + self._meta = getattr(parent, 'meta', {}) + #: A dictionary (-like object) with defaults for parameters. if default_map is None \ and parent is not None \ @@ -291,31 +295,80 @@ class Context(object): def __enter__(self): self._depth += 1 + push_context(self) return self def __exit__(self, exc_type, exc_value, tb): + pop_context() self._depth -= 1 if self._depth == 0: self.close() - def _get_invoked_subcommands(self): - from warnings import warn - warn(Warning('This API does not work properly and has been largely ' - 'removed in Click 3.2 to fix a regression the ' - 'introduction of this API caused. Consult the ' - 'upgrade documentation for more information. For ' - 'more information about this see ' - 'http://click.pocoo.org/upgrading/#upgrading-to-3.2'), - stacklevel=2) - if self.invoked_subcommand is None: - return [] - return [self.invoked_subcommand] - def _set_invoked_subcommands(self, value): - self.invoked_subcommand = \ - len(value) > 1 and '*' or value and value[0] or None - invoked_subcommands = property(_get_invoked_subcommands, - _set_invoked_subcommands) - del _get_invoked_subcommands, _set_invoked_subcommands + @contextmanager + def scope(self, cleanup=True): + """This helper method can be used with the context object to promote + it to the current thread local (see :func:`get_current_context`). + The default behavior of this is to invoke the cleanup functions which + can be disabled by setting `cleanup` to `False`. The cleanup + functions are typically used for things such as closing file handles. + + If the cleanup is intended the context object can also be directly + used as a context manager. + + Example usage:: + + with ctx.scope(): + assert get_current_context() is ctx + + This is equivalent:: + + with ctx: + assert get_current_context() is ctx + + .. versionadded:: 5.0 + + :param cleanup: controls if the cleanup functions should be run or + not. The default is to run these functions. In + some situations the context only wants to be + temporarily pushed in which case this can be disabled. + Nested pushes automatically defer the cleanup. + """ + if not cleanup: + self._depth += 1 + try: + with self as rv: + yield rv + finally: + if not cleanup: + self._depth -= 1 + + @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 + state here if they need to. It is however the responsibility of + that code to manage this dictionary well. + + The keys are supposed to be unique dotted strings. For instance + module paths are a good choice for it. What is stored in there is + irrelevant for the operation of click. However what is important is + that code that places data here adheres to the general semantics of + the system. + + Example usage:: + + LANG_KEY = __name__ + '.lang' + + def set_language(value): + ctx = get_current_context() + ctx.meta[LANG_KEY] = value + + def get_language(): + return get_current_context().meta.get(LANG_KEY, 'en_US') + + .. versionadded:: 5.0 + """ + return self._meta def make_formatter(self): """Creates the formatter for the help and usage output.""" @@ -434,11 +487,6 @@ class Context(object): self, callback = args[:2] ctx = self - # This is just to improve the error message in cases where old - # code incorrectly invoked this method. This will eventually be - # removed. - injected_arguments = False - # It's also possible to invoke another command which might or # might not have a callback. In that case we also fill # in defaults and make a new context for this command. @@ -453,26 +501,11 @@ class Context(object): for param in other_cmd.params: if param.name not in kwargs and param.expose_value: kwargs[param.name] = param.get_default(ctx) - injected_arguments = True args = args[2:] - if getattr(callback, '__click_pass_context__', False): - args = (ctx,) + args with augment_usage_errors(self): - try: - with ctx: - return callback(*args, **kwargs) - except TypeError as e: - if not injected_arguments: - raise - if 'got multiple values for' in str(e): - raise RuntimeError( - 'You called .invoke() on the context with a command ' - 'but provided parameters as positional arguments. ' - 'This is not supported but sometimes worked by chance ' - 'in older versions of Click. To fix this see ' - 'http://click.pocoo.org/upgrading/#upgrading-to-3.2') - raise + with ctx: + return callback(*args, **kwargs) def forward(*args, **kwargs): """Similar to :meth:`invoke` but fills in default keyword @@ -557,7 +590,8 @@ class BaseCommand(object): if key not in extra: extra[key] = value ctx = Context(self, info_name=info_name, parent=parent, **extra) - self.parse_args(ctx, args) + with ctx.scope(cleanup=False): + self.parse_args(ctx, args) return ctx def parse_args(self, ctx, args): @@ -624,6 +658,8 @@ class BaseCommand(object): 'Either switch to Python 2 or consult ' 'http://click.pocoo.org/python3/ ' 'for mitigation steps.') + else: + _check_for_unicode_literals() if args is None: args = sys.argv[1:] diff --git a/click/decorators.py b/click/decorators.py index e3184ba..3afd47a 100644 --- a/click/decorators.py +++ b/click/decorators.py @@ -3,16 +3,18 @@ import inspect from functools import update_wrapper -from ._compat import iteritems +from ._compat import iteritems, _check_for_unicode_literals from .utils import echo +from .globals import get_current_context def pass_context(f): """Marks a callback as wanting to receive the current context object as first argument. """ - f.__click_pass_context__ = True - return f + def new_func(*args, **kwargs): + return f(get_current_context(), *args, **kwargs) + return update_wrapper(new_func, f) def pass_obj(f): @@ -20,10 +22,8 @@ def pass_obj(f): context onwards (:attr:`Context.obj`). This is useful if that object represents the state of a nested system. """ - @pass_context def new_func(*args, **kwargs): - ctx = args[0] - return ctx.invoke(f, ctx.obj, *args[1:], **kwargs) + return f(get_current_context().obj, *args, **kwargs) return update_wrapper(new_func, f) @@ -50,9 +50,8 @@ def make_pass_decorator(object_type, ensure=False): remembered on the context if it's not there yet. """ def decorator(f): - @pass_context def new_func(*args, **kwargs): - ctx = args[0] + ctx = get_current_context() if ensure: obj = ctx.ensure_object(object_type) else: @@ -84,6 +83,7 @@ def _make_command(f, name, attrs, cls): else: help = inspect.cleandoc(help) attrs['help'] = help + _check_for_unicode_literals() return cls(name=name or f.__name__.lower(), callback=f, params=params, **attrs) @@ -111,7 +111,9 @@ def command(name=None, cls=None, **attrs): if cls is None: cls = Command def decorator(f): - return _make_command(f, name, attrs, cls) + cmd = _make_command(f, name, attrs, cls) + cmd.__doc__ = f.__doc__ + return cmd return decorator diff --git a/click/formatting.py b/click/formatting.py index a70a46f..a4bd081 100644 --- a/click/formatting.py +++ b/click/formatting.py @@ -18,12 +18,6 @@ def iter_rows(rows, col_count): yield row + ('',) * (col_count - len(row)) -def add_subsequent_indent(text, subsequent_indent): - lines = text.splitlines() - lines = lines[:1] + [subsequent_indent + line for line in lines[1:]] - return '\n'.join(lines) - - def wrap_text(text, width=78, initial_indent='', subsequent_indent='', preserve_paragraphs=False): """A helper function that intelligently wraps text. By default, it @@ -46,13 +40,11 @@ def wrap_text(text, width=78, initial_indent='', subsequent_indent='', """ from ._textwrap import TextWrapper text = text.expandtabs() - post_wrap_indent = subsequent_indent[:-1] - subsequent_indent = subsequent_indent[-1:] wrapper = TextWrapper(width, initial_indent=initial_indent, subsequent_indent=subsequent_indent, replace_whitespace=False) if not preserve_paragraphs: - return add_subsequent_indent(wrapper.fill(text), post_wrap_indent) + return wrapper.fill(text) p = [] buf = [] @@ -83,11 +75,9 @@ def wrap_text(text, width=78, initial_indent='', subsequent_indent='', for indent, raw, text in p: with wrapper.extra_indent(' ' * indent): if raw: - rv.append(add_subsequent_indent(wrapper.indent_only(text), - post_wrap_indent)) + rv.append(wrapper.indent_only(text)) else: - rv.append(add_subsequent_indent(wrapper.fill(text), - post_wrap_indent)) + rv.append(wrapper.fill(text)) return '\n\n'.join(rv) @@ -133,14 +123,23 @@ class HelpFormatter(object): :param args: whitespace separated list of arguments. :param prefix: the prefix for the first line. """ - prefix = '%*s%s' % (self.current_indent, prefix, prog) - self.write(prefix) + usage_prefix = '%*s%s ' % (self.current_indent, prefix, prog) + text_width = self.width - self.current_indent - text_width = max(self.width - self.current_indent - term_len(prefix), 10) - indent = ' ' * (term_len(prefix) + 1) - self.write(wrap_text(args, text_width, - initial_indent=' ', - subsequent_indent=indent)) + if text_width >= (term_len(usage_prefix) + 20): + # The arguments will fit to the right of the prefix. + indent = ' ' * term_len(usage_prefix) + self.write(wrap_text(args, text_width, + initial_indent=usage_prefix, + subsequent_indent=indent)) + else: + # The prefix is too long, put the arguments on the next line. + self.write(usage_prefix) + self.write('\n') + indent = ' ' * (max(self.current_indent, term_len(prefix)) + 4) + self.write(wrap_text(args, text_width, + initial_indent=indent, + subsequent_indent=indent)) self.write('\n') diff --git a/click/globals.py b/click/globals.py new file mode 100644 index 0000000..14338e6 --- /dev/null +++ b/click/globals.py @@ -0,0 +1,48 @@ +from threading import local + + +_local = local() + + +def get_current_context(silent=False): + """Returns the current click context. This can be used as a way to + 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. + + To push the current context, :meth:`Context.scope` can be used. + + .. versionadded:: 5.0 + + :param silent: is set to `True` the return value is `None` if no context + is available. The default behavior is to raise a + :exc:`RuntimeError`. + """ + try: + return getattr(_local, 'stack')[-1] + except (AttributeError, IndexError): + if not silent: + raise RuntimeError('There is no active click context.') + + +def push_context(ctx): + """Pushes a new context to the current stack.""" + _local.__dict__.setdefault('stack', []).append(ctx) + + +def pop_context(): + """Removes the top level from the stack.""" + _local.stack.pop() + + +def resolve_color_default(color=None): + """"Internal helper to get the default value of the color flag. If a + value is passed it's returned unchanged, otherwise it's looked up from + the current context. + """ + if color is not None: + return color + ctx = get_current_context(silent=True) + if ctx is not None: + return ctx.color diff --git a/click/termui.py b/click/termui.py index a39f2df..9dad8a7 100644 --- a/click/termui.py +++ b/click/termui.py @@ -7,6 +7,7 @@ from ._compat import raw_input, text_type, string_types, \ from .utils import echo from .exceptions import Abort, UsageError from .types import convert_type +from .globals import resolve_color_default # The prompt functions to use. The doc tools currently override these @@ -68,6 +69,11 @@ def prompt(text, default=None, hide_input=False, echo(text, nl=False, err=err) return f('') except (KeyboardInterrupt, EOFError): + # getpass doesn't print a newline if the user aborts input with ^C. + # Allegedly this behavior is inherited from getpass(3). + # A doc bug has been filed at https://bugs.python.org/issue24711 + if hide_input: + echo(None, err=err) raise Abort() if value_proc is None: @@ -197,6 +203,7 @@ def echo_via_pager(text, color=None): :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) from ._termui_impl import pager @@ -287,6 +294,7 @@ def progressbar(iterable=None, length=None, label=None, show_eta=True, which is not the case by default. """ from ._termui_impl import ProgressBar + color = resolve_color_default(color) return ProgressBar(iterable=iterable, length=length, show_eta=show_eta, show_percent=show_percent, show_pos=show_pos, item_show_func=item_show_func, fill_char=fill_char, diff --git a/click/testing.py b/click/testing.py index df07458..25ba130 100644 --- a/click/testing.py +++ b/click/testing.py @@ -277,8 +277,14 @@ class CliRunner(object): except SystemExit as e: if e.code != 0: exception = e - exit_code = e.code + exc_info = sys.exc_info() + + exit_code = e.code + 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 diff --git a/click/utils.py b/click/utils.py index 7b3669b..d6e38c6 100644 --- a/click/utils.py +++ b/click/utils.py @@ -2,6 +2,8 @@ import os import sys from collections import deque +from .globals import resolve_color_default + from ._compat import text_type, open_stream, get_filesystem_encoding, \ get_streerror, string_types, PY2, binary_streams, text_streams, \ filename_to_ui, auto_wrap_for_ansi, strip_ansi, should_strip_ansi, \ @@ -197,6 +199,10 @@ class LazyFile(object): def __exit__(self, exc_type, exc_value, tb): self.close_intelligently() + def __iter__(self): + self.open() + return iter(self._f) + class KeepOpenFile(object): @@ -215,6 +221,9 @@ class KeepOpenFile(object): def __repr__(self): return repr(self._file) + def __iter__(self): + return iter(self._file) + def echo(message=None, file=None, nl=True, err=False, color=None): """Prints a message plus a newline to the given file or stdout. On @@ -266,6 +275,13 @@ def echo(message=None, file=None, nl=True, err=False, color=None): if message is not None and not isinstance(message, echo_native_types): message = text_type(message) + if nl: + message = message or u'' + if isinstance(message, text_type): + message += u'\n' + else: + message += b'\n' + # If there is a message, and we're in Python 3, and the value looks # like bytes, we manually need to find the binary stream and write the # message in there. This is done separately so that most stream @@ -276,8 +292,6 @@ def echo(message=None, file=None, nl=True, err=False, color=None): if binary_file is not None: file.flush() binary_file.write(message) - if nl: - binary_file.write(b'\n') binary_file.flush() return @@ -287,6 +301,7 @@ def echo(message=None, file=None, nl=True, err=False, color=None): # to strip the color or we use the colorama support to translate the # ansi codes to API calls. if message and not is_bytes(message): + color = resolve_color_default(color) if should_strip_ansi(file, color): message = strip_ansi(message) elif WIN: @@ -297,8 +312,6 @@ def echo(message=None, file=None, nl=True, err=False, color=None): if message: file.write(message) - if nl: - file.write(u'\n') file.flush() diff --git a/docs/_themes/LICENSE b/docs/_themes/LICENSE deleted file mode 100644 index 8543fb8..0000000 --- a/docs/_themes/LICENSE +++ /dev/null @@ -1,37 +0,0 @@ -Copyright (c) 2014 by Armin Ronacher. - -Some rights reserved. - -Redistribution and use in source and binary forms of the theme, with or -without modification, are permitted provided that the following conditions -are met: - -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - -* The names of the contributors may not be used to endorse or - promote products derived from this software without specific - prior written permission. - -We kindly ask you to only use these themes in an unmodified manner just -for Click and Click-related products, not for unrelated projects. If you -like the visual style and want to use it for your own projects, please -consider making some larger changes to the themes (such as changing -font faces, sizes, colors or margins). - -THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/_themes/README b/docs/_themes/README deleted file mode 100644 index b3292bd..0000000 --- a/docs/_themes/README +++ /dev/null @@ -1,31 +0,0 @@ -Flask Sphinx Styles -=================== - -This repository contains sphinx styles for Flask and Flask related -projects. To use this style in your Sphinx documentation, follow -this guide: - -1. put this folder as _themes into your docs folder. Alternatively - you can also use git submodules to check out the contents there. -2. add this to your conf.py: - - sys.path.append(os.path.abspath('_themes')) - html_theme_path = ['_themes'] - html_theme = 'flask' - -The following themes exist: - -- 'flask' - the standard flask documentation theme for large - projects -- 'flask_small' - small one-page theme. Intended to be used by - very small addon libraries for flask. - -The following options exist for the flask_small theme: - - [options] - index_logo = '' filename of a picture in _static - to be used as replacement for the - h1 in the index.rst file. - index_logo_height = 120px height of the index logo - github_fork = '' repository name on github for the - "fork me" badge diff --git a/docs/_themes/click/layout.html b/docs/_themes/click/layout.html deleted file mode 100644 index 919e780..0000000 --- a/docs/_themes/click/layout.html +++ /dev/null @@ -1,20 +0,0 @@ -{%- extends "basic/layout.html" %} -{%- block extrahead %} - {{ super() }} -{% endblock %} -{%- block relbar2 %}{% endblock %} -{% block header %} - {{ super() }} - {% if pagename == 'index' %} -
- {% endif %} -{% endblock %} -{%- block footer %} - - {% if pagename == 'index' %} -
- {% endif %} -{%- endblock %} diff --git a/docs/_themes/click/relations.html b/docs/_themes/click/relations.html deleted file mode 100644 index 3bbcde8..0000000 --- a/docs/_themes/click/relations.html +++ /dev/null @@ -1,19 +0,0 @@ -

Related Topics

- diff --git a/docs/_themes/click/static/click.css_t b/docs/_themes/click/static/click.css_t deleted file mode 100644 index 452fb4f..0000000 --- a/docs/_themes/click/static/click.css_t +++ /dev/null @@ -1,403 +0,0 @@ -/* - * click.css_t - * ~~~~~~~~~~~ - * - * :copyright: Copyright 2014 by Armin Ronacher. - * :license: Flask Design License, see LICENSE for details. - */ - -{% set page_width = '940px' %} -{% set sidebar_width = '220px' %} - -@import url("basic.css"); - -@import url(http://fonts.googleapis.com/css?family=Ubuntu+Mono:400,400italic,700,700italic); -@import url(http://fonts.googleapis.com/css?family=Open+Sans:300,400); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: 'Ubuntu Mono', 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono'; - font-size: 15px; - background-color: white; - color: #000; - margin: 0; - padding: 0; -} - -div.document { - width: {{ page_width }}; - margin: 30px auto 0 auto; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 {{ sidebar_width }}; -} - -div.sphinxsidebar { - width: {{ sidebar_width }}; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: 'Open Sans', 'Helvetica', 'Arial', sans-serif; - font-weight: 300; - margin: 20px 0px 10px 0px; - padding: 0; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 0 30px; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - width: {{ page_width }}; - margin: 20px auto 30px auto; - font-size: 14px; - color: #888; - text-align: right; -} - -div.footer a { - color: #888; -} - -div.related { - display: none; -} - -div.sphinxsidebar a { - color: #444; - text-decoration: none; - border-bottom: 1px dotted #999; -} - -div.sphinxsidebar a:hover { - border-bottom: 1px solid #999; -} - -div.sphinxsidebar { - font-size: 14px; - line-height: 1.5; -} - -div.sphinxsidebarwrapper { - padding: 18px 10px; -} - -div.sphinxsidebarwrapper p.logo { - padding: 0 0 20px 0; - margin: 0; - text-align: left; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - color: #444; - font-size: 18px; - font-weight: normal; - margin: 0 0 5px 0; - padding: 0; -} - -div.sphinxsidebar h4 { - font-size: 15px; -} - -div.sphinxsidebar h3 a { - color: #444; -} - -div.sphinxsidebar p.logo a, -div.sphinxsidebar h3 a, -div.sphinxsidebar p.logo a:hover, -div.sphinxsidebar h3 a:hover { - border: none; -} - -div.sphinxsidebar p { - color: #555; - margin: 10px 0; -} - -div.sphinxsidebar ul { - margin: 10px 0; - padding: 0; - color: #000; -} - -div.sphinxsidebar input { - border: 1px solid #ccc; - font-family: 'Ubuntu Mono', 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono'; - font-size: 13px; -} - -div.sphinxsidebar #searchbox input[type="text"] { - width: 120px; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #5D2CD1; - text-decoration: underline; -} - -a:hover { - color: #7546E3; - text-decoration: underline; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: 'Open Sans', 'Helvetica', 'Arial', sans-serif; - font-weight: 400; - margin: 30px 0px 10px 0px; - padding: 0; -} - -div.indexwrapper h1 { - text-indent: -999999px; - background: url(click.png) no-repeat center center; - height: 200px; -} - -@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { - div.indexwrapper h1 { - background: url(click@2x.png) no-repeat center center; - background-size: 420px 175px; - } -} - -div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: #ddd; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #eaeaea; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -div.admonition { - background: #fafafa; - margin: 20px -30px; - padding: 10px 30px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition tt.xref, div.admonition a tt { - border-bottom: 1px solid #fafafa; -} - -dd div.admonition { - margin-left: -60px; - padding-left: 60px; -} - -div.admonition p.admonition-title { - font-family: 'Open Sans', 'Helvetica', 'Arial', sans-serif; - font-weight: 400; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition p.last { - margin-bottom: 0; -} - -div.highlight { - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt { - font-family: 'Ubuntu Mono', 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono'; - font-size: 15px; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; - background: #fdfdfd; - font-size: 0.9em; -} - -table.footnote + table.footnote { - margin-top: -15px; - border-top: none; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td.label { - width: 0px; - padding: 0.3em 0 0.3em 0.5em; -} - -table.footnote td { - padding: 0.3em 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -blockquote { - margin: 0 0 0 30px; - padding: 0; -} - -ul, ol { - margin: 10px 0 10px 30px; - padding: 0; -} - -pre { - padding: 7px 0 7px 30px; - margin: 15px 0; - line-height: 1.3em; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; - border-bottom: 1px solid white; -} - -a.reference { - text-decoration: none; - border-bottom: 1px dotted #5D2CD1; -} - -a.reference:hover { - border-bottom: 1px solid #7546E3; -} - -a.footnote-reference { - text-decoration: none; - font-size: 0.7em; - vertical-align: top; - border-bottom: 1px dotted #5D2CD1; -} - -a.footnote-reference:hover { - border-bottom: 1px solid #7546E3; -} - -a:hover tt { - background: #EEE; -} diff --git a/docs/_themes/click/theme.conf b/docs/_themes/click/theme.conf deleted file mode 100644 index d1ab008..0000000 --- a/docs/_themes/click/theme.conf +++ /dev/null @@ -1,4 +0,0 @@ -[theme] -inherit = basic -stylesheet = click.css -pygments_style = tango diff --git a/docs/advanced.rst b/docs/advanced.rst index b84333c..f2b83dd 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -338,3 +338,42 @@ your own commands and commands from another application are discouraged and if you can avoid it, you should. It's a much better idea to have everything below a subcommand be forwarded to another application than to handle some arguments yourself. + + +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 +: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 +:func:`echo` function does this to infer the default value of the `color` +flag. + +Example usage:: + + def get_current_command_name(): + return click.get_current_context().info_name + +It should be noted that this only works within the current thread. If you +spawn additional threads then those threads will not have the ability to +refer to the current context. If you want to give another thread the +ability to refer to this context you need to use the context within the +thread as a context manager:: + + def spawn_thread(ctx, func): + def wrapper(): + with ctx: + func() + t = threading.Thread(target=wrapper) + t.start() + return t + +Now the thread function can access the context like the main thread would +do. However if you do use this for threading you need to be very careful +as the vast majority of the context is not thread safe! You are only +allowed to read from the context, but not to perform any modifications on +it. diff --git a/docs/api.rst b/docs/api.rst index b4304f1..63f04b1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -106,6 +106,8 @@ Context .. autoclass:: Context :members: +.. autofunction:: get_current_context + Types ----- diff --git a/docs/commands.rst b/docs/commands.rst index eac64a7..642f873 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -331,12 +331,12 @@ process the result of the previous command. There are various ways in which this can be facilitated. The most obvious way is to store a value on the context object and process it from function to function. This works by decorating a function with :func:`pass_context` after which the -context object is provided and a subcommand can store it's data there. +context object is provided and a subcommand can store its data there. Another way to accomplish this is to setup pipelines by returning processing functions. Think of it like this: when a subcommand gets -invoked it processes all of it's parameters and comes up with a plan of -how to do it's processing. At that point it then returns a processing +invoked it processes all of its parameters and comes up with a plan of +how to do its processing. At that point it then returns a processing function and returns. Where do the returned functions go? The chained multicommand can register diff --git a/docs/conf.py b/docs/conf.py index e726702..dd69995 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,7 +17,6 @@ import datetime # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.append(os.path.abspath('_themes')) sys.path.append(os.path.abspath('..')) sys.path.append(os.path.abspath('.')) @@ -92,7 +91,7 @@ exclude_patterns = ['_build'] # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = 'click' +#html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -101,7 +100,7 @@ html_theme_options = { } # Add any paths that contain custom themes here, relative to this directory. -html_theme_path = ['_themes'] +#html_theme_path = ['_themes'] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". diff --git a/docs/contrib.rst b/docs/contrib.rst new file mode 100644 index 0000000..b9a2a01 --- /dev/null +++ b/docs/contrib.rst @@ -0,0 +1,25 @@ +.. _contrib: + +============= +click-contrib +============= + +As the userbase of Click grows, more and more major feature requests pop up in +Click's bugtracker. As reasonable as it may be for those features to be bundled +with Click instead of being a standalone project, many of those requested +features are either highly experimental or have unproven practical use, while +potentially being a burden to maintain. + +This is why click-contrib_ exists. The GitHub organization is a collection of +possibly experimental third-party packages whose featureset does not belong +into Click, but also a playground for major features that may be added to Click +in the future. It is also meant to coordinate and concentrate effort on writing +third-party extensions for Click, and to ease the effort of searching for such +extensions. In that sense it could be described as a low-maintenance +alternative to extension repositories of other frameworks. + +Please note that the quality and stability of those packages may be different +than what you expect from Click itself. While published under a common +organization, they are still projects separate from Click. + +.. _click-contrib: https://github.com/click-contrib/ diff --git a/docs/index.rst b/docs/index.rst index 602bd1d..5bfea5d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -93,6 +93,7 @@ Miscellaneous Pages .. toctree:: :maxdepth: 2 + contrib changelog upgrading license diff --git a/docs/options.rst b/docs/options.rst index bd534a8..7881117 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -330,6 +330,27 @@ replaced with the :func:`password_option` decorator: def encrypt(password): click.echo('Encrypting password to %s' % password.encode('rot13')) +Dynamic Defaults for Prompts +---------------------------- + +The ``auto_envvar_prefix`` and ``default_map`` options for the context +allow the program to read option values from the environment or a +configuration file. However, this overrides the prompting mechanism, so +that the user does not get the option to change the value interactively. + +If you want to let the user configure the default value, but still be +prompted if the option isn't specified on the command line, you can do so +by supplying a callable as the default value. For example, to get a default +from the environment: + +.. click:example:: + + @click.command() + @click.option('--username', prompt=True, + default=lambda: os.environ.get('USER', '')) + def hello(username): + print("Hello,", username) + Callbacks and Eager Options --------------------------- diff --git a/docs/python3.rst b/docs/python3.rst index 29c7771..39e8fe6 100644 --- a/docs/python3.rst +++ b/docs/python3.rst @@ -152,3 +152,26 @@ Python 3 bug tracker: * `LC_CTYPE=C: pydoc leaves terminal in an unusable state `_ (this is relevant to Click because the pager support is provided by the stdlib pydoc module) + +Unicode Literals +---------------- + +Starting with Click 5.0 there will be a warning for the use of the +``unicode_literals`` future import in Python 2. This has been done due to +the negative consequences of this import with regards to unintentionally +causing bugs due to introducing Unicode data to APIs that are incapable of +handling them. For some examples of this issue, see the discussion on +this github issue: `python-future#22 +`_. + +If you use ``unicode_literals`` in any file that defines a Click command +or that invokes a click command you will be given a warning. You are +strongly encouraged to not use ``unicode_literals`` and instead use +explicit ``u`` prefixes for your Unicode strings. + +If you do want to ignore the warning and continue using +``unicode_literals`` on your own peril, you can disable the warning as +follows:: + + import click + click.disable_unicode_literals_warning = True diff --git a/docs/setuptools.rst b/docs/setuptools.rst index 645ec6f..870591a 100644 --- a/docs/setuptools.rst +++ b/docs/setuptools.rst @@ -13,14 +13,14 @@ Why would you want to do that? There are a bunch of reasons: module the Python interpreter loads has an incorrect name. This might sound like a small issue but it has quite significant implications. - The first module is not called by it's actual name, but the + The first module is not called by its actual name, but the interpreter renames it to ``__main__``. While that is a perfectly valid name it means that if another piece of code wants to import from - that module it will trigger the import a second time under it's real - name and all the sudden your code is imported twice. + that module it will trigger the import a second time under its real + name and all of a sudden your code is imported twice. 2. Not on all platforms are things that easy to execute. On Linux and OS - X you can add a comment to the beginning of the file (``#/usr/bin/env + X you can add a comment to the beginning of the file (``#!/usr/bin/env python``) and your script works like an executable (assuming it has the executable bit set). This however does not work on Windows. While on Windows you can associate interpreters with file extensions diff --git a/docs/testing.rst b/docs/testing.rst index 7719c29..3c046bf 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -38,6 +38,29 @@ Example:: assert result.exit_code == 0 assert result.output == 'Hello Peter!\n' +For subcommand testing, a subcommand name must be specified in the `args` parameter of :meth:`CliRunner.invoke` method. + +Example:: + + import click + from click.testing import CliRunner + + def test_sync(): + @click.group() + @click.option('--debug/--no-debug', default=False) + def cli(debug): + click.echo('Debug mode is %s' % ('on' if debug else 'off')) + + @cli.command() + def sync(): + click.echo('Syncing') + + runner = CliRunner() + result = runner.invoke(cli, ['--debug', 'sync']) + assert result.exit_code == 0 + assert 'Debug mode is on' in result.output + assert 'Syncing' in result.output + File System Isolation --------------------- diff --git a/docs/why.rst b/docs/why.rst index 102ffdd..cd9c037 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -60,7 +60,7 @@ hard: full understanding of the command line how the parser is going to behave. This goes against Click's ambitions of dispatching to subparsers. -* argparse currently does not support disabling of interspearsed +* argparse currently does not support disabling of interspersed arguments. Without this feature it's not possible to safely implement Click's nested parsing nature. diff --git a/tests/test_chain.py b/tests/test_chain.py index d1f9c1f..9f86a8f 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -103,37 +103,6 @@ def test_chaining_with_arguments(runner): ] -def test_context_subcommand_info_sync(recwarn): - @click.command() - def cli(): - pass - - def _assert_warning(): - assert 'removed in Click 3.2' in str(recwarn.pop(Warning).message) - - ctx = click.Context(cli, info_name='cli') - - assert ctx.invoked_subcommand is None - - ctx.invoked_subcommand = 'foo' - assert ctx.invoked_subcommand == 'foo' - assert ctx.invoked_subcommands == ['foo'] - - ctx.invoked_subcommands = ['foo'] - assert ctx.invoked_subcommand == 'foo' - assert ctx.invoked_subcommands == ['foo'] - - ctx.invoked_subcommands = [] - assert ctx.invoked_subcommand is None - assert ctx.invoked_subcommands == [] - - ctx.invoked_subcommands = ['foo', 'bar'] - assert ctx.invoked_subcommand == '*' - assert ctx.invoked_subcommands == ['*'] - - assert 'removed in Click 3.2' in str(recwarn.pop(Warning).message) - - def test_pipeline(runner): @click.group(chain=True, invoke_without_command=True) @click.option('-i', '--input', type=click.File('r')) diff --git a/tests/test_commands.py b/tests/test_commands.py index ac9760f..9b6a6fb 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -19,23 +19,6 @@ def test_other_command_invoke(runner): assert result.output == '42\n' -def test_other_command_invoke_invalid_custom_error(runner): - @click.command() - @click.pass_context - def cli(ctx): - return ctx.invoke(other_cmd, 42) - - @click.command() - @click.argument('arg', type=click.INT) - def other_cmd(arg): - click.echo(arg) - - result = runner.invoke(cli, []) - assert isinstance(result.exception, RuntimeError) - assert 'upgrading-to-3.2' in str(result.exception) - assert click.__version__ < '5.0' - - def test_other_command_forward(runner): cli = click.Group() diff --git a/tests/test_context.py b/tests/test_context.py index ed84681..c5ac9c1 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -110,3 +110,77 @@ def test_multi_enter(runner): result = runner.invoke(cli, []) assert result.exception is None assert called == [True] + + +def test_global_context_object(runner): + @click.command() + @click.pass_context + def cli(ctx): + assert click.get_current_context() is ctx + ctx.obj = 'FOOBAR' + assert click.get_current_context().obj == 'FOOBAR' + + assert click.get_current_context(silent=True) is None + runner.invoke(cli, [], catch_exceptions=False) + assert click.get_current_context(silent=True) is None + + +def test_context_meta(runner): + LANG_KEY = __name__ + '.lang' + + def set_language(value): + click.get_current_context().meta[LANG_KEY] = value + + def get_language(): + return click.get_current_context().meta.get(LANG_KEY, 'en_US') + + @click.command() + @click.pass_context + def cli(ctx): + assert get_language() == 'en_US' + set_language('de_DE') + assert get_language() == 'de_DE' + + runner.invoke(cli, [], catch_exceptions=False) + + +def test_context_pushing(): + rv = [] + + @click.command() + def cli(): + pass + + ctx = click.Context(cli) + + @ctx.call_on_close + def test_callback(): + rv.append(42) + + with ctx.scope(cleanup=False): + # Internal + assert ctx._depth == 2 + + assert rv == [] + + with ctx.scope(): + # Internal + assert ctx._depth == 1 + + assert rv == [42] + + +def test_pass_obj(runner): + @click.group() + @click.pass_context + def cli(ctx): + ctx.obj = 'test' + + @cli.command() + @click.pass_obj + def test(obj): + click.echo(obj) + + result = runner.invoke(cli, ['test']) + assert not result.exception + assert result.output == 'test\n' diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 448f0d6..e2f550e 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -72,7 +72,7 @@ def test_wrapping_long_options_strings(runner): """A command. """ - # 54 is chosen as a lenthg where the second line is one character + # 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'], terminal_width=54) @@ -89,6 +89,43 @@ def test_wrapping_long_options_strings(runner): ] +def test_wrapping_long_command_name(runner): + @click.group() + def cli(): + """Top level command + """ + + @cli.group() + def a_very_very_very_long(): + """Second level + """ + + @a_very_very_very_long.command() + @click.argument('first') + @click.argument('second') + @click.argument('third') + @click.argument('fourth') + @click.argument('fifth') + @click.argument('sixth') + def command(): + """A command. + """ + + 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 ', + ' [OPTIONS] FIRST SECOND THIRD FOURTH FIFTH', + ' SIXTH', + '', + ' A command.', + '', + 'Options:', + ' --help Show this message and exit.', + ] + + def test_formatting_empty_help_lines(runner): @click.command() def cli(): diff --git a/tests/test_imports.py b/tests/test_imports.py index 373f247..3a7da4b 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -29,7 +29,8 @@ click.echo(json.dumps(rv)) ALLOWED_IMPORTS = set([ 'weakref', 'os', 'struct', 'collections', 'sys', 'contextlib', - 'functools', 'stat', 're', 'codecs', 'inspect', 'itertools', 'io' + 'functools', 'stat', 're', 'codecs', 'inspect', 'itertools', 'io', + 'threading' ]) diff --git a/tests/test_testing.py b/tests/test_testing.py index 590248d..5aabaa3 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,3 +1,5 @@ +import sys + import pytest import click @@ -140,3 +142,44 @@ def test_with_color_but_pause_not_blocking(): result = runner.invoke(cli, color=True) assert not result.exception assert result.output == '' + + +def test_exit_code_and_output_from_sys_exit(): + # See issue #362 + @click.command() + def cli_string(): + click.echo('hello world') + sys.exit('error') + + @click.command() + def cli_int(): + click.echo('hello world') + sys.exit(1) + + @click.command() + def cli_float(): + click.echo('hello world') + sys.exit(1.0) + + @click.command() + def cli_no_error(): + click.echo('hello world') + + runner = CliRunner() + + result = runner.invoke(cli_string) + assert result.exit_code == 1 + assert result.output == 'hello world\nerror\n' + + result = runner.invoke(cli_int) + assert result.exit_code == 1 + assert result.output == 'hello world\n' + + result = runner.invoke(cli_float) + assert result.exit_code == 1 + assert result.output == 'hello world\n1.0\n' + + result = runner.invoke(cli_no_error) + assert result.exit_code == 0 + assert result.output == 'hello world\n' + diff --git a/tests/test_utils.py b/tests/test_utils.py index c31f0b9..a92e09f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,10 @@ import os import sys -import click +import pytest + +import click +import click.utils import click._termui_impl @@ -123,8 +126,24 @@ def test_prompts(runner): assert result.output == 'Foo [Y/n]: n\nno :(\n' -def test_echo_via_pager(monkeypatch, capfd): - monkeypatch.setitem(os.environ, 'PAGER', 'cat') +def test_prompts_abort(monkeypatch, capsys): + def f(_): + raise KeyboardInterrupt() + + monkeypatch.setattr('click.termui.hidden_prompt_func', f) + + try: + click.prompt('Password', hide_input=True) + except click.Abort: + click.echo('Screw you.') + + out, err = capsys.readouterr() + assert out == 'Password: \nScrew you.\n' + + +@pytest.mark.parametrize('cat', ['cat', 'cat ', 'cat ']) +def test_echo_via_pager(monkeypatch, capfd, cat): + monkeypatch.setitem(os.environ, 'PAGER', cat) monkeypatch.setattr(click._termui_impl, 'isatty', lambda x: True) click.echo_via_pager('haha') out, err = capfd.readouterr() @@ -234,3 +253,23 @@ def test_open_file(runner): result = runner.invoke(cli, ['-'], input='foobar') assert result.exception is None assert result.output == 'foobar\nmeep\n' + + +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() + + +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()