Repository: pallets/click
Pull request #900 (2017-12-09 06:14:25)

Improve docs for Choice type (addresses issue #892)


Patch:
diff --git a/.ci/appveyor.yml b/.ci/appveyor.yml
index 1d81f1e..caa509a 100644
--- a/.ci/appveyor.yml
+++ b/.ci/appveyor.yml
@@ -32,6 +32,22 @@ environment:
       PYTHON_VERSION: "3.4.x"
       PYTHON_ARCH: "64"
 
+    - PYTHON: "C:\\Python35"
+      PYTHON_VERSION: "3.5.x"
+      PYTHON_ARCH: "32"
+
+    - PYTHON: "C:\\Python35-x64"
+      PYTHON_VERSION: "3.5.x"
+      PYTHON_ARCH: "64"
+
+    - PYTHON: "C:\\Python36"
+      PYTHON_VERSION: "3.6.x"
+      PYTHON_ARCH: "32"
+
+    - PYTHON: "C:\\Python36-x64"
+      PYTHON_VERSION: "3.6.x"
+      PYTHON_ARCH: "64"
+
 branches:
   only:
     - master
diff --git a/.gitignore b/.gitignore
index 2035180..ed66c15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
 *.pyo
 *.egg-ignore
 *.egg-info
+.pytest_cache
 dist
 build
 docs/_build
diff --git a/CHANGES b/CHANGES
index 5e8c57c..d76faf9 100644
--- a/CHANGES
+++ b/CHANGES
@@ -31,6 +31,14 @@ Version 7.0
   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
 -----------
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 69d5594..99b493a 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -41,7 +41,7 @@ Running the testsuite
 ---------------------
 
 You probably want to set up a `virtualenv
-<http://virtualenv.readthedocs.org/en/latest/index.html>`_.
+<https://virtualenv.readthedocs.io/en/latest/index.html>`_.
 
 The minimal requirement for running the testsuite is ``py.test``.  You can
 install it with::
diff --git a/click/__init__.py b/click/__init__.py
index 58df503..6de314b 100644
--- a/click/__init__.py
+++ b/click/__init__.py
@@ -66,7 +66,7 @@ __all__ = [
 
     # Types
     'ParamType', 'File', 'Path', 'Choice', 'IntRange', 'Tuple', 'STRING',
-    'INT', 'FLOAT', 'BOOL', 'UUID', 'UNPROCESSED', 'FloatRange'
+    'INT', 'FLOAT', 'BOOL', 'UUID', 'UNPROCESSED', 'FloatRange',
 
     # Utilities
     'echo', 'get_binary_stream', 'get_text_stream', 'open_file',
diff --git a/click/_compat.py b/click/_compat.py
index 312e729..afdea24 100644
--- a/click/_compat.py
+++ b/click/_compat.py
@@ -12,20 +12,23 @@ 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):
@@ -46,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
@@ -82,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)
@@ -102,6 +113,8 @@ class _FixupStream(object):
         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()
@@ -112,6 +125,8 @@ class _FixupStream(object):
         return True
 
     def writable(self):
+        if self._force_writable:
+            return True
         x = getattr(self._stream, 'writable', None)
         if x is not None:
             return x()
@@ -167,11 +182,12 @@ if PY2:
     # 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
-    except ImportError:
-        set_binary_mode = lambda x: x
-    else:
+
         def set_binary_mode(f):
             try:
                 fileno = f.fileno()
@@ -180,6 +196,22 @@ if PY2:
             else:
                 msvcrt.setmode(fileno, os.O_BINARY)
             return f
+    except ImportError:
+        pass
+
+    try:
+        def set_binary_mode(f):
+            try:
+                fileno = f.fileno()
+            except Exception:
+                pass
+            else:
+                import fcntl
+                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
@@ -197,19 +229,22 @@ if PY2:
         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):
@@ -301,7 +336,8 @@ else:
 
         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:
@@ -327,9 +363,11 @@ else:
         # 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:
@@ -355,7 +393,8 @@ else:
         # 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)
@@ -382,19 +421,22 @@ else:
         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):
@@ -423,7 +465,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
@@ -463,7 +505,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/core.py b/click/core.py
index b307407..25155d6 100644
--- a/click/core.py
+++ b/click/core.py
@@ -59,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__,
@@ -1230,7 +1228,7 @@ class CommandCollection(MultiCommand):
 
 
 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.
diff --git a/click/decorators.py b/click/decorators.py
index 64af015..8e5a462 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`.
     """
diff --git a/click/types.py b/click/types.py
index fab63e4..8799682 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'
diff --git a/docs/commands.rst b/docs/commands.rst
index 5a7ef79..2320432 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:
 
diff --git a/docs/options.rst b/docs/options.rst
index dd61e5b..bb29fcd 100644
--- a/docs/options.rst
+++ b/docs/options.rst
@@ -292,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/quickstart.rst b/docs/quickstart.rst
index 8d02296..234f809 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`
diff --git a/examples/validation/validation.py b/examples/validation/validation.py
index 4b95091..00fa0a6 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/tox.ini b/tox.ini
index de1fb38..9f72ab8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,8 +5,10 @@ envlist = py{26,27,33,34,35,36,py}
 passenv = LANG
 commands = {env:TEST_RUNNER:pytest} {posargs}
 deps =
-    pytest
+    {env:_CLICK_TEST_PYTEST_DEP:pytest}
     colorama: colorama
     coverage: coverage
 setenv =
     coverage: TEST_RUNNER=coverage run -m pytest
+    # pytest 3.3+ does not support Python 2.6 and 3.3 anymore.
+    py26,py33: _CLICK_TEST_PYTEST_DEP=pytest<3.3


Test patch:
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 573bcbf..94e0aae 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -412,7 +412,7 @@ def test_required_option(runner):
 def test_evaluation_order(runner):
     called = []
 
-    def memo(ctx, value):
+    def memo(ctx, param, value):
         called.append(value)
         return value
 
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 9b6a6fb..e900c43 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -62,7 +62,7 @@ def test_auto_shorthelp(runner):
         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 +206,7 @@ def test_other_command_invoke_with_defaults(runner):
     @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 +253,17 @@ def test_unprocessed_options(runner):
         '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']
diff --git a/tests/test_compat.py b/tests/test_compat.py
index e4ecdc8..9dacc21 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 @@ if click.__version__ >= '3.0':
         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 4c1c491..b3de277 100644
--- a/tests/test_formatting.py
+++ b/tests/test_formatting.py
@@ -74,11 +74,11 @@ def test_wrapping_long_options_strings(runner):
 
     # 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 test_wrapping_long_command_name(runner):
         """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',
         '',
diff --git a/tests/test_options.py b/tests/test_options.py
index 44a1097..537fbd4 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -199,7 +199,7 @@ def test_nargs_envvar(runner):
 
 
 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