From b602e0525974739f1cdfc4dcb9ef6ba7f3a57e34 Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 25 Sep 2018 14:28:30 -0700 Subject: [PATCH 001/293] start 8.0 --- CHANGES.rst | 5 +++++ click/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 866d0a6dc..c7d838bef 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Click Changelog =============== +Version 8.0 +----------- + +Unreleased + Version 7.1 ----------- diff --git a/click/__init__.py b/click/__init__.py index 0fa5e3b81..5c4fa6ba2 100644 --- a/click/__init__.py +++ b/click/__init__.py @@ -94,4 +94,4 @@ disable_unicode_literals_warning = False -__version__ = '7.1.dev' +__version__ = '8.0.dev' From 98cd31a348a9fd82b4c76e6abd31b60bc9c41169 Mon Sep 17 00:00:00 2001 From: Andrey Kislyuk Date: Thu, 27 Sep 2018 18:43:23 -0700 Subject: [PATCH 002/293] docs/bashcomplete.rst: Fix typo --- docs/bashcomplete.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/bashcomplete.rst b/docs/bashcomplete.rst index bff38fac6..fd642099e 100644 --- a/docs/bashcomplete.rst +++ b/docs/bashcomplete.rst @@ -29,7 +29,7 @@ least a dash has been provided. Example:: --deep --help --rev --shallow -r Additionally, custom suggestions can be provided for arguments and options with -the ``autocompletion`` parameter. ``autocompletion`` should a callback function +the ``autocompletion`` parameter. ``autocompletion`` should be 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: From c6926845b121564c2f6540fbbc0af760727ac658 Mon Sep 17 00:00:00 2001 From: Radovan Bast Date: Tue, 2 Oct 2018 12:38:34 +0200 Subject: [PATCH 003/293] fix a typo in documentation --- docs/why.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/why.rst b/docs/why.rst index 76b84e71c..a2901b7d6 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -101,7 +101,7 @@ following: - Click has a strong understanding of what types are and can give the user consistent error messages if something goes wrong. A subcommand written by a different developer will not suddenly die with a - different error messsage because it's manually handled. + different error message because it's manually handled. - Click has enough meta information available for its whole program that it can evolve over time to improve the user experience without forcing developers to adjust their programs. For instance, if Click From 0d95fb0d234d2bfe0a29f673399b72b4b42e1fb2 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 26 Oct 2018 17:42:25 -0700 Subject: [PATCH 004/293] revert to lowercase name "click" --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1c18dc784..a6e0a9477 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ version = re.search(r"__version__ = \'(.*?)\'", f.read()).group(1) setup( - name="Click", + name="click", version=version, url="https://palletsprojects.com/p/click/", project_urls={ From 46e0bb9454beab26d14e62b7d152bf44300a8583 Mon Sep 17 00:00:00 2001 From: Jon Dufresne Date: Fri, 2 Nov 2018 21:35:38 -0700 Subject: [PATCH 005/293] Remove reference to deprecated easy_install easy_install is deprecated and its use is discouraged by PyPA: https://setuptools.readthedocs.io/en/latest/easy_install.html > Warning: Easy Install is deprecated. Do not use it. Instead use pip. Follow upstream advice and only recommended supported tools. --- docs/quickstart.rst | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 4479d0eaf..ccec1492b 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -32,12 +32,7 @@ install separate copies of Python, but it does provide a clever way to keep different project environments isolated. Let's see how virtualenv works. -If you are on Mac OS X or Linux, chances are that one of the following two -commands will work for you:: - - $ sudo easy_install virtualenv - -or even better:: +If you are on Mac OS X or Linux:: $ sudo pip install virtualenv From b3dd1b2459397ed4faa5c2f23bd7358d1effe9e4 Mon Sep 17 00:00:00 2001 From: Russ Webber Date: Tue, 20 Nov 2018 19:41:27 +1100 Subject: [PATCH 006/293] fixed edit() not supporting bytes --- click/_termui_impl.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/click/_termui_impl.py b/click/_termui_impl.py index 00a8e5ef1..f015ace4e 100644 --- a/click/_termui_impl.py +++ b/click/_termui_impl.py @@ -436,17 +436,20 @@ def edit(self, text): import tempfile text = text or '' - if text and not text.endswith('\n'): + binary_data = type(text) is bytes + + if not binary_data and text and not text.endswith('\n'): text += '\n' fd, name = tempfile.mkstemp(prefix='editor-', suffix=self.extension) try: - if WIN: - encoding = 'utf-8-sig' - text = text.replace('\n', '\r\n') - else: - encoding = 'utf-8' - text = text.encode(encoding) + if not binary_data: + if WIN: + encoding = 'utf-8-sig' + text = text.replace('\n', '\r\n') + else: + encoding = 'utf-8' + text = text.encode(encoding) f = os.fdopen(fd, 'wb') f.write(text) @@ -464,7 +467,10 @@ def edit(self, text): rv = f.read() finally: f.close() - return rv.decode('utf-8-sig').replace('\r\n', '\n') + if binary_data: + return rv + else: + return rv.decode('utf-8-sig').replace('\r\n', '\n') finally: os.unlink(name) From 12ebfd350990a33fbae9f6d1e75c10e852bdb37e Mon Sep 17 00:00:00 2001 From: Russ Webber Date: Tue, 20 Nov 2018 21:04:08 +1100 Subject: [PATCH 007/293] add bytearray support too --- click/_termui_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/click/_termui_impl.py b/click/_termui_impl.py index f015ace4e..c24a6ae2f 100644 --- a/click/_termui_impl.py +++ b/click/_termui_impl.py @@ -436,7 +436,7 @@ def edit(self, text): import tempfile text = text or '' - binary_data = type(text) is bytes + binary_data = type(text) in [bytes, bytearray] if not binary_data and text and not text.endswith('\n'): text += '\n' From 3ce663c9e532ca46e516b38f69c0fee5c1fa8bd4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 18 Dec 2018 22:16:55 +0100 Subject: [PATCH 008/293] Clarify optparse relationship --- docs/why.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/why.rst b/docs/why.rst index a2901b7d6..47cfe834a 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -22,11 +22,11 @@ There are many alternatives to Click and you can have a look at them if you enjoy them better. The obvious ones are ``optparse`` and ``argparse`` from the standard library. -Click is actually implemented as a wrapper around a mild fork of -``optparse`` and does not implement any parsing itself. The reason it's -not based on ``argparse`` is that ``argparse`` does not allow proper -nesting of commands by design and has some deficiencies when it comes to -POSIX compliant argument handling. +Click is actually implements its own parsing of arugments and does not use +``optparse`` or ``argparse`` following the ``optparse`` parsing behavior. +The reason it's not based on ``argparse`` is that ``argparse`` does not +allow proper nesting of commands by design and has some deficiencies when +it comes to POSIX compliant argument handling. Click is designed to be fun to work with and at the same time not stand in your way. It's not overly flexible either. Currently, for instance, it From 20a2bd8a13ea681283b8948f18ec73950ee68786 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 20 Dec 2018 17:37:33 +0100 Subject: [PATCH 009/293] Fixed a typo in the docs --- docs/why.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/why.rst b/docs/why.rst index 47cfe834a..0690a63b2 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -22,7 +22,7 @@ There are many alternatives to Click and you can have a look at them if you enjoy them better. The obvious ones are ``optparse`` and ``argparse`` from the standard library. -Click is actually implements its own parsing of arugments and does not use +Click is actually implements its own parsing of arguments and does not use ``optparse`` or ``argparse`` following the ``optparse`` parsing behavior. The reason it's not based on ``argparse`` is that ``argparse`` does not allow proper nesting of commands by design and has some deficiencies when From 876e8a911493b60afdb5749a9f55c4467b5d3415 Mon Sep 17 00:00:00 2001 From: Matthias Urlichs Date: Fri, 21 Dec 2018 14:54:28 +0100 Subject: [PATCH 010/293] superfluous word ;-) --- docs/why.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/why.rst b/docs/why.rst index 0690a63b2..5b029616a 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -22,7 +22,7 @@ There are many alternatives to Click and you can have a look at them if you enjoy them better. The obvious ones are ``optparse`` and ``argparse`` from the standard library. -Click is actually implements its own parsing of arguments and does not use +Click actually implements its own parsing of arguments and does not use ``optparse`` or ``argparse`` following the ``optparse`` parsing behavior. The reason it's not based on ``argparse`` is that ``argparse`` does not allow proper nesting of commands by design and has some deficiencies when From 3c767d87ed263d7b094cf1036195cb01a90477af Mon Sep 17 00:00:00 2001 From: Jon Dufresne Date: Fri, 28 Dec 2018 09:57:05 -0500 Subject: [PATCH 011/293] Remove unused compat shim for 'bytes' Never used. Both Python 2.7 and Python 3 have the type bytes. On Python 2.7 it is an alias for str, same as what was previously defined. --- click/_compat.py | 1 - 1 file changed, 1 deletion(-) diff --git a/click/_compat.py b/click/_compat.py index 937e2301d..1ad18ec99 100644 --- a/click/_compat.py +++ b/click/_compat.py @@ -155,7 +155,6 @@ def seekable(self): if PY2: text_type = unicode - bytes = str raw_input = raw_input string_types = (str, unicode) int_types = (int, long) From 19a59bc8a18e529de5ac36c74023edad729f3db2 Mon Sep 17 00:00:00 2001 From: Jan Gazda <1oglop1@gmail.com> Date: Mon, 31 Dec 2018 12:33:49 +0100 Subject: [PATCH 012/293] Add StackOverflow tag --- CONTRIBUTING.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 4db9b65a3..bad879660 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -10,8 +10,8 @@ Support questions Please, don't use the issue tracker for this. Check whether the ``#pocoo`` IRC channel on Freenode can help with your issue. If your problem is not strictly Click-specific, ``#python`` on Freenode is generally more -active. `StackOverflow `_ is also worth -considering. +active. `StackOverflow `_ is also worth +considering. Use tag `python-click` for new question or search. Reporting issues ================ From 711ea22680f95b6fa28c499e5d45931103b263fc Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 31 Dec 2018 07:57:55 -0800 Subject: [PATCH 013/293] Update CONTRIBUTING.rst --- CONTRIBUTING.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index bad879660..07bef59cd 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -10,8 +10,10 @@ Support questions Please, don't use the issue tracker for this. Check whether the ``#pocoo`` IRC channel on Freenode can help with your issue. If your problem is not strictly Click-specific, ``#python`` on Freenode is generally more -active. `StackOverflow `_ is also worth -considering. Use tag `python-click` for new question or search. +active. Also try searching or asking on `Stack Overflow`_ with the +``python-click`` tag. + +.. _Stack Overflow: https://stackoverflow.com/questions/tagged/python-click?sort=votes Reporting issues ================ From 87c03cb57c2b9525500a2396c359627ce854eb80 Mon Sep 17 00:00:00 2001 From: Yu ISHIKAWA Date: Mon, 7 Jan 2019 16:40:32 -0800 Subject: [PATCH 014/293] Modify the code about the custom multi commands. --- docs/commands.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands.rst b/docs/commands.rst index 53834f754..4642e744c 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -202,7 +202,7 @@ A custom multi command just needs to implement a list and load method: def list_commands(self, ctx): rv = [] for filename in os.listdir(plugin_folder): - if filename.endswith('.py'): + if filename.endswith('.py') and filename != '__init__.py': rv.append(filename[:-3]) rv.sort() return rv From bddbba67e466c3efe6b23378633b7d002d0288e2 Mon Sep 17 00:00:00 2001 From: Moul Date: Tue, 22 Jan 2019 12:32:34 +0100 Subject: [PATCH 015/293] [doc] typo --- docs/commands.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands.rst b/docs/commands.rst index 53834f754..717d2233d 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -287,7 +287,7 @@ Multi Command Chaining Sometimes it is useful to be allowed to invoke more than one subcommand in one go. For instance if you have installed a setuptools package before you might be familiar with the ``setup.py sdist bdist_wheel upload`` -command chain which invokes ``dist`` before ``bdist_wheel`` before +command chain which invokes ``sdist`` before ``bdist_wheel`` before ``upload``. Starting with Click 3.0 this is very simple to implement. All you have to do is to pass ``chain=True`` to your multicommand: From f4d6ed8e886ddfe97c9ac9dc272c676b17658663 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Sun, 24 Feb 2019 17:38:21 +0100 Subject: [PATCH 016/293] Use dict instead of multiline string (#1241) I can't help but I find using a dictionary-style entry_points definition more pythonic. --- docs/setuptools.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/setuptools.rst b/docs/setuptools.rst index 870591afb..44eb616eb 100644 --- a/docs/setuptools.rst +++ b/docs/setuptools.rst @@ -126,14 +126,15 @@ These would be the modified contents of ``setup.py``:: setup( name='yourpackage', - version='0.1', + version='0.1.0', packages=find_packages(), include_package_data=True, install_requires=[ 'Click', ], - entry_points=''' - [console_scripts] - yourscript=yourpackage.scripts.yourscript:cli - ''', + entry_points={ + 'console_scripts': [ + 'yourscript = yourpackage.scripts.yourscript:cli', + ], + }, ) From b55efa000e040aef42a2721f33d29e930dc5a6ca Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Sun, 24 Feb 2019 18:21:47 +0100 Subject: [PATCH 017/293] Use code-block markup, align snippets, add setup.py --- docs/setuptools.rst | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/docs/setuptools.rst b/docs/setuptools.rst index 44eb616eb..186dfdc1a 100644 --- a/docs/setuptools.rst +++ b/docs/setuptools.rst @@ -45,6 +45,8 @@ Python package and a ``setup.py`` file. Imagine this directory structure:: +.. code-block:: text + yourscript.py setup.py @@ -59,21 +61,24 @@ Contents of ``yourscript.py``: """Example script.""" click.echo('Hello World!') -Contents of ``setup.py``:: +Contents of ``setup.py``: + +.. code-block:: python from setuptools import setup setup( name='yourscript', - version='0.1', + version='0.1.0', py_modules=['yourscript'], install_requires=[ 'Click', ], - entry_points=''' - [console_scripts] - yourscript=yourscript:cli - ''', + entry_points={ + 'console_scripts': [ + 'yourscript = yourscript:cli', + ], + }, ) The magic is in the ``entry_points`` parameter. Below @@ -88,7 +93,9 @@ Testing The Script ------------------ To test the script, you can make a new virtualenv and then install your -package:: +package: + +.. code-block:: console $ virtualenv venv $ . venv/bin/activate @@ -105,22 +112,28 @@ Scripts in Packages If your script is growing and you want to switch over to your script being contained in a Python package the changes necessary are minimal. Let's -assume your directory structure changed to this:: +assume your directory structure changed to this: - yourpackage/ - __init__.py - main.py - utils.py - scripts/ +.. code-block:: text + + project/ + yourpackage/ __init__.py - yourscript.py + main.py + utils.py + scripts/ + __init__.py + yourscript.py + setup.py In this case instead of using ``py_modules`` in your ``setup.py`` file you can use ``packages`` and the automatic package finding support of setuptools. In addition to that it's also recommended to include other package data. -These would be the modified contents of ``setup.py``:: +These would be the modified contents of ``setup.py``: + +.. code-block:: python from setuptools import setup, find_packages From abf740c1aad3bf44ecada3a84d1292cee9a33419 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Sun, 24 Feb 2019 23:15:58 +0100 Subject: [PATCH 018/293] Fix broken markup (setuptools chapter) --- docs/setuptools.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setuptools.rst b/docs/setuptools.rst index 186dfdc1a..62999d731 100644 --- a/docs/setuptools.rst +++ b/docs/setuptools.rst @@ -43,7 +43,7 @@ Introduction To bundle your script with setuptools, all you need is the script in a Python package and a ``setup.py`` file. -Imagine this directory structure:: +Imagine this directory structure: .. code-block:: text From 5bbe6b49d37dd9f76fd2db73e6d52dabb0baef82 Mon Sep 17 00:00:00 2001 From: Jimmy Zeng Date: Mon, 6 May 2019 11:58:08 -0400 Subject: [PATCH 019/293] add repr to Command --- CHANGES.rst | 5 +++++ click/core.py | 5 +++++ tests/test_basic.py | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index c7d838bef..d1831d3dd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,11 @@ Version 8.0 Unreleased +- Adds a repr to Command, showing the command name for friendlier debugging. (`#1276`_, `#1295`_) + +.. _#1276: https://github.com/pallets/click/issues/1276 +.. _#1295: https://github.com/pallets/click/pull/1295 + Version 7.1 ----------- diff --git a/click/core.py b/click/core.py index 7a1e3422b..224746d0f 100644 --- a/click/core.py +++ b/click/core.py @@ -771,6 +771,8 @@ class Command(BaseCommand): .. versionchanged:: 2.0 Added the `context_settings` parameter. + .. versionchanged:: 8.0 + Added repr showing the command name :param name: the name of the command to use unless a group overrides it. :param context_settings: an optional dictionary with defaults that are @@ -815,6 +817,9 @@ def __init__(self, name, context_settings=None, callback=None, self.hidden = hidden self.deprecated = deprecated + def __repr__(self): + return '<%s %s>' % (self.__class__.__name__, self.name) + def get_usage(self, ctx): formatter = ctx.make_formatter() self.format_usage(ctx, formatter) diff --git a/tests/test_basic.py b/tests/test_basic.py index 8de6301a8..f800b4d20 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -23,6 +23,24 @@ def cli(): assert result.exit_code == 0 +def test_repr(): + @click.command() + def command(): + pass + + @click.group() + def group(): + pass + + @group.command() + def subcommand(): + pass + + assert repr(command) == '' + assert repr(group) == '' + assert repr(subcommand) == '' + + def test_return_values(): @click.command() def cli(): From f8a247ada2990faf8a668bcc0b63024498e4f3af Mon Sep 17 00:00:00 2001 From: Jimmy Zeng Date: Mon, 6 May 2019 12:05:41 -0400 Subject: [PATCH 020/293] fix typo --- CHANGES.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index d1831d3dd..57a7934d1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,9 +6,9 @@ Version 8.0 Unreleased -- Adds a repr to Command, showing the command name for friendlier debugging. (`#1276`_, `#1295`_) +- Adds a repr to Command, showing the command name for friendlier debugging. (`#1267`_, `#1295`_) -.. _#1276: https://github.com/pallets/click/issues/1276 +.. _#1267: https://github.com/pallets/click/issues/1267 .. _#1295: https://github.com/pallets/click/pull/1295 From f82fe353ca82c5559a9e09491adb694a261a7722 Mon Sep 17 00:00:00 2001 From: Lindsay Young Date: Mon, 6 May 2019 13:50:16 -0400 Subject: [PATCH 021/293] Create CODE_OF_CONDUCT.md Ticket is referenced in [#Issue 1](https://github.com/pallets/meta/issues/1) in the Meta repo. I spoke with @davidism and the decision was to use Contributor Covenant. It has easy GitHub integration and quality content. --- CODE_OF_CONDUCT.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..f4ba197de --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at report@palletsprojects.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq From 1e7331f3463a75962171067681021ccefeacfb73 Mon Sep 17 00:00:00 2001 From: alexey Date: Thu, 23 May 2019 16:59:11 +0100 Subject: [PATCH 022/293] Remove one unused import and one redundant import --- click/_compat.py | 1 - click/_termui_impl.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/click/_compat.py b/click/_compat.py index 1ad18ec99..6f8c7f98c 100644 --- a/click/_compat.py +++ b/click/_compat.py @@ -258,7 +258,6 @@ def filename_to_ui(value): value = value.decode(get_filesystem_encoding(), 'replace') return value else: - import io text_type = str raw_input = input string_types = (str,) diff --git a/click/_termui_impl.py b/click/_termui_impl.py index c24a6ae2f..d025452e7 100644 --- a/click/_termui_impl.py +++ b/click/_termui_impl.py @@ -16,7 +16,7 @@ import time import math import contextlib -from ._compat import _default_text_stdout, range_type, PY2, isatty, \ +from ._compat import _default_text_stdout, range_type, isatty, \ open_stream, strip_ansi, term_len, get_best_encoding, WIN, int_types, \ CYGWIN from .utils import echo From af24c8de54091afe9752c4718272a9dde8746598 Mon Sep 17 00:00:00 2001 From: Joshua Storck Date: Fri, 31 May 2019 11:21:02 -0400 Subject: [PATCH 023/293] Adding support to the context to distinguish the source of a command line parameter --- click/__init__.py | 2 +- click/core.py | 53 ++++++++++++++++++++++++++++++++++++++++++- tests/test_context.py | 42 ++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/click/__init__.py b/click/__init__.py index 5c4fa6ba2..30303219c 100644 --- a/click/__init__.py +++ b/click/__init__.py @@ -14,7 +14,7 @@ # Core classes from .core import Context, BaseCommand, Command, MultiCommand, Group, \ - CommandCollection, Parameter, Option, Argument + CommandCollection, Parameter, Option, Argument, ParameterSource # Globals from .globals import get_current_context diff --git a/click/core.py b/click/core.py index 224746d0f..a0d8d9e37 100644 --- a/click/core.py +++ b/click/core.py @@ -129,6 +129,26 @@ def sort_key(item): return sorted(declaration_order, key=sort_key) +class ParameterSource(object): + """ + This is an enum that indicates the source of a command line option, + which is one of the following: COMMANDLINE, ENVIRONMENT, DEFAULT. + The DEFAULT indicates that the default value in the decorator was used. + This class should be converted to an enum when Python 2 support is + dropped. + """ + COMMANDLINE = "COMMANDLINE" + ENVIRONMENT = "ENVIRONMENT" + DEFAULT = "DEFAULT" + + VALUES = [COMMANDLINE, ENVIRONMENT, DEFAULT] + + @classmethod + def validate(clz, value): + if value not in clz.VALUES: + raise ValueError("Invalid ParameterSource value: '{}'. Valid " + "values are: {}".format(",".join(VALUES))) + class Context(object): """The context is a special internal object that holds state relevant @@ -339,7 +359,8 @@ def __init__(self, command, parent=None, info_name=None, obj=None, self._close_callbacks = [] self._depth = 0 - + self._parameter_sources = {} + def __enter__(self): self._depth += 1 push_context(self) @@ -572,6 +593,32 @@ def forward(*args, **kwargs): return self.invoke(cmd, **kwargs) + def set_parameter_source(self, name, source): + """Sets the `source` of a parameter. This indicates the + location from which the value of the parameter was obtained. + + :param name: the name of the command line parameter + :param source: the source of the the command line parameter, + which should be a valid ParameterSource value + """ + ParameterSource.validate(source) + self._parameter_sources[name] = source + + def get_parameter_source(self, name): + """Get the `source` of a parameter. This indicates the + location from which the value of the parameter was obtained. + This can be useful for determining when a user specified + an option on the command line that is the same as the default. + In that case, the source would be ParameterSource.COMMANDLINE, + even though the value of the parameter was equivalent to the + default. + + :param name: the name of the command line parameter + :returns: the source + :rtype: ParameterSource + """ + return self._parameter_sources.get(name) + class BaseCommand(object): """The base command implements the minimal API contract of commands. @@ -1394,10 +1441,14 @@ def add_to_parser(self, parser, ctx): def consume_value(self, ctx, opts): value = opts.get(self.name) + source = ParameterSource.COMMANDLINE if value is None: value = self.value_from_envvar(ctx) + source = ParameterSource.ENVIRONMENT if value is None: value = ctx.lookup_default(self.name) + source = ParameterSource.DEFAULT + ctx.set_parameter_source(self.name, source) return value def type_cast_value(self, ctx, value): diff --git a/tests/test_context.py b/tests/test_context.py index 35933beba..7d3d459c2 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -256,3 +256,45 @@ def cli(ctx): ctx.exit(0) assert cli.main([], 'test_exit_not_standalone', standalone_mode=False) == 0 + +def test_parameter_source_default(): + @click.command() + @click.pass_context + @click.option("-o", "--option", default=1) + def cli(ctx, option): + assert ctx.get_parameter_source("option") == click.ParameterSource.DEFAULT + + cli.main([], "test_parameter_source_default", standalone_mode=False) == 0 + + +def test_parameter_source_commandline(): + @click.command() + @click.pass_context + @click.option("-o", "--option", default=1) + def cli(ctx, option): + assert ctx.get_parameter_source("option") == click.ParameterSource.COMMANDLINE + + cli.main(["-o", "1"], "test_parameter_source_commandline", standalone_mode=False) == 0 + cli.main(["--option", "1"], "test_parameter_source_default", standalone_mode=False) == 0 + + +def test_parameter_source_environment(runner): + @click.command() + @click.pass_context + @click.option("-o", "--option", default=1) + def cli(ctx, option): + assert ctx.get_parameter_source("option") == click.ParameterSource.ENVIRONMENT + + runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"CLI_OPTION": "1"}, + standalone_mode=False) + +def test_parameter_source_environment_variable_specified(runner): + @click.command() + @click.pass_context + @click.option("-o", "--option", default=1, envvar="NAME") + def cli(ctx, option): + assert ctx.get_parameter_source("option") == click.ParameterSource.ENVIRONMENT + + runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"NAME": "1"}, + standalone_mode=False) + From 17e6f3ba3933beb1c514a4fb7e319c13c658f59d Mon Sep 17 00:00:00 2001 From: Joshua Storck Date: Fri, 31 May 2019 11:38:35 -0400 Subject: [PATCH 024/293] Removing ParameterSource from __init__.py --- click/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/click/__init__.py b/click/__init__.py index 30303219c..5c4fa6ba2 100644 --- a/click/__init__.py +++ b/click/__init__.py @@ -14,7 +14,7 @@ # Core classes from .core import Context, BaseCommand, Command, MultiCommand, Group, \ - CommandCollection, Parameter, Option, Argument, ParameterSource + CommandCollection, Parameter, Option, Argument # Globals from .globals import get_current_context From 73f470ef2397c71bd8e84d1805402f6203e7e9ab Mon Sep 17 00:00:00 2001 From: Joshua Storck Date: Fri, 31 May 2019 23:32:15 -0400 Subject: [PATCH 025/293] Added documentation on how to use get_parameter_source. Added DEFAULT_MAP as a source and added unit tests for it. --- click/__init__.py | 2 +- click/core.py | 14 +++++++++----- docs/advanced.rst | 27 +++++++++++++++++++++++++++ tests/test_context.py | 33 ++++++++++++++++++++++----------- 4 files changed, 59 insertions(+), 17 deletions(-) diff --git a/click/__init__.py b/click/__init__.py index 5c4fa6ba2..30303219c 100644 --- a/click/__init__.py +++ b/click/__init__.py @@ -14,7 +14,7 @@ # Core classes from .core import Context, BaseCommand, Command, MultiCommand, Group, \ - CommandCollection, Parameter, Option, Argument + CommandCollection, Parameter, Option, Argument, ParameterSource # Globals from .globals import get_current_context diff --git a/click/core.py b/click/core.py index a0d8d9e37..e28a38581 100644 --- a/click/core.py +++ b/click/core.py @@ -132,7 +132,7 @@ def sort_key(item): class ParameterSource(object): """ This is an enum that indicates the source of a command line option, - which is one of the following: COMMANDLINE, ENVIRONMENT, DEFAULT. + which is one of the following: COMMANDLINE, ENVIRONMENT, DEFAULT, DEFAULT_MAP. The DEFAULT indicates that the default value in the decorator was used. This class should be converted to an enum when Python 2 support is dropped. @@ -140,8 +140,9 @@ class ParameterSource(object): COMMANDLINE = "COMMANDLINE" ENVIRONMENT = "ENVIRONMENT" DEFAULT = "DEFAULT" - - VALUES = [COMMANDLINE, ENVIRONMENT, DEFAULT] + DEFAULT_MAP = "DEFAULT_MAP" + + VALUES = [COMMANDLINE, ENVIRONMENT, DEFAULT, DEFAULT_MAP] @classmethod def validate(clz, value): @@ -1447,8 +1448,9 @@ def consume_value(self, ctx, opts): source = ParameterSource.ENVIRONMENT if value is None: value = ctx.lookup_default(self.name) - source = ParameterSource.DEFAULT - ctx.set_parameter_source(self.name, source) + source = ParameterSource.DEFAULT_MAP + if value is not None: + ctx.set_parameter_source(self.name, source) return value def type_cast_value(self, ctx, value): @@ -1495,6 +1497,8 @@ def full_process_value(self, ctx, value): if value is None and not ctx.resilient_parsing: value = self.get_default(ctx) + if value is not None: + ctx.set_parameter_source(self.name, ParameterSource.DEFAULT) if self.required and self.value_is_missing(value): raise MissingParameter(ctx=ctx, param=self) diff --git a/docs/advanced.rst b/docs/advanced.rst index f8c0ed8e0..7cdb7ef0c 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -377,3 +377,30 @@ 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. + + +Detecting the Source of a Parameter +----------------------------------- + +In some situations it's helpful to understand whether or not an option +or parameter came from the command line, the environment, the default +value, or the default_map. The :meth:`Context.get_parameter_source` +method can be used to find this out. + +.. click:example:: + + @click.command() + @click.argument('port', nargs=1, default=8080, envvar="PORT") + @click.pass_context + def cli(ctx, port): + source = ctx.get_parameter_source("port") + click.echo("Port came from {}".format(source)) + +.. click:run:: + + invoke(cli, prog_name='cli', args=['8080']) + println() + invoke(cli, prog_name='cli', args=[], env={"PORT": "8080"}) + println() + invoke(cli, prog_name='cli', args=[]) + println() diff --git a/tests/test_context.py b/tests/test_context.py index 7d3d459c2..ccf5d4a74 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -263,9 +263,20 @@ def test_parameter_source_default(): @click.option("-o", "--option", default=1) def cli(ctx, option): assert ctx.get_parameter_source("option") == click.ParameterSource.DEFAULT + ctx.exit(1) - cli.main([], "test_parameter_source_default", standalone_mode=False) == 0 + assert cli.main([], "test_parameter_source_default", standalone_mode=False) == 1 +def test_parameter_source_default_map(): + @click.command() + @click.pass_context + @click.option("-o", "--option", default=1) + def cli(ctx, option): + assert ctx.get_parameter_source("option") == click.ParameterSource.DEFAULT_MAP + ctx.exit(1) + + assert cli.main([], "test_parameter_source_default", standalone_mode=False, default_map={ "option": 1}) == 1 + def test_parameter_source_commandline(): @click.command() @@ -273,9 +284,10 @@ def test_parameter_source_commandline(): @click.option("-o", "--option", default=1) def cli(ctx, option): assert ctx.get_parameter_source("option") == click.ParameterSource.COMMANDLINE - - cli.main(["-o", "1"], "test_parameter_source_commandline", standalone_mode=False) == 0 - cli.main(["--option", "1"], "test_parameter_source_default", standalone_mode=False) == 0 + ctx.exit(1) + + assert cli.main(["-o", "1"], "test_parameter_source_commandline", standalone_mode=False) == 1 + assert cli.main(["--option", "1"], "test_parameter_source_default", standalone_mode=False) == 1 def test_parameter_source_environment(runner): @@ -284,9 +296,9 @@ def test_parameter_source_environment(runner): @click.option("-o", "--option", default=1) def cli(ctx, option): assert ctx.get_parameter_source("option") == click.ParameterSource.ENVIRONMENT - - runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"CLI_OPTION": "1"}, - standalone_mode=False) + sys.exit(1) + + assert runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"TEST_OPTION": "1"}, auto_envvar_prefix="TEST").exit_code == 1 def test_parameter_source_environment_variable_specified(runner): @click.command() @@ -294,7 +306,6 @@ def test_parameter_source_environment_variable_specified(runner): @click.option("-o", "--option", default=1, envvar="NAME") def cli(ctx, option): assert ctx.get_parameter_source("option") == click.ParameterSource.ENVIRONMENT - - runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"NAME": "1"}, - standalone_mode=False) - + sys.exit(1) + + assert runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"NAME": "1"}).exit_code == 1 From 64bbbe46f87d90648ba2b972240fe591934ef55c Mon Sep 17 00:00:00 2001 From: Joshua Storck Date: Sun, 2 Jun 2019 08:31:12 -0400 Subject: [PATCH 026/293] pydocstyle and flake8 fixes --- click/core.py | 48 ++++++++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/click/core.py b/click/core.py index e28a38581..41f6b8ee8 100644 --- a/click/core.py +++ b/click/core.py @@ -130,13 +130,14 @@ def sort_key(item): return sorted(declaration_order, key=sort_key) class ParameterSource(object): + """This is an enum that indicates the source of a command line option. + + The enum has one of the following values: COMMANDLINE, + ENVIRONMENT, DEFAULT, DEFAULT_MAP. The DEFAULT indicates that the + default value in the decorator was used. This class should be + converted to an enum when Python 2 support is dropped. """ - This is an enum that indicates the source of a command line option, - which is one of the following: COMMANDLINE, ENVIRONMENT, DEFAULT, DEFAULT_MAP. - The DEFAULT indicates that the default value in the decorator was used. - This class should be converted to an enum when Python 2 support is - dropped. - """ + COMMANDLINE = "COMMANDLINE" ENVIRONMENT = "ENVIRONMENT" DEFAULT = "DEFAULT" @@ -146,6 +147,14 @@ class ParameterSource(object): @classmethod def validate(clz, value): + """Validate that the specified value is a valid enum. + + This method will raise a ValueError if the value is + not a valid enum. + + :param clz: ParameterSource.class + :param value: the string value to verify + """ if value not in clz.VALUES: raise ValueError("Invalid ParameterSource value: '{}'. Valid " "values are: {}".format(",".join(VALUES))) @@ -595,31 +604,34 @@ def forward(*args, **kwargs): return self.invoke(cmd, **kwargs) def set_parameter_source(self, name, source): - """Sets the `source` of a parameter. This indicates the - location from which the value of the parameter was obtained. + """Set the source of a parameter. + + This indicates the location from which the value of the + parameter was obtained. :param name: the name of the command line parameter - :param source: the source of the the command line parameter, - which should be a valid ParameterSource value + :param source: the source of the command line parameter, which + should be a valid ParameterSource value """ ParameterSource.validate(source) self._parameter_sources[name] = source def get_parameter_source(self, name): - """Get the `source` of a parameter. This indicates the - location from which the value of the parameter was obtained. - This can be useful for determining when a user specified - an option on the command line that is the same as the default. - In that case, the source would be ParameterSource.COMMANDLINE, - even though the value of the parameter was equivalent to the - default. + """Get the `source` of a parameter. + + This indicates the location from which the value of the + parameter was obtained. This can be useful for determining + when a user specified an option on the command line that is + the same as the default. In that case, the source would be + ParameterSource.COMMANDLINE, even though the value of the + parameter was equivalent to the default. :param name: the name of the command line parameter :returns: the source :rtype: ParameterSource """ return self._parameter_sources.get(name) - + class BaseCommand(object): """The base command implements the minimal API contract of commands. From 36da06fe44c5cb7a14c67e5e5be1458ee5d5e521 Mon Sep 17 00:00:00 2001 From: Joshua Storck Date: Sun, 2 Jun 2019 08:43:13 -0400 Subject: [PATCH 027/293] Changing documentation in ParameterSource to indicate 'parameter' instead of 'option'. Removed emphasis of the word source in Context.get_parameter_source. Changed ParameterSource.VALUE to a set instead of list. Fixed bugs in ParameterSource.validate and added unit test for it. Changed Context.get_parameter_source to return a KeyError when an invalid parameter name is specified. --- click/core.py | 10 +++++----- tests/test_context.py | 8 +++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/click/core.py b/click/core.py index 41f6b8ee8..c1f395521 100644 --- a/click/core.py +++ b/click/core.py @@ -130,7 +130,7 @@ def sort_key(item): return sorted(declaration_order, key=sort_key) class ParameterSource(object): - """This is an enum that indicates the source of a command line option. + """This is an enum that indicates the source of a command line parameter. The enum has one of the following values: COMMANDLINE, ENVIRONMENT, DEFAULT, DEFAULT_MAP. The DEFAULT indicates that the @@ -143,7 +143,7 @@ class ParameterSource(object): DEFAULT = "DEFAULT" DEFAULT_MAP = "DEFAULT_MAP" - VALUES = [COMMANDLINE, ENVIRONMENT, DEFAULT, DEFAULT_MAP] + VALUES = {COMMANDLINE, ENVIRONMENT, DEFAULT, DEFAULT_MAP} @classmethod def validate(clz, value): @@ -157,7 +157,7 @@ def validate(clz, value): """ if value not in clz.VALUES: raise ValueError("Invalid ParameterSource value: '{}'. Valid " - "values are: {}".format(",".join(VALUES))) + "values are: {}".format(value, ",".join(clz.VALUES))) class Context(object): @@ -617,7 +617,7 @@ def set_parameter_source(self, name, source): self._parameter_sources[name] = source def get_parameter_source(self, name): - """Get the `source` of a parameter. + """Get the source of a parameter. This indicates the location from which the value of the parameter was obtained. This can be useful for determining @@ -630,7 +630,7 @@ def get_parameter_source(self, name): :returns: the source :rtype: ParameterSource """ - return self._parameter_sources.get(name) + return self._parameter_sources[name] class BaseCommand(object): diff --git a/tests/test_context.py b/tests/test_context.py index ccf5d4a74..972cd45aa 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- import click - +import pytest def test_ensure_context_objects(runner): class Foo(object): @@ -300,6 +300,7 @@ def cli(ctx, option): assert runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"TEST_OPTION": "1"}, auto_envvar_prefix="TEST").exit_code == 1 + def test_parameter_source_environment_variable_specified(runner): @click.command() @click.pass_context @@ -309,3 +310,8 @@ def cli(ctx, option): sys.exit(1) assert runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"NAME": "1"}).exit_code == 1 + + +def test_validate_parameter_source(): + with pytest.raises(ValueError): + click.ParameterSource.validate("DEFAUL") From 23661efbaa8552c465ba6b6627525d26727afd0f Mon Sep 17 00:00:00 2001 From: Joshua Storck Date: Sun, 2 Jun 2019 08:49:10 -0400 Subject: [PATCH 028/293] Adding entry to the changelog for Version 8.0 --- CHANGES.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 57a7934d1..ba919332e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,10 +7,12 @@ Version 8.0 Unreleased - Adds a repr to Command, showing the command name for friendlier debugging. (`#1267`_, `#1295`_) - +- Add support for distinguishing the source of a command line parameter. (`#1264`_, `#1329`_) + .. _#1267: https://github.com/pallets/click/issues/1267 .. _#1295: https://github.com/pallets/click/pull/1295 - +.. _#1264: https://github.com/pallets/click/issues/1264 +.. _#1329: https://github.com/pallets/click/pull/1329 Version 7.1 ----------- From b24a3c69353791ade7bded61c76b0c31bdee12ea Mon Sep 17 00:00:00 2001 From: Joshua Storck Date: Sun, 2 Jun 2019 08:52:12 -0400 Subject: [PATCH 029/293] Changing the name of Context._parameter_sources to Context._sources_by_paramname --- click/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/click/core.py b/click/core.py index c1f395521..196f5a789 100644 --- a/click/core.py +++ b/click/core.py @@ -369,7 +369,7 @@ def __init__(self, command, parent=None, info_name=None, obj=None, self._close_callbacks = [] self._depth = 0 - self._parameter_sources = {} + self._sources_by_paramname = {} def __enter__(self): self._depth += 1 @@ -614,7 +614,7 @@ def set_parameter_source(self, name, source): should be a valid ParameterSource value """ ParameterSource.validate(source) - self._parameter_sources[name] = source + self._sources_by_paramname[name] = source def get_parameter_source(self, name): """Get the source of a parameter. @@ -630,7 +630,7 @@ def get_parameter_source(self, name): :returns: the source :rtype: ParameterSource """ - return self._parameter_sources[name] + return self._sources_by_paramname[name] class BaseCommand(object): From c296d4415efbbf1e6e7095ea3295462ae962cde4 Mon Sep 17 00:00:00 2001 From: Joshua Storck Date: Mon, 3 Jun 2019 10:50:30 -0400 Subject: [PATCH 030/293] Changing ParameterSource.validate unit test to take a descriptive bad value. Changing Context._sources_by_paramname to Context._source_by_paramname. Renaming ParameterSource.validate clz arg to cls and removing doc for that argument in the docstring --- click/core.py | 13 ++++++------- tests/test_context.py | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/click/core.py b/click/core.py index 196f5a789..b0bfccd60 100644 --- a/click/core.py +++ b/click/core.py @@ -146,18 +146,17 @@ class ParameterSource(object): VALUES = {COMMANDLINE, ENVIRONMENT, DEFAULT, DEFAULT_MAP} @classmethod - def validate(clz, value): + def validate(cls, value): """Validate that the specified value is a valid enum. This method will raise a ValueError if the value is not a valid enum. - :param clz: ParameterSource.class :param value: the string value to verify """ - if value not in clz.VALUES: + if value not in cls.VALUES: raise ValueError("Invalid ParameterSource value: '{}'. Valid " - "values are: {}".format(value, ",".join(clz.VALUES))) + "values are: {}".format(value, ",".join(cls.VALUES))) class Context(object): @@ -369,7 +368,7 @@ def __init__(self, command, parent=None, info_name=None, obj=None, self._close_callbacks = [] self._depth = 0 - self._sources_by_paramname = {} + self._source_by_paramname = {} def __enter__(self): self._depth += 1 @@ -614,7 +613,7 @@ def set_parameter_source(self, name, source): should be a valid ParameterSource value """ ParameterSource.validate(source) - self._sources_by_paramname[name] = source + self._source_by_paramname[name] = source def get_parameter_source(self, name): """Get the source of a parameter. @@ -630,7 +629,7 @@ def get_parameter_source(self, name): :returns: the source :rtype: ParameterSource """ - return self._sources_by_paramname[name] + return self._source_by_paramname[name] class BaseCommand(object): diff --git a/tests/test_context.py b/tests/test_context.py index 972cd45aa..35707498b 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -314,4 +314,4 @@ def cli(ctx, option): def test_validate_parameter_source(): with pytest.raises(ValueError): - click.ParameterSource.validate("DEFAUL") + click.ParameterSource.validate("NOT_A_VALID_PARAMETER_SOURCE") From 7e8146d1c48e022b9cf531b0f70e073816f33079 Mon Sep 17 00:00:00 2001 From: mattsb42 Date: Sun, 9 Jun 2019 21:27:31 -0700 Subject: [PATCH 031/293] clarify Parameter behavior when nargs=-1 --- click/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/click/core.py b/click/core.py index b0bfccd60..eb124b43b 100644 --- a/click/core.py +++ b/click/core.py @@ -1381,7 +1381,8 @@ class Parameter(object): :param nargs: the number of arguments to match. If not ``1`` the return value is a tuple instead of single value. The default for nargs is ``1`` (except if the type is a tuple, then it's - the arity of the tuple). + the arity of the tuple). If ``nargs=-1``, all remaining + parameters are collected. :param metavar: how the value is represented in the help page. :param expose_value: if this is `True` then the value is passed onwards to the command callback and stored on the context, From 00f82cd1efe84f1636687d3d02c602462d276d49 Mon Sep 17 00:00:00 2001 From: Min ho Kim Date: Sun, 7 Jul 2019 01:04:53 +1000 Subject: [PATCH 032/293] Fix typos --- CHANGES.rst | 6 +++--- click/_bashcomplete.py | 2 +- click/_compat.py | 2 +- click/_winconsole.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index ba919332e..c1ab945f8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,7 +8,7 @@ Unreleased - Adds a repr to Command, showing the command name for friendlier debugging. (`#1267`_, `#1295`_) - Add support for distinguishing the source of a command line parameter. (`#1264`_, `#1329`_) - + .. _#1267: https://github.com/pallets/click/issues/1267 .. _#1295: https://github.com/pallets/click/pull/1295 .. _#1264: https://github.com/pallets/click/issues/1264 @@ -509,7 +509,7 @@ Version 3.0 (codename "clonk clonk", released on August 12th 2014) -- formatter now no longer attempts to accomodate for terminals +- formatter now no longer attempts to accommodate for terminals smaller than 50 characters. If that happens it just assumes a minimal width. - added a way to not swallow exceptions in the test system. @@ -518,7 +518,7 @@ Version 3.0 - the CLI runner's result object now has a traceback attached. - improved automatic short help detection to work better with dots that do not terminate sentences. -- when definining options without actual valid option strings +- when defining options without actual valid option strings now, Click will give an error message instead of silently passing. This should catch situations where users wanted to created arguments instead of options. diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py index a5f1084c9..0015b174d 100644 --- a/click/_bashcomplete.py +++ b/click/_bashcomplete.py @@ -130,7 +130,7 @@ def start_of_option(param_str): 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 + :param cmd_param: the current command parameter :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 diff --git a/click/_compat.py b/click/_compat.py index 6f8c7f98c..2be0a67e2 100644 --- a/click/_compat.py +++ b/click/_compat.py @@ -306,7 +306,7 @@ def _find_binary_reader(stream): def _find_binary_writer(stream): # We need to figure out if the given stream is already binary. - # This can happen because the official docs recommend detatching + # This can happen because the official docs recommend detaching # the streams to get binary streams. Some code might do this, so # we need to deal with this case explicitly. if _is_binary_writer(stream, False): diff --git a/click/_winconsole.py b/click/_winconsole.py index bbb080dda..07d08a187 100644 --- a/click/_winconsole.py +++ b/click/_winconsole.py @@ -6,7 +6,7 @@ # There are some general differences in regards to how this works # compared to the original patches as we do not need to patch # the entire interpreter but just work in our little world of -# echo and prmopt. +# echo and prompt. import io import os From d64eddae7d59cebd24b5100d72147fcf2e7cd1dc Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 17 Jul 2019 07:58:43 -0700 Subject: [PATCH 033/293] standardize license --- LICENSE.rst | 47 ++++++++++++++++++----------------------------- click/parser.py | 5 +++++ setup.py | 6 +++--- 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/LICENSE.rst b/LICENSE.rst index 87ce152aa..d12a84918 100644 --- a/LICENSE.rst +++ b/LICENSE.rst @@ -1,39 +1,28 @@ -Copyright © 2014 by the Pallets team. +Copyright 2014 Pallets -Some rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: -Redistribution and use in source and binary forms of the software as -well as documentation, with or without modification, are permitted -provided that the following conditions are met: - -- Redistributions of source code must retain the above copyright +1. 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 +2. 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. -- Neither the name of the copyright holder nor the names of its +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. -THIS SOFTWARE AND DOCUMENTATION 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 HOLDER 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 SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF -SUCH DAMAGE. - ----- - -Click uses parts of optparse written by Gregory P. Ward and maintained -by the Python Software Foundation. This is limited to code in parser.py. - -Copyright © 2001-2006 Gregory P. Ward. All rights reserved. -Copyright © 2002-2006 Python Software Foundation. All rights reserved. +THIS SOFTWARE 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 +HOLDER 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 +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/click/parser.py b/click/parser.py index 1c3ae9c8e..3d8bf6902 100644 --- a/click/parser.py +++ b/click/parser.py @@ -16,6 +16,11 @@ and might cause us issues. """ +# This code uses parts of optparse written by Gregory P. Ward and +# maintained by the Python Software Foundation. +# Copyright 2001-2006 Gregory P. Ward +# Copyright 2002-2006 Python Software Foundation + import re from collections import deque from .exceptions import UsageError, NoSuchOption, BadOptionUsage, \ diff --git a/setup.py b/setup.py index a6e0a9477..6f8fbde0a 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ readme = f.read() with io.open("click/__init__.py", "rt", encoding="utf8") as f: - version = re.search(r"__version__ = \'(.*?)\'", f.read()).group(1) + version = re.search(r"__version__ = '(.*?)'", f.read()).group(1) setup( name="click", @@ -17,10 +17,10 @@ "Code": "https://github.com/pallets/click", "Issue tracker": "https://github.com/pallets/click/issues", }, - license="BSD", + license="BSD-3-Clause", author="Armin Ronacher", author_email="armin.ronacher@active-4.com", - maintainer="Pallets Team", + maintainer="Pallets", maintainer_email="contact@palletsprojects.com", description="Composable command line interface toolkit", long_description=readme, From d19e10916c4250e55d2e40bc701c3a81887ee1a7 Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Thu, 18 Jul 2019 14:06:44 +1000 Subject: [PATCH 034/293] Fix Simple Typo: serverly -> severely --- click/_winconsole.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/click/_winconsole.py b/click/_winconsole.py index 07d08a187..5c68ce21d 100644 --- a/click/_winconsole.py +++ b/click/_winconsole.py @@ -81,7 +81,7 @@ class Py_buffer(ctypes.Structure): # On PyPy we cannot get buffers so our ability to operate here is -# serverly limited. +# severely limited. if pythonapi is None: get_buffer = None else: From 04a17ae5a0113b268502a6c1cb0e15789f7e44ea Mon Sep 17 00:00:00 2001 From: Christopher Palmer Date: Fri, 31 May 2019 12:52:25 -0400 Subject: [PATCH 035/293] allow ProgressBar.update to set current_item This allows updating what item is displayed when using ProgressBar.update to control the exact progress. --- CHANGES.rst | 5 +++++ click/_termui_impl.py | 15 ++++++++++++++- click/termui.py | 14 ++++++++++++++ tests/test_termui.py | 44 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index c3a541dee..f12698464 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,11 +8,16 @@ Unreleased - Adds a repr to Command, showing the command name for friendlier debugging. (`#1267`_, `#1295`_) - Add support for distinguishing the source of a command line parameter. (`#1264`_, `#1329`_) +- Add an optional parameter to ``ProgressBar.update`` to set the + ``current_item``. (`#1226`_, `#1332`_) .. _#1267: https://github.com/pallets/click/issues/1267 .. _#1295: https://github.com/pallets/click/pull/1295 .. _#1264: https://github.com/pallets/click/issues/1264 .. _#1329: https://github.com/pallets/click/pull/1329 +.. _#1226: https://github.com/pallets/click/issues/1226 +.. _#1332: https://github.com/pallets/click/pull/1332 + Version 7.1 ----------- diff --git a/click/_termui_impl.py b/click/_termui_impl.py index d025452e7..1c709b998 100644 --- a/click/_termui_impl.py +++ b/click/_termui_impl.py @@ -260,8 +260,21 @@ def make_step(self, n_steps): self.eta_known = self.length_known - def update(self, n_steps): + def update(self, n_steps, current_item=None): + """Update the progress bar by advancing a specified number of + steps, and optionally set the ``current_item`` for this new + position. + + :param n_steps: Number of steps to advance. + :param current_item: Optional item to set as ``current_item`` + for the updated position. + + .. versionadded:: 8.0 + Added the ``current_item`` optional parameter. + """ self.make_step(n_steps) + if current_item is not None: + self.current_item = current_item self.render_progress() def finish(self): diff --git a/click/termui.py b/click/termui.py index 0e8e84ca8..e4416531a 100644 --- a/click/termui.py +++ b/click/termui.py @@ -302,6 +302,20 @@ def progressbar(iterable=None, length=None, label=None, show_eta=True, process_chunk(chunk) bar.update(chunks.bytes) + The ``update()`` method also takes an optional value specifying the + ``current_item`` at the new position. This is useful when used + together with ``item_show_func`` to customize the output for each + manual step:: + + with click.progressbar( + length=total_size, + label='Unzipping archive', + item_show_func=lambda a: a.filename + ) as bar: + for archive in zip_file: + archive.extract() + bar.update(archive.size, archive) + .. versionadded:: 2.0 .. versionadded:: 4.0 diff --git a/tests/test_termui.py b/tests/test_termui.py index 79fb7c6f5..b6cd516ed 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -239,6 +239,50 @@ def cli(): assert '100% ' in lines[3] +def test_progressbar_item_show_func(runner, monkeypatch): + fake_clock = FakeClock() + + @click.command() + def cli(): + with click.progressbar(range(4), item_show_func=lambda x: "Custom {}".format(x)) 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 'Custom 0' in lines[0] + assert 'Custom 1' in lines[1] + assert 'Custom 2' in lines[2] + assert 'Custom None' in lines[3] + + +def test_progressbar_update_with_item_show_func(runner, monkeypatch): + fake_clock = FakeClock() + + @click.command() + def cli(): + with click.progressbar(length=6, item_show_func=lambda x: "Custom {}".format(x)) as progress: + while not progress.finished: + fake_clock.advance_time() + progress.update(2, progress.pos) + 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 'Custom 0' in lines[0] + assert 'Custom 2' in lines[1] + assert 'Custom 4' in lines[2] + + @pytest.mark.parametrize( 'key_char', (u'h', u'H', u'é', u'À', u' ', u'字', u'àH', u'àR') ) From cbb3c0c3d969cc35f49b18280bd7a11f139e3185 Mon Sep 17 00:00:00 2001 From: Nick Cross Date: Wed, 3 Jul 2019 21:21:25 +0100 Subject: [PATCH 036/293] Correct ZSH completion definitions --- CHANGES.rst | 2 ++ click/_bashcomplete.py | 4 ++++ tests/test_compat.py | 8 ++++++++ 3 files changed, 14 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index f12698464..84da8cfc0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,7 @@ Version 8.0 Unreleased +- Fix autoloading for ZSH completion and add error handling. (`#1348`_ ) - Adds a repr to Command, showing the command name for friendlier debugging. (`#1267`_, `#1295`_) - Add support for distinguishing the source of a command line parameter. (`#1264`_, `#1329`_) - Add an optional parameter to ``ProgressBar.update`` to set the @@ -16,6 +17,7 @@ Unreleased .. _#1264: https://github.com/pallets/click/issues/1264 .. _#1329: https://github.com/pallets/click/pull/1329 .. _#1226: https://github.com/pallets/click/issues/1226 +.. _#1348: https://github.com/pallets/click/pull/1348 .. _#1332: https://github.com/pallets/click/pull/1332 diff --git a/click/_bashcomplete.py b/click/_bashcomplete.py index 0015b174d..aa6a3bfa2 100644 --- a/click/_bashcomplete.py +++ b/click/_bashcomplete.py @@ -39,10 +39,14 @@ ''' COMPLETION_SCRIPT_ZSH = ''' +#compdef %(script_names)s + %(complete_func)s() { local -a completions local -a completions_with_descriptions local -a response + (( ! $+commands[%(script_names)s] )) && return 1 + response=("${(@f)$( env COMP_WORDS=\"${words[*]}\" \\ COMP_CWORD=$((CURRENT-1)) \\ %(autocomplete_var)s=\"complete_zsh\" \\ diff --git a/tests/test_compat.py b/tests/test_compat.py index abc733b19..674cded9a 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -25,6 +25,14 @@ def test_bash_func_name(): assert '_COMPLETE_VAR=complete $1' in script +def test_zsh_func_name(): + from click._bashcomplete import get_completion_script + script = get_completion_script('foo-bar', '_COMPLETE_VAR', 'zsh').strip() + assert script.startswith('#compdef foo-bar') + assert 'compdef _foo_bar_completion foo-bar;' in script + assert '(( ! $+commands[foo-bar] )) && return 1' in script + + @pytest.mark.xfail(WIN, reason="Jupyter not tested/supported on Windows") def test_is_jupyter_kernel_output(): class JupyterKernelFakeStream(object): From ffbb7ea17d851ce4ec0a93af6c6276342357b92e Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Sun, 25 Aug 2019 23:13:36 +1000 Subject: [PATCH 037/293] Fix simple typo: aliase -> alias --- examples/aliases/aliases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/aliases/aliases.py b/examples/aliases/aliases.py index 38ef72c5c..dca45ccc1 100644 --- a/examples/aliases/aliases.py +++ b/examples/aliases/aliases.py @@ -41,7 +41,7 @@ def get_command(self, ctx, cmd_name): # will create the config object is missing. cfg = ctx.ensure_object(Config) - # Step three: lookup an explicit command aliase in the config + # Step three: lookup an explicit command alias in the config if cmd_name in cfg.aliases: actual_cmd = cfg.aliases[cmd_name] return click.Group.get_command(self, ctx, actual_cmd) From dc7b70846c72f9ef8ab5d22dc42f51e422a35d47 Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Mon, 26 Aug 2019 07:32:30 +1000 Subject: [PATCH 038/293] Additional fix requested while fixing typo on the same line --- examples/aliases/aliases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/aliases/aliases.py b/examples/aliases/aliases.py index dca45ccc1..eb5bad495 100644 --- a/examples/aliases/aliases.py +++ b/examples/aliases/aliases.py @@ -41,7 +41,7 @@ def get_command(self, ctx, cmd_name): # will create the config object is missing. cfg = ctx.ensure_object(Config) - # Step three: lookup an explicit command alias in the config + # Step three: look up an explicit command alias in the config if cmd_name in cfg.aliases: actual_cmd = cfg.aliases[cmd_name] return click.Group.get_command(self, ctx, actual_cmd) From 163e55a47b0fd94653728af273b9e6722556a53c Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Mon, 28 Oct 2019 12:25:20 +0100 Subject: [PATCH 039/293] xrange() is history in imagepipe.py --- examples/imagepipe/imagepipe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/imagepipe/imagepipe.py b/examples/imagepipe/imagepipe.py index 37a152113..cc35dca33 100644 --- a/examples/imagepipe/imagepipe.py +++ b/examples/imagepipe/imagepipe.py @@ -210,7 +210,7 @@ def smoothen_cmd(images, iterations): for image in images: click.echo('Smoothening "%s" %d time%s' % (image.filename, iterations, iterations != 1 and 's' or '',)) - for x in xrange(iterations): + for x in range(iterations): image = copy_filename(image.filter(ImageFilter.BLUR), image) yield image From 4907043da7818d947eb04b21a685c0bf70af6595 Mon Sep 17 00:00:00 2001 From: Karthikeyan Singaravelan Date: Thu, 19 Dec 2019 18:11:05 +0530 Subject: [PATCH 040/293] Fix SyntaxWarning in test over literal comparison. --- tests/test_basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_basic.py b/tests/test_basic.py index f800b4d20..b83d86a14 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -48,7 +48,7 @@ def cli(): with cli.make_context('foo', []) as ctx: rv = cli.invoke(ctx) - assert rv is 42 + assert rv == 42 def test_basic_group(runner): From fa0a7b841a42954323e69a8fc67520bbeca29dd3 Mon Sep 17 00:00:00 2001 From: aaronsarna <6665406+aaronsarna@users.noreply.github.com> Date: Thu, 30 Jan 2020 16:11:14 -0500 Subject: [PATCH 041/293] Fix error in new AppEngine environments Newer AppEngine environments don't define the SERVER_SOFTWARE env variable. Without this change, importing click dies with a KeyError. See related pull request for urllib3: https://github.com/urllib3/urllib3/pull/1704 --- click/_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/click/_compat.py b/click/_compat.py index 69198fff7..f914d38b4 100644 --- a/click/_compat.py +++ b/click/_compat.py @@ -11,7 +11,7 @@ MSYS2 = sys.platform.startswith('win') and ('GCC' in sys.version) # Determine local App Engine environment, per Google's own suggestion APP_ENGINE = ('APPENGINE_RUNTIME' in os.environ and - 'Development/' in os.environ['SERVER_SOFTWARE']) + 'Development/' in os.environ.get('SERVER_SOFTWARE', '')) WIN = sys.platform.startswith('win') and not APP_ENGINE and not MSYS2 DEFAULT_COLUMNS = 80 From cd35e790c1e41485c18d3e8b5d0891c0280cc96f Mon Sep 17 00:00:00 2001 From: jab Date: Fri, 31 Jan 2020 01:42:21 +0000 Subject: [PATCH 042/293] Add changelog entry for #1462. --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index b3c951258..4e8c93270 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -32,6 +32,7 @@ Unreleased - Always return of of the passed choices for ``click.Choice`` :issue:`1277`, :pr:`1318` - Fix ``OSError`` when running in MSYS2. :issue:`1338` +- Fix error in new AppEngine environments. :issue:`1462` Version 7.0 From efa6b4f4389d30aac7f1ce62dc08f3bbe5b331d6 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Mon, 28 Oct 2019 12:27:29 +0100 Subject: [PATCH 043/293] Undefined name: import sys for lines 300 and 311 --- tests/test_context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_context.py b/tests/test_context.py index 35707498b..3618d59d3 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import sys import click import pytest From 1be658d5cbdfa03258331301ace0368cdbe73e2a Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 22 Feb 2020 06:20:08 -0800 Subject: [PATCH 044/293] simplify parameter source tests --- tests/test_context.py | 46 ++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/tests/test_context.py b/tests/test_context.py index 3618d59d3..6a26b9de2 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import sys import click import pytest @@ -258,37 +257,40 @@ def cli(ctx): assert cli.main([], 'test_exit_not_standalone', standalone_mode=False) == 0 -def test_parameter_source_default(): + +def test_parameter_source_default(runner): @click.command() @click.pass_context @click.option("-o", "--option", default=1) def cli(ctx, option): - assert ctx.get_parameter_source("option") == click.ParameterSource.DEFAULT - ctx.exit(1) + click.echo(ctx.get_parameter_source("option")) + + rv = runner.invoke(cli) + assert rv.output.rstrip() == click.ParameterSource.DEFAULT - assert cli.main([], "test_parameter_source_default", standalone_mode=False) == 1 -def test_parameter_source_default_map(): +def test_parameter_source_default_map(runner): @click.command() @click.pass_context @click.option("-o", "--option", default=1) def cli(ctx, option): - assert ctx.get_parameter_source("option") == click.ParameterSource.DEFAULT_MAP - ctx.exit(1) + click.echo(ctx.get_parameter_source("option")) - assert cli.main([], "test_parameter_source_default", standalone_mode=False, default_map={ "option": 1}) == 1 + rv = runner.invoke(cli, default_map={"option": 1}) + assert rv.output.rstrip() == click.ParameterSource.DEFAULT_MAP -def test_parameter_source_commandline(): +def test_parameter_source_commandline(runner): @click.command() @click.pass_context @click.option("-o", "--option", default=1) def cli(ctx, option): - assert ctx.get_parameter_source("option") == click.ParameterSource.COMMANDLINE - ctx.exit(1) - - assert cli.main(["-o", "1"], "test_parameter_source_commandline", standalone_mode=False) == 1 - assert cli.main(["--option", "1"], "test_parameter_source_default", standalone_mode=False) == 1 + click.echo(ctx.get_parameter_source("option")) + + rv = runner.invoke(cli, ["-o", "1"]) + assert rv.output.rstrip() == click.ParameterSource.COMMANDLINE + rv = runner.invoke(cli, ["--option", "1"]) + assert rv.output.rstrip() == click.ParameterSource.COMMANDLINE def test_parameter_source_environment(runner): @@ -296,10 +298,10 @@ def test_parameter_source_environment(runner): @click.pass_context @click.option("-o", "--option", default=1) def cli(ctx, option): - assert ctx.get_parameter_source("option") == click.ParameterSource.ENVIRONMENT - sys.exit(1) - - assert runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"TEST_OPTION": "1"}, auto_envvar_prefix="TEST").exit_code == 1 + click.echo(ctx.get_parameter_source("option")) + + rv = runner.invoke(cli, auto_envvar_prefix="TEST", env={"TEST_OPTION": "1"}) + assert rv.output.rstrip() == click.ParameterSource.ENVIRONMENT def test_parameter_source_environment_variable_specified(runner): @@ -307,10 +309,10 @@ def test_parameter_source_environment_variable_specified(runner): @click.pass_context @click.option("-o", "--option", default=1, envvar="NAME") def cli(ctx, option): - assert ctx.get_parameter_source("option") == click.ParameterSource.ENVIRONMENT - sys.exit(1) + click.echo(ctx.get_parameter_source("option")) - assert runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"NAME": "1"}).exit_code == 1 + rv = runner.invoke(cli, env={"NAME": "1"}) + assert rv.output.rstrip() == click.ParameterSource.ENVIRONMENT def test_validate_parameter_source(): From 22bfb0c7a051eb29fbcc4c0de846258ccf4c9ef5 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sat, 15 Feb 2020 14:58:43 -0500 Subject: [PATCH 045/293] Support Debian's sensible-editor --- CHANGES.rst | 2 ++ click/_termui_impl.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 17fd5b3ce..3c809c90b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -44,6 +44,8 @@ Unreleased interpreter. :issue:`1139` - Fix how default values for file-type options are shown during prompts. :issue:`914` +- Consider ``sensible-editor`` when determining the editor to use for + ``click.edit()``. :pr:`1469` Version 7.0 diff --git a/click/_termui_impl.py b/click/_termui_impl.py index c3e14c6c5..66668213b 100644 --- a/click/_termui_impl.py +++ b/click/_termui_impl.py @@ -427,7 +427,7 @@ def get_editor(self): return rv if WIN: return 'notepad' - for editor in 'vim', 'nano': + for editor in 'sensible-editor', 'vim', 'nano': if os.system('which %s >/dev/null 2>&1' % editor) == 0: return editor return 'vi' From f605f9576b69dd185dfbaae18dbc27a0b7f9ded5 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 9 Mar 2020 07:39:08 -0700 Subject: [PATCH 046/293] deprecate old param callback style --- CHANGES.rst | 3 +++ src/click/core.py | 13 +++++-------- tests/test_compat.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index c671929e6..b1edaac73 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -66,6 +66,9 @@ Unreleased - Add completion support for Fish shell. :pr:`1423` - Decoding bytes option values falls back to UTF-8 in more cases. :pr:`1468` +- Make the warning about old 2-arg parameter callbacks a deprecation + warning, to be removed in 8.0. This has been a warning since Click + 2.0. :pr:`1492` Version 7.0 diff --git a/src/click/core.py b/src/click/core.py index 21026963a..f58bf26d2 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -110,18 +110,16 @@ def invoke_param_callback(callback, ctx, param, value): args = getattr(code, "co_argcount", 3) if args < 3: - # This will become a warning in Click 3.0: from warnings import warn warn( - Warning( - "Invoked legacy parameter callback '{}'. The new" - " signature for such callbacks starting with Click 2.0" - " is (ctx, param, value).".format(callback) - ), + "Parameter callbacks take 3 args, (ctx, param, value). The" + " 2-arg style is deprecated and will be removed in 8.0.".format(callback), + DeprecationWarning, stacklevel=3, ) return callback(ctx, value) + return callback(ctx, param, value) @@ -1440,8 +1438,7 @@ class Parameter(object): without any arguments. :param callback: a callback that should be executed after the parameter was matched. This is called as ``fn(ctx, param, - value)`` and needs to return the value. Before Click - 2.0, the signature was ``(ctx, value)``. + value)`` and needs to return the value. :param nargs: the number of arguments to match. If not ``1`` the return value is a tuple instead of single value. The default for nargs is ``1`` (except if the type is a tuple, then it's diff --git a/tests/test_compat.py b/tests/test_compat.py index c26834a9a..7851ab8ec 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -14,7 +14,7 @@ def legacy_callback(ctx, value): def cli(foo): click.echo(foo) - with pytest.warns(Warning, match="Invoked legacy parameter callback"): + with pytest.warns(DeprecationWarning, match="2-arg style"): result = runner.invoke(cli, ["--foo", "wat"]) assert result.exit_code == 0 assert "WAT" in result.output From 1e054e0c8713ed83716a2037bdb17125bb734b33 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 9 Mar 2020 08:19:22 -0700 Subject: [PATCH 047/293] start version 8.0 --- src/click/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/click/__init__.py b/src/click/__init__.py index 843dc0211..a033e8bf3 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -76,4 +76,4 @@ # literals. disable_unicode_literals_warning = False -__version__ = "7.1" +__version__ = "8.0.dev" From 19fdc8509a1b946c088512e0e5e388a8d012f0ce Mon Sep 17 00:00:00 2001 From: Joshua Bronson Date: Wed, 1 Apr 2020 19:50:55 -0400 Subject: [PATCH 048/293] Remove no-op try/except (#1521) --- src/click/_termui_impl.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 704a1e19c..cdc6fab63 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -532,10 +532,8 @@ def open_url(url, wait=False, locate=False): import subprocess def _unquote_file(url): - try: - import urllib - except ImportError: - import urllib + import urllib + if url.startswith("file://"): url = urllib.unquote(url[7:]) return url From 15b4418f630320af3fe9918aaefc0a9270266dfa Mon Sep 17 00:00:00 2001 From: Robert Bost Date: Mon, 30 Mar 2020 15:54:23 -0600 Subject: [PATCH 049/293] Minor typo fix in bash completion template --- src/click/_bashcomplete.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/click/_bashcomplete.py b/src/click/_bashcomplete.py index cbabd8db7..d5162b226 100644 --- a/src/click/_bashcomplete.py +++ b/src/click/_bashcomplete.py @@ -26,7 +26,7 @@ return 0 } -%(complete_func)setup() { +%(complete_func)s_setup() { local COMPLETION_OPTIONS="" local BASH_VERSION_ARR=(${BASH_VERSION//./ }) # Only BASH version 4.4 and later have the nosort option. @@ -38,7 +38,7 @@ complete $COMPLETION_OPTIONS -F %(complete_func)s %(script_names)s } -%(complete_func)setup +%(complete_func)s_setup """ COMPLETION_SCRIPT_ZSH = """ From 9374365fdd5af45fc3dc4047963fa30aad9ce15c Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 19 Apr 2020 20:34:05 -0700 Subject: [PATCH 050/293] drop support for Python 2.7 and 3.5 --- .azure-pipelines.yml | 23 ++++++++--------------- .pre-commit-config.yaml | 4 ++-- CHANGES.rst | 1 + setup.cfg | 34 ++++++++++++++++++++++++++++++---- setup.py | 39 +-------------------------------------- tox.ini | 2 +- 6 files changed, 43 insertions(+), 60 deletions(-) diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 86e701017..2b4d41a9f 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -9,25 +9,18 @@ variables: strategy: matrix: - Python 3.8 Linux: + Linux: vmImage: ubuntu-latest - Python 3.8 Windows: + Windows: vmImage: windows-latest - Python 3.8 Mac: + Mac: vmImage: macos-latest - PyPy 3 Linux: - python.version: pypy3 - Python 3.7 Linux: + Python 3.7: python.version: '3.7' - Python 3.6 Linux: + Python 3.6: python.version: '3.6' - Python 3.5 Linux: - python.version: '3.5' - Python 2.7 Linux: - python.version: '2.7' - Python 2.7 Windows: - vmImage: windows-latest - python.version: '2.7' + PyPy: + python.version: pypy3 Docs: TOXENV: docs Style: @@ -39,7 +32,7 @@ pool: steps: - task: UsePythonVersion@0 inputs: - versionSpec: "$(python.version)" + versionSpec: $(python.version) displayName: Use Python $(python.version) - script: pip --disable-pip-version-check install -U tox diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 579fe9c02..1931abf18 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,10 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.1.0 + rev: v2.2.0 hooks: - id: pyupgrade - repo: https://github.com/asottile/reorder_python_imports - rev: v2.0.0 + rev: v2.2.0 hooks: - id: reorder-python-imports args: ["--application-directories", "src"] diff --git a/CHANGES.rst b/CHANGES.rst index 1bb28666d..dd337b9eb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,7 @@ Version 8.0 Unreleased +- Drop support for Python 2 and 3.5. - Adds a repr to Command, showing the command name for friendlier debugging. :issue:`1267`, :pr:`1295` - Add support for distinguishing the source of a command line diff --git a/setup.cfg b/setup.cfg index c2d41cc84..c0c4ecf08 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,34 @@ [metadata] -license_file = LICENSE.rst +name = click +version = attr: click.__version__ +url = https://palletsprojects.com/p/click/ +project_urls = + Documentation = https://click.palletsprojects.com/ + Code = https://github.com/pallets/click + Issue tracker = https://github.com/pallets/click/issues +license = BSD-3-Clause +license_files = LICENSE.rst +maintainer = Pallets +maintainer_email = contact@palletsprojects.com +description = Composable command line interface toolkit +long_description = file: README.rst +long_description_content_type = text/x-rst +classifiers = + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python -[bdist_wheel] -universal = 1 +[options] +packages = find: +package_dir = = src +include_package_data = true +python_requires = >= 3.6 +# Dependencies are in setup.py for GitHub's dependency graph. + +[options.packages.find] +where = src [tool:pytest] testpaths = tests @@ -10,7 +36,7 @@ filterwarnings = error [coverage:run] -branch = True +branch = true source = src tests diff --git a/setup.py b/setup.py index a7af7f102..48bf7c047 100644 --- a/setup.py +++ b/setup.py @@ -1,40 +1,3 @@ -import io -import re - -from setuptools import find_packages from setuptools import setup -with io.open("README.rst", "rt", encoding="utf8") as f: - readme = f.read() - -with io.open("src/click/__init__.py", "rt", encoding="utf8") as f: - version = re.search(r'__version__ = "(.*?)"', f.read()).group(1) - -setup( - name="click", - version=version, - url="https://palletsprojects.com/p/click/", - project_urls={ - "Documentation": "https://click.palletsprojects.com/", - "Code": "https://github.com/pallets/click", - "Issue tracker": "https://github.com/pallets/click/issues", - }, - license="BSD-3-Clause", - maintainer="Pallets", - maintainer_email="contact@palletsprojects.com", - description="Composable command line interface toolkit", - long_description=readme, - packages=find_packages("src"), - package_dir={"": "src"}, - include_package_data=True, - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", - 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 :: 3", - ], -) +setup(name="click") diff --git a/tox.ini b/tox.ini index beed51d92..8f0044dd0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{38,37,36,35,27,py3,py} + py{38,37,36,py3} style docs skip_missing_interpreters = true From 5cddd7f652a885c16e2e9154ab242f6730c3ea92 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 19 Apr 2020 20:38:18 -0700 Subject: [PATCH 051/293] remove deprecated callback parameter style --- src/click/core.py | 20 +------------------- tests/test_compat.py | 16 ---------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index 37a762c73..5e8bf6c00 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -105,24 +105,6 @@ def batch(iterable, batch_size): return list(zip(*repeat(iter(iterable), batch_size))) -def invoke_param_callback(callback, ctx, param, value): - code = getattr(callback, "__code__", None) - args = getattr(code, "co_argcount", 3) - - if args < 3: - from warnings import warn - - warn( - "Parameter callbacks take 3 args, (ctx, param, value). The" - " 2-arg style is deprecated and will be removed in 8.0.".format(callback), - DeprecationWarning, - stacklevel=3, - ) - return callback(ctx, value) - - return callback(ctx, param, value) - - @contextmanager def augment_usage_errors(ctx, param=None): """Context manager that attaches extra information to exceptions.""" @@ -1702,7 +1684,7 @@ def handle_parse_result(self, ctx, opts, args): value = None if self.callback is not None: try: - value = invoke_param_callback(self.callback, ctx, self, value) + value = self.callback(ctx, self, value) except Exception: if not ctx.resilient_parsing: raise diff --git a/tests/test_compat.py b/tests/test_compat.py index 7851ab8ec..26647ba99 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1,25 +1,9 @@ import pytest -import click from click._compat import should_strip_ansi from click._compat import WIN -def test_legacy_callbacks(runner): - def legacy_callback(ctx, value): - return value.upper() - - @click.command() - @click.option("--foo", callback=legacy_callback) - def cli(foo): - click.echo(foo) - - with pytest.warns(DeprecationWarning, match="2-arg style"): - result = runner.invoke(cli, ["--foo", "wat"]) - assert result.exit_code == 0 - assert "WAT" in result.output - - def test_bash_func_name(): from click._bashcomplete import get_completion_script From 00883dd3d0a29f68f375cab5e21cef0669941aba Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 19 Apr 2020 21:44:28 -0700 Subject: [PATCH 052/293] remove py2 parts of _compat module --- docs/conf.py | 5 + src/click/__init__.py | 6 +- src/click/_compat.py | 545 +++++++++++++------------------------- src/click/_termui_impl.py | 22 +- src/click/_unicodefun.py | 49 ---- src/click/_winconsole.py | 75 +----- src/click/core.py | 26 +- src/click/decorators.py | 5 +- src/click/exceptions.py | 21 +- src/click/termui.py | 9 +- src/click/testing.py | 53 ++-- src/click/types.py | 26 +- src/click/utils.py | 54 ++-- tests/test_arguments.py | 23 +- tests/test_options.py | 7 +- tests/test_testing.py | 12 +- 16 files changed, 283 insertions(+), 655 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f04804ca1..54c7a4d98 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,11 @@ from pallets_sphinx_themes import get_version from pallets_sphinx_themes import ProjectLink +import click._compat + +# compat until pallets-sphinx-themes is updated +click._compat.text_type = str + # Project -------------------------------------------------------------- project = "Click" diff --git a/src/click/__init__.py b/src/click/__init__.py index a033e8bf3..9cd0129bf 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -72,8 +72,4 @@ from .utils import get_text_stream from .utils import open_file -# Controls if click should emit the warning about the use of unicode -# literals. -disable_unicode_literals_warning = False - -__version__ = "8.0.dev" +__version__ = "8.0.0.dev" diff --git a/src/click/_compat.py b/src/click/_compat.py index cbc64a140..63bebef83 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -1,4 +1,3 @@ -# flake8: noqa import codecs import io import os @@ -6,7 +5,6 @@ import sys from weakref import WeakKeyDictionary -PY2 = sys.version_info[0] == 2 CYGWIN = sys.platform.startswith("cygwin") MSYS2 = sys.platform.startswith("win") and ("GCC" in sys.version) # Determine local App Engine environment, per Google's own suggestion @@ -15,8 +13,9 @@ ) WIN = sys.platform.startswith("win") and not APP_ENGINE and not MSYS2 DEFAULT_COLUMNS = 80 - - +auto_wrap_for_ansi = None +colorama = None +get_winterm_size = None _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") @@ -68,25 +67,7 @@ def __init__( **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 - # was forced upon Python 2, so we need to unbreak - # it to look like Python 2. - if PY2: - - def write(self, x): - if isinstance(x, str) or is_bytes(x): - try: - self.flush() - except Exception: - pass - return self.buffer.write(str(x)) - return io.TextIOWrapper.write(self, x) - - def writelines(self, lines): - for line in lines: - self.write(line) + super().__init__(stream, encoding, errors, **extra) def __del__(self): try: @@ -121,11 +102,7 @@ def read1(self, size): f = getattr(self._stream, "read1", None) if f is not None: return f(size) - # We only dispatch to readline instead of read in Python 2 as we - # do not want cause problems with the different implementation - # of line buffering. - if PY2: - return self._stream.readline(size) + return self._stream.read(size) def readable(self): @@ -166,320 +143,207 @@ def seekable(self): return True -if PY2: - text_type = unicode - raw_input = raw_input - string_types = (str, unicode) - int_types = (int, long) - iteritems = lambda x: x.iteritems() - range_type = xrange - - from pipes import quote as shlex_quote - - def is_bytes(x): - return isinstance(x, (buffer, bytearray)) - - _identifier_re = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") - - # For Windows, we need to force stdout/stdin/stderr to binary if it's - # fetched for that. This obviously is not the most correct way to do - # it as it changes global state. Unfortunately, there does not seem to - # be a clear better way to do it as just reopening the file in binary - # mode does not change anything. - # - # An option would be to do what Python 3 does and to open the file as - # binary only, patch it back to the system, and then use a wrapper - # stream that converts newlines. It's not quite clear what's the - # correct option here. - # - # This code also lives in _winconsole for the fallback to the console - # emulation stream. - # - # 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 +def is_bytes(x): + return isinstance(x, (bytes, memoryview, bytearray)) + +def _is_binary_reader(stream, default=False): try: - import msvcrt - except ImportError: - pass - else: + return isinstance(stream.read(0), bytes) + except Exception: + return default + # This happens in some cases where the stream was already + # closed. In this case, we assume the default. - def set_binary_mode(f): - try: - fileno = f.fileno() - except Exception: - pass - else: - msvcrt.setmode(fileno, os.O_BINARY) - return f +def _is_binary_writer(stream, default=False): try: - import fcntl - except ImportError: - pass - else: + stream.write(b"") + except Exception: + try: + stream.write("") + return False + except Exception: + pass + return default + return True - 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 - def isidentifier(x): - return _identifier_re.search(x) is not None +def _find_binary_reader(stream): + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detaching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_reader(stream, False): + return stream - def get_binary_stdin(): - return set_binary_mode(sys.stdin) + buf = getattr(stream, "buffer", None) - def get_binary_stdout(): - _wrap_std_stream("stdout") - return set_binary_mode(sys.stdout) + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_reader(buf, True): + return buf - def get_binary_stderr(): - _wrap_std_stream("stderr") - return set_binary_mode(sys.stderr) - 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, force_readable=True) +def _find_binary_writer(stream): + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detaching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_writer(stream, False): + return stream - def get_text_stdout(encoding=None, errors=None): - _wrap_std_stream("stdout") - rv = _get_windows_console_stream(sys.stdout, encoding, errors) - if rv is not None: - return rv - return _make_text_stream(sys.stdout, encoding, errors, force_writable=True) + buf = getattr(stream, "buffer", None) - def get_text_stderr(encoding=None, errors=None): - _wrap_std_stream("stderr") - rv = _get_windows_console_stream(sys.stderr, encoding, errors) - if rv is not None: - return rv - return _make_text_stream(sys.stderr, encoding, errors, force_writable=True) + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_writer(buf, True): + return buf - def filename_to_ui(value): - if isinstance(value, bytes): - value = value.decode(get_filesystem_encoding(), "replace") - return value +def _stream_is_misconfigured(stream): + """A stream is misconfigured if its encoding is ASCII.""" + # If the stream does not have an encoding set, we assume it's set + # to ASCII. This appears to happen in certain unittest + # environments. It's not quite clear what the correct behavior is + # but this at least will force Click to recover somehow. + return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii") -else: - import io - text_type = str - raw_input = input - string_types = (str,) - int_types = (int,) - range_type = range - isidentifier = lambda x: x.isidentifier() - iteritems = lambda x: iter(x.items()) +def _is_compat_stream_attr(stream, attr, value): + """A stream attribute is compatible if it is equal to the + desired value or the desired value is unset and the attribute + has a value. + """ + stream_value = getattr(stream, attr, None) + return stream_value == value or (value is None and stream_value is not None) - from shlex import quote as shlex_quote - def is_bytes(x): - return isinstance(x, (bytes, memoryview, bytearray)) +def _is_compatible_text_stream(stream, encoding, errors): + """Check if a stream's encoding and errors attributes are + compatible with the desired values. + """ + return _is_compat_stream_attr( + stream, "encoding", encoding + ) and _is_compat_stream_attr(stream, "errors", errors) + + +def _force_correct_text_stream( + text_stream, + encoding, + errors, + is_binary, + find_binary, + force_readable=False, + force_writable=False, +): + if is_binary(text_stream, False): + binary_reader = text_stream + else: + # If the stream looks compatible, and won't default to a + # misconfigured ascii encoding, return it as-is. + if _is_compatible_text_stream(text_stream, encoding, errors) and not ( + encoding is None and _stream_is_misconfigured(text_stream) + ): + return text_stream + + # Otherwise, get the underlying binary reader. + binary_reader = find_binary(text_stream) + + # If that's not possible, silently use the original reader + # and get mojibake instead of exceptions. + if binary_reader is None: + return text_stream + + # Default errors to replace instead of strict in order to get + # something that works. + if errors is None: + errors = "replace" - def _is_binary_reader(stream, default=False): - try: - return isinstance(stream.read(0), bytes) - except Exception: - return default - # This happens in some cases where the stream was already - # closed. In this case, we assume the default. + # Wrap the binary stream in a text stream with the correct + # encoding parameters. + return _make_text_stream( + binary_reader, + encoding, + errors, + force_readable=force_readable, + force_writable=force_writable, + ) - def _is_binary_writer(stream, default=False): - try: - stream.write(b"") - except Exception: - try: - stream.write("") - return False - except Exception: - pass - return default - return True - def _find_binary_reader(stream): - # We need to figure out if the given stream is already binary. - # This can happen because the official docs recommend detaching - # the streams to get binary streams. Some code might do this, so - # we need to deal with this case explicitly. - if _is_binary_reader(stream, False): - return stream - - buf = getattr(stream, "buffer", None) - - # Same situation here; this time we assume that the buffer is - # actually binary in case it's closed. - if buf is not None and _is_binary_reader(buf, True): - return buf - - def _find_binary_writer(stream): - # We need to figure out if the given stream is already binary. - # This can happen because the official docs recommend detaching - # the streams to get binary streams. Some code might do this, so - # we need to deal with this case explicitly. - if _is_binary_writer(stream, False): - return stream - - buf = getattr(stream, "buffer", None) - - # Same situation here; this time we assume that the buffer is - # actually binary in case it's closed. - if buf is not None and _is_binary_writer(buf, True): - return buf - - def _stream_is_misconfigured(stream): - """A stream is misconfigured if its encoding is ASCII.""" - # If the stream does not have an encoding set, we assume it's set - # to ASCII. This appears to happen in certain unittest - # environments. It's not quite clear what the correct behavior is - # but this at least will force Click to recover somehow. - return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii") - - def _is_compat_stream_attr(stream, attr, value): - """A stream attribute is compatible if it is equal to the - desired value or the desired value is unset and the attribute - has a value. - """ - stream_value = getattr(stream, attr, None) - return stream_value == value or (value is None and stream_value is not None) - - def _is_compatible_text_stream(stream, encoding, errors): - """Check if a stream's encoding and errors attributes are - compatible with the desired values. - """ - return _is_compat_stream_attr( - stream, "encoding", encoding - ) and _is_compat_stream_attr(stream, "errors", errors) - - def _force_correct_text_stream( - text_stream, +def _force_correct_text_reader(text_reader, encoding, errors, force_readable=False): + return _force_correct_text_stream( + text_reader, encoding, errors, - is_binary, - find_binary, - force_readable=False, - force_writable=False, - ): - if is_binary(text_stream, False): - binary_reader = text_stream - else: - # If the stream looks compatible, and won't default to a - # misconfigured ascii encoding, return it as-is. - if _is_compatible_text_stream(text_stream, encoding, errors) and not ( - encoding is None and _stream_is_misconfigured(text_stream) - ): - return text_stream - - # Otherwise, get the underlying binary reader. - binary_reader = find_binary(text_stream) - - # If that's not possible, silently use the original reader - # and get mojibake instead of exceptions. - if binary_reader is None: - return text_stream - - # Default errors to replace instead of strict in order to get - # something that works. - if errors is None: - errors = "replace" - - # Wrap the binary stream in a text stream with the correct - # encoding parameters. - return _make_text_stream( - binary_reader, - encoding, - errors, - force_readable=force_readable, - force_writable=force_writable, - ) + _is_binary_reader, + _find_binary_reader, + force_readable=force_readable, + ) - def _force_correct_text_reader(text_reader, encoding, errors, force_readable=False): - return _force_correct_text_stream( - text_reader, - encoding, - errors, - _is_binary_reader, - _find_binary_reader, - force_readable=force_readable, - ) - def _force_correct_text_writer(text_writer, encoding, errors, force_writable=False): - return _force_correct_text_stream( - text_writer, - encoding, - errors, - _is_binary_writer, - _find_binary_writer, - force_writable=force_writable, - ) +def _force_correct_text_writer(text_writer, encoding, errors, force_writable=False): + return _force_correct_text_stream( + text_writer, + encoding, + errors, + _is_binary_writer, + _find_binary_writer, + force_writable=force_writable, + ) - def get_binary_stdin(): - reader = _find_binary_reader(sys.stdin) - if reader is None: - raise RuntimeError("Was not able to determine binary stream for sys.stdin.") - return reader - - def get_binary_stdout(): - writer = _find_binary_writer(sys.stdout) - if writer is None: - raise RuntimeError( - "Was not able to determine binary stream for sys.stdout." - ) - return writer - - def get_binary_stderr(): - writer = _find_binary_writer(sys.stderr) - if writer is None: - raise RuntimeError( - "Was not able to determine binary stream for sys.stderr." - ) - return writer - - 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, 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, force_writable=True - ) +def get_binary_stdin(): + reader = _find_binary_reader(sys.stdin) + if reader is None: + raise RuntimeError("Was not able to determine binary stream for sys.stdin.") + return reader - 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, force_writable=True - ) - def filename_to_ui(value): - if isinstance(value, bytes): - value = value.decode(get_filesystem_encoding(), "replace") - else: - value = value.encode("utf-8", "surrogateescape").decode("utf-8", "replace") - return value +def get_binary_stdout(): + writer = _find_binary_writer(sys.stdout) + if writer is None: + raise RuntimeError("Was not able to determine binary stream for sys.stdout.") + return writer + + +def get_binary_stderr(): + writer = _find_binary_writer(sys.stderr) + if writer is None: + raise RuntimeError("Was not able to determine binary stream for sys.stderr.") + return writer + + +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, force_readable=True) -def get_streerror(e, default=None): +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, 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, force_writable=True) + + +def filename_to_ui(value): + if isinstance(value, bytes): + value = value.decode(get_filesystem_encoding(), "replace") + else: + value = value.encode("utf-8", "surrogateescape").decode("utf-8", "replace") + return value + + +def get_strerror(e, default=None): if hasattr(e, "strerror"): msg = e.strerror else: @@ -493,25 +357,11 @@ def get_streerror(e, default=None): def _wrap_io_open(file, mode, encoding, errors): - """On Python 2, :func:`io.open` returns a text file wrapper that - requires passing ``unicode`` to ``write``. Need to open the file in - binary mode then wrap it in a subclass that can write ``str`` and - ``unicode``. - - Also handles not passing ``encoding`` and ``errors`` in binary mode. - """ - binary = "b" in mode - - if binary: - kwargs = {} - else: - kwargs = {"encoding": encoding, "errors": errors} - - if not PY2 or binary: - return io.open(file, mode, **kwargs) + """Handles not passing ``encoding`` and ``errors`` in binary mode.""" + if "b" in mode: + return io.open(file, mode) - f = io.open(file, "{}b".format(mode.replace("t", ""))) - return _make_text_stream(f, **kwargs) + return io.open(file, mode, encoding=encoding, errors=errors) def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False): @@ -587,15 +437,6 @@ def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False return _AtomicFile(f, tmp_filename, os.path.realpath(filename)), True -# Used in a destructor call, needs extra protection from interpreter cleanup. -if hasattr(os, "replace"): - _replace = os.replace - _can_replace = True -else: - _replace = os.rename - _can_replace = not WIN - - class _AtomicFile(object): def __init__(self, f, tmp_filename, real_filename): self._f = f @@ -611,12 +452,7 @@ def close(self, delete=False): if self.closed: return self._f.close() - if not _can_replace: - try: - os.remove(self._real_filename) - except OSError: - pass - _replace(self._tmp_filename, self._real_filename) + os.replace(self._tmp_filename, self._real_filename) self.closed = True def __getattr__(self, name): @@ -632,11 +468,6 @@ def __repr__(self): return repr(self._f) -auto_wrap_for_ansi = None -colorama = None -get_winterm_size = None - - def strip_ansi(value): return _ansi_re.sub("", value) @@ -668,23 +499,13 @@ def should_strip_ansi(stream=None, color=None): # Windows has a smaller terminal DEFAULT_COLUMNS = 79 - from ._winconsole import _get_windows_console_stream, _wrap_std_stream + from ._winconsole import _get_windows_console_stream def _get_argv_encoding(): import locale return locale.getpreferredencoding() - if PY2: - - def raw_input(prompt=""): - sys.stderr.flush() - if prompt: - stdout = _default_text_stdout() - stdout.write(prompt) - stdin = _default_text_stdin() - return stdin.readline().rstrip("\r\n") - try: import colorama except ImportError: @@ -712,7 +533,7 @@ def auto_wrap_for_ansi(stream, color=None): def _safe_write(s): try: return _write(s) - except: + except BaseException: ansi_wrapper.reset_all() raise @@ -735,8 +556,8 @@ def get_winterm_size(): def _get_argv_encoding(): return getattr(sys.stdin, "encoding", None) or get_filesystem_encoding() - _get_windows_console_stream = lambda *x: None - _wrap_std_stream = lambda *x: None + def _get_windows_console_stream(f, encoding, errors): + return None def term_len(x): diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index cdc6fab63..1a6adf19c 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -7,17 +7,15 @@ import contextlib import math import os +import shlex import sys import time from ._compat import _default_text_stdout from ._compat import CYGWIN from ._compat import get_best_encoding -from ._compat import int_types from ._compat import isatty from ._compat import open_stream -from ._compat import range_type -from ._compat import shlex_quote from ._compat import strip_ansi from ._compat import term_len from ._compat import WIN @@ -45,7 +43,7 @@ def _length_hint(obj): hint = get_hint(obj) except TypeError: return None - if hint is NotImplemented or not isinstance(hint, int_types) or hint < 0: + if hint is NotImplemented or not isinstance(hint, int) or hint < 0: return None return hint @@ -89,7 +87,7 @@ def __init__( if iterable is None: if length is None: raise TypeError("iterable or length is required") - iterable = range_type(length) + iterable = range(length) self.iter = iter(iterable) self.length = length self.length_known = length is not None @@ -361,7 +359,7 @@ def pager(generator, color=None): try: if ( hasattr(os, "system") - and os.system("more {}".format(shlex_quote(filename))) == 0 + and os.system("more {}".format(shlex.quote(filename))) == 0 ): return _pipepager(generator, "more", color) return _nullpager(stdout, generator, color) @@ -431,7 +429,7 @@ def _tempfilepager(generator, cmd, color): with open_stream(filename, "wb")[0] as f: f.write(text.encode(encoding)) try: - os.system("{} {}".format(shlex_quote(cmd), shlex_quote(filename))) + os.system("{} {}".format(shlex.quote(cmd), shlex.quote(filename))) finally: os.unlink(filename) @@ -476,7 +474,7 @@ def edit_file(self, filename): environ = None try: c = subprocess.Popen( - "{} {}".format(shlex_quote(editor), shlex_quote(filename)), + "{} {}".format(shlex.quote(editor), shlex.quote(filename)), env=environ, shell=True, ) @@ -553,16 +551,16 @@ def _unquote_file(url): elif WIN: if locate: url = _unquote_file(url) - args = "explorer /select,{}".format(shlex_quote(url)) + args = "explorer /select,{}".format(shlex.quote(url)) else: - args = 'start {} "" {}'.format("/WAIT" if wait else "", shlex_quote(url)) + args = 'start {} "" {}'.format("/WAIT" if wait else "", shlex.quote(url)) return os.system(args) elif CYGWIN: if locate: url = _unquote_file(url) - args = "cygstart {}".format(shlex_quote(os.path.dirname(url))) + args = "cygstart {}".format(shlex.quote(os.path.dirname(url))) else: - args = "cygstart {} {}".format("-w" if wait else "", shlex_quote(url)) + args = "cygstart {} {}".format("-w" if wait else "", shlex.quote(url)) return os.system(args) try: diff --git a/src/click/_unicodefun.py b/src/click/_unicodefun.py index 781c36522..57545e0a6 100644 --- a/src/click/_unicodefun.py +++ b/src/click/_unicodefun.py @@ -1,58 +1,9 @@ import codecs import os -import sys - -from ._compat import PY2 - - -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: - 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 - - from . import disable_unicode_literals_warning - - if not PY2 or 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" - " https://click.palletsprojects.com/python3/" - ), - stacklevel=bad_frame, - ) def _verify_python3_env(): """Ensures that the environment is good for unicode on Python 3.""" - if PY2: - return try: import locale diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index cae8dedca..18310ad49 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -9,10 +9,7 @@ # echo and prompt. import ctypes import io -import os -import sys import time -import zlib from ctypes import byref from ctypes import c_char from ctypes import c_char_p @@ -23,7 +20,6 @@ from ctypes import POINTER from ctypes import py_object from ctypes import windll -from ctypes import WinError from ctypes import WINFUNCTYPE from ctypes.wintypes import DWORD from ctypes.wintypes import HANDLE @@ -33,8 +29,6 @@ import msvcrt from ._compat import _NonClosingTextIOWrapper -from ._compat import PY2 -from ._compat import text_type try: from ctypes import pythonapi @@ -97,9 +91,6 @@ class Py_buffer(ctypes.Structure): ("internal", c_void_p), ] - if PY2: - _fields_.insert(-1, ("smalltable", c_ssize_t * 2)) - # On PyPy we cannot get buffers so our ability to operate here is # severely limited. @@ -204,7 +195,7 @@ def name(self): return self.buffer.name def write(self, x): - if isinstance(x, text_type): + if isinstance(x, str): return self._text_stream.write(x) try: self.flush() @@ -253,20 +244,6 @@ def write(self, text): written += to_write -_wrapped_std_streams = set() - - -def _wrap_std_stream(name): - # Python 2 & Windows 7 and below - if ( - PY2 - and sys.getwindowsversion()[:2] <= (6, 1) - and name not in _wrapped_std_streams - ): - setattr(sys, name, WindowsChunkedWriter(getattr(sys, name))) - _wrapped_std_streams.add(name) - - def _get_text_stdin(buffer_stream): text_stream = _NonClosingTextIOWrapper( io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)), @@ -297,37 +274,6 @@ def _get_text_stderr(buffer_stream): return ConsoleStream(text_stream, buffer_stream) -if PY2: - - def _hash_py_argv(): - return zlib.crc32("\x00".join(sys.argv[1:])) - - _initial_argv_hash = _hash_py_argv() - - def _get_windows_argv(): - argc = c_int(0) - argv_unicode = CommandLineToArgvW(GetCommandLineW(), byref(argc)) - if not argv_unicode: - raise WinError() - try: - argv = [argv_unicode[i] for i in range(0, argc.value)] - finally: - LocalFree(argv_unicode) - del argv_unicode - - if not hasattr(sys, "frozen"): - argv = argv[1:] - while len(argv) > 0: - arg = argv[0] - if not arg.startswith("-") or arg == "-": - break - argv = argv[1:] - if arg.startswith(("-c", "-m")): - break - - return argv[1:] - - _stream_factories = { 0: _get_text_stdin, 1: _get_text_stdout, @@ -351,20 +297,15 @@ def _is_console(f): def _get_windows_console_stream(f, encoding, errors): if ( get_buffer is not None - and encoding in ("utf-16-le", None) - and errors in ("strict", None) + and encoding in {"utf-16-le", None} + and errors in {"strict", None} and _is_console(f) ): func = _stream_factories.get(f.fileno()) if func is not None: - if not PY2: - f = getattr(f, "buffer", None) - if f is None: - return None - else: - # If we are on Python 2 we need to set the stream that we - # deal with to binary mode as otherwise the exercise if a - # bit moot. The same problems apply as for - # get_binary_stdin and friends from _compat. - msvcrt.setmode(f.fileno(), os.O_BINARY) + f = getattr(f, "buffer", None) + + if f is None: + return None + return func(f) diff --git a/src/click/core.py b/src/click/core.py index 5e8bf6c00..1f8890295 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -6,11 +6,6 @@ from functools import update_wrapper from itertools import repeat -from ._compat import isidentifier -from ._compat import iteritems -from ._compat import PY2 -from ._compat import string_types -from ._unicodefun import _check_for_unicode_literals from ._unicodefun import _verify_python3_env from .exceptions import Abort from .exceptions import BadParameter @@ -31,7 +26,6 @@ from .types import convert_type from .types import IntRange from .utils import echo -from .utils import get_os_args from .utils import make_default_short_help from .utils import make_str from .utils import PacifyFlushWrapper @@ -736,7 +730,7 @@ def make_context(self, info_name, args, parent=None, **extra): :param extra: extra keyword arguments forwarded to the context constructor. """ - for key, value in iteritems(self.context_settings): + for key, value in self.context_settings.items(): if key not in extra: extra[key] = value ctx = Context(self, info_name=info_name, parent=parent, **extra) @@ -797,16 +791,12 @@ def main( :param extra: extra keyword arguments are forwarded to the context constructor. See :class:`Context` for more information. """ - # If we are in Python 3, we will verify that the environment is - # sane at this point or reject further execution to avoid a - # broken script. - if not PY2: - _verify_python3_env() - else: - _check_for_unicode_literals() + # Verify that the environment is configured correctly, or reject + # further execution to avoid a broken script. + _verify_python3_env() if args is None: - args = get_os_args() + args = sys.argv[1:] else: args = list(args) @@ -1841,7 +1831,7 @@ def _parse_decls(self, decls, expose_value): possible_names = [] for decl in decls: - if isidentifier(decl): + if decl.isidentifier(): if name is not None: raise TypeError("Name defined twice") name = decl @@ -1863,7 +1853,7 @@ def _parse_decls(self, decls, expose_value): if name is None and possible_names: possible_names.sort(key=lambda x: -len(x[0])) # group long options first name = possible_names[0][1].replace("-", "_").lower() - if not isidentifier(name): + if not name.isidentifier(): name = None if name is None: @@ -1942,7 +1932,7 @@ def _write_opts(opts): ) ) if self.default is not None and (self.show_default or ctx.show_default): - if isinstance(self.show_default, string_types): + if isinstance(self.show_default, str): default_string = "({})".format(self.show_default) elif isinstance(self.default, (list, tuple)): default_string = ", ".join(str(d) for d in self.default) diff --git a/src/click/decorators.py b/src/click/decorators.py index c7b5af6cc..e0596c80e 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -2,8 +2,6 @@ import sys from functools import update_wrapper -from ._compat import iteritems -from ._unicodefun import _check_for_unicode_literals from .core import Argument from .core import Command from .core import Group @@ -94,7 +92,6 @@ 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().replace("_", "-"), callback=f, @@ -287,7 +284,7 @@ def callback(ctx, param, value): else: for dist in pkg_resources.working_set: scripts = dist.get_entry_map().get("console_scripts") or {} - for _, entry_point in iteritems(scripts): + for entry_point in scripts.values(): if entry_point.module_name == module: ver = dist.version break diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 592ee38f0..f75f4dbb5 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -1,6 +1,5 @@ from ._compat import filename_to_ui from ._compat import get_text_stderr -from ._compat import PY2 from .utils import echo @@ -13,15 +12,11 @@ def _join_param_hints(param_hint): class ClickException(Exception): """An exception that Click can handle and show to the user.""" - #: The exit code for this exception + #: The exit code for this exception. exit_code = 1 def __init__(self, message): - ctor_msg = message - if PY2: - if ctor_msg is not None: - ctor_msg = ctor_msg.encode("utf-8") - Exception.__init__(self, ctor_msg) + super().__init__(message) self.message = message def format_message(self): @@ -30,12 +25,6 @@ def format_message(self): def __str__(self): return self.message - if PY2: - __unicode__ = __str__ - - def __str__(self): - return self.message.encode("utf-8") - def show(self, file=None): if file is None: file = get_text_stderr() @@ -162,12 +151,6 @@ def __str__(self): else: return self.message - if PY2: - __unicode__ = __str__ - - def __str__(self): - return self.__unicode__().encode("utf-8") - class NoSuchOption(UsageError): """Raised if click attempted to handle an option that does not diff --git a/src/click/termui.py b/src/click/termui.py index 7f29eb666..73f35f54f 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -8,10 +8,7 @@ from ._compat import DEFAULT_COLUMNS from ._compat import get_winterm_size from ._compat import isatty -from ._compat import raw_input -from ._compat import string_types from ._compat import strip_ansi -from ._compat import text_type from ._compat import WIN from .exceptions import Abort from .exceptions import UsageError @@ -24,7 +21,7 @@ # The prompt functions to use. The doc tools currently override these # functions to customize how they work. -visible_prompt_func = raw_input +visible_prompt_func = input _ansi_colors = { "black": 30, @@ -278,13 +275,13 @@ def echo_via_pager(text_or_generator, color=None): if inspect.isgeneratorfunction(text_or_generator): i = text_or_generator() - elif isinstance(text_or_generator, string_types): + elif isinstance(text_or_generator, str): 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) + text_generator = (el if isinstance(el, str) else str(el) for el in i) from ._termui_impl import pager diff --git a/src/click/testing.py b/src/click/testing.py index a3dba3b30..9c3c01214 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -1,4 +1,5 @@ import contextlib +import io import os import shlex import shutil @@ -8,16 +9,7 @@ from . import formatting from . import termui from . import utils -from ._compat import iteritems -from ._compat import PY2 -from ._compat import string_types - - -if PY2: - from cStringIO import StringIO -else: - import io - from ._compat import _find_binary_reader +from ._compat import _find_binary_reader class EchoingStdin(object): @@ -51,19 +43,18 @@ def __repr__(self): def make_input_stream(input, charset): # Is already an input stream. if hasattr(input, "read"): - if PY2: - return input rv = _find_binary_reader(input) + if rv is not None: return rv + raise TypeError("Could not find binary reader for input stream.") if input is None: input = b"" elif not isinstance(input, bytes): input = input.encode(charset) - if PY2: - return StringIO(input) + return io.BytesIO(input) @@ -184,23 +175,17 @@ def isolation(self, input=None, env=None, color=False): env = self.make_env(env) - if PY2: - 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 = 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) + bytes_output = io.BytesIO() + + if self.echo_stdin: + input = EchoingStdin(input, bytes_output) + + input = io.TextIOWrapper(input, encoding=self.charset) + 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 @@ -244,7 +229,7 @@ def should_strip_ansi(stream=None, color=None): old_env = {} try: - for key, value in iteritems(env): + for key, value in env.items(): old_env[key] = os.environ.get(key) if value is None: try: @@ -255,7 +240,7 @@ def should_strip_ansi(stream=None, color=None): os.environ[key] = value yield (bytes_output, not self.mix_stderr and bytes_error) finally: - for key, value in iteritems(old_env): + for key, value in old_env.items(): if value is None: try: del os.environ[key] @@ -317,7 +302,7 @@ def invoke( exception = None exit_code = 0 - if isinstance(args, string_types): + if isinstance(args, str): args = shlex.split(args) try: diff --git a/src/click/types.py b/src/click/types.py index 505c39f85..5647df508 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -5,10 +5,8 @@ from ._compat import _get_argv_encoding from ._compat import filename_to_ui from ._compat import get_filesystem_encoding -from ._compat import get_streerror +from ._compat import get_strerror from ._compat import open_stream -from ._compat import PY2 -from ._compat import text_type from .exceptions import BadParameter from .utils import LazyFile from .utils import safecall @@ -94,9 +92,10 @@ def convert(self, value, param, ctx): return self.func(value) except ValueError: try: - value = text_type(value) + value = str(value) except UnicodeError: - value = str(value).decode("utf-8", "replace") + value = value.decode("utf-8", "replace") + self.fail(value, param, ctx) @@ -179,14 +178,9 @@ def convert(self, value, param, ctx): } if not self.case_sensitive: - if PY2: - lower = str.lower - else: - lower = str.casefold - - normed_value = lower(normed_value) + normed_value = normed_value.casefold() normed_choices = { - lower(normed_choice): original + normed_choice.casefold(): original for normed_choice, original in normed_choices.items() } @@ -427,8 +421,6 @@ def convert(self, value, param, ctx): import uuid try: - if PY2 and isinstance(value, text_type): - value = value.encode("ascii") return uuid.UUID(value) except ValueError: self.fail("{} is not a valid UUID value".format(value), param, ctx) @@ -517,7 +509,7 @@ def convert(self, value, param, ctx): except (IOError, OSError) as e: # noqa: B014 self.fail( "Could not open file: {}: {}".format( - filename_to_ui(value), get_streerror(e) + filename_to_ui(value), get_strerror(e) ), param, ctx, @@ -589,7 +581,7 @@ def __init__( def coerce_path_result(self, rv): if self.type is not None and not isinstance(rv, self.type): - if self.type is text_type: + if self.type is str: rv = rv.decode(get_filesystem_encoding()) else: rv = rv.encode(get_filesystem_encoding()) @@ -701,7 +693,7 @@ def convert_type(ty, default=None): return Tuple(ty) if isinstance(ty, ParamType): return ty - if ty is text_type or ty is str or ty is None: + if ty is str or ty is None: return STRING if ty is int: return INT diff --git a/src/click/utils.py b/src/click/utils.py index 79265e732..f5aac4911 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -3,30 +3,22 @@ from ._compat import _default_text_stderr from ._compat import _default_text_stdout +from ._compat import _find_binary_writer from ._compat import auto_wrap_for_ansi from ._compat import binary_streams from ._compat import filename_to_ui from ._compat import get_filesystem_encoding -from ._compat import get_streerror +from ._compat import get_strerror from ._compat import is_bytes from ._compat import open_stream -from ._compat import PY2 from ._compat import should_strip_ansi -from ._compat import string_types from ._compat import strip_ansi from ._compat import text_streams -from ._compat import text_type from ._compat import WIN from .globals import resolve_color_default -if not PY2: - from ._compat import _find_binary_writer -elif WIN: - from ._winconsole import _get_windows_argv - from ._winconsole import _hash_py_argv - from ._winconsole import _initial_argv_hash -echo_native_types = string_types + (bytes, bytearray) +echo_native_types = (str, bytes, bytearray) def _posixify(name): @@ -52,7 +44,7 @@ def make_str(value): return value.decode(get_filesystem_encoding()) except UnicodeError: return value.decode("utf-8", "replace") - return text_type(value) + return str(value) def make_default_short_help(help, max_length=45): @@ -129,7 +121,7 @@ def open(self): except (IOError, OSError) as e: # noqa: E402 from .exceptions import FileError - raise FileError(self.name, hint=get_streerror(e)) + raise FileError(self.name, hint=get_strerror(e)) self._f = rv return rv @@ -231,11 +223,11 @@ def echo(message=None, file=None, nl=True, err=False, color=None): # Convert non bytes/text into the native string type. if message is not None and not isinstance(message, echo_native_types): - message = text_type(message) + message = str(message) if nl: message = message or u"" - if isinstance(message, text_type): + if isinstance(message, str): message += u"\n" else: message += b"\n" @@ -245,7 +237,7 @@ def echo(message=None, file=None, nl=True, err=False, color=None): # message in there. This is done separately so that most stream # types will work as you would expect. Eg: you can write to StringIO # for other cases. - if message and not PY2 and is_bytes(message): + if message and is_bytes(message): binary_file = _find_binary_writer(file) if binary_file is not None: file.flush() @@ -340,23 +332,21 @@ def open_file( def get_os_args(): - """This returns the argument part of sys.argv in the most appropriate - form for processing. What this means is that this return value is in - a format that works for Click to process but does not necessarily - correspond well to what's actually standard for the interpreter. - - On most environments the return value is ``sys.argv[:1]`` unchanged. - However if you are on Windows and running Python 2 the return value - will actually be a list of unicode strings instead because the - default behavior on that platform otherwise will not be able to - carry all possible values that sys.argv can have. - - .. versionadded:: 6.0 + """Returns the argument part of ``sys.argv``, removing the first + value which is the name of the script. + + .. deprecated:: 8.0 + Will be removed in 8.1. Access ``sys.argv[1:]`` directly + instead. """ - # We can only extract the unicode argv if sys.argv has not been - # changed since the startup of the application. - if PY2 and WIN and _initial_argv_hash == _hash_py_argv(): - return _get_windows_argv() + import warnings + + warnings.warn( + "'get_os_args' is deprecated and will be removed in 8.1. Access" + " 'sys.argv[1:]' directly instead.", + DeprecationWarning, + stacklevel=2, + ) return sys.argv[1:] diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 0b510c00b..bb580fc7a 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -4,8 +4,6 @@ import pytest import click -from click._compat import PY2 -from click._compat import text_type def test_nargs_star(runner): @@ -83,18 +81,13 @@ def test_bytes_args(runner, monkeypatch): @click.argument("arg") def from_bytes(arg): assert isinstance( - arg, text_type + arg, str ), "UTF-8 encoded argument should be implicitly converted to Unicode" # Simulate empty locale environment variables - if PY2: - monkeypatch.setattr(sys.stdin, "encoding", "ANSI_X3.4-1968") - monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "ANSI_X3.4-1968") - monkeypatch.setattr(sys, "getdefaultencoding", lambda: "ascii") - else: - monkeypatch.setattr(sys.stdin, "encoding", "utf-8") - monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "utf-8") - monkeypatch.setattr(sys, "getdefaultencoding", lambda: "utf-8") + monkeypatch.setattr(sys.stdin, "encoding", "utf-8") + monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "utf-8") + monkeypatch.setattr(sys, "getdefaultencoding", lambda: "utf-8") runner.invoke( from_bytes, @@ -268,7 +261,7 @@ def cmd(a, b, c): click.echo(arg) result = runner.invoke(cmd, ["a", "b", "c"]) - assert result.output.splitlines() == ["(u'a',)" if PY2 else "('a',)", "b", "c"] + assert result.output.splitlines() == ["('a',)", "b", "c"] def test_nargs_specified_plus_star_ordering(runner): @@ -281,11 +274,7 @@ def cmd(a, b, c): click.echo(arg) result = runner.invoke(cmd, ["a", "b", "c", "d", "e", "f"]) - assert result.output.splitlines() == [ - "(u'a', u'b', u'c')" if PY2 else "('a', 'b', 'c')", - "d", - "(u'e', u'f')" if PY2 else "('e', 'f')", - ] + assert result.output.splitlines() == ["('a', 'b', 'c')", "d", "('e', 'f')"] def test_defaults_for_nargs(runner): diff --git a/tests/test_options.py b/tests/test_options.py index 4baa37451..3eea5e60d 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -5,7 +5,6 @@ import pytest import click -from click._compat import text_type def test_prefixes(runner): @@ -170,11 +169,11 @@ def test_multiple_default_type(runner): @click.option("--arg1", multiple=True, default=("foo", "bar")) @click.option("--arg2", multiple=True, default=(1, "a")) def cmd(arg1, arg2): - assert all(isinstance(e[0], text_type) for e in arg1) - assert all(isinstance(e[1], text_type) for e in arg1) + assert all(isinstance(e[0], str) for e in arg1) + assert all(isinstance(e[1], str) for e in arg1) assert all(isinstance(e[0], int) for e in arg2) - assert all(isinstance(e[1], text_type) for e in arg2) + assert all(isinstance(e[1], str) for e in arg2) result = runner.invoke( cmd, "--arg1 a b --arg1 test 1 --arg2 2 two --arg2 4 four".split() diff --git a/tests/test_testing.py b/tests/test_testing.py index 22a285d90..99701b5b0 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,19 +1,13 @@ import os import sys +from io import BytesIO import pytest import click -from click._compat import PY2 from click._compat import WIN from click.testing import CliRunner -# Use the most reasonable io that users would use for the python version. -if PY2: - from cStringIO import StringIO as ReasonableBytesIO -else: - from io import BytesIO as ReasonableBytesIO - def test_runner(): @click.command() @@ -51,12 +45,12 @@ def test(): o.flush() runner = CliRunner() - result = runner.invoke(test, input=ReasonableBytesIO(b"Hello World!\n")) + result = runner.invoke(test, input=BytesIO(b"Hello World!\n")) assert not result.exception assert result.output == "Hello World!\n" runner = CliRunner(echo_stdin=True) - result = runner.invoke(test, input=ReasonableBytesIO(b"Hello World!\n")) + result = runner.invoke(test, input=BytesIO(b"Hello World!\n")) assert not result.exception assert result.output == "Hello World!\nHello World!\n" From 62f84aa3242d6d9f57243b941b013b2536ea3b9c Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 19 Apr 2020 22:16:48 -0700 Subject: [PATCH 053/293] remove more compat code --- examples/aliases/aliases.py | 6 +----- examples/complex/complex/cli.py | 2 -- examples/validation/validation.py | 7 ++----- src/click/_bashcomplete.py | 6 +----- src/click/_termui_impl.py | 3 --- src/click/_winconsole.py | 6 +++--- src/click/termui.py | 12 ++++-------- src/click/testing.py | 8 ++------ src/click/types.py | 9 ++++----- src/click/utils.py | 9 ++++----- tests/test_imports.py | 9 ++------- tests/test_utils.py | 19 ++----------------- 12 files changed, 25 insertions(+), 71 deletions(-) diff --git a/examples/aliases/aliases.py b/examples/aliases/aliases.py index e7f850d3f..bf0950089 100644 --- a/examples/aliases/aliases.py +++ b/examples/aliases/aliases.py @@ -1,12 +1,8 @@ +import configparser import os import click -try: - import configparser -except ImportError: - import ConfigParser as configparser - class Config(object): """The config in this example only holds aliases.""" diff --git a/examples/complex/complex/cli.py b/examples/complex/complex/cli.py index c539fe8fe..bb436138a 100644 --- a/examples/complex/complex/cli.py +++ b/examples/complex/complex/cli.py @@ -39,8 +39,6 @@ def list_commands(self, ctx): def get_command(self, ctx, name): try: - if sys.version_info[0] == 2: - name = name.encode("ascii", "replace") mod = __import__( "complex.commands.cmd_{}".format(name), None, None, ["cli"] ) diff --git a/examples/validation/validation.py b/examples/validation/validation.py index c4f7352c2..3ca57458d 100644 --- a/examples/validation/validation.py +++ b/examples/validation/validation.py @@ -1,9 +1,6 @@ -import click +from urllib import parse as urlparse -try: - from urllib import parse as urlparse -except ImportError: - import urlparse +import click def validate_count(ctx, param, value): diff --git a/src/click/_bashcomplete.py b/src/click/_bashcomplete.py index cbabd8db7..9e963f78a 100644 --- a/src/click/_bashcomplete.py +++ b/src/click/_bashcomplete.py @@ -1,6 +1,7 @@ import copy import os import re +from collections import abc from .core import Argument from .core import MultiCommand @@ -9,11 +10,6 @@ from .types import Choice from .utils import echo -try: - from collections import abc -except ImportError: - import collections as abc - WORDBREAK = "=" # Note, only BASH version 4.4 and later have the nosort option. diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 1a6adf19c..2ea695e61 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -125,9 +125,6 @@ def __next__(self): # twice works and does "what you want". return next(iter(self)) - # Python 2 compat - next = __next__ - def is_fast(self): return time.time() - self.start <= self.short_limit diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index 18310ad49..5a3cdaab0 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -32,11 +32,11 @@ try: from ctypes import pythonapi - - PyObject_GetBuffer = pythonapi.PyObject_GetBuffer - PyBuffer_Release = pythonapi.PyBuffer_Release except ImportError: pythonapi = None +else: + PyObject_GetBuffer = pythonapi.PyObject_GetBuffer + PyBuffer_Release = pythonapi.PyBuffer_Release c_ssize_p = POINTER(c_ssize_t) diff --git a/src/click/termui.py b/src/click/termui.py index 73f35f54f..dd51cdf6c 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -216,14 +216,10 @@ def get_terminal_size(): """Returns the current size of the terminal as tuple in the form ``(width, height)`` in columns and rows. """ - # If shutil has get_terminal_size() (Python 3.3 and later) use that - if sys.version_info >= (3, 3): - import shutil - - shutil_get_terminal_size = getattr(shutil, "get_terminal_size", None) - if shutil_get_terminal_size: - sz = shutil_get_terminal_size() - return sz.columns, sz.lines + import shutil + + if hasattr(shutil, "get_terminal_size"): + return shutil.get_terminal_size() # We provide a sensible default for get_winterm_size() when being invoked # inside a subprocess. Without this, it would not provide a useful input. diff --git a/src/click/testing.py b/src/click/testing.py index 9c3c01214..eef339949 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -110,9 +110,7 @@ class CliRunner(object): works in single-threaded systems without any concurrency as it changes the global interpreter state. - :param charset: the character set for the input and output data. This is - UTF-8 by default and should not be changed currently as - the reporting to Click only works in Python 2 properly. + :param charset: the character set for the input and output data. :param env: a dictionary with environment variables for overriding. :param echo_stdin: if this is set to `True`, then reading from stdin writes to stdout. This is useful for showing examples in @@ -125,9 +123,7 @@ class CliRunner(object): independently """ - def __init__(self, charset=None, env=None, echo_stdin=False, mix_stderr=True): - if charset is None: - charset = "utf-8" + def __init__(self, charset="utf-8", env=None, echo_stdin=False, mix_stderr=True): self.charset = charset self.env = env or {} self.echo_stdin = echo_stdin diff --git a/src/click/types.py b/src/click/types.py index 5647df508..d794235f6 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -721,11 +721,10 @@ def convert_type(ty, default=None): #: A dummy parameter type that just does nothing. From a user's -#: perspective this appears to just be the same as `STRING` but internally -#: no string conversion takes place. This is necessary to achieve the -#: same bytes/unicode behavior on Python 2/3 in situations where you want -#: to not convert argument types. This is usually useful when working -#: with file paths as they can appear in bytes and unicode. +#: perspective this appears to just be the same as `STRING` but +#: internally no string conversion takes place if the input was bytes. +#: This is usually useful when working with file paths as they can +#: appear in bytes and unicode. #: #: For path related uses the :class:`Path` type is a better choice but #: there are situations where an unprocessed type is useful which is why diff --git a/src/click/utils.py b/src/click/utils.py index f5aac4911..0f634bbf6 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -232,11 +232,10 @@ def echo(message=None, file=None, nl=True, err=False, color=None): 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 - # types will work as you would expect. Eg: you can write to StringIO - # for other cases. + # If there is a message 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 types will work as you + # would expect. Eg: you can write to StringIO for other cases. if message and is_bytes(message): binary_file = _find_binary_writer(file) if binary_file is not None: diff --git a/tests/test_imports.py b/tests/test_imports.py index b99d453d0..be8730f64 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -6,10 +6,7 @@ IMPORT_TEST = b"""\ -try: - import __builtin__ as builtins -except ImportError: - import builtins +import builtins found_imports = set() real_import = builtins.__import__ @@ -61,9 +58,7 @@ def test_light_imports(): [sys.executable, "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE ) rv = c.communicate(IMPORT_TEST)[0] - - if sys.version_info[0] != 2: - rv = rv.decode("utf-8") + rv = rv.decode("utf-8") imported = json.loads(rv) for module in imported: diff --git a/tests/test_utils.py b/tests/test_utils.py index d86f59b81..2ca1a8a95 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ import os import stat import sys +from io import StringIO import pytest @@ -19,19 +20,7 @@ def test_echo(runner): 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 - # does not do anything crazy. In Python 3 - if sys.version_info[0] == 2: - import StringIO - - sys.stdout = x = StringIO.StringIO() - try: - click.echo("\xf6") - finally: - sys.stdout = sys.__stdout__ - assert x.getvalue() == "\xf6\n" - - # And in any case, if wrapped, we expect bytes to survive. + # if wrapped, we expect bytes to survive. @click.command() def cli(): click.echo(b"\xf6") @@ -224,10 +213,6 @@ def test_echo_color_flag(monkeypatch, capfd): def test_echo_writing_to_standard_error(capfd, monkeypatch): def emulate_input(text): """Emulate keyboard input.""" - if sys.version_info[0] == 2: - from StringIO import StringIO - else: - from io import StringIO monkeypatch.setattr(sys, "stdin", StringIO(text)) click.echo("Echo to standard output") From b444e6355ec50417546f42138bb833d1e9e8b306 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 19 Apr 2020 22:22:49 -0700 Subject: [PATCH 054/293] apply pyupgrade --py36-plus --- .pre-commit-config.yaml | 1 + examples/aliases/aliases.py | 6 ++-- examples/bashcompletion/bashcompletion.py | 4 +-- examples/colors/colors.py | 10 ++---- examples/complex/complex/cli.py | 6 ++-- examples/imagepipe/imagepipe.py | 43 ++++++++++------------ examples/naval/naval.py | 10 +++--- examples/repo/repo.py | 18 +++++----- examples/termui/termui.py | 15 ++++---- examples/validation/validation.py | 6 ++-- src/click/_bashcomplete.py | 2 +- src/click/_compat.py | 8 ++--- src/click/_termui_impl.py | 30 ++++++++-------- src/click/_winconsole.py | 9 +++-- src/click/core.py | 44 +++++++++++------------ src/click/exceptions.py | 20 +++++------ src/click/formatting.py | 2 +- src/click/parser.py | 21 ++++++----- src/click/termui.py | 6 ++-- src/click/testing.py | 12 +++---- src/click/types.py | 20 +++++------ src/click/utils.py | 20 +++++------ tests/test_arguments.py | 9 +++-- tests/test_bashcomplete.py | 1 - tests/test_basic.py | 17 +++++---- tests/test_chain.py | 10 +++--- tests/test_commands.py | 7 ++-- tests/test_compat.py | 2 +- tests/test_context.py | 13 ++++--- tests/test_defaults.py | 2 +- tests/test_formatting.py | 9 +++-- tests/test_options.py | 11 +++--- tests/test_termui.py | 17 +++++---- tests/test_testing.py | 4 +-- tests/test_utils.py | 22 ++++++------ 35 files changed, 204 insertions(+), 233 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1931abf18..3bd32949e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,7 @@ repos: rev: v2.2.0 hooks: - id: pyupgrade + args: ["--py36-plus"] - repo: https://github.com/asottile/reorder_python_imports rev: v2.2.0 hooks: diff --git a/examples/aliases/aliases.py b/examples/aliases/aliases.py index bf0950089..c01672bda 100644 --- a/examples/aliases/aliases.py +++ b/examples/aliases/aliases.py @@ -4,7 +4,7 @@ import click -class Config(object): +class Config: """The config in this example only holds aliases.""" def __init__(self): @@ -121,7 +121,7 @@ def commit(): @pass_config def status(config): """Shows the status.""" - click.echo("Status for {}".format(config.path)) + click.echo(f"Status for {config.path}") @cli.command() @@ -135,4 +135,4 @@ def alias(config, alias_, cmd, config_file): """Adds an alias to the specified configuration file.""" config.add_alias(alias_, cmd) config.write_config(config_file) - click.echo("Added '{}' as alias for '{}'".format(alias_, cmd)) + click.echo(f"Added '{alias_}' as alias for '{cmd}'") diff --git a/examples/bashcompletion/bashcompletion.py b/examples/bashcompletion/bashcompletion.py index 0502dbce1..592bf19c8 100644 --- a/examples/bashcompletion/bashcompletion.py +++ b/examples/bashcompletion/bashcompletion.py @@ -18,7 +18,7 @@ def get_env_vars(ctx, args, incomplete): @cli.command(help="A command to print environment variables") @click.argument("envvar", type=click.STRING, autocompletion=get_env_vars) def cmd1(envvar): - click.echo("Environment variable: {}".format(envvar)) + click.echo(f"Environment variable: {envvar}") click.echo("Value: {}".format(os.environ[envvar])) @@ -39,7 +39,7 @@ def list_users(ctx, args, incomplete): @group.command(help="Choose a user") @click.argument("user", type=click.STRING, autocompletion=list_users) def subcmd(user): - click.echo("Chosen user is {}".format(user)) + click.echo(f"Chosen user is {user}") cli.add_command(group) diff --git a/examples/colors/colors.py b/examples/colors/colors.py index 012538d5f..cb7af3170 100644 --- a/examples/colors/colors.py +++ b/examples/colors/colors.py @@ -30,15 +30,11 @@ def cli(): Give it a try! """ for color in all_colors: - click.echo(click.style("I am colored {}".format(color), fg=color)) + click.echo(click.style(f"I am colored {color}", fg=color)) for color in all_colors: - click.echo( - click.style("I am colored {} and bold".format(color), fg=color, bold=True) - ) + click.echo(click.style(f"I am colored {color} and bold", fg=color, bold=True)) for color in all_colors: - click.echo( - click.style("I am reverse colored {}".format(color), fg=color, reverse=True) - ) + click.echo(click.style(f"I am reverse colored {color}", fg=color, reverse=True)) click.echo(click.style("I am blinking", blink=True)) click.echo(click.style("I am underlined", underline=True)) diff --git a/examples/complex/complex/cli.py b/examples/complex/complex/cli.py index bb436138a..5d00dba50 100644 --- a/examples/complex/complex/cli.py +++ b/examples/complex/complex/cli.py @@ -7,7 +7,7 @@ CONTEXT_SETTINGS = dict(auto_envvar_prefix="COMPLEX") -class Environment(object): +class Environment: def __init__(self): self.verbose = False self.home = os.getcwd() @@ -39,9 +39,7 @@ def list_commands(self, ctx): def get_command(self, ctx, name): try: - mod = __import__( - "complex.commands.cmd_{}".format(name), None, None, ["cli"] - ) + mod = __import__(f"complex.commands.cmd_{name}", None, None, ["cli"]) except ImportError: return return mod.cli diff --git a/examples/imagepipe/imagepipe.py b/examples/imagepipe/imagepipe.py index d46c33f59..95f5c4245 100644 --- a/examples/imagepipe/imagepipe.py +++ b/examples/imagepipe/imagepipe.py @@ -60,10 +60,8 @@ def generator(f): @processor def new_func(stream, *args, **kwargs): - for item in stream: - yield item - for item in f(*args, **kwargs): - yield item + yield from stream + yield from f(*args, **kwargs) return update_wrapper(new_func, f) @@ -89,7 +87,7 @@ def open_cmd(images): """ for image in images: try: - click.echo("Opening '{}'".format(image)) + click.echo(f"Opening '{image}'") if image == "-": img = Image.open(click.get_binary_stdin()) img.filename = "-" @@ -97,7 +95,7 @@ def open_cmd(images): img = Image.open(image) yield img except Exception as e: - click.echo("Could not open image '{}': {}".format(image, e), err=True) + click.echo(f"Could not open image '{image}': {e}", err=True) @cli.command("save") @@ -114,12 +112,10 @@ def save_cmd(images, filename): for idx, image in enumerate(images): try: fn = filename.format(idx + 1) - click.echo("Saving '{}' as '{}'".format(image.filename, fn)) + click.echo(f"Saving '{image.filename}' as '{fn}'") yield image.save(fn) except Exception as e: - click.echo( - "Could not save image '{}': {}".format(image.filename, e), err=True - ) + click.echo(f"Could not save image '{image.filename}': {e}", err=True) @cli.command("display") @@ -127,7 +123,7 @@ def save_cmd(images, filename): def display_cmd(images): """Opens all images in an image viewer.""" for image in images: - click.echo("Displaying '{}'".format(image.filename)) + click.echo(f"Displaying '{image.filename}'") image.show() yield image @@ -142,7 +138,7 @@ def resize_cmd(images, width, height): """ for image in images: w, h = (width or image.size[0], height or image.size[1]) - click.echo("Resizing '{}' to {}x{}".format(image.filename, w, h)) + click.echo(f"Resizing '{image.filename}' to {w}x{h}") image.thumbnail((w, h)) yield image @@ -160,7 +156,7 @@ def crop_cmd(images, border): if border is not None: for idx, val in enumerate(box): box[idx] = max(0, val - border) - click.echo("Cropping '{}' by {}px".format(image.filename, border)) + click.echo(f"Cropping '{image.filename}' by {border}px") yield copy_filename(image.crop(box), image) else: yield image @@ -176,7 +172,7 @@ def convert_rotation(ctx, param, value): return (Image.ROTATE_180, 180) if value in ("-90", "270", "l", "left"): return (Image.ROTATE_270, 270) - raise click.BadParameter("invalid rotation '{}'".format(value)) + raise click.BadParameter(f"invalid rotation '{value}'") def convert_flip(ctx, param, value): @@ -187,7 +183,7 @@ def convert_flip(ctx, param, value): return (Image.FLIP_LEFT_RIGHT, "left to right") if value in ("tb", "topbottom", "upsidedown", "ud"): return (Image.FLIP_LEFT_RIGHT, "top to bottom") - raise click.BadParameter("invalid flip '{}'".format(value)) + raise click.BadParameter(f"invalid flip '{value}'") @cli.command("transpose") @@ -201,11 +197,11 @@ def transpose_cmd(images, rotate, flip): for image in images: if rotate is not None: mode, degrees = rotate - click.echo("Rotate '{}' by {}deg".format(image.filename, degrees)) + click.echo(f"Rotate '{image.filename}' by {degrees}deg") image = copy_filename(image.transpose(mode), image) if flip is not None: mode, direction = flip - click.echo("Flip '{}' {}".format(image.filename, direction)) + click.echo(f"Flip '{image.filename}' {direction}") image = copy_filename(image.transpose(mode), image) yield image @@ -217,7 +213,7 @@ def blur_cmd(images, radius): """Applies gaussian blur.""" blur = ImageFilter.GaussianBlur(radius) for image in images: - click.echo("Blurring '{}' by {}px".format(image.filename, radius)) + click.echo(f"Blurring '{image.filename}' by {radius}px") yield copy_filename(image.filter(blur), image) @@ -248,7 +244,7 @@ def smoothen_cmd(images, iterations): def emboss_cmd(images): """Embosses an image.""" for image in images: - click.echo("Embossing '{}'".format(image.filename)) + click.echo(f"Embossing '{image.filename}'") yield copy_filename(image.filter(ImageFilter.EMBOSS), image) @@ -260,7 +256,7 @@ def emboss_cmd(images): def sharpen_cmd(images, factor): """Sharpens an image.""" for image in images: - click.echo("Sharpen '{}' by {}".format(image.filename, factor)) + click.echo(f"Sharpen '{image.filename}' by {factor}") enhancer = ImageEnhance.Sharpness(image) yield copy_filename(enhancer.enhance(max(1.0, factor)), image) @@ -282,13 +278,12 @@ def paste_cmd(images, left, right): yield image return - click.echo("Paste '{}' on '{}'".format(to_paste.filename, image.filename)) + click.echo(f"Paste '{to_paste.filename}' on '{image.filename}'") mask = None if to_paste.mode == "RGBA" or "transparency" in to_paste.info: mask = to_paste image.paste(to_paste, (left, right), mask) - image.filename += "+{}".format(to_paste.filename) + image.filename += f"+{to_paste.filename}" yield image - for image in imageiter: - yield image + yield from imageiter diff --git a/examples/naval/naval.py b/examples/naval/naval.py index b8d31e17a..7310e6d93 100644 --- a/examples/naval/naval.py +++ b/examples/naval/naval.py @@ -21,7 +21,7 @@ def ship(): @click.argument("name") def ship_new(name): """Creates a new ship.""" - click.echo("Created ship {}".format(name)) + click.echo(f"Created ship {name}") @ship.command("move") @@ -31,7 +31,7 @@ def ship_new(name): @click.option("--speed", metavar="KN", default=10, help="Speed in knots.") def ship_move(ship, x, y, speed): """Moves SHIP to the new location X,Y.""" - click.echo("Moving ship {} to {},{} with speed {}".format(ship, x, y, speed)) + click.echo(f"Moving ship {ship} to {x},{y} with speed {speed}") @ship.command("shoot") @@ -40,7 +40,7 @@ def ship_move(ship, x, y, speed): @click.argument("y", type=float) def ship_shoot(ship, x, y): """Makes SHIP fire to X,Y.""" - click.echo("Ship {} fires to {},{}".format(ship, x, y)) + click.echo(f"Ship {ship} fires to {x},{y}") @cli.group("mine") @@ -61,7 +61,7 @@ def mine(): @click.option("ty", "--drifting", flag_value="drifting", help="Drifting mine.") def mine_set(x, y, ty): """Sets a mine at a specific coordinate.""" - click.echo("Set {} mine at {},{}".format(ty, x, y)) + click.echo(f"Set {ty} mine at {x},{y}") @mine.command("remove") @@ -69,4 +69,4 @@ def mine_set(x, y, ty): @click.argument("y", type=float) def mine_remove(x, y): """Removes a mine at a specific coordinate.""" - click.echo("Removed mine at {},{}".format(x, y)) + click.echo(f"Removed mine at {x},{y}") diff --git a/examples/repo/repo.py b/examples/repo/repo.py index 5fd6ead88..ff44d554d 100644 --- a/examples/repo/repo.py +++ b/examples/repo/repo.py @@ -5,7 +5,7 @@ import click -class Repo(object): +class Repo: def __init__(self, home): self.home = home self.config = {} @@ -14,10 +14,10 @@ def __init__(self, home): def set_config(self, key, value): self.config[key] = value if self.verbose: - click.echo(" config[{}] = {}".format(key, value), file=sys.stderr) + click.echo(f" config[{key}] = {value}", file=sys.stderr) def __repr__(self): - return "".format(self.home) + return f"" pass_repo = click.make_pass_decorator(Repo) @@ -82,7 +82,7 @@ def clone(repo, src, dest, shallow, rev): repo.home = dest if shallow: click.echo("Making shallow checkout") - click.echo("Checking out revision {}".format(rev)) + click.echo(f"Checking out revision {rev}") @cli.command() @@ -93,7 +93,7 @@ def delete(repo): This will throw away the current repository. """ - click.echo("Destroying repo {}".format(repo.home)) + click.echo(f"Destroying repo {repo.home}") click.echo("Deleted!") @@ -136,7 +136,7 @@ def commit(repo, files, message): marker = "# Files to be committed:" hint = ["", "", marker, "#"] for file in files: - hint.append("# U {}".format(file)) + hint.append(f"# U {file}") message = click.edit("\n".join(hint)) if message is None: click.echo("Aborted!") @@ -147,8 +147,8 @@ def commit(repo, files, message): return else: msg = "\n".join(message) - click.echo("Files to be committed: {}".format(files)) - click.echo("Commit message:\n{}".format(msg)) + click.echo(f"Files to be committed: {files}") + click.echo(f"Commit message:\n{msg}") @cli.command(short_help="Copies files.") @@ -163,4 +163,4 @@ def copy(repo, src, dst, force): files from SRC to DST. """ for fn in src: - click.echo("Copy from {} -> {}".format(fn, dst)) + click.echo(f"Copy from {fn} -> {dst}") diff --git a/examples/termui/termui.py b/examples/termui/termui.py index 7b3da4337..b772f1374 100644 --- a/examples/termui/termui.py +++ b/examples/termui/termui.py @@ -1,4 +1,3 @@ -# coding: utf-8 import math import random import time @@ -16,8 +15,8 @@ def cli(): def colordemo(): """Demonstrates ANSI color support.""" for color in "red", "green", "blue": - click.echo(click.style("I am colored {}".format(color), fg=color)) - click.echo(click.style("I am background colored {}".format(color), bg=color)) + click.echo(click.style(f"I am colored {color}", fg=color)) + click.echo(click.style(f"I am background colored {color}", bg=color)) @cli.command() @@ -56,7 +55,7 @@ def filter(items): def show_item(item): if item is not None: - return "Item #{}".format(item) + return f"Item #{item}" with click.progressbar( filter(items), @@ -71,7 +70,7 @@ def show_item(item): length=count, label="Counting", bar_template="%(label)s %(bar)s | %(info)s", - fill_char=click.style(u"█", fg="cyan"), + fill_char=click.style("█", fg="cyan"), empty_char=" ", ) as bar: for item in bar: @@ -94,7 +93,7 @@ def show_item(item): length=count, show_percent=False, label="Slowing progress bar", - fill_char=click.style(u"█", fg="green"), + fill_char=click.style("█", fg="green"), ) as bar: for item in steps: time.sleep(item) @@ -119,13 +118,13 @@ def locate(url): def edit(): """Opens an editor with some text in it.""" MARKER = "# Everything below is ignored\n" - message = click.edit("\n\n{}".format(MARKER)) + message = click.edit(f"\n\n{MARKER}") if message is not None: msg = message.split(MARKER, 1)[0].rstrip("\n") if not msg: click.echo("Empty message!") else: - click.echo("Message:\n{}".format(msg)) + click.echo(f"Message:\n{msg}") else: click.echo("You did not enter anything!") diff --git a/examples/validation/validation.py b/examples/validation/validation.py index 3ca57458d..6f87eb007 100644 --- a/examples/validation/validation.py +++ b/examples/validation/validation.py @@ -44,6 +44,6 @@ def cli(count, foo, url): 'If a value is provided it needs to be the value "wat".', param_hint=["--foo"], ) - click.echo("count: {}".format(count)) - click.echo("foo: {}".format(foo)) - click.echo("url: {!r}".format(url)) + click.echo(f"count: {count}") + click.echo(f"foo: {foo}") + click.echo(f"url: {url!r}") diff --git a/src/click/_bashcomplete.py b/src/click/_bashcomplete.py index 9e963f78a..1c865f624 100644 --- a/src/click/_bashcomplete.py +++ b/src/click/_bashcomplete.py @@ -94,7 +94,7 @@ def get_completion_script(prog_name, complete_var, shell): return ( script % { - "complete_func": "_{}_completion".format(cf_name), + "complete_func": f"_{cf_name}_completion", "script_names": prog_name, "autocomplete_var": complete_var, } diff --git a/src/click/_compat.py b/src/click/_compat.py index 63bebef83..96b0dd8d3 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -80,7 +80,7 @@ def isatty(self): return self._stream.isatty() -class _FixupStream(object): +class _FixupStream: """The new io interface needs more from streams than streams traditionally implement. As such, this fix-up code is necessary in some circumstances. @@ -359,9 +359,9 @@ def get_strerror(e, default=None): def _wrap_io_open(file, mode, encoding, errors): """Handles not passing ``encoding`` and ``errors`` in binary mode.""" if "b" in mode: - return io.open(file, mode) + return open(file, mode) - return io.open(file, mode, encoding=encoding, errors=errors) + return open(file, mode, encoding=encoding, errors=errors) def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False): @@ -437,7 +437,7 @@ def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False return _AtomicFile(f, tmp_filename, os.path.realpath(filename)), True -class _AtomicFile(object): +class _AtomicFile: def __init__(self, f, tmp_filename, real_filename): self._f = f self._tmp_filename = tmp_filename diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 2ea695e61..84e3fc5ba 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This module contains implementations for the termui module. To keep the import time of Click down, some infrequently used functionality is @@ -48,7 +47,7 @@ def _length_hint(obj): return hint -class ProgressBar(object): +class ProgressBar: def __init__( self, iterable, @@ -162,15 +161,15 @@ def format_eta(self): hours = t % 24 t //= 24 if t > 0: - return "{}d {:02}:{:02}:{:02}".format(t, hours, minutes, seconds) + return f"{t}d {hours:02}:{minutes:02}:{seconds:02}" else: - return "{:02}:{:02}:{:02}".format(hours, minutes, seconds) + return f"{hours:02}:{minutes:02}:{seconds:02}" return "" def format_pos(self): pos = str(self.pos) if self.length_known: - pos += "/{}".format(self.length) + pos += f"/{self.length}" return pos def format_pct(self): @@ -321,8 +320,7 @@ def generator(self): raise RuntimeError("You need to use progress bars in a with block.") if self.is_hidden: - for rv in self.iter: - yield rv + yield from self.iter else: for rv in self.iter: self.current_item = rv @@ -391,7 +389,7 @@ def _pipepager(generator, cmd, color): text = strip_ansi(text) c.stdin.write(text.encode(encoding, "replace")) - except (IOError, KeyboardInterrupt): + except (OSError, KeyboardInterrupt): pass else: c.stdin.close() @@ -439,7 +437,7 @@ def _nullpager(stream, generator, color): stream.write(text) -class Editor(object): +class Editor: def __init__(self, editor=None, env=None, require_save=True, extension=".txt"): self.editor = editor self.env = env @@ -456,7 +454,7 @@ def get_editor(self): if WIN: return "notepad" for editor in "sensible-editor", "vim", "nano": - if os.system("which {} >/dev/null 2>&1".format(editor)) == 0: + if os.system(f"which {editor} >/dev/null 2>&1") == 0: return editor return "vi" @@ -477,9 +475,9 @@ def edit_file(self, filename): ) exit_code = c.wait() if exit_code != 0: - raise ClickException("{}: Editing failed!".format(editor)) + raise ClickException(f"{editor}: Editing failed!") except OSError as e: - raise ClickException("{}: Editing failed: {}".format(editor, e)) + raise ClickException(f"{editor}: Editing failed: {e}") def edit(self, text): import tempfile @@ -579,11 +577,11 @@ def _unquote_file(url): def _translate_ch_to_exc(ch): - if ch == u"\x03": + if ch == "\x03": raise KeyboardInterrupt() - if ch == u"\x04" and not WIN: # Unix-like, Ctrl+D + if ch == "\x04" and not WIN: # Unix-like, Ctrl+D raise EOFError() - if ch == u"\x1a" and WIN: # Windows, Ctrl+Z + if ch == "\x1a" and WIN: # Windows, Ctrl+Z raise EOFError() @@ -630,7 +628,7 @@ def getchar(echo): func = msvcrt.getwch rv = func() - if rv in (u"\x00", u"\xe0"): + if rv in ("\x00", "\xe0"): # \x00 and \xe0 are control characters that indicate special key, # see above. rv += func() diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index 5a3cdaab0..c46081f15 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # This module is based on the excellent work by Adam Bartoš who # provided a lot of what went into the implementation here in # the discussion to issue1602 in the Python bug tracker. @@ -146,7 +145,7 @@ def readinto(self, b): # wait for KeyboardInterrupt time.sleep(0.1) if not rv: - raise OSError("Windows error: {}".format(GetLastError())) + raise OSError(f"Windows error: {GetLastError()}") if buffer[0] == EOF: return 0 @@ -163,7 +162,7 @@ def _get_error_message(errno): return "ERROR_SUCCESS" elif errno == ERROR_NOT_ENOUGH_MEMORY: return "ERROR_NOT_ENOUGH_MEMORY" - return "Windows error {}".format(errno) + return f"Windows error {errno}" def write(self, b): bytes_to_be_written = len(b) @@ -185,7 +184,7 @@ def write(self, b): return bytes_written -class ConsoleStream(object): +class ConsoleStream: def __init__(self, text_stream, byte_stream): self._text_stream = text_stream self.buffer = byte_stream @@ -219,7 +218,7 @@ def __repr__(self): ) -class WindowsChunkedWriter(object): +class WindowsChunkedWriter: """ Wraps a stream (such as stdout), acting as a transparent proxy for all attribute access apart from method 'write()' which we wrap to write in diff --git a/src/click/core.py b/src/click/core.py index 1f8890295..78b9ce4b6 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -132,7 +132,7 @@ def sort_key(item): return sorted(declaration_order, key=sort_key) -class ParameterSource(object): +class ParameterSource: """This is an enum that indicates the source of a command line parameter. The enum has one of the following values: COMMANDLINE, @@ -164,7 +164,7 @@ def validate(cls, value): ) -class Context(object): +class Context: """The context is a special internal object that holds state relevant for the script execution at every single level. It's normally invisible to commands unless they opt-in to getting access to it. @@ -513,7 +513,7 @@ def command_path(self): if self.info_name is not None: rv = self.info_name if self.parent is not None: - rv = "{} {}".format(self.parent.command_path, rv) + rv = f"{self.parent.command_path} {rv}" return rv.lstrip() def find_root(self): @@ -666,7 +666,7 @@ def get_parameter_source(self, name): return self._source_by_paramname[name] -class BaseCommand(object): +class BaseCommand: """The base command implements the minimal API contract of commands. Most code will never use this as it does not implement a lot of useful functionality but it can act as the direct subclass of alternative @@ -707,7 +707,7 @@ def __init__(self, name, context_settings=None): self.context_settings = context_settings def __repr__(self): - return "<{} {}>".format(self.__class__.__name__, self.name) + return f"<{self.__class__.__name__} {self.name}>" def get_usage(self, ctx): raise NotImplementedError("Base commands cannot get usage") @@ -757,7 +757,7 @@ def main( prog_name=None, complete_var=None, standalone_mode=True, - **extra + **extra, ): """This is the way to invoke a script with all the bells and whistles as a command line application. This will always terminate @@ -832,7 +832,7 @@ def main( raise e.show() sys.exit(e.exit_code) - except IOError as e: + except OSError as e: if e.errno == errno.EPIPE: sys.stdout = PacifyFlushWrapper(sys.stdout) sys.stderr = PacifyFlushWrapper(sys.stderr) @@ -935,7 +935,7 @@ def __init__( self.deprecated = deprecated def __repr__(self): - return "<{} {}>".format(self.__class__.__name__, self.name) + return f"<{self.__class__.__name__} {self.name}>" def get_usage(self, ctx): """Formats the usage line into a string and returns it. @@ -1140,7 +1140,7 @@ def __init__( subcommand_metavar=None, chain=False, result_callback=None, - **attrs + **attrs, ): Command.__init__(self, name, **attrs) if no_args_is_help is None: @@ -1350,7 +1350,7 @@ def resolve_command(self, ctx, args): if cmd is None and not ctx.resilient_parsing: if split_opt(cmd_name)[0]: self.parse_args(ctx, ctx.args) - ctx.fail("No such command '{}'.".format(original_cmd_name)) + ctx.fail(f"No such command '{original_cmd_name}'.") return cmd_name, cmd, args[1:] @@ -1457,7 +1457,7 @@ def list_commands(self, ctx): return sorted(rv) -class Parameter(object): +class Parameter: 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 @@ -1545,7 +1545,7 @@ def __init__( self.autocompletion = autocompletion def __repr__(self): - return "<{} {}>".format(self.__class__.__name__, self.name) + return f"<{self.__class__.__name__} {self.name}>" @property def human_readable_name(self): @@ -1755,7 +1755,7 @@ def __init__( hidden=False, show_choices=True, show_envvar=False, - **attrs + **attrs, ): default_is_missing = attrs.get("default", _missing) is _missing Parameter.__init__(self, param_decls, type=type, **attrs) @@ -1885,7 +1885,7 @@ def add_to_parser(self, parser, ctx): if self.is_flag: kwargs.pop("nargs", None) - action_const = "{}_const".format(action) + action_const = f"{action}_const" if self.is_bool_flag and self.secondary_opts: parser.add_option(self.opts, action=action_const, const=True, **kwargs) parser.add_option( @@ -1909,7 +1909,7 @@ def _write_opts(opts): if any_slashes: any_prefix_is_slash[:] = [True] if not self.is_flag and not self.count: - rv += " {}".format(self.make_metavar()) + rv += f" {self.make_metavar()}" return rv rv = [_write_opts(self.opts)] @@ -1922,7 +1922,7 @@ def _write_opts(opts): envvar = self.envvar if envvar is None: if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: - envvar = "{}_{}".format(ctx.auto_envvar_prefix, self.name.upper()) + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" if envvar is not None: extra.append( "env var: {}".format( @@ -1933,21 +1933,19 @@ def _write_opts(opts): ) if self.default is not None and (self.show_default or ctx.show_default): if isinstance(self.show_default, str): - default_string = "({})".format(self.show_default) + default_string = f"({self.show_default})" elif isinstance(self.default, (list, tuple)): default_string = ", ".join(str(d) for d in self.default) elif inspect.isfunction(self.default): default_string = "(dynamic)" else: default_string = self.default - extra.append("default: {}".format(default_string)) + extra.append(f"default: {default_string}") if self.required: extra.append("required") if extra: - help = "{}[{}]".format( - "{} ".format(help) if help else "", "; ".join(extra) - ) + help = "{}[{}]".format(f"{help} " if help else "", "; ".join(extra)) return ("; " if any_prefix_is_slash else " / ").join(rv), help @@ -1992,7 +1990,7 @@ def resolve_envvar_value(self, ctx): if rv is not None: return rv if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: - envvar = "{}_{}".format(ctx.auto_envvar_prefix, self.name.upper()) + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" return os.environ.get(envvar) def value_from_envvar(self, ctx): @@ -2047,7 +2045,7 @@ def make_metavar(self): if not var: var = self.name.upper() if not self.required: - var = "[{}]".format(var) + var = f"[{var}]" if self.nargs != 1: var += "..." return var diff --git a/src/click/exceptions.py b/src/click/exceptions.py index f75f4dbb5..2776a0251 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -28,7 +28,7 @@ def __str__(self): def show(self, file=None): if file is None: file = get_text_stderr() - echo("Error: {}".format(self.format_message()), file=file) + echo(f"Error: {self.format_message()}", file=file) class UsageError(ClickException): @@ -58,8 +58,8 @@ def show(self, file=None): ) if self.ctx is not None: color = self.ctx.color - echo("{}\n{}".format(self.ctx.get_usage(), hint), file=file, color=color) - echo("Error: {}".format(self.format_message()), file=file, color=color) + echo(f"{self.ctx.get_usage()}\n{hint}", file=file, color=color) + echo(f"Error: {self.format_message()}", file=file, color=color) class BadParameter(UsageError): @@ -91,10 +91,10 @@ def format_message(self): elif self.param is not None: param_hint = self.param.get_error_hint(self.ctx) else: - return "Invalid value: {}".format(self.message) + return f"Invalid value: {self.message}" param_hint = _join_param_hints(param_hint) - return "Invalid value for {}: {}".format(param_hint, self.message) + return f"Invalid value for {param_hint}: {self.message}" class MissingParameter(BadParameter): @@ -133,13 +133,13 @@ def format_message(self): msg_extra = self.param.type.get_missing_message(self.param) if msg_extra: if msg: - msg += ". {}".format(msg_extra) + msg += f". {msg_extra}" else: msg = msg_extra return "Missing {}{}{}{}".format( param_type, - " {}".format(param_hint) if param_hint else "", + f" {param_hint}" if param_hint else "", ". " if msg else ".", msg or "", ) @@ -147,7 +147,7 @@ def format_message(self): def __str__(self): if self.message is None: param_name = self.param.name if self.param else None - return "missing parameter: {}".format(param_name) + return f"missing parameter: {param_name}" else: return self.message @@ -161,7 +161,7 @@ class NoSuchOption(UsageError): def __init__(self, option_name, message=None, possibilities=None, ctx=None): if message is None: - message = "no such option: {}".format(option_name) + message = f"no such option: {option_name}" UsageError.__init__(self, message, ctx) self.option_name = option_name self.possibilities = possibilities @@ -216,7 +216,7 @@ def __init__(self, filename, hint=None): self.filename = filename def format_message(self): - return "Could not open file {}: {}".format(self.ui_filename, self.message) + return f"Could not open file {self.ui_filename}: {self.message}" class Abort(RuntimeError): diff --git a/src/click/formatting.py b/src/click/formatting.py index 319c7f616..5a8b81c89 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -91,7 +91,7 @@ def _flush_par(): return "\n\n".join(rv) -class HelpFormatter(object): +class HelpFormatter: """This class helps with formatting text-based help pages. It's usually just needed for very special internal cases, but it's also exposed so that developers can write their own fancy outputs. diff --git a/src/click/parser.py b/src/click/parser.py index 95442afcb..ae486d031 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This module started out as largely a copy paste from the stdlib's optparse module with the features removed that we do not need from @@ -84,8 +83,8 @@ def _fetch(c): def _error_opt_args(nargs, opt): if nargs == 1: - raise BadOptionUsage(opt, "{} option requires an argument".format(opt)) - raise BadOptionUsage(opt, "{} option requires {} arguments".format(opt, nargs)) + raise BadOptionUsage(opt, f"{opt} option requires an argument") + raise BadOptionUsage(opt, f"{opt} option requires {nargs} arguments") def split_opt(opt): @@ -123,7 +122,7 @@ def split_arg_string(string): return rv -class Option(object): +class Option: def __init__(self, opts, dest, action=None, nargs=1, const=None, obj=None): self._short_opts = [] self._long_opts = [] @@ -132,7 +131,7 @@ def __init__(self, opts, dest, action=None, nargs=1, const=None, obj=None): for opt in opts: prefix, value = split_opt(opt) if not prefix: - raise ValueError("Invalid start character for option ({})".format(opt)) + raise ValueError(f"Invalid start character for option ({opt})") self.prefixes.add(prefix[0]) if len(prefix) == 1 and len(value) == 1: self._short_opts.append(opt) @@ -165,11 +164,11 @@ def process(self, value, state): elif self.action == "count": state.opts[self.dest] = state.opts.get(self.dest, 0) + 1 else: - raise ValueError("unknown action '{}'".format(self.action)) + raise ValueError(f"unknown action '{self.action}'") state.order.append(self.obj) -class Argument(object): +class Argument: def __init__(self, dest, nargs=1, obj=None): self.dest = dest self.nargs = nargs @@ -182,13 +181,13 @@ def process(self, value, state): value = None elif holes != 0: raise BadArgumentUsage( - "argument {} takes {} values".format(self.dest, self.nargs) + f"argument {self.dest} takes {self.nargs} values" ) state.opts[self.dest] = value state.order.append(self.obj) -class ParsingState(object): +class ParsingState: def __init__(self, rargs): self.opts = {} self.largs = [] @@ -196,7 +195,7 @@ def __init__(self, rargs): self.order = [] -class OptionParser(object): +class OptionParser: """The option parser is an internal class that is ultimately used to parse options and arguments. It's modelled after optparse and brings a similar but vastly simplified API. It should generally not be used @@ -348,7 +347,7 @@ def _match_long_opt(self, opt, explicit_value, state): del state.rargs[:nargs] elif explicit_value is not None: - raise BadOptionUsage(opt, "{} option does not take a value".format(opt)) + raise BadOptionUsage(opt, f"{opt} option does not take a value") else: value = None diff --git a/src/click/termui.py b/src/click/termui.py index dd51cdf6c..b9c91c757 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -153,7 +153,7 @@ def prompt_func(text): try: result = value_proc(value) except UsageError as e: - echo("Error: {}".format(e.message), err=err) # noqa: B306 + echo(f"Error: {e.message}", err=err) # noqa: B306 continue if not confirmation_prompt: return result @@ -504,12 +504,12 @@ def style( try: bits.append("\033[{}m".format(_ansi_colors[fg])) except KeyError: - raise TypeError("Unknown color '{}'".format(fg)) + raise TypeError(f"Unknown color '{fg}'") if bg: try: bits.append("\033[{}m".format(_ansi_colors[bg] + 10)) except KeyError: - raise TypeError("Unknown color '{}'".format(bg)) + raise TypeError(f"Unknown color '{bg}'") if bold is not None: bits.append("\033[{}m".format(1 if bold else 22)) if dim is not None: diff --git a/src/click/testing.py b/src/click/testing.py index eef339949..717d2e473 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -12,7 +12,7 @@ from ._compat import _find_binary_reader -class EchoingStdin(object): +class EchoingStdin: def __init__(self, input, output): self._input = input self._output = output @@ -58,7 +58,7 @@ def make_input_stream(input, charset): return io.BytesIO(input) -class Result(object): +class Result: """Holds the captured result of an invoked CLI script.""" def __init__( @@ -104,7 +104,7 @@ def __repr__(self): ) -class CliRunner(object): +class CliRunner: """The CLI runner provides functionality to invoke a Click command line script for unittesting purposes in a isolated environment. This only works in single-threaded systems without any concurrency as it changes the @@ -191,7 +191,7 @@ def isolation(self, input=None, env=None, color=False): def visible_input(prompt=None): sys.stdout.write(prompt or "") val = input.readline().rstrip("\r\n") - sys.stdout.write("{}\n".format(val)) + sys.stdout.write(f"{val}\n") sys.stdout.flush() return val @@ -261,7 +261,7 @@ def invoke( env=None, catch_exceptions=True, color=False, - **extra + **extra, ): """Invokes a command in an isolated environment. The arguments are forwarded directly to the command line script, the `extra` keyword @@ -359,5 +359,5 @@ def isolated_filesystem(self): os.chdir(cwd) try: shutil.rmtree(t) - except (OSError, IOError): # noqa: B014 + except OSError: # noqa: B014 pass diff --git a/src/click/types.py b/src/click/types.py index d794235f6..7aae2fe09 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -12,7 +12,7 @@ from .utils import safecall -class ParamType(object): +class ParamType: """Helper for converting values through types. The following is necessary for a valid type: @@ -258,7 +258,7 @@ def convert(self, value, param, ctx): try: return int(value) except ValueError: - self.fail("{} is not a valid integer".format(value), param, ctx) + self.fail(f"{value} is not a valid integer", param, ctx) def __repr__(self): return "INT" @@ -320,7 +320,7 @@ def convert(self, value, param, ctx): return rv def __repr__(self): - return "IntRange({}, {})".format(self.min, self.max) + return f"IntRange({self.min}, {self.max})" class FloatParamType(ParamType): @@ -330,9 +330,7 @@ def convert(self, value, param, ctx): try: return float(value) except ValueError: - self.fail( - "{} is not a valid floating point value".format(value), param, ctx - ) + self.fail(f"{value} is not a valid floating point value", param, ctx) def __repr__(self): return "FLOAT" @@ -394,7 +392,7 @@ def convert(self, value, param, ctx): return rv def __repr__(self): - return "FloatRange({}, {})".format(self.min, self.max) + return f"FloatRange({self.min}, {self.max})" class BoolParamType(ParamType): @@ -408,7 +406,7 @@ def convert(self, value, param, ctx): return True elif value in ("false", "f", "0", "no", "n"): return False - self.fail("{} is not a valid boolean".format(value), param, ctx) + self.fail(f"{value} is not a valid boolean", param, ctx) def __repr__(self): return "BOOL" @@ -423,7 +421,7 @@ def convert(self, value, param, ctx): try: return uuid.UUID(value) except ValueError: - self.fail("{} is not a valid UUID value".format(value), param, ctx) + self.fail(f"{value} is not a valid UUID value", param, ctx) def __repr__(self): return "UUID" @@ -506,7 +504,7 @@ def convert(self, value, param, ctx): else: ctx.call_on_close(safecall(f.flush)) return f - except (IOError, OSError) as e: # noqa: B014 + except OSError as e: # noqa: B014 self.fail( "Could not open file: {}: {}".format( filename_to_ui(value), get_strerror(e) @@ -713,7 +711,7 @@ def convert_type(ty, default=None): try: if issubclass(ty, ParamType): raise AssertionError( - "Attempted to use an uninstantiated parameter type ({}).".format(ty) + f"Attempted to use an uninstantiated parameter type ({ty})." ) except TypeError: pass diff --git a/src/click/utils.py b/src/click/utils.py index 0f634bbf6..b18c83dd9 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -72,7 +72,7 @@ def make_default_short_help(help, max_length=45): return "".join(result) -class LazyFile(object): +class LazyFile: """A lazy file works like a regular file but it does not fully open the file but it does perform some basic checks early to see if the filename parameter does make sense. This is useful for safely opening @@ -105,7 +105,7 @@ def __getattr__(self, name): def __repr__(self): if self._f is not None: return repr(self._f) - return "".format(self.name, self.mode) + return f"" def open(self): """Opens the file if it's not yet open. This call might fail with @@ -118,7 +118,7 @@ def open(self): rv, self.should_close = open_stream( self.name, self.mode, self.encoding, self.errors, atomic=self.atomic ) - except (IOError, OSError) as e: # noqa: E402 + except OSError as e: # noqa: E402 from .exceptions import FileError raise FileError(self.name, hint=get_strerror(e)) @@ -148,7 +148,7 @@ def __iter__(self): return iter(self._f) -class KeepOpenFile(object): +class KeepOpenFile: def __init__(self, file): self._file = file @@ -226,9 +226,9 @@ def echo(message=None, file=None, nl=True, err=False, color=None): message = str(message) if nl: - message = message or u"" + message = message or "" if isinstance(message, str): - message += u"\n" + message += "\n" else: message += b"\n" @@ -276,7 +276,7 @@ def get_binary_stream(name): """ opener = binary_streams.get(name) if opener is None: - raise TypeError("Unknown standard stream '{}'".format(name)) + raise TypeError(f"Unknown standard stream '{name}'") return opener() @@ -293,7 +293,7 @@ def get_text_stream(name, encoding=None, errors="strict"): """ opener = text_streams.get(name) if opener is None: - raise TypeError("Unknown standard stream '{}'".format(name)) + raise TypeError(f"Unknown standard stream '{name}'") return opener(encoding, errors) @@ -419,7 +419,7 @@ def get_app_dir(app_name, roaming=True, force_posix=False): ) -class PacifyFlushWrapper(object): +class PacifyFlushWrapper: """This wrapper is used to catch and suppress BrokenPipeErrors resulting from ``.flush()`` being called on broken pipe during the shutdown/final-GC of the Python interpreter. Notably ``.flush()`` is always called on @@ -434,7 +434,7 @@ def __init__(self, wrapped): def flush(self): try: self.wrapped.flush() - except IOError as e: + except OSError as e: import errno if e.errno != errno.EPIPE: diff --git a/tests/test_arguments.py b/tests/test_arguments.py index bb580fc7a..497048645 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import sys import pytest @@ -12,7 +11,7 @@ def test_nargs_star(runner): @click.argument("dst") def copy(src, dst): click.echo("src={}".format("|".join(src))) - click.echo("dst={}".format(dst)) + click.echo(f"dst={dst}") result = runner.invoke(copy, ["foo.txt", "bar.txt", "dir"]) assert not result.exception @@ -33,7 +32,7 @@ def test_nargs_tup(runner): @click.argument("name", nargs=1) @click.argument("point", nargs=2, type=click.INT) def copy(name, point): - click.echo("name={}".format(name)) + click.echo(f"name={name}") click.echo("point={0[0]}/{0[1]}".format(point)) result = runner.invoke(copy, ["peter", "1", "2"]) @@ -91,7 +90,7 @@ def from_bytes(arg): runner.invoke( from_bytes, - [u"Something outside of ASCII range: 林".encode("UTF-8")], + ["Something outside of ASCII range: 林".encode()], catch_exceptions=False, ) @@ -208,7 +207,7 @@ def test_missing_arg(runner): @click.command() @click.argument("arg") def cmd(arg): - click.echo("arg:{}".format(arg)) + click.echo(f"arg:{arg}") result = runner.invoke(cmd, []) assert result.exit_code == 2 diff --git a/tests/test_bashcomplete.py b/tests/test_bashcomplete.py index df4ef287c..f48927a54 100644 --- a/tests/test_bashcomplete.py +++ b/tests/test_bashcomplete.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import pytest import click diff --git a/tests/test_basic.py b/tests/test_basic.py index f07b6d1f5..9f9d71eaa 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os import uuid @@ -82,7 +81,7 @@ def test_basic_option(runner): @click.command() @click.option("--foo", default="no value") def cli(foo): - click.echo(u"FOO:[{}]".format(foo)) + click.echo(f"FOO:[{foo}]") result = runner.invoke(cli, []) assert not result.exception @@ -100,9 +99,9 @@ def cli(foo): assert not result.exception assert "FOO:[]" in result.output - result = runner.invoke(cli, [u"--foo=\N{SNOWMAN}"]) + result = runner.invoke(cli, ["--foo=\N{SNOWMAN}"]) assert not result.exception - assert u"FOO:[\N{SNOWMAN}]" in result.output + assert "FOO:[\N{SNOWMAN}]" in result.output def test_int_option(runner): @@ -131,7 +130,7 @@ def test_uuid_option(runner): ) def cli(u): assert type(u) is uuid.UUID - click.echo("U:[{}]".format(u)) + click.echo(f"U:[{u}]") result = runner.invoke(cli, []) assert not result.exception @@ -151,7 +150,7 @@ def test_float_option(runner): @click.option("--foo", default=42, type=click.FLOAT) def cli(foo): assert type(foo) is float - click.echo("FOO:[{}]".format(foo)) + click.echo(f"FOO:[{foo}]") result = runner.invoke(cli, []) assert not result.exception @@ -182,7 +181,7 @@ def cli(with_foo): assert result.output == "False\n" result = runner.invoke(cli, []) assert not result.exception - assert result.output == "{}\n".format(default) + assert result.output == f"{default}\n" for default in True, False: @@ -196,7 +195,7 @@ def cli(flag): assert result.output == "{}\n".format(not default) result = runner.invoke(cli, []) assert not result.exception - assert result.output == "{}\n".format(default) + assert result.output == f"{default}\n" def test_boolean_conversion(runner): @@ -219,7 +218,7 @@ def cli(flag): result = runner.invoke(cli, []) assert not result.exception - assert result.output == "{}\n".format(default) + assert result.output == f"{default}\n" def test_file_option(runner): diff --git a/tests/test_chain.py b/tests/test_chain.py index c227270f2..cf9b19816 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -77,12 +77,12 @@ def cli(): @cli.command("sdist") @click.option("--format") def sdist(format): - click.echo("sdist called {}".format(format)) + click.echo(f"sdist called {format}") @cli.command("bdist") @click.option("--format") def bdist(format): - click.echo("bdist called {}".format(format)) + click.echo(f"bdist called {format}") result = runner.invoke(cli, ["bdist", "--format=1", "sdist", "--format=2"]) assert not result.exception @@ -97,12 +97,12 @@ def cli(): @cli.command("sdist") @click.argument("format") def sdist(format): - click.echo("sdist called {}".format(format)) + click.echo(f"sdist called {format}") @cli.command("bdist") @click.argument("format") def bdist(format): - click.echo("bdist called {}".format(format)) + click.echo(f"bdist called {format}") result = runner.invoke(cli, ["bdist", "1", "sdist", "2"]) assert not result.exception @@ -192,7 +192,7 @@ def bad_cli2(): @click.group(chain=True) @click.argument("arg") def cli(arg): - click.echo("cli:{}".format(arg)) + click.echo(f"cli:{arg}") @cli.command() def a(): diff --git a/tests/test_commands.py b/tests/test_commands.py index 1d99218cd..1e19746a4 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import re import click @@ -26,7 +25,7 @@ def test_other_command_forward(runner): @cli.command() @click.option("--count", default=1) def test(count): - click.echo("Count: {:d}".format(count)) + click.echo(f"Count: {count:d}") @cli.command() @click.option("--count", default=1) @@ -102,7 +101,7 @@ def test_group_with_args(runner): @click.group() @click.argument("obj") def cli(obj): - click.echo("obj={}".format(obj)) + click.echo(f"obj={obj}") @cli.command() def move(): @@ -264,7 +263,7 @@ def test_unprocessed_options(runner): @click.argument("args", nargs=-1, type=click.UNPROCESSED) @click.option("--verbose", "-v", count=True) def cli(verbose, args): - click.echo("Verbosity: {}".format(verbose)) + click.echo(f"Verbosity: {verbose}") click.echo("Args: {}".format("|".join(args))) result = runner.invoke(cli, ["-foo", "-vvvvx", "--muhaha", "x", "y", "-x"]) diff --git a/tests/test_compat.py b/tests/test_compat.py index 26647ba99..3c458e9c7 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -23,7 +23,7 @@ def test_zsh_func_name(): @pytest.mark.xfail(WIN, reason="Jupyter not tested/supported on Windows") def test_is_jupyter_kernel_output(): - class JupyterKernelFakeStream(object): + class JupyterKernelFakeStream: pass # implementation detail, aka cheapskate test diff --git a/tests/test_context.py b/tests/test_context.py index 06cc9071c..44befa584 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import pytest import click @@ -6,7 +5,7 @@ def test_ensure_context_objects(runner): - class Foo(object): + class Foo: def __init__(self): self.title = "default" @@ -28,7 +27,7 @@ def test(foo): def test_get_context_objects(runner): - class Foo(object): + class Foo: def __init__(self): self.title = "default" @@ -51,7 +50,7 @@ def test(foo): def test_get_context_objects_no_ensuring(runner): - class Foo(object): + class Foo: def __init__(self): self.title = "default" @@ -74,7 +73,7 @@ def test(foo): def test_get_context_objects_missing(runner): - class Foo(object): + class Foo: pass pass_foo = click.make_pass_decorator(Foo) @@ -132,7 +131,7 @@ def cli(ctx): def test_context_meta(runner): - LANG_KEY = "{}.lang".format(__name__) + LANG_KEY = f"{__name__}.lang" def set_language(value): click.get_current_context().meta[LANG_KEY] = value @@ -219,7 +218,7 @@ def test_make_pass_decorator_args(runner): invocation order. """ - class Foo(object): + class Foo: title = "foocmd" pass_foo = click.make_pass_decorator(Foo) diff --git a/tests/test_defaults.py b/tests/test_defaults.py index ce4490331..d55b1f483 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -6,7 +6,7 @@ def test_basic_defaults(runner): @click.option("--foo", default=42, type=click.FLOAT) def cli(foo): assert type(foo) is float - click.echo("FOO:[{}]".format(foo)) + click.echo(f"FOO:[{foo}]") result = runner.invoke(cli, []) assert not result.exception diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 4fabbb20f..b2f72b7d2 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import click @@ -151,7 +150,7 @@ def test_formatting_usage_error(runner): @click.command() @click.argument("arg") def cmd(arg): - click.echo("arg:{}".format(arg)) + click.echo(f"arg:{arg}") result = runner.invoke(cmd, []) assert result.exit_code == 2 @@ -208,7 +207,7 @@ def cmd(): @cmd.command() @click.argument("bar") def foo(bar): - click.echo("foo:{}".format(bar)) + click.echo(f"foo:{bar}") result = runner.invoke(cmd, ["foo"]) assert result.exit_code == 2 @@ -224,7 +223,7 @@ def test_formatting_usage_error_no_help(runner): @click.command(add_help_option=False) @click.argument("arg") def cmd(arg): - click.echo("arg:{}".format(arg)) + click.echo(f"arg:{arg}") result = runner.invoke(cmd, []) assert result.exit_code == 2 @@ -239,7 +238,7 @@ 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:{}".format(arg)) + click.echo(f"arg:{arg}") result = runner.invoke(cmd, []) assert result.exit_code == 2 diff --git a/tests/test_options.py b/tests/test_options.py index 3eea5e60d..95cdad5b3 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os import re @@ -12,7 +11,7 @@ def test_prefixes(runner): @click.option("++foo", is_flag=True, help="das foo") @click.option("--bar", is_flag=True, help="das bar") def cli(foo, bar): - click.echo("foo={} bar={}".format(foo, bar)) + click.echo(f"foo={foo} bar={bar}") result = runner.invoke(cli, ["++foo", "--bar"]) assert not result.exception @@ -57,7 +56,7 @@ def test_counting(runner): @click.command() @click.option("-v", count=True, help="Verbosity", type=click.IntRange(0, 3)) def cli(v): - click.echo("verbosity={:d}".format(v)) + click.echo(f"verbosity={v:d}") result = runner.invoke(cli, ["-vvv"]) assert not result.exception @@ -86,7 +85,7 @@ def cli(): result = runner.invoke(cli, [unknown_flag]) assert result.exception - assert "no such option: {}".format(unknown_flag) in result.output + assert f"no such option: {unknown_flag}" in result.output def test_multiple_required(runner): @@ -108,7 +107,7 @@ def test_empty_envvar(runner): @click.command() @click.option("--mypath", type=click.Path(exists=True), envvar="MYPATH") def cli(mypath): - click.echo("mypath: {}".format(mypath)) + click.echo(f"mypath: {mypath}") result = runner.invoke(cli, [], env={"MYPATH": ""}) assert result.exit_code == 0 @@ -145,7 +144,7 @@ def cmd(arg): cmd, [], auto_envvar_prefix="TEST", - env={"TEST_ARG": "foo{}bar".format(os.path.pathsep)}, + env={"TEST_ARG": f"foo{os.path.pathsep}bar"}, ) assert not result.exception assert result.output == "foo|bar\n" diff --git a/tests/test_termui.py b/tests/test_termui.py index 003a2697d..616ab7d1f 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import time import pytest @@ -7,7 +6,7 @@ from click._compat import WIN -class FakeClock(object): +class FakeClock: def __init__(self): self.now = time.time() @@ -42,7 +41,7 @@ def cli(): def test_progressbar_length_hint(runner, monkeypatch): - class Hinted(object): + class Hinted: def __init__(self, n): self.items = list(range(n)) @@ -123,7 +122,7 @@ def test_progressbar_format_pos(runner, pos, length): with _create_progress(length, length_known=length != 0, pos=pos) as progress: result = progress.format_pos() if progress.length_known: - assert result == "{}/{}".format(pos, length) + assert result == f"{pos}/{length}" else: assert result == str(pos) @@ -291,7 +290,7 @@ def test_progressbar_item_show_func(runner, monkeypatch): @click.command() def cli(): with click.progressbar( - range(4), item_show_func=lambda x: "Custom {}".format(x) + range(4), item_show_func=lambda x: f"Custom {x}" ) as progress: for _ in progress: fake_clock.advance_time() @@ -315,7 +314,7 @@ def test_progressbar_update_with_item_show_func(runner, monkeypatch): @click.command() def cli(): with click.progressbar( - length=6, item_show_func=lambda x: "Custom {}".format(x) + length=6, item_show_func=lambda x: f"Custom {x}" ) as progress: while not progress.finished: fake_clock.advance_time() @@ -333,7 +332,7 @@ def cli(): assert "Custom 4" in lines[2] -@pytest.mark.parametrize("key_char", (u"h", u"H", u"é", u"À", u" ", u"字", u"àH", u"àR")) +@pytest.mark.parametrize("key_char", ("h", "H", "é", "À", " ", "字", "àH", "àR")) @pytest.mark.parametrize("echo", [True, False]) @pytest.mark.skipif(not WIN, reason="Tests user-input using the msvcrt module.") def test_getchar_windows(runner, monkeypatch, key_char, echo): @@ -344,7 +343,7 @@ def test_getchar_windows(runner, monkeypatch, key_char, echo): @pytest.mark.parametrize( - "special_key_char, key_char", [(u"\x00", "a"), (u"\x00", "b"), (u"\xe0", "c")] + "special_key_char, key_char", [("\x00", "a"), ("\x00", "b"), ("\xe0", "c")] ) @pytest.mark.skipif( not WIN, reason="Tests special character inputs using the msvcrt module." @@ -359,7 +358,7 @@ def test_getchar_special_key_windows(runner, monkeypatch, special_key_char, key_ @pytest.mark.parametrize( - ("key_char", "exc"), [(u"\x03", KeyboardInterrupt), (u"\x1a", EOFError)], + ("key_char", "exc"), [("\x03", KeyboardInterrupt), ("\x1a", EOFError)], ) @pytest.mark.skipif(not WIN, reason="Tests user-input using the msvcrt module.") def test_getchar_windows_exceptions(runner, monkeypatch, key_char, exc): diff --git a/tests/test_testing.py b/tests/test_testing.py index 99701b5b0..5b2c813ce 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -59,7 +59,7 @@ def test_prompts(): @click.command() @click.option("--foo", prompt=True) def test(foo): - click.echo("foo={}".format(foo)) + click.echo(f"foo={foo}") runner = CliRunner() result = runner.invoke(test, input="wau wau\n") @@ -69,7 +69,7 @@ def test(foo): @click.command() @click.option("--foo", prompt=True, hide_input=True) def test(foo): - click.echo("foo={}".format(foo)) + click.echo(f"foo={foo}") runner = CliRunner() result = runner.invoke(test, input="wau wau\n") diff --git a/tests/test_utils.py b/tests/test_utils.py index 2ca1a8a95..bf3956d1f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -12,7 +12,7 @@ def test_echo(runner): with runner.isolation() as outstreams: - click.echo(u"\N{SNOWMAN}") + click.echo("\N{SNOWMAN}") click.echo(b"\x44\x44") click.echo(42, nl=False) click.echo(b"a", nl=False) @@ -38,8 +38,8 @@ def test_echo_custom_file(): import io f = io.StringIO() - click.echo(u"hello", file=f) - assert f.getvalue() == u"hello\n" + click.echo("hello", file=f) + assert f.getvalue() == "hello\n" @pytest.mark.parametrize( @@ -80,14 +80,12 @@ def test_unstyle_other_ansi(text, expect): def test_filename_formatting(): assert click.format_filename(b"foo.txt") == "foo.txt" assert click.format_filename(b"/x/foo.txt") == "/x/foo.txt" - assert click.format_filename(u"/x/foo.txt") == "/x/foo.txt" - assert click.format_filename(u"/x/foo.txt", shorten=True) == "foo.txt" + assert click.format_filename("/x/foo.txt") == "/x/foo.txt" + assert click.format_filename("/x/foo.txt", shorten=True) == "foo.txt" # filesystem encoding on windows permits this. if not WIN: - assert ( - click.format_filename(b"/x/foo\xff.txt", shorten=True) == u"foo\ufffd.txt" - ) + assert click.format_filename(b"/x/foo\xff.txt", shorten=True) == "foo\ufffd.txt" def test_prompts(runner): @@ -192,21 +190,21 @@ def test_echo_color_flag(monkeypatch, capfd): click.echo(styled_text, color=False) out, err = capfd.readouterr() - assert out == "{}\n".format(text) + assert out == f"{text}\n" click.echo(styled_text, color=True) out, err = capfd.readouterr() - assert out == "{}\n".format(styled_text) + assert out == f"{styled_text}\n" isatty = True click.echo(styled_text) out, err = capfd.readouterr() - assert out == "{}\n".format(styled_text) + assert out == f"{styled_text}\n" isatty = False click.echo(styled_text) out, err = capfd.readouterr() - assert out == "{}\n".format(text) + assert out == f"{text}\n" @pytest.mark.skipif(WIN, reason="Test too complex to make work windows.") From 9897d81d0830f2048360cbd45993de1b37ce7371 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 19 Apr 2020 23:54:14 -0700 Subject: [PATCH 055/293] f-strings everywhere --- docs/advanced.rst | 14 ++-- docs/arguments.rst | 2 +- docs/bashcomplete.rst | 6 +- docs/commands.rst | 10 +-- docs/documentation.rst | 4 +- docs/index.rst | 2 +- docs/options.rst | 40 +++++++----- docs/quickstart.rst | 2 +- docs/testing.rst | 6 +- docs/utils.rst | 11 ++-- examples/aliases/aliases.py | 2 +- examples/bashcompletion/bashcompletion.py | 2 +- examples/complex/complex/commands/cmd_init.py | 2 +- examples/imagepipe/imagepipe.py | 5 +- examples/repo/repo.py | 2 +- examples/termui/termui.py | 2 +- examples/validation/validation.py | 3 +- src/click/_bashcomplete.py | 2 +- src/click/_compat.py | 4 +- src/click/_termui_impl.py | 21 +++--- src/click/_textwrap.py | 2 +- src/click/_unicodefun.py | 10 +-- src/click/_winconsole.py | 4 +- src/click/core.py | 61 ++++++++--------- src/click/decorators.py | 5 +- src/click/exceptions.py | 17 ++--- src/click/formatting.py | 14 ++-- src/click/parser.py | 6 +- src/click/termui.py | 24 +++---- src/click/testing.py | 7 +- src/click/types.py | 65 ++++++------------- src/click/utils.py | 2 +- tests/test_arguments.py | 12 ++-- tests/test_basic.py | 10 +-- tests/test_chain.py | 5 +- tests/test_commands.py | 4 +- tests/test_defaults.py | 4 +- tests/test_options.py | 13 ++-- tests/test_termui.py | 4 +- tests/test_testing.py | 4 +- 40 files changed, 188 insertions(+), 227 deletions(-) diff --git a/docs/advanced.rst b/docs/advanced.rst index 6278893d5..80036111f 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -46,7 +46,7 @@ it would accept ``pus`` as an alias (so long as it was unique): return None elif len(matches) == 1: return click.Group.get_command(self, ctx, matches[0]) - ctx.fail('Too many matches: %s' % ', '.join(sorted(matches))) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") And it can then be used like this: @@ -92,7 +92,7 @@ it's good to know that the system works this way. @click.option('--url', callback=open_url) def cli(url, fp=None): if fp is not None: - click.echo('%s: %s' % (url, fp.code)) + click.echo(f"{url}: {fp.code}") In this case the callback returns the URL unchanged but also passes a second ``fp`` value to the callback. What's more recommended is to pass @@ -116,7 +116,7 @@ the information in a wrapper however: @click.option('--url', callback=open_url) def cli(url): if url is not None: - click.echo('%s: %s' % (url.url, url.fp.code)) + click.echo(f"{url.url}: {url.fp.code}") Token Normalization @@ -140,7 +140,7 @@ function that converts the token to lowercase: @click.command(context_settings=CONTEXT_SETTINGS) @click.option('--name', default='Pete') def cli(name): - click.echo('Name: %s' % name) + click.echo(f"Name: {name}") And how it works on the command line: @@ -171,7 +171,7 @@ Example: @cli.command() @click.option('--count', default=1) def test(count): - click.echo('Count: %d' % count) + click.echo(f'Count: {count}') @cli.command() @click.option('--count', default=1) @@ -300,7 +300,7 @@ In the end you end up with something like this: """A fake wrapper around Python's timeit.""" cmdline = ['echo', 'python', '-mtimeit'] + list(timeit_args) if verbose: - click.echo('Invoking: %s' % ' '.join(cmdline)) + click.echo(f"Invoking: {' '.join(cmdline)}") call(cmdline) And what it looks like: @@ -396,7 +396,7 @@ method can be used to find this out. @click.pass_context def cli(ctx, port): source = ctx.get_parameter_source("port") - click.echo("Port came from {}".format(source)) + click.echo(f"Port came from {source}") .. click:run:: diff --git a/docs/arguments.rst b/docs/arguments.rst index 6ae35388c..e5765c940 100644 --- a/docs/arguments.rst +++ b/docs/arguments.rst @@ -55,7 +55,7 @@ Example: def copy(src, dst): """Move file SRC to DST.""" for fn in src: - click.echo('move %s to folder %s' % (fn, dst)) + click.echo(f"move {fn} to folder {dst}") And what it looks like: diff --git a/docs/bashcomplete.rst b/docs/bashcomplete.rst index b2bb1b646..bc0ba7d42 100644 --- a/docs/bashcomplete.rst +++ b/docs/bashcomplete.rst @@ -50,8 +50,8 @@ suggestions: @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]) + click.echo(f"Environment variable: {envvar}") + click.echo(f"Value: {os.environ[envvar]}") Completion help strings @@ -79,7 +79,7 @@ suggestions with help strings: @click.command() @click.argument("color", type=click.STRING, autocompletion=get_colors) def cmd1(color): - click.echo('Chosen color is %s' % color) + click.echo(f"Chosen color is {color}") Activation diff --git a/docs/commands.rst b/docs/commands.rst index da26ac0f9..2bb91115b 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -25,7 +25,7 @@ when an inner command runs: @click.group() @click.option('--debug/--no-debug', default=False) def cli(debug): - click.echo('Debug mode is %s' % ('on' if debug else 'off')) + click.echo(f"Debug mode is {'on' if debug else 'off'}") @cli.command() # @cli, not @click! def sync(): @@ -96,7 +96,7 @@ script like this: @cli.command() @click.pass_context def sync(ctx): - click.echo('Debug is %s' % (ctx.obj['DEBUG'] and 'on' or 'off')) + click.echo(f"Debug is {'on' if ctx.obj['DEBUG'] else 'off'}") if __name__ == '__main__': cli(obj={}) @@ -166,7 +166,7 @@ Example: if ctx.invoked_subcommand is None: click.echo('I was invoked without subcommand') else: - click.echo('I am about to invoke %s' % ctx.invoked_subcommand) + click.echo(f"I am about to invoke {ctx.invoked_subcommand}") @cli.command() def sync(): @@ -466,7 +466,7 @@ Example usage: @cli.command() @click.option('--port', default=8000) def runserver(port): - click.echo('Serving on http://127.0.0.1:%d/' % port) + click.echo(f"Serving on http://127.0.0.1:{port}/") if __name__ == '__main__': cli(default_map={ @@ -512,7 +512,7 @@ This example does the same as the previous example: @cli.command() @click.option('--port', default=8000) def runserver(port): - click.echo('Serving on http://127.0.0.1:%d/' % port) + click.echo(f"Serving on http://127.0.0.1:{port}/") if __name__ == '__main__': cli() diff --git a/docs/documentation.rst b/docs/documentation.rst index 982ad4269..181c5b3b7 100644 --- a/docs/documentation.rst +++ b/docs/documentation.rst @@ -24,7 +24,7 @@ Simple example: def hello(count, name): """This script prints hello NAME COUNT times.""" for x in range(count): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") And what it looks like: @@ -173,7 +173,7 @@ desired. This can be customized at all levels: def hello(count, name): """This script prints hello times.""" for x in range(count): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") Example: diff --git a/docs/index.rst b/docs/index.rst index 7fac84987..683f1ef0d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -36,7 +36,7 @@ What does it look like? Here is an example of a simple Click program: 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) + click.echo(f"Hello {name}!") if __name__ == '__main__': hello() diff --git a/docs/options.rst b/docs/options.rst index 4942c8a2b..41653aa00 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -85,7 +85,7 @@ simply pass in `required=True` as an argument to the decorator. @click.option('--from', '-f', 'from_') @click.option('--to', '-t') def reserved_param_name(from_, to): - click.echo('from %s to %s' % (from_, to)) + click.echo(f"from {from_} to {to}") And on the command line: @@ -121,7 +121,8 @@ the ``nargs`` parameter. The values are then stored as a tuple. @click.command() @click.option('--pos', nargs=2, type=float) def findme(pos): - click.echo('%s / %s' % pos) + a, b = pos + click.echo(f"{a} / {b}") And on the command line: @@ -146,7 +147,8 @@ the tuple. For this you can directly specify a tuple as type: @click.command() @click.option('--item', type=(str, int)) def putitem(item): - click.echo('name=%s id=%d' % item) + name, id = item + click.echo(f"name={name} id={id}") And on the command line: @@ -163,7 +165,8 @@ used. The above example is thus equivalent to this: @click.command() @click.option('--item', nargs=2, type=click.Tuple([str, int])) def putitem(item): - click.echo('name=%s id=%d' % item) + name, id = item + click.echo(f"name={name} id={id}") .. _multiple-options: @@ -212,7 +215,7 @@ for instance: @click.command() @click.option('-v', '--verbose', count=True) def log(verbose): - click.echo('Verbosity: %s' % verbose) + click.echo(f"Verbosity: {verbose}") And on the command line: @@ -281,7 +284,7 @@ can alternatively split the parameters through ``;`` instead: @click.command() @click.option('/debug;/no-debug') def log(debug): - click.echo('debug=%s' % debug) + click.echo(f"debug={debug}") if __name__ == '__main__': log() @@ -402,7 +405,7 @@ Example: @click.command() @click.option('--name', prompt=True) def hello(name): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") And what it looks like: @@ -419,7 +422,7 @@ a different one: @click.command() @click.option('--name', prompt='Your name please') def hello(name): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") What it looks like: @@ -443,7 +446,7 @@ useful for password input: @click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True) def encrypt(password): - click.echo('Encrypting password to %s' % password.encode('rot13')) + click.echo(f"Encrypting password to {password.encode('rot13')}") What it looks like: @@ -459,7 +462,7 @@ replaced with the :func:`password_option` decorator: @click.command() @click.password_option() def encrypt(password): - click.echo('Encrypting password to %s' % password.encode('rot13')) + click.echo(f"Encrypting password to {password.encode('rot13')}") Dynamic Defaults for Prompts ---------------------------- @@ -625,7 +628,7 @@ Example usage: @click.command() @click.option('--username') def greet(username): - click.echo('Hello %s!' % username) + click.echo(f'Hello {username}!') if __name__ == '__main__': greet(auto_envvar_prefix='GREETER') @@ -650,12 +653,12 @@ Example: @click.group() @click.option('--debug/--no-debug') def cli(debug): - click.echo('Debug mode is %s' % ('on' if debug else 'off')) + click.echo(f"Debug mode is {'on' if debug else 'off'}") @cli.command() @click.option('--username') def greet(username): - click.echo('Hello %s!' % username) + click.echo(f"Hello {username}!") if __name__ == '__main__': cli(auto_envvar_prefix='GREETER') @@ -677,7 +680,7 @@ Example usage: @click.command() @click.option('--username', envvar='USERNAME') def greet(username): - click.echo('Hello %s!' % username) + click.echo(f"Hello {username}!") if __name__ == '__main__': greet() @@ -726,7 +729,7 @@ And from the command line: .. click:run:: import os - invoke(perform, env={'PATHS': './foo/bar%s./test' % os.path.pathsep}) + invoke(perform, env={"PATHS": f"./foo/bar{os.path.pathsep}./test"}) Other Prefix Characters ----------------------- @@ -742,7 +745,7 @@ POSIX semantics. However in certain situations this can be useful: @click.command() @click.option('+w/-w') def chmod(w): - click.echo('writable=%s' % w) + click.echo(f"writable={w}") if __name__ == '__main__': chmod() @@ -762,7 +765,7 @@ boolean flag you need to separate it with ``;`` instead of ``/``: @click.command() @click.option('/debug;/no-debug') def log(debug): - click.echo('debug=%s' % debug) + click.echo(f"debug={debug}") if __name__ == '__main__': log() @@ -834,7 +837,8 @@ Example: @click.command() @click.option('--rolls', callback=validate_rolls, default='1d6') def roll(rolls): - click.echo('Rolling a %d-sided dice %d time(s)' % rolls) + sides, times = rolls + click.echo(f"Rolling a {sides}-sided dice {times} time(s)") if __name__ == '__main__': roll() diff --git a/docs/quickstart.rst b/docs/quickstart.rst index c8cb2d9da..51f7f22ff 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -240,7 +240,7 @@ To add parameters, use the :func:`option` and :func:`argument` decorators: @click.argument('name') def hello(count, name): for x in range(count): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") What it looks like: diff --git a/docs/testing.rst b/docs/testing.rst index 52a888dd7..57acaf2bb 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -30,7 +30,7 @@ data, exit code, and optional exception attached: @click.command() @click.argument('name') def hello(name): - click.echo('Hello %s!' % name) + click.echo(f'Hello {name}!') .. code-block:: python :caption: test_hello.py @@ -54,7 +54,7 @@ For subcommand testing, a subcommand name must be specified in the `args` parame @click.group() @click.option('--debug/--no-debug', default=False) def cli(debug): - click.echo('Debug mode is %s' % ('on' if debug else 'off')) + click.echo(f"Debug mode is {'on' if debug else 'off'}") @cli.command() def sync(): @@ -126,7 +126,7 @@ stream (stdin). This is very useful for testing prompts, for instance: @click.command() @click.option('--foo', prompt=True) def prompt(foo): - click.echo('foo=%s' % foo) + click.echo(f"foo={foo}") .. code-block:: python :caption: test_prompt.py diff --git a/docs/utils.rst b/docs/utils.rst index 902e5fde2..7dd8dbb46 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -112,15 +112,14 @@ Example: @click.command() def less(): - click.echo_via_pager('\n'.join('Line %d' % idx - for idx in range(200))) + click.echo_via_pager("\n".join(f"Line {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 + yield f"Line {idx}\n" @click.command() def less(): @@ -264,7 +263,7 @@ context of a full Unicode string. Example:: - click.echo('Path: %s' % click.format_filename(b'foo.txt')) + click.echo(f"Path: {click.format_filename(b'foo.txt')}") Standard Streams @@ -349,7 +348,7 @@ Example usage:: rv = {} for section in parser.sections(): for key, value in parser.items(section): - rv['%s.%s' % (section, key)] = value + rv[f"{section}.{key}"] = value return rv @@ -396,7 +395,7 @@ loop. So code like this will render correctly:: with click.progressbar([1, 2, 3]) as bar: for x in bar: - print('sleep({})...'.format(x)) + print(f"sleep({x})...") time.sleep(x) Another useful feature is to associate a label with the progress bar which diff --git a/examples/aliases/aliases.py b/examples/aliases/aliases.py index c01672bda..c3da657fd 100644 --- a/examples/aliases/aliases.py +++ b/examples/aliases/aliases.py @@ -65,7 +65,7 @@ def get_command(self, ctx, cmd_name): return None elif len(matches) == 1: return click.Group.get_command(self, ctx, matches[0]) - ctx.fail("Too many matches: {}".format(", ".join(sorted(matches)))) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") def read_config(ctx, param, value): diff --git a/examples/bashcompletion/bashcompletion.py b/examples/bashcompletion/bashcompletion.py index 592bf19c8..3f8c9dfc0 100644 --- a/examples/bashcompletion/bashcompletion.py +++ b/examples/bashcompletion/bashcompletion.py @@ -19,7 +19,7 @@ def get_env_vars(ctx, args, incomplete): @click.argument("envvar", type=click.STRING, autocompletion=get_env_vars) def cmd1(envvar): click.echo(f"Environment variable: {envvar}") - click.echo("Value: {}".format(os.environ[envvar])) + click.echo(f"Value: {os.environ[envvar]}") @click.group(help="A group that holds a subcommand") diff --git a/examples/complex/complex/commands/cmd_init.py b/examples/complex/complex/commands/cmd_init.py index c2cf77093..8802458a9 100644 --- a/examples/complex/complex/commands/cmd_init.py +++ b/examples/complex/complex/commands/cmd_init.py @@ -10,4 +10,4 @@ def cli(ctx, path): """Initializes a repository.""" if path is None: path = ctx.home - ctx.log("Initialized the repository in %s", click.format_filename(path)) + ctx.log(f"Initialized the repository in {click.format_filename(path)}") diff --git a/examples/imagepipe/imagepipe.py b/examples/imagepipe/imagepipe.py index 95f5c4245..57432faaf 100644 --- a/examples/imagepipe/imagepipe.py +++ b/examples/imagepipe/imagepipe.py @@ -230,9 +230,8 @@ def smoothen_cmd(images, iterations): """Applies a smoothening filter.""" for image in images: click.echo( - "Smoothening '{}' {} time{}".format( - image.filename, iterations, "s" if iterations != 1 else "" - ) + f"Smoothening {image.filename!r} {iterations}" + f" time{'s' if iterations != 1 else ''}" ) for _ in range(iterations): image = copy_filename(image.filter(ImageFilter.BLUR), image) diff --git a/examples/repo/repo.py b/examples/repo/repo.py index ff44d554d..b773f3ae7 100644 --- a/examples/repo/repo.py +++ b/examples/repo/repo.py @@ -78,7 +78,7 @@ def clone(repo, src, dest, shallow, rev): """ if dest is None: dest = posixpath.split(src)[-1] or "." - click.echo("Cloning repo {} to {}".format(src, os.path.abspath(dest))) + click.echo(f"Cloning repo {src} to {os.path.basename(dest)}") repo.home = dest if shallow: click.echo("Making shallow checkout") diff --git a/examples/termui/termui.py b/examples/termui/termui.py index b772f1374..f4886b142 100644 --- a/examples/termui/termui.py +++ b/examples/termui/termui.py @@ -24,7 +24,7 @@ def pager(): """Demonstrates using the pager.""" lines = [] for x in range(200): - lines.append("{}. Hello World!".format(click.style(str(x), fg="green"))) + lines.append(f"{click.style(str(x), fg='green')}. Hello World!") click.echo_via_pager("\n".join(lines)) diff --git a/examples/validation/validation.py b/examples/validation/validation.py index 6f87eb007..3f78df0e7 100644 --- a/examples/validation/validation.py +++ b/examples/validation/validation.py @@ -17,8 +17,7 @@ def convert(self, value, param, ctx): value = urlparse.urlparse(value) if value.scheme not in ("http", "https"): self.fail( - "invalid URL scheme ({}). Only HTTP URLs are" - " allowed".format(value.scheme), + f"invalid URL scheme ({value.scheme}). Only HTTP URLs are allowed", param, ctx, ) diff --git a/src/click/_bashcomplete.py b/src/click/_bashcomplete.py index 1c865f624..b9e4900e0 100644 --- a/src/click/_bashcomplete.py +++ b/src/click/_bashcomplete.py @@ -345,7 +345,7 @@ def do_complete_fish(cli, prog_name): for item in get_choices(cli, prog_name, args, incomplete): if item[1]: - echo("{arg}\t{desc}".format(arg=item[0], desc=item[1])) + echo(f"{item[0]}\t{item[1]}") else: echo(item[0]) diff --git a/src/click/_compat.py b/src/click/_compat.py index 96b0dd8d3..85568ca3e 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -64,7 +64,7 @@ def __init__( errors, force_readable=False, force_writable=False, - **extra + **extra, ): self._stream = stream = _FixupStream(stream, force_readable, force_writable) super().__init__(stream, encoding, errors, **extra) @@ -415,7 +415,7 @@ def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False while True: tmp_filename = os.path.join( os.path.dirname(filename), - ".__atomic-write{:08x}".format(random.randrange(1 << 32)), + f".__atomic-write{random.randrange(1 << 32):08x}", ) try: fd = os.open(tmp_filename, flags, 0o666 if perm is None else perm) diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 84e3fc5ba..f03aa854d 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -173,7 +173,7 @@ def format_pos(self): return pos def format_pct(self): - return "{: 4}%".format(int(self.pct * 100))[1:] + return f"{int(self.pct * 100): 4}%"[1:] def format_bar(self): if self.length_known: @@ -352,10 +352,7 @@ def pager(generator, color=None): fd, filename = tempfile.mkstemp() os.close(fd) try: - if ( - hasattr(os, "system") - and os.system("more {}".format(shlex.quote(filename))) == 0 - ): + if hasattr(os, "system") and os.system(f"more {shlex.quote(filename)}") == 0: return _pipepager(generator, "more", color) return _nullpager(stdout, generator, color) finally: @@ -374,7 +371,7 @@ def _pipepager(generator, cmd, color): # condition that cmd_detail = cmd.rsplit("/", 1)[-1].split() if color is None and cmd_detail[0] == "less": - less_flags = "{}{}".format(os.environ.get("LESS", ""), " ".join(cmd_detail[1:])) + less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_detail[1:])}" if not less_flags: env["LESS"] = "-R" color = True @@ -424,7 +421,7 @@ def _tempfilepager(generator, cmd, color): with open_stream(filename, "wb")[0] as f: f.write(text.encode(encoding)) try: - os.system("{} {}".format(shlex.quote(cmd), shlex.quote(filename))) + os.system(f"{shlex.quote(cmd)} {shlex.quote(filename)}") finally: os.unlink(filename) @@ -469,7 +466,7 @@ def edit_file(self, filename): environ = None try: c = subprocess.Popen( - "{} {}".format(shlex.quote(editor), shlex.quote(filename)), + f"{shlex.quote(editor)} {shlex.quote(filename)}", env=environ, shell=True, ) @@ -546,16 +543,16 @@ def _unquote_file(url): elif WIN: if locate: url = _unquote_file(url) - args = "explorer /select,{}".format(shlex.quote(url)) + args = f"explorer /select,{shlex.quote(url)}" else: - args = 'start {} "" {}'.format("/WAIT" if wait else "", shlex.quote(url)) + args = f"start {'/WAIT' if wait else ''} \"\" {shlex.quote(url)}" return os.system(args) elif CYGWIN: if locate: url = _unquote_file(url) - args = "cygstart {}".format(shlex.quote(os.path.dirname(url))) + args = f"cygstart {shlex.quote(os.path.dirname(url))}" else: - args = "cygstart {} {}".format("-w" if wait else "", shlex.quote(url)) + args = f"cygstart {'-w' if wait else ''} {shlex.quote(url)}" return os.system(args) try: diff --git a/src/click/_textwrap.py b/src/click/_textwrap.py index 6959087b7..7a052b70d 100644 --- a/src/click/_textwrap.py +++ b/src/click/_textwrap.py @@ -33,5 +33,5 @@ def indent_only(self, text): indent = self.initial_indent if idx > 0: indent = self.subsequent_indent - rv.append(indent + line) + rv.append(f"{indent}{line}") return "\n".join(rv) diff --git a/src/click/_unicodefun.py b/src/click/_unicodefun.py index 57545e0a6..7f3f234cb 100644 --- a/src/click/_unicodefun.py +++ b/src/click/_unicodefun.py @@ -55,9 +55,9 @@ def _verify_python3_env(): ) else: extra += ( - "This system lists a couple of UTF-8 supporting locales" - " that you can pick from. The following suitable" - " locales were discovered: {}".format(", ".join(sorted(good_locales))) + "This system lists some UTF-8 supporting locales that" + " you can pick from. The following suitable locales" + f" were discovered: {', '.join(sorted(good_locales))}" ) bad_locale = None @@ -71,12 +71,12 @@ def _verify_python3_env(): "\n\nClick discovered that you exported a UTF-8 locale" " but the locale system could not pick up from it" " because it does not exist. The exported locale is" - " '{}' but it is not supported".format(bad_locale) + f" {bad_locale!r} but it is not supported" ) raise RuntimeError( "Click will abort further execution because Python 3 was" " configured to use ASCII as encoding for the environment." " Consult https://click.palletsprojects.com/python3/ for" - " mitigation steps.{}".format(extra) + f" mitigation steps.{extra}" ) diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index c46081f15..923fdba65 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -213,9 +213,7 @@ def isatty(self): return self.buffer.isatty() def __repr__(self): - return "".format( - self.name, self.encoding - ) + return f"" class WindowsChunkedWriter: diff --git a/src/click/core.py b/src/click/core.py index 78b9ce4b6..e4061aaac 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -36,12 +36,12 @@ SUBCOMMANDS_METAVAR = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." DEPRECATED_HELP_NOTICE = " (DEPRECATED)" -DEPRECATED_INVOKE_NOTICE = "DeprecationWarning: The command %(name)s is deprecated." +DEPRECATED_INVOKE_NOTICE = "DeprecationWarning: The command {name} is deprecated." def _maybe_show_deprecated_notice(cmd): if cmd.deprecated: - echo(style(DEPRECATED_INVOKE_NOTICE % {"name": cmd.name}, fg="red"), err=True) + echo(style(DEPRECATED_INVOKE_NOTICE.format(name=cmd.name), fg="red"), err=True) def fast_exit(code): @@ -56,7 +56,7 @@ def fast_exit(code): def _bashcomplete(cmd, prog_name, complete_var=None): """Internal handler for the bash completion support.""" if complete_var is None: - complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) + complete_var = f"_{prog_name}_COMPLETE".replace("-", "_").upper() complete_instr = os.environ.get(complete_var) if not complete_instr: return @@ -81,17 +81,11 @@ def _check_multicommand(base_command, cmd_name, cmd, register=False): " that is in chain mode. This is not supported." ) raise RuntimeError( - "{}. Command '{}' is set to chain and '{}' was added as" - " subcommand but it in itself is a multi command. ('{}' is a {}" - " within a chained {} named '{}').".format( - hint, - base_command.name, - cmd_name, - cmd_name, - cmd.__class__.__name__, - base_command.__class__.__name__, - base_command.name, - ) + f"{hint}. Command {base_command.name!r} is set to chain and" + f" {cmd_name!r} was added as a subcommand but it in itself is a" + f" multi command. ({cmd_name!r} is a {type(cmd).__name__}" + f" within a chained {type(base_command).__name__} named" + f" {base_command.name!r})." ) @@ -159,8 +153,8 @@ def validate(cls, value): """ if value not in cls.VALUES: raise ValueError( - "Invalid ParameterSource value: '{}'. Valid " - "values are: {}".format(value, ",".join(cls.VALUES)) + f"Invalid ParameterSource value: {value!r}. Valid" + f" values are: {','.join(cls.VALUES)}" ) @@ -381,8 +375,8 @@ def __init__( and parent.auto_envvar_prefix is not None and self.info_name is not None ): - auto_envvar_prefix = "{}_{}".format( - parent.auto_envvar_prefix, self.info_name.upper() + auto_envvar_prefix = ( + f"{parent.auto_envvar_prefix}_{self.info_name.upper()}" ) else: auto_envvar_prefix = auto_envvar_prefix.upper() @@ -1088,9 +1082,9 @@ def parse_args(self, ctx, args): if args and not ctx.allow_extra_args and not ctx.resilient_parsing: ctx.fail( - "Got unexpected extra argument{} ({})".format( - "s" if len(args) != 1 else "", " ".join(map(make_str, args)) - ) + "Got unexpected extra" + f" argument{'s' if len(args) != 1 else ''}" + f" ({' '.join(map(make_str, args))})" ) ctx.args = args @@ -1598,8 +1592,8 @@ def type_cast_value(self, ctx, value): if self.nargs <= 1: raise TypeError( "Attempted to invoke composite type but nargs has" - " been set to {}. This is not supported; nargs" - " needs to be set to a fixed value > 1.".format(self.nargs) + f" been set to {self.nargs}. This is not supported;" + " nargs needs to be set to a fixed value > 1." ) if self.multiple: return tuple(self.type(x or (), self, ctx) for x in value or ()) @@ -1863,8 +1857,9 @@ def _parse_decls(self, decls, expose_value): if not opts and not secondary_opts: raise TypeError( - "No options defined but a name was passed ({}). Did you" - " mean to declare an argument instead of an option?".format(name) + f"No options defined but a name was passed ({name})." + " Did you mean to declare an argument instead of an" + " option?" ) return name, opts, secondary_opts @@ -1924,13 +1919,12 @@ def _write_opts(opts): if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" if envvar is not None: - extra.append( - "env var: {}".format( - ", ".join(str(d) for d in envvar) - if isinstance(envvar, (list, tuple)) - else envvar - ) + var_str = ( + ", ".join(str(d) for d in envvar) + if isinstance(envvar, (list, tuple)) + else envvar ) + extra.append(f"env var: {var_str}") if self.default is not None and (self.show_default or ctx.show_default): if isinstance(self.show_default, str): default_string = f"({self.show_default})" @@ -1945,7 +1939,8 @@ def _write_opts(opts): if self.required: extra.append("required") if extra: - help = "{}[{}]".format(f"{help} " if help else "", "; ".join(extra)) + extra_str = ";".join(extra) + help = f"{help} [{extra_str}]" if help else f"[{extra_str}]" return ("; " if any_prefix_is_slash else " / ").join(rv), help @@ -2061,7 +2056,7 @@ def _parse_decls(self, decls, expose_value): else: raise TypeError( "Arguments take exactly one parameter declaration, got" - " {}".format(len(decls)) + f" {len(decls)}." ) return name, [arg], [] diff --git a/src/click/decorators.py b/src/click/decorators.py index e0596c80e..30133051a 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -66,7 +66,8 @@ def new_func(*args, **kwargs): if obj is None: raise RuntimeError( "Managed to invoke callback without a context" - " object of type '{}' existing".format(object_type.__name__) + f" object of type {object_type.__name__!r}" + " existing." ) return ctx.invoke(f, obj, *args, **kwargs) @@ -96,7 +97,7 @@ def _make_command(f, name, attrs, cls): name=name or f.__name__.lower().replace("_", "-"), callback=f, params=params, - **attrs + **attrs, ) diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 2776a0251..25b02bb0c 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -53,8 +53,9 @@ def show(self, file=None): color = None hint = "" if self.cmd is not None and self.cmd.get_help_option(self.ctx) is not None: - hint = "Try '{} {}' for help.\n".format( - self.ctx.command_path, self.ctx.help_option_names[0] + hint = ( + f"Try '{self.ctx.command_path}" + f" {self.ctx.help_option_names[0]}' for help.\n" ) if self.ctx is not None: color = self.ctx.color @@ -137,12 +138,8 @@ def format_message(self): else: msg = msg_extra - return "Missing {}{}{}{}".format( - param_type, - f" {param_hint}" if param_hint else "", - ". " if msg else ".", - msg or "", - ) + hint_str = f" {param_hint}" if param_hint else "" + return f"Missing {param_type}{hint_str}.{' ' if msg else ''}{msg or ''}" def __str__(self): if self.message is None: @@ -170,10 +167,10 @@ def format_message(self): bits = [self.message] if self.possibilities: if len(self.possibilities) == 1: - bits.append("Did you mean {}?".format(self.possibilities[0])) + bits.append(f"Did you mean {self.possibilities[0]}?") else: possibilities = sorted(self.possibilities) - bits.append("(Possible options: {})".format(", ".join(possibilities))) + bits.append(f"(Possible options: {', '.join(possibilities)})") return " ".join(bits) diff --git a/src/click/formatting.py b/src/click/formatting.py index 5a8b81c89..a298c2e65 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -134,7 +134,7 @@ def write_usage(self, prog, args="", prefix="Usage: "): :param args: whitespace separated list of arguments. :param prefix: the prefix for the first line. """ - usage_prefix = "{:>{w}}{} ".format(prefix, prog, w=self.current_indent) + usage_prefix = f"{prefix:>{self.current_indent}}{prog} " text_width = self.width - self.current_indent if text_width >= (term_len(usage_prefix) + 20): @@ -163,7 +163,7 @@ def write_usage(self, prog, args="", prefix="Usage: "): def write_heading(self, heading): """Writes a heading into the buffer.""" - self.write("{:>{w}}{}:\n".format("", heading, w=self.current_indent)) + self.write(f"{'':>{self.current_indent}}{heading}:\n") def write_paragraph(self): """Writes a paragraph into the buffer.""" @@ -204,7 +204,7 @@ def write_dl(self, rows, col_max=30, col_spacing=2): first_col = min(widths[0], col_max) + col_spacing for first, second in iter_rows(rows, len(widths)): - self.write("{:>{w}}{}".format("", first, w=self.current_indent)) + self.write(f"{'':>{self.current_indent}}{first}") if not second: self.write("\n") continue @@ -219,14 +219,10 @@ def write_dl(self, rows, col_max=30, col_spacing=2): lines = wrapped_text.splitlines() if lines: - self.write("{}\n".format(lines[0])) + self.write(f"{lines[0]}\n") for line in lines[1:]: - self.write( - "{:>{w}}{}\n".format( - "", line, w=first_col + self.current_indent - ) - ) + self.write(f"{'':>{first_col + self.current_indent}}{line}\n") if len(lines) > 1: # separate long help from next option diff --git a/src/click/parser.py b/src/click/parser.py index ae486d031..158abb0de 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -100,7 +100,7 @@ def normalize_opt(opt, ctx): if ctx is None or ctx.token_normalize_func is None: return opt prefix, opt = split_opt(opt) - return prefix + ctx.token_normalize_func(opt) + return f"{prefix}{ctx.token_normalize_func(opt)}" def split_arg_string(string): @@ -361,7 +361,7 @@ def _match_short_opt(self, arg, state): unknown_options = [] for ch in arg[1:]: - opt = normalize_opt(prefix + ch, self.ctx) + opt = normalize_opt(f"{prefix}{ch}", self.ctx) option = self._short_opt.get(opt) i += 1 @@ -399,7 +399,7 @@ def _match_short_opt(self, arg, state): # to the state as new larg. This way there is basic combinatorics # that can be achieved while still ignoring unknown arguments. if self.ignore_unknown_options and unknown_options: - state.largs.append("{}{}".format(prefix, "".join(unknown_options))) + state.largs.append(f"{prefix}{''.join(unknown_options)}") def _process_opts(self, arg, state): explicit_value = None diff --git a/src/click/termui.py b/src/click/termui.py index b9c91c757..a1bdf2ab8 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -56,10 +56,10 @@ def _build_prompt( ): prompt = text if type is not None and show_choices and isinstance(type, Choice): - prompt += " ({})".format(", ".join(map(str, type.choices))) + prompt += f" ({', '.join(map(str, type.choices))})" if default is not None and show_default: - prompt = "{} [{}]".format(prompt, _format_default(default)) - return prompt + suffix + prompt = f"{prompt} [{_format_default(default)}]" + return f"{prompt}{suffix}" def _format_default(default): @@ -502,24 +502,24 @@ def style( bits = [] if fg: try: - bits.append("\033[{}m".format(_ansi_colors[fg])) + bits.append(f"\033[{_ansi_colors[fg]}m") except KeyError: - raise TypeError(f"Unknown color '{fg}'") + raise TypeError(f"Unknown color {fg!r}") if bg: try: - bits.append("\033[{}m".format(_ansi_colors[bg] + 10)) + bits.append(f"\033[{_ansi_colors[bg] + 10}m") except KeyError: - raise TypeError(f"Unknown color '{bg}'") + raise TypeError(f"Unknown color {bg!r}") if bold is not None: - bits.append("\033[{}m".format(1 if bold else 22)) + bits.append(f"\033[{1 if bold else 22}m") if dim is not None: - bits.append("\033[{}m".format(2 if dim else 22)) + bits.append(f"\033[{2 if dim else 22}m") if underline is not None: - bits.append("\033[{}m".format(4 if underline else 24)) + bits.append(f"\033[{4 if underline else 24}m") if blink is not None: - bits.append("\033[{}m".format(5 if blink else 25)) + bits.append(f"\033[{5 if blink else 25}m") if reverse is not None: - bits.append("\033[{}m".format(7 if reverse else 27)) + bits.append(f"\033[{7 if reverse else 27}m") bits.append(text) if reset: bits.append(_ansi_reset_all) diff --git a/src/click/testing.py b/src/click/testing.py index 717d2e473..fd6bf61b1 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -99,9 +99,8 @@ def stderr(self): ) def __repr__(self): - return "<{} {}>".format( - type(self).__name__, repr(self.exception) if self.exception else "okay" - ) + exc_str = repr(self.exception) if self.exception else "okay" + return f"<{type(self).__name__} {exc_str}>" class CliRunner: @@ -196,7 +195,7 @@ def visible_input(prompt=None): return val def hidden_input(prompt=None): - sys.stdout.write("{}\n".format(prompt or "")) + sys.stdout.write(f"{prompt or ''}\n") sys.stdout.flush() return input.readline().rstrip("\r\n") diff --git a/src/click/types.py b/src/click/types.py index 7aae2fe09..93cf70195 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -157,10 +157,11 @@ def __init__(self, choices, case_sensitive=True): self.case_sensitive = case_sensitive def get_metavar(self, param): - return "[{}]".format("|".join(self.choices)) + return f"[{'|'.join(self.choices)}]" def get_missing_message(self, param): - return "Choose from:\n\t{}.".format(",\n\t".join(self.choices)) + choice_str = ",\n\t".join(self.choices) + return f"Choose from:\n\t{choice_str}" def convert(self, value, param, ctx): # Match through normalization and case sensitivity @@ -188,15 +189,13 @@ def convert(self, value, param, ctx): return normed_choices[normed_value] self.fail( - "invalid choice: {}. (choose from {})".format( - value, ", ".join(self.choices) - ), + f"invalid choice: {value}. (choose from {', '.join(self.choices)})", param, ctx, ) def __repr__(self): - return "Choice('{}')".format(list(self.choices)) + return f"Choice({list(self.choices)})" class DateTime(ParamType): @@ -226,7 +225,7 @@ def __init__(self, formats=None): self.formats = formats or ["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"] def get_metavar(self, param): - return "[{}]".format("|".join(self.formats)) + return f"[{'|'.join(self.formats)}]" def _try_to_convert_date(self, value, format): try: @@ -242,9 +241,7 @@ def convert(self, value, param, ctx): return dtime self.fail( - "invalid datetime format: {}. (choose from {})".format( - value, ", ".join(self.formats) - ) + f"invalid datetime format: {value}. (choose from {', '.join(self.formats)})" ) def __repr__(self): @@ -295,25 +292,19 @@ def convert(self, value, param, ctx): ): if self.min is None: self.fail( - "{} is bigger than the maximum valid value {}.".format( - rv, self.max - ), + f"{rv} is bigger than the maximum valid value {self.max}.", param, ctx, ) elif self.max is None: self.fail( - "{} is smaller than the minimum valid value {}.".format( - rv, self.min - ), + f"{rv} is smaller than the minimum valid value {self.min}.", param, ctx, ) else: self.fail( - "{} is not in the valid range of {} to {}.".format( - rv, self.min, self.max - ), + f"{rv} is not in the valid range of {self.min} to {self.max}.", param, ctx, ) @@ -367,25 +358,19 @@ def convert(self, value, param, ctx): ): if self.min is None: self.fail( - "{} is bigger than the maximum valid value {}.".format( - rv, self.max - ), + f"{rv} is bigger than the maximum valid value {self.max}.", param, ctx, ) elif self.max is None: self.fail( - "{} is smaller than the minimum valid value {}.".format( - rv, self.min - ), + f"{rv} is smaller than the minimum valid value {self.min}.", param, ctx, ) else: self.fail( - "{} is not in the valid range of {} to {}.".format( - rv, self.min, self.max - ), + f"{rv} is not in the valid range of {self.min} to {self.max}.", param, ctx, ) @@ -506,9 +491,7 @@ def convert(self, value, param, ctx): return f except OSError as e: # noqa: B014 self.fail( - "Could not open file: {}: {}".format( - filename_to_ui(value), get_strerror(e) - ), + f"Could not open file: {filename_to_ui(value)}: {get_strerror(e)}", param, ctx, ) @@ -600,40 +583,32 @@ def convert(self, value, param, ctx): if not self.exists: return self.coerce_path_result(rv) self.fail( - "{} '{}' does not exist.".format( - self.path_type, filename_to_ui(value) - ), + f"{self.path_type} {filename_to_ui(value)!r} does not exist.", param, ctx, ) if not self.file_okay and stat.S_ISREG(st.st_mode): self.fail( - "{} '{}' is a file.".format(self.path_type, filename_to_ui(value)), + f"{self.path_type} {filename_to_ui(value)!r} is a file.", param, ctx, ) if not self.dir_okay and stat.S_ISDIR(st.st_mode): self.fail( - "{} '{}' is a directory.".format( - self.path_type, filename_to_ui(value) - ), + f"{self.path_type} {filename_to_ui(value)!r} is a directory.", param, ctx, ) if self.writable and not os.access(value, os.W_OK): self.fail( - "{} '{}' is not writable.".format( - self.path_type, filename_to_ui(value) - ), + f"{self.path_type} {filename_to_ui(value)!r} is not writable.", param, ctx, ) if self.readable and not os.access(value, os.R_OK): self.fail( - "{} '{}' is not readable.".format( - self.path_type, filename_to_ui(value) - ), + f"{self.path_type} {filename_to_ui(value)!r} is not readable.", param, ctx, ) @@ -660,7 +635,7 @@ def __init__(self, types): @property def name(self): - return "<{}>".format(" ".join(ty.name for ty in self.types)) + return f"<{' '.join(ty.name for ty in self.types)}>" @property def arity(self): diff --git a/src/click/utils.py b/src/click/utils.py index b18c83dd9..ffd26b388 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -408,7 +408,7 @@ def get_app_dir(app_name, roaming=True, force_posix=False): folder = os.path.expanduser("~") return os.path.join(folder, app_name) if force_posix: - return os.path.join(os.path.expanduser("~/.{}".format(_posixify(app_name)))) + return os.path.join(os.path.expanduser(f"~/.{_posixify(app_name)}")) if sys.platform == "darwin": return os.path.join( os.path.expanduser("~/Library/Application Support"), app_name diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 497048645..c5fee4242 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -10,7 +10,7 @@ def test_nargs_star(runner): @click.argument("src", nargs=-1) @click.argument("dst") def copy(src, dst): - click.echo("src={}".format("|".join(src))) + click.echo(f"src={'|'.join(src)}") click.echo(f"dst={dst}") result = runner.invoke(copy, ["foo.txt", "bar.txt", "dir"]) @@ -33,7 +33,8 @@ def test_nargs_tup(runner): @click.argument("point", nargs=2, type=click.INT) def copy(name, point): click.echo(f"name={name}") - click.echo("point={0[0]}/{0[1]}".format(point)) + x, y = point + click.echo(f"point={x}/{y}") result = runner.invoke(copy, ["peter", "1", "2"]) assert not result.exception @@ -53,7 +54,8 @@ def test_nargs_tup_composite(runner): @click.command() @click.argument("item", **opts) def copy(item): - click.echo("name={0[0]} id={0[1]:d}".format(item)) + name, id = item + click.echo(f"name={name} id={id:d}") result = runner.invoke(copy, ["peter", "1"]) assert not result.exception @@ -187,7 +189,7 @@ def test_empty_nargs(runner): @click.command() @click.argument("arg", nargs=-1) def cmd(arg): - click.echo("arg:{}".format("|".join(arg))) + click.echo(f"arg:{'|'.join(arg)}") result = runner.invoke(cmd, []) assert result.exit_code == 0 @@ -196,7 +198,7 @@ def cmd(arg): @click.command() @click.argument("arg", nargs=-1, required=True) def cmd2(arg): - click.echo("arg:{}".format("|".join(arg))) + click.echo(f"arg:{'|'.join(arg)}") result = runner.invoke(cmd2, []) assert result.exit_code == 2 diff --git a/tests/test_basic.py b/tests/test_basic.py index 9f9d71eaa..c33e3a4ac 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -108,7 +108,7 @@ def test_int_option(runner): @click.command() @click.option("--foo", default=42) def cli(foo): - click.echo("FOO:[{}]".format(foo * 2)) + click.echo(f"FOO:[{foo * 2}]") result = runner.invoke(cli, []) assert not result.exception @@ -192,7 +192,7 @@ def cli(flag): result = runner.invoke(cli, ["--flag"]) assert not result.exception - assert result.output == "{}\n".format(not default) + assert result.output == f"{not default}\n" result = runner.invoke(cli, []) assert not result.exception assert result.output == f"{default}\n" @@ -307,8 +307,8 @@ def write_to_dir(o): @click.command() @click.option("-f", type=click.Path(exists=True)) def showtype(f): - click.echo("is_file={}".format(os.path.isfile(f))) - click.echo("is_dir={}".format(os.path.isdir(f))) + click.echo(f"is_file={os.path.isfile(f)}") + click.echo(f"is_dir={os.path.isdir(f)}") with runner.isolated_filesystem(): result = runner.invoke(showtype, ["-f", "xxx"]) @@ -321,7 +321,7 @@ def showtype(f): @click.command() @click.option("-f", type=click.Path()) def exists(f): - click.echo("exists={}".format(os.path.exists(f))) + click.echo(f"exists={os.path.exists(f)}") with runner.isolated_filesystem(): result = runner.invoke(exists, ["-f", "xxx"]) diff --git a/tests/test_chain.py b/tests/test_chain.py index cf9b19816..046277909 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -7,9 +7,8 @@ def debug(): click.echo( - "{}={}".format( - sys._getframe(1).f_code.co_name, "|".join(click.get_current_context().args) - ) + f"{sys._getframe(1).f_code.co_name}" + f"={'|'.join(click.get_current_context().args)}" ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 1e19746a4..eace114a4 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -210,7 +210,7 @@ def cli(ctx, debug): @cli.command() @click.pass_context def sync(ctx): - click.echo("Debug is {}".format("on" if ctx.obj["DEBUG"] else "off")) + click.echo(f"Debug is {'on' if ctx.obj['DEBUG'] else 'off'}") result = runner.invoke(cli, ["sync"]) assert result.exception is None @@ -264,7 +264,7 @@ def test_unprocessed_options(runner): @click.option("--verbose", "-v", count=True) def cli(verbose, args): click.echo(f"Verbosity: {verbose}") - click.echo("Args: {}".format("|".join(args))) + click.echo(f"Args: {'|'.join(args)}") result = runner.invoke(cli, ["-foo", "-vvvvx", "--muhaha", "x", "y", "-x"]) assert not result.exception diff --git a/tests/test_defaults.py b/tests/test_defaults.py index d55b1f483..0e438eb89 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -32,8 +32,8 @@ def test_nargs_plus_multiple(runner): "--arg", default=((1, 2), (3, 4)), nargs=2, multiple=True, type=click.INT ) def cli(arg): - for item in arg: - click.echo("<{0[0]:d}|{0[1]:d}>".format(item)) + for a, b in arg: + click.echo(f"<{a:d}|{b:d}>") result = runner.invoke(cli, []) assert not result.exception diff --git a/tests/test_options.py b/tests/test_options.py index 95cdad5b3..1b9b27e0e 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -44,8 +44,8 @@ def test_nargs_tup_composite_mult(runner): @click.command() @click.option("--item", type=(str, int), multiple=True) def copy(item): - for item in item: - click.echo("name={0[0]} id={0[1]:d}".format(item)) + for name, id in item: + click.echo(f"name={name} id={id:d}") result = runner.invoke(copy, ["--item", "peter", "1", "--item", "max", "2"]) assert not result.exception @@ -421,12 +421,13 @@ def cmd(config): result = runner.invoke(cmd, ["--help"],) assert result.exit_code == 0 + i = " " * 21 assert ( " -C, --config PATH Configuration file to use.\n" - "{i}\n" - "{i}If not given, the environment variable CONFIG_FILE is\n" - "{i}consulted and used if set. If neither are given, a default\n" - "{i}configuration file is loaded.".format(i=" " * 21) + f"{i}\n" + f"{i}If not given, the environment variable CONFIG_FILE is\n" + f"{i}consulted and used if set. If neither are given, a default\n" + f"{i}configuration file is loaded." ) in result.output diff --git a/tests/test_termui.py b/tests/test_termui.py index 616ab7d1f..7165023b4 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -247,7 +247,7 @@ def cli(f): click.echo(f.name) result = runner.invoke(cli) - assert result.output == "file [{0}]: \n{0}\n".format(__file__) + assert result.output == f"file [{__file__}]: \n{__file__}\n" def test_secho(runner): @@ -354,7 +354,7 @@ def test_getchar_special_key_windows(runner, monkeypatch, special_key_char, key_ click._termui_impl.msvcrt, "getwch", lambda: ordered_inputs.pop() ) monkeypatch.setattr(click.termui, "_getchar", None) - assert click.getchar() == special_key_char + key_char + assert click.getchar() == f"{special_key_char}{key_char}" @pytest.mark.parametrize( diff --git a/tests/test_testing.py b/tests/test_testing.py index 5b2c813ce..7360473f3 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -125,7 +125,7 @@ def cli(): assert not result.exception result = runner.invoke(cli, color=True) - assert result.output == "{}\n".format(click.style("hello world", fg="blue")) + assert result.output == f"{click.style('hello world', fg='blue')}\n" assert not result.exception @@ -213,7 +213,7 @@ def cli_no_error(): def test_env(): @click.command() def cli_env(): - click.echo("ENV={}".format(os.environ["TEST_CLICK_ENV"])) + click.echo(f"ENV={os.environ['TEST_CLICK_ENV']}") runner = CliRunner() From a1d726b5a244a831533e4d27a5be3975288de698 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 20 Apr 2020 00:31:06 -0700 Subject: [PATCH 056/293] remove Python 2/3 from docs --- docs/index.rst | 2 +- docs/python3.rst | 191 --------------------------------------- docs/quickstart.rst | 6 +- docs/unicode-support.rst | 112 +++++++++++++++++++++++ docs/utils.rst | 34 +++---- docs/why.rst | 1 - docs/wincmd.rst | 18 +--- src/click/_unicodefun.py | 10 +- src/click/core.py | 4 +- src/click/utils.py | 10 +- 10 files changed, 138 insertions(+), 250 deletions(-) delete mode 100644 docs/python3.rst create mode 100644 docs/unicode-support.rst diff --git a/docs/index.rst b/docs/index.rst index 683f1ef0d..24afcc83a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -81,7 +81,7 @@ usage patterns. utils bashcomplete exceptions - python3 + unicode-support wincmd API Reference diff --git a/docs/python3.rst b/docs/python3.rst deleted file mode 100644 index c1736a8d7..000000000 --- a/docs/python3.rst +++ /dev/null @@ -1,191 +0,0 @@ -Python 3 Support -================ - -.. currentmodule:: click - -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.4 or higher. - -.. _python3-limitations: - -Python 3 Limitations --------------------- - -At the moment, Click suffers from a few problems with Python 3: - -* The command line in Unix traditionally is in bytes, not Unicode. While - there are encoding hints for all of this, there are generally some - situations where this can break. The most common one is SSH - connections to machines with different locales. - - Misconfigured environments can currently cause a wide range of Unicode - problems in Python 3 due to the lack of support for roundtripping - surrogate escapes. This will not be fixed in Click itself! - - For more information see :ref:`python3-surrogates`. - -* Standard input and output in Python 3 is opened in Unicode mode by - default. Click has to reopen the stream in binary mode in certain - situations. Because there is no standardized way to do this, this - might not always work. Primarily this can become a problem when - testing command-line applications. - - This is not supported:: - - sys.stdin = io.StringIO('Input here') - sys.stdout = io.StringIO() - - Instead you need to do this:: - - input = 'Input here' - in_stream = io.BytesIO(input.encode('utf-8')) - sys.stdin = io.TextIOWrapper(in_stream, encoding='utf-8') - out_stream = io.BytesIO() - sys.stdout = io.TextIOWrapper(out_stream, encoding='utf-8') - - Remember that in that case, you need to use ``out_stream.getvalue()`` - and not ``sys.stdout.getvalue()`` if you want to access the buffer - contents as the wrapper will not forward that method. - -Python 2 and 3 Differences --------------------------- - -Click attempts to minimize the differences between Python 2 and Python 3 -by following best practices for both languages. - -In Python 2, the following is true: - -* ``sys.stdin``, ``sys.stdout``, and ``sys.stderr`` are opened in binary - mode, but under some circumstances they support Unicode output. Click - attempts to not subvert this but provides support for forcing streams - to be Unicode-based. -* ``sys.argv`` is always byte-based. Click will pass bytes to all - input types and convert as necessary. The :class:`STRING` type - automatically will decode properly the input value into a string by - trying the most appropriate encodings. -* When dealing with files, Click will never go through the Unicode APIs - and will instead use the operating system's byte APIs to open the - files. - -In Python 3, the following is true: - -* ``sys.stdin``, ``sys.stdout`` and ``sys.stderr`` are by default - text-based. When Click needs a binary stream, it attempts to discover - the underlying binary stream. See :ref:`python3-limitations` for how - this works. -* ``sys.argv`` is always Unicode-based. This also means that the native - type for input values to the types in Click is Unicode, and not bytes. - - This causes problems if the terminal is incorrectly set and Python - does not figure out the encoding. In that case, the Unicode string - will contain error bytes encoded as surrogate escapes. -* When dealing with files, Click will always use the Unicode file system - API calls by using the operating system's reported or guessed - filesystem encoding. Surrogates are supported for filenames, so it - should be possible to open files through the :class:`File` type even - if the environment is misconfigured. - -.. _python3-surrogates: - -Python 3 Surrogate Handling ---------------------------- - -Click in Python 3 does all the Unicode handling in the standard library -and is subject to its behavior. In Python 2, Click does all the Unicode -handling itself, which means there are differences in error behavior. - -The most glaring difference is that in Python 2, Unicode will "just work", -while in Python 3, it requires extra care. The reason for this is that in -Python 3, the encoding detection is done in the interpreter, and on Linux -and certain other operating systems, its encoding handling is problematic. - -The biggest source of frustration is that Click scripts invoked by -init systems (sysvinit, upstart, systemd, etc.), deployment tools (salt, -puppet), or cron jobs (cron) will refuse to work unless a Unicode locale is -exported. - -If Click encounters such an environment it will prevent further execution -to force you to set a locale. This is done because Click cannot know -about the state of the system once it's invoked and restore the values -before Python's Unicode handling kicked in. - -If you see something like this error in Python 3:: - - Traceback (most recent call last): - ... - RuntimeError: Click will abort further execution because Python 3 was - configured to use ASCII as encoding for the environment. Either switch - to Python 2 or consult the Python 3 section of the docs for - mitigation steps. - -.. note:: - - In Python 3.7 and later you will no longer get a ``RuntimeError`` in - many cases thanks to :pep:`538` and :pep:`540`, which changed the - default assumption in unconfigured environments. - -You are dealing with an environment where Python 3 thinks you are -restricted to ASCII data. The solution to these problems is different -depending on which locale your computer is running in. - -For instance, if you have a German Linux machine, you can fix the problem -by exporting the locale to ``de_DE.utf-8``:: - - export LC_ALL=de_DE.utf-8 - export LANG=de_DE.utf-8 - -If you are on a US machine, ``en_US.utf-8`` is the encoding of choice. On -some newer Linux systems, you could also try ``C.UTF-8`` as the locale:: - - export LC_ALL=C.UTF-8 - export LANG=C.UTF-8 - -On some systems it was reported that `UTF-8` has to be written as `UTF8` -and vice versa. To see which locales are supported you can invoke -``locale -a``:: - - locale -a - -You need to do this before you invoke your Python script. If you are -curious about the reasons for this, you can join the discussions in the -Python 3 bug tracker: - -* `ASCII is a bad filesystem default encoding - `_ -* `Use surrogateescape as default error handler - `_ -* `Python 3 raises Unicode errors in the C locale - `_ -* `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) - -Note (Python 3.7 onwards): Even though your locale may not be properly -configured, Python 3.7 Click will not raise the above exception because Python -3.7 programs are better at choosing default locales. This doesn't change the -general issue that your locale may be misconfigured. - -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/quickstart.rst b/docs/quickstart.rst index 51f7f22ff..fd6bce4e5 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -157,7 +157,7 @@ Echoing Why does this example use :func:`echo` instead of the regular :func:`print` function? The answer to this question is that Click -attempts to support both Python 2 and Python 3 the same way and to be very +attempts to support different environments consistently and to be very robust even when the environment is misconfigured. Click wants to be functional at least on a basic level even if everything is completely broken. @@ -169,9 +169,7 @@ correction in case the terminal is misconfigured instead of dying with an As an added benefit, starting with Click 2.0, the echo function also has good support for ANSI colors. It will automatically strip ANSI codes if the output stream is a file and if colorama is supported, ANSI colors -will also work on Windows. Note that in Python 2, the :func:`echo` function -does not parse color code information from bytearrays. See :ref:`ansi-colors` -for more information. +will also work on Windows. See :ref:`ansi-colors`. If you don't need this, you can also use the `print()` construct / function. diff --git a/docs/unicode-support.rst b/docs/unicode-support.rst new file mode 100644 index 000000000..680e73995 --- /dev/null +++ b/docs/unicode-support.rst @@ -0,0 +1,112 @@ +Unicode Support +=============== + +.. currentmodule:: click + +Click has to take extra care to support Unicode text in different +environments. + +* The command line in Unix is traditionally bytes, not Unicode. While + there are encoding hints, there are some situations where this can + break. The most common one is SSH connections to machines with + different locales. + + Misconfigured environments can cause a wide range of Unicode + problems due to the lack of support for roundtripping surrogate + escapes. This will not be fixed in Click itself! + +* Standard input and output is opened in text mode by default. Click + has to reopen the stream in binary mode in certain situations. + Because there is no standard way to do this, it might not always + work. Primarily this can become a problem when testing command-line + applications. + + This is not supported:: + + sys.stdin = io.StringIO('Input here') + sys.stdout = io.StringIO() + + Instead you need to do this:: + + input = 'Input here' + in_stream = io.BytesIO(input.encode('utf-8')) + sys.stdin = io.TextIOWrapper(in_stream, encoding='utf-8') + out_stream = io.BytesIO() + sys.stdout = io.TextIOWrapper(out_stream, encoding='utf-8') + + Remember in that case, you need to use ``out_stream.getvalue()`` + and not ``sys.stdout.getvalue()`` if you want to access the buffer + contents as the wrapper will not forward that method. + +* ``sys.stdin``, ``sys.stdout`` and ``sys.stderr`` are by default + text-based. When Click needs a binary stream, it attempts to + discover the underlying binary stream. + +* ``sys.argv`` is always text. This means that the native type for + input values to the types in Click is Unicode, not bytes. + + This causes problems if the terminal is incorrectly set and Python + does not figure out the encoding. In that case, the Unicode string + will contain error bytes encoded as surrogate escapes. + +* When dealing with files, Click will always use the Unicode file + system API by using the operating system's reported or guessed + filesystem encoding. Surrogates are supported for filenames, so it + should be possible to open files through the :class:`File` type even + if the environment is misconfigured. + + +Surrogate Handling +------------------ + +Click does all the Unicode handling in the standard library and is +subject to its behavior. Unicode requires extra care. The reason for +this is that the encoding detection is done in the interpreter, and on +Linux and certain other operating systems, its encoding handling is +problematic. + +The biggest source of frustration is that Click scripts invoked by init +systems, deployment tools, or cron jobs will refuse to work unless a +Unicode locale is exported. + +If Click encounters such an environment it will prevent further +execution to force you to set a locale. This is done because Click +cannot know about the state of the system once it's invoked and restore +the values before Python's Unicode handling kicked in. + +If you see something like this error:: + + Traceback (most recent call last): + ... + RuntimeError: Click will abort further execution because Python was + configured to use ASCII as encoding for the environment. Consult + https://click.palletsprojects.com/unicode-support/ for mitigation + steps. + +You are dealing with an environment where Python thinks you are +restricted to ASCII data. The solution to these problems is different +depending on which locale your computer is running in. + +For instance, if you have a German Linux machine, you can fix the +problem by exporting the locale to ``de_DE.utf-8``:: + + export LC_ALL=de_DE.utf-8 + export LANG=de_DE.utf-8 + +If you are on a US machine, ``en_US.utf-8`` is the encoding of choice. +On some newer Linux systems, you could also try ``C.UTF-8`` as the +locale:: + + export LC_ALL=C.UTF-8 + export LANG=C.UTF-8 + +On some systems it was reported that ``UTF-8`` has to be written as +``UTF8`` and vice versa. To see which locales are supported you can +invoke ``locale -a``. + +You need to export the values before you invoke your Python script. + +In Python 3.7 and later you will no longer get a ``RuntimeError`` in +many cases thanks to :pep:`538` and :pep:`540`, which changed the +default assumption in unconfigured environments. This doesn't change the +general issue that your locale may be misconfigured. diff --git a/docs/utils.rst b/docs/utils.rst index 7dd8dbb46..6338df941 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -13,9 +13,7 @@ Printing to Stdout The most obvious helper is the :func:`echo` function, which in many ways works like the Python ``print`` statement or function. The main difference is -that it works the same in Python 2 and 3, it intelligently detects -misconfigured output streams, and it will never fail (except in Python 3; for -more information see :ref:`python3-limitations`). +that it works the same in many different terminal environments. Example:: @@ -23,10 +21,8 @@ Example:: click.echo('Hello World!') -Most importantly, it can print both Unicode and binary data, unlike the -built-in ``print`` function in Python 3, which cannot output any bytes. It -will, however, emit a trailing newline by default, which needs to be -suppressed by passing ``nl=False``:: +It can output both text and binary data. It will emit a trailing newline +by default, which needs to be suppressed by passing ``nl=False``:: click.echo(b'\xe2\x98\x83', nl=False) @@ -34,19 +30,17 @@ Last but not least :func:`echo` uses click's intelligent internal output streams to stdout and stderr which support unicode output on the Windows console. This means for as long as you are using `click.echo` you can output unicode characters (there are some limitations on the default font -with regards to which characters can be displayed). This functionality is -new in Click 6.0. +with regards to which characters can be displayed). .. versionadded:: 6.0 -Click now emulates output streams on Windows to support unicode to the +Click emulates output streams on Windows to support unicode to the Windows console through separate APIs. For more information see :doc:`wincmd`. .. versionadded:: 3.0 -Starting with Click 3.0 you can also easily print to standard error by -passing ``err=True``:: +You can also easily print to standard error by passing ``err=True``:: click.echo('Hello World!', err=True) @@ -58,11 +52,10 @@ ANSI Colors .. versionadded:: 2.0 -Starting with Click 2.0, the :func:`echo` function gained extra -functionality to deal with ANSI colors and styles. Note that on Windows, -this functionality is only available if `colorama`_ is installed. If it -is installed, then ANSI codes are intelligently handled. Note that in Python -2, the echo function doesn't parse color code information from bytearrays. +The :func:`echo` function gained extra functionality to deal with ANSI +colors and styles. Note that on Windows, this functionality is only +available if `colorama`_ is installed. If it is installed, then ANSI +codes are intelligently handled. Primarily this means that: @@ -252,9 +245,7 @@ Printing Filenames ------------------ Because filenames might not be Unicode, formatting them can be a bit -tricky. Generally, this is easier in Python 2 than on 3, as you can just -write the bytes to stdout with the ``print`` function, but in Python 3, you -will always need to operate in Unicode. +tricky. The way this works with click is through the :func:`format_filename` function. It does a best-effort conversion of the filename to Unicode and @@ -280,8 +271,7 @@ Because of this, click provides the :func:`get_binary_stream` and different Python versions and for a wide variety of terminal configurations. The end result is that these functions will always return a functional -stream object (except in very odd cases in Python 3; see -:ref:`python3-limitations`). +stream object (except in very odd cases; see :doc:`/unicode-support`). Example:: diff --git a/docs/why.rst b/docs/why.rst index 9418bfe74..d0912137b 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -12,7 +12,6 @@ line utility for Python out there which ticks the following boxes: * supports loading values from environment variables out of the box * support for prompting of custom values * is fully nestable and composable -* works the same in Python 2 and 3 * supports file handling out of the box * comes with useful common helpers (getting terminal dimensions, ANSI colors, fetching direct keyboard input, screen clearing, diff --git a/docs/wincmd.rst b/docs/wincmd.rst index 901ee9535..5727f2ff8 100644 --- a/docs/wincmd.rst +++ b/docs/wincmd.rst @@ -3,11 +3,7 @@ Windows Console Notes .. versionadded:: 6.0 -Until Click 6.0 there are various bugs and limitations with using Click on -a Windows console. Most notably the decoding of command line arguments -was performed with the wrong encoding on Python 2 and on all versions of -Python output of unicode characters was impossible. Starting with Click -6.0 we now emulate output streams on Windows to support unicode to the +Click emulates output streams on Windows to support unicode to the Windows console through separate APIs and we perform different decoding of parameters. @@ -22,18 +18,10 @@ performed to the type expected value as late as possible. This has some advantages as it allows us to accept the data in the most appropriate form for the operating system and Python version. -For instance paths are left as bytes on Python 2 unless you explicitly -tell it otherwise. - This caused some problems on Windows where initially the wrong encoding was used and garbage ended up in your input data. We not only fixed the encoding part, but we also now extract unicode parameters from `sys.argv`. -This means that on Python 2 under Windows, the arguments processed will -*most likely* be of unicode nature and not bytes. This was something that -previously did not really happen unless you explicitly passed in unicode -parameters so your custom types need to be aware of this. - There is also another limitation with this: if `sys.argv` was modified prior to invoking a click handler, we have to fall back to the regular byte input in which case not all unicode values are available but only a @@ -55,10 +43,6 @@ stream will also use ``utf-16-le`` as internal encoding. However there is some hackery going on that the underlying raw IO buffer is still bypassing the unicode APIs and byte output through an indirection is still possible. -This hackery is used on both Python 2 and Python 3 as neither version of -Python has native support for cmd.exe with unicode characters. There are -some limitations you need to be aware of: - * This unicode support is limited to ``click.echo``, ``click.prompt`` as well as ``click.get_text_stream``. * Depending on if unicode values or byte strings are passed the control diff --git a/src/click/_unicodefun.py b/src/click/_unicodefun.py index 7f3f234cb..53ec9d267 100644 --- a/src/click/_unicodefun.py +++ b/src/click/_unicodefun.py @@ -2,8 +2,8 @@ import os -def _verify_python3_env(): - """Ensures that the environment is good for unicode on Python 3.""" +def _verify_python_env(): + """Ensures that the environment is good for Unicode.""" try: import locale @@ -75,8 +75,8 @@ def _verify_python3_env(): ) raise RuntimeError( - "Click will abort further execution because Python 3 was" + "Click will abort further execution because Python was" " configured to use ASCII as encoding for the environment." - " Consult https://click.palletsprojects.com/python3/ for" - f" mitigation steps.{extra}" + " Consult https://click.palletsprojects.com/unicode-support/" + f" for mitigation steps.{extra}" ) diff --git a/src/click/core.py b/src/click/core.py index e4061aaac..b7124df4f 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -6,7 +6,7 @@ from functools import update_wrapper from itertools import repeat -from ._unicodefun import _verify_python3_env +from ._unicodefun import _verify_python_env from .exceptions import Abort from .exceptions import BadParameter from .exceptions import ClickException @@ -787,7 +787,7 @@ def main( """ # Verify that the environment is configured correctly, or reject # further execution to avoid a broken script. - _verify_python3_env() + _verify_python_env() if args is None: args = sys.argv[1:] diff --git a/src/click/utils.py b/src/click/utils.py index ffd26b388..bd9dd8e7a 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -265,11 +265,7 @@ def echo(message=None, file=None, nl=True, err=False, color=None): def get_binary_stream(name): - """Returns a system stream for byte processing. This essentially - returns the stream from the sys module with the given name but it - solves some compatibility issues between different Python versions. - Primarily this function is necessary for getting binary streams on - Python 3. + """Returns a system stream for byte processing. :param name: the name of the stream to open. Valid names are ``'stdin'``, ``'stdout'`` and ``'stderr'`` @@ -283,8 +279,8 @@ def get_binary_stream(name): def get_text_stream(name, encoding=None, errors="strict"): """Returns a system stream for text processing. This usually returns a wrapped stream around a binary stream returned from - :func:`get_binary_stream` but it also can take shortcuts on Python 3 - for already correctly configured streams. + :func:`get_binary_stream` but it also can take shortcuts for already + correctly configured streams. :param name: the name of the stream to open. Valid names are ``'stdin'``, ``'stdout'`` and ``'stderr'`` From 7cc7d4037282819f0c2728d2bcb4c815f73c951d Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 23 May 2020 14:16:07 -0700 Subject: [PATCH 057/293] add EditorConfig --- .editorconfig | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..e32c8029d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 +max_line_length = 88 + +[*.{yml,yaml,json,js,css,html}] +indent_size = 2 From 93611accaebc32085c1fd773015d654694ddfc55 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 23 May 2020 16:03:57 -0700 Subject: [PATCH 058/293] use pip-compile to pin dev requirements --- .pre-commit-config.yaml | 8 +++--- .readthedocs.yaml | 2 +- MANIFEST.in | 1 + docs/requirements.txt | 4 --- requirements/dev.in | 5 ++++ requirements/dev.txt | 57 +++++++++++++++++++++++++++++++++++++++++ requirements/docs.in | 4 +++ requirements/docs.txt | 36 ++++++++++++++++++++++++++ requirements/tests.in | 2 ++ requirements/tests.txt | 16 ++++++++++++ tox.ini | 6 ++--- 11 files changed, 128 insertions(+), 13 deletions(-) delete mode 100644 docs/requirements.txt create mode 100644 requirements/dev.in create mode 100644 requirements/dev.txt create mode 100644 requirements/docs.in create mode 100644 requirements/docs.txt create mode 100644 requirements/tests.in create mode 100644 requirements/tests.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3bd32949e..bcdd24762 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.2.0 + rev: v2.4.3 hooks: - id: pyupgrade args: ["--py36-plus"] - repo: https://github.com/asottile/reorder_python_imports - rev: v2.2.0 + rev: v2.3.0 hooks: - id: reorder-python-imports args: ["--application-directories", "src"] @@ -14,14 +14,14 @@ repos: hooks: - id: black - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.9 + rev: 3.8.2 hooks: - id: flake8 additional_dependencies: - flake8-bugbear - flake8-implicit-str-concat - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.5.0 + rev: v3.1.0 hooks: - id: check-byte-order-marker - id: trailing-whitespace diff --git a/.readthedocs.yaml b/.readthedocs.yaml index af232107a..1cbed9fa0 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,8 +1,8 @@ version: 2 python: install: + - requirements: docs/requirements.txt - method: pip path: . - - requirements: docs/requirements.txt sphinx: builder: dirhtml diff --git a/MANIFEST.in b/MANIFEST.in index b130d0445..8690e3550 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include CHANGES.rst include tox.ini +include requirements/*.txt graft artwork graft docs prune docs/_build diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index a8f28e200..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Sphinx~=2.4.4 -Pallets-Sphinx-Themes~=1.2.3 -sphinxcontrib-log-cabinet~=1.0.1 -sphinx-issues~=1.2.0 diff --git a/requirements/dev.in b/requirements/dev.in new file mode 100644 index 000000000..c854000e4 --- /dev/null +++ b/requirements/dev.in @@ -0,0 +1,5 @@ +-r docs.in +-r tests.in +pip-tools +pre-commit +tox diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 000000000..aa36890f4 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,57 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/dev.in +# +alabaster==0.7.12 # via sphinx +appdirs==1.4.4 # via virtualenv +attrs==19.3.0 # via pytest +babel==2.8.0 # via sphinx +certifi==2020.4.5.1 # via requests +cfgv==3.1.0 # via pre-commit +chardet==3.0.4 # via requests +click==7.1.2 # via pip-tools +colorama==0.4.3 # via -r requirements/tests.in +distlib==0.3.0 # via virtualenv +docutils==0.16 # via sphinx +filelock==3.0.12 # via tox, virtualenv +identify==1.4.16 # via pre-commit +idna==2.9 # via requests +imagesize==1.2.0 # via sphinx +jinja2==2.11.2 # via sphinx +markupsafe==1.1.1 # via jinja2 +more-itertools==8.3.0 # via pytest +nodeenv==1.3.5 # via pre-commit +packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox +pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in +pip-tools==5.1.2 # via -r requirements/dev.in +pluggy==0.13.1 # via pytest, tox +pre-commit==2.4.0 # via -r requirements/dev.in +py==1.8.1 # via pytest, tox +pygments==2.6.1 # via sphinx +pyparsing==2.4.7 # via packaging +pytest==5.4.2 # via -r requirements/tests.in +pytz==2020.1 # via babel +pyyaml==5.3.1 # via pre-commit +requests==2.23.0 # via sphinx +six==1.15.0 # via packaging, pip-tools, tox, virtualenv +snowballstemmer==2.0.0 # via sphinx +sphinx-issues==1.2.0 # via -r requirements/docs.in +sphinx==3.0.3 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinxcontrib-applehelp==1.0.2 # via sphinx +sphinxcontrib-devhelp==1.0.2 # via sphinx +sphinxcontrib-htmlhelp==1.0.3 # via sphinx +sphinxcontrib-jsmath==1.0.1 # via sphinx +sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in +sphinxcontrib-qthelp==1.0.3 # via sphinx +sphinxcontrib-serializinghtml==1.1.4 # via sphinx +toml==0.10.1 # via pre-commit, tox +tox==3.15.1 # via -r requirements/dev.in +urllib3==1.25.9 # via requests +virtualenv==20.0.21 # via pre-commit, tox +wcwidth==0.1.9 # via pytest + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/docs.in b/requirements/docs.in new file mode 100644 index 000000000..7ec501b6d --- /dev/null +++ b/requirements/docs.in @@ -0,0 +1,4 @@ +Pallets-Sphinx-Themes +Sphinx +sphinx-issues +sphinxcontrib-log-cabinet diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 000000000..3e8043b29 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,36 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/docs.in +# +alabaster==0.7.12 # via sphinx +babel==2.8.0 # via sphinx +certifi==2020.4.5.1 # via requests +chardet==3.0.4 # via requests +docutils==0.16 # via sphinx +idna==2.9 # via requests +imagesize==1.2.0 # via sphinx +jinja2==2.11.2 # via sphinx +markupsafe==1.1.1 # via jinja2 +packaging==20.4 # via pallets-sphinx-themes, sphinx +pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in +pygments==2.6.1 # via sphinx +pyparsing==2.4.7 # via packaging +pytz==2020.1 # via babel +requests==2.23.0 # via sphinx +six==1.15.0 # via packaging +snowballstemmer==2.0.0 # via sphinx +sphinx-issues==1.2.0 # via -r requirements/docs.in +sphinx==3.0.3 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinxcontrib-applehelp==1.0.2 # via sphinx +sphinxcontrib-devhelp==1.0.2 # via sphinx +sphinxcontrib-htmlhelp==1.0.3 # via sphinx +sphinxcontrib-jsmath==1.0.1 # via sphinx +sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in +sphinxcontrib-qthelp==1.0.3 # via sphinx +sphinxcontrib-serializinghtml==1.1.4 # via sphinx +urllib3==1.25.9 # via requests + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/tests.in b/requirements/tests.in new file mode 100644 index 000000000..808be0535 --- /dev/null +++ b/requirements/tests.in @@ -0,0 +1,2 @@ +pytest +colorama diff --git a/requirements/tests.txt b/requirements/tests.txt new file mode 100644 index 000000000..682ebd18d --- /dev/null +++ b/requirements/tests.txt @@ -0,0 +1,16 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/tests.in +# +attrs==19.3.0 # via pytest +colorama==0.4.3 # via -r requirements/tests.in +more-itertools==8.3.0 # via pytest +packaging==20.4 # via pytest +pluggy==0.13.1 # via pytest +py==1.8.1 # via pytest +pyparsing==2.4.7 # via packaging +pytest==5.4.2 # via -r requirements/tests.in +six==1.15.0 # via packaging +wcwidth==0.1.9 # via pytest diff --git a/tox.ini b/tox.ini index 8f0044dd0..9b6d47135 100644 --- a/tox.ini +++ b/tox.ini @@ -6,9 +6,7 @@ envlist = skip_missing_interpreters = true [testenv] -deps = - pytest - colorama +deps = -r requirements/tests.txt commands = pytest --tb=short --basetemp={envtmpdir} {posargs} [testenv:style] @@ -17,5 +15,5 @@ skip_install = true commands = pre-commit run --all-files --show-diff-on-failure [testenv:docs] -deps = -r docs/requirements.txt +deps = -r requirements/docs.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html From 96ea4b8cbd89bf1a1bb3e2e831a5483de6994de3 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 23 May 2020 16:08:32 -0700 Subject: [PATCH 059/293] use GitHub Actions for CI --- .azure-pipelines.yml | 42 ----------------------------- .github/workflows/tests.yaml | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 42 deletions(-) delete mode 100644 .azure-pipelines.yml create mode 100644 .github/workflows/tests.yaml diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml deleted file mode 100644 index 2b4d41a9f..000000000 --- a/.azure-pipelines.yml +++ /dev/null @@ -1,42 +0,0 @@ -trigger: - - master - - '*.x' - -variables: - vmImage: ubuntu-latest - python.version: '3.8' - TOXENV: py - -strategy: - matrix: - Linux: - vmImage: ubuntu-latest - Windows: - vmImage: windows-latest - Mac: - vmImage: macos-latest - Python 3.7: - python.version: '3.7' - Python 3.6: - python.version: '3.6' - PyPy: - python.version: pypy3 - Docs: - TOXENV: docs - Style: - TOXENV: style - -pool: - vmImage: $(vmImage) - -steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: $(python.version) - displayName: Use Python $(python.version) - - - script: pip --disable-pip-version-check install -U tox - displayName: Install tox - - - script: tox - displayName: Run tox diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 000000000..4c457e176 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,52 @@ +name: Tests +on: + push: + branches: + - master + - '*.x' + pull_request: + branches: + - master + - '*.x' +jobs: + tests: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - {name: Linux, python: '3.8', os: ubuntu-latest, tox: py38} + - {name: Windows, python: '3.8', os: windows-latest, tox: py38} + - {name: Mac, python: '3.8', os: macos-latest, tox: py38} + - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} + - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} + - {name: 'PyPy', python: pypy3, os: ubuntu-latest, tox: pypy3} + - {name: Style, python: '3.8', os: ubuntu-latest, tox: style} + - {name: Docs, python: '3.8', os: ubuntu-latest, tox: docs} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: update pip + run: | + pip install -U wheel + pip install -U setuptools + python -m pip install -U pip + - name: get pip cache dir + id: pip-cache + run: echo "::set-output name=dir::$(pip cache dir)" + - name: cache pip + uses: actions/cache@v1 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('setup.py') }}|${{ hashFiles('requirements/*.txt') }} + - name: cache pre-commit + uses: actions/cache@v1 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ matrix.python }}|${{ hashFiles('.pre-commit-config.yaml') }} + if: matrix.tox == 'style' + - run: pip install tox + - run: tox -e ${{ matrix.tox }} From 334c5c08415a4cf975d0bcd5e9e3448b8a650a53 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 27 May 2020 06:46:39 -0700 Subject: [PATCH 060/293] Bump sphinx from 3.0.3 to 3.0.4 (#1563) Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.0.3 to 3.0.4. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.0.3...v3.0.4) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index aa36890f4..7b97bb6df 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -38,7 +38,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging, pip-tools, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.0.3 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.0.4 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index 3e8043b29..60459d788 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -22,7 +22,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.0.3 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.0.4 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx From da7bf34eb286b3371215b0ae35e0ed1e3781619a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 28 May 2020 08:53:08 -0700 Subject: [PATCH 061/293] Bump pip-tools from 5.1.2 to 5.2.0 (#1564) Bumps [pip-tools](https://github.com/jazzband/pip-tools) from 5.1.2 to 5.2.0. - [Release notes](https://github.com/jazzband/pip-tools/releases) - [Changelog](https://github.com/jazzband/pip-tools/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/pip-tools/compare/5.1.2...5.2.0) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 7b97bb6df..5e870289e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -25,7 +25,7 @@ more-itertools==8.3.0 # via pytest nodeenv==1.3.5 # via pre-commit packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pip-tools==5.1.2 # via -r requirements/dev.in +pip-tools==5.2.0 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox pre-commit==2.4.0 # via -r requirements/dev.in py==1.8.1 # via pytest, tox From ec08c56407310f00a73bb935b54615e75f04e01f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2020 08:44:05 +0000 Subject: [PATCH 062/293] Bump pytest from 5.4.2 to 5.4.3 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.4.2 to 5.4.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.4.2...5.4.3) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 5e870289e..d2d4f1b91 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -31,7 +31,7 @@ pre-commit==2.4.0 # via -r requirements/dev.in py==1.8.1 # via pytest, tox pygments==2.6.1 # via sphinx pyparsing==2.4.7 # via packaging -pytest==5.4.2 # via -r requirements/tests.in +pytest==5.4.3 # via -r requirements/tests.in pytz==2020.1 # via babel pyyaml==5.3.1 # via pre-commit requests==2.23.0 # via sphinx diff --git a/requirements/tests.txt b/requirements/tests.txt index 682ebd18d..f37170203 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -11,6 +11,6 @@ packaging==20.4 # via pytest pluggy==0.13.1 # via pytest py==1.8.1 # via pytest pyparsing==2.4.7 # via packaging -pytest==5.4.2 # via -r requirements/tests.in +pytest==5.4.3 # via -r requirements/tests.in six==1.15.0 # via packaging wcwidth==0.1.9 # via pytest From c99e0e8baf7012df214147dc4aa92c51f3cd6edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 6 Jun 2020 02:02:08 +0200 Subject: [PATCH 063/293] Include --help option in completion (#1504) --- CHANGES.rst | 6 ++++ src/click/_bashcomplete.py | 6 ++-- tests/test_bashcomplete.py | 62 +++++++++++++++++++++++++------------- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 273c00f9e..d4cfb7367 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. currentmodule:: click +Version 7.2 +----------- + +- Include ``--help`` option in completion. :pr:`1504` + + Version 7.1.2 ------------- diff --git a/src/click/_bashcomplete.py b/src/click/_bashcomplete.py index 8bca24480..63a9a6dc5 100644 --- a/src/click/_bashcomplete.py +++ b/src/click/_bashcomplete.py @@ -297,7 +297,7 @@ def get_choices(cli, prog_name, args, incomplete): completions = [] if not has_double_dash and start_of_option(incomplete): # completions for partial options - for param in ctx.command.params: + for param in ctx.command.get_params(ctx): if isinstance(param, Option) and not param.hidden: param_opts = [ param_opt @@ -309,11 +309,11 @@ def get_choices(cli, prog_name, args, incomplete): ) return completions # completion for option values from user supplied values - for param in ctx.command.params: + for param in ctx.command.get_params(ctx): 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: + for param in ctx.command.get_params(ctx): if is_incomplete_argument(ctx.params, param): return get_user_autocompletions(ctx, all_args, incomplete, param) diff --git a/tests/test_bashcomplete.py b/tests/test_bashcomplete.py index df4ef287c..2fa34d999 100644 --- a/tests/test_bashcomplete.py +++ b/tests/test_bashcomplete.py @@ -20,7 +20,7 @@ def test_single_command(): def cli(local_opt): pass - assert choices_without_help(cli, [], "-") == ["--local-opt"] + assert choices_without_help(cli, [], "-") == ["--local-opt", "--help"] assert choices_without_help(cli, [], "") == [] @@ -30,7 +30,7 @@ def test_boolean_flag(): def cli(local_opt): pass - assert choices_without_help(cli, [], "-") == ["--shout", "--no-shout"] + assert choices_without_help(cli, [], "-") == ["--shout", "--no-shout", "--help"] def test_multi_value_option(): @@ -44,7 +44,7 @@ def cli(local_opt): def sub(local_opt): pass - assert choices_without_help(cli, [], "-") == ["--pos"] + assert choices_without_help(cli, [], "-") == ["--pos", "--help"] assert choices_without_help(cli, ["--pos"], "") == [] assert choices_without_help(cli, ["--pos", "1.0"], "") == [] assert choices_without_help(cli, ["--pos", "1.0", "1.0"], "") == ["sub"] @@ -56,7 +56,7 @@ def test_multi_option(): def cli(local_opt): pass - assert choices_without_help(cli, [], "-") == ["--message", "-m"] + assert choices_without_help(cli, [], "-") == ["--message", "-m", "--help"] assert choices_without_help(cli, ["-m"], "") == [] @@ -72,9 +72,9 @@ def sub(local_opt): pass assert choices_without_help(cli, [], "") == ["sub"] - assert choices_without_help(cli, [], "-") == ["--global-opt"] + assert choices_without_help(cli, [], "-") == ["--global-opt", "--help"] assert choices_without_help(cli, ["sub"], "") == [] - assert choices_without_help(cli, ["sub"], "-") == ["--local-opt"] + assert choices_without_help(cli, ["sub"], "-") == ["--local-opt", "--help"] def test_long_chain(): @@ -116,16 +116,17 @@ def search_colors(ctx, args, incomplete): def csub(csub_opt, color): pass - assert choices_without_help(cli, [], "-") == ["--cli-opt"] + assert choices_without_help(cli, [], "-") == ["--cli-opt", "--help"] assert choices_without_help(cli, [], "") == ["asub"] - assert choices_without_help(cli, ["asub"], "-") == ["--asub-opt"] + assert choices_without_help(cli, ["asub"], "-") == ["--asub-opt", "--help"] assert choices_without_help(cli, ["asub"], "") == ["bsub"] - assert choices_without_help(cli, ["asub", "bsub"], "-") == ["--bsub-opt"] + assert choices_without_help(cli, ["asub", "bsub"], "-") == ["--bsub-opt", "--help"] assert choices_without_help(cli, ["asub", "bsub"], "") == ["csub"] assert choices_without_help(cli, ["asub", "bsub", "csub"], "-") == [ "--csub-opt", "--csub", "--search-color", + "--help", ] assert ( choices_without_help(cli, ["asub", "bsub", "csub", "--csub-opt"], "") @@ -173,16 +174,22 @@ def bsub(bsub_opt, arg): def csub(csub_opt, arg): pass - assert choices_without_help(cli, [], "-") == ["--cli-opt"] + assert choices_without_help(cli, [], "-") == ["--cli-opt", "--help"] assert choices_without_help(cli, [], "") == ["cliarg1", "cliarg2"] - assert choices_without_help(cli, ["cliarg1", "asub"], "-") == ["--asub-opt"] + assert choices_without_help(cli, ["cliarg1", "asub"], "-") == [ + "--asub-opt", + "--help", + ] assert choices_without_help(cli, ["cliarg1", "asub"], "") == ["bsub", "csub"] assert choices_without_help(cli, ["cliarg1", "bsub"], "") == ["arg1", "arg2"] assert choices_without_help(cli, ["cliarg1", "asub", "--asub-opt"], "") == [] assert choices_without_help( cli, ["cliarg1", "asub", "--asub-opt", "5", "bsub"], "-" - ) == ["--bsub-opt"] - assert choices_without_help(cli, ["cliarg1", "asub", "bsub"], "-") == ["--bsub-opt"] + ) == ["--bsub-opt", "--help"] + assert choices_without_help(cli, ["cliarg1", "asub", "bsub"], "-") == [ + "--bsub-opt", + "--help", + ] assert choices_without_help(cli, ["cliarg1", "asub", "csub"], "") == [ "carg1", "carg2", @@ -191,7 +198,10 @@ def csub(csub_opt, arg): "carg1", "carg2", ] - assert choices_without_help(cli, ["cliarg1", "asub", "csub"], "-") == ["--csub-opt"] + assert choices_without_help(cli, ["cliarg1", "asub", "csub"], "-") == [ + "--csub-opt", + "--help", + ] assert choices_with_help(cli, ["cliarg1", "asub"], "b") == [("bsub", "bsub help")] @@ -222,6 +232,7 @@ def cli(): ("--opt1", "opt1 help"), ("--opt2", None), ("--opt3", None), + ("--help", "Show this message and exit."), ] assert choices_without_help(cli, [], "--opt") == ["--opt1", "--opt2", "--opt3"] assert choices_without_help(cli, [], "--opt1=") == ["opt11", "opt12"] @@ -234,14 +245,23 @@ def cli(): "opt21", "opt22", ] - assert choices_without_help(cli, ["--opt2", "opt21"], "-") == ["--opt1", "--opt3"] - assert choices_without_help(cli, ["--opt1", "opt11"], "-") == ["--opt2", "--opt3"] + assert choices_without_help(cli, ["--opt2", "opt21"], "-") == [ + "--opt1", + "--opt3", + "--help", + ] + assert choices_without_help(cli, ["--opt1", "opt11"], "-") == [ + "--opt2", + "--opt3", + "--help", + ] assert choices_without_help(cli, ["--opt1"], "opt") == ["opt11", "opt12"] assert choices_without_help(cli, ["--opt3"], "opti") == ["option"] assert choices_without_help(cli, ["--opt1", "invalid_opt"], "-") == [ "--opt2", "--opt3", + "--help", ] @@ -268,7 +288,7 @@ def test_boolean_flag_choice(): def cli(local_opt): pass - assert choices_without_help(cli, [], "-") == ["--shout", "--no-shout"] + assert choices_without_help(cli, [], "-") == ["--shout", "--no-shout", "--help"] assert choices_without_help(cli, ["--shout"], "") == ["arg1", "arg2"] @@ -382,7 +402,7 @@ def dsub(): ] assert choices_without_help( cli, ["sub", "--sub-opt", "subopt1", "subarg1", "bsub"], "-" - ) == ["--bsub-opt"] + ) == ["--bsub-opt", "--help"] assert choices_without_help( cli, ["sub", "--sub-opt", "subopt1", "subarg1", "bsub"], "" ) == ["bsubarg1", "bsubarg2"] @@ -472,14 +492,14 @@ def hsub(): assert choices_without_help(cli, [], "h") == [] # If the user exactly types out the hidden command, complete its subcommands. assert choices_without_help(cli, ["hgroup"], "") == ["hgroupsub"] - assert choices_without_help(cli, ["hsub"], "--h") == ["--hname"] + assert choices_without_help(cli, ["hsub"], "--h") == ["--hname", "--help"] @pytest.mark.parametrize( ("args", "part", "expect"), [ - ([], "-", ["--opt"]), - (["value"], "--", ["--opt"]), + ([], "-", ["--opt", "--help"]), + (["value"], "--", ["--opt", "--help"]), ([], "-o", []), (["--opt"], "-o", []), (["--"], "", ["name", "-o", "--opt", "--"]), From ad5087a9295e52cab60e841793e3f31ba87328e7 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2020 08:39:50 +0000 Subject: [PATCH 064/293] Bump tox from 3.15.1 to 3.15.2 Bumps [tox](https://github.com/tox-dev/tox) from 3.15.1 to 3.15.2. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.15.1...3.15.2) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index d2d4f1b91..675c55e25 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -47,7 +47,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, tox -tox==3.15.1 # via -r requirements/dev.in +tox==3.15.2 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox wcwidth==0.1.9 # via pytest From 05f69ca5a0650d5f2e8a647d718fd6b1aa58907c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2020 14:16:04 +0000 Subject: [PATCH 065/293] Bump sphinx from 3.0.4 to 3.1.0 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.0.4 to 3.1.0. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.0.4...v3.1.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index d2d4f1b91..1194f93f8 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -38,7 +38,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging, pip-tools, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.0.4 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.1.0 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index 60459d788..4097bb334 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -22,7 +22,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.0.4 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.1.0 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx From 26850568f0669d5dbba9a6ed2cc05fcd12ffd10c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2020 08:14:28 +0000 Subject: [PATCH 066/293] Bump pre-commit from 2.4.0 to 2.5.0 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.4.0 to 2.5.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.4.0...v2.5.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index bb9db03a7..162f45642 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -27,7 +27,7 @@ packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in pip-tools==5.2.0 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox -pre-commit==2.4.0 # via -r requirements/dev.in +pre-commit==2.5.0 # via -r requirements/dev.in py==1.8.1 # via pytest, tox pygments==2.6.1 # via sphinx pyparsing==2.4.7 # via packaging From e5231a596640625f9bb1fd41c8fe350e354cddf9 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2020 08:44:10 +0000 Subject: [PATCH 067/293] Bump pip-tools from 5.2.0 to 5.2.1 Bumps [pip-tools](https://github.com/jazzband/pip-tools) from 5.2.0 to 5.2.1. - [Release notes](https://github.com/jazzband/pip-tools/releases) - [Changelog](https://github.com/jazzband/pip-tools/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/pip-tools/compare/5.2.0...5.2.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 162f45642..83d699b58 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -25,7 +25,7 @@ more-itertools==8.3.0 # via pytest nodeenv==1.3.5 # via pre-commit packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pip-tools==5.2.0 # via -r requirements/dev.in +pip-tools==5.2.1 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox pre-commit==2.5.0 # via -r requirements/dev.in py==1.8.1 # via pytest, tox From c4926ff8fa82d28c47a2e205a68687395c7b2801 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2020 08:47:40 +0000 Subject: [PATCH 068/293] Bump pre-commit from 2.5.0 to 2.5.1 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.5.0 to 2.5.1. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.5.0...v2.5.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 83d699b58..305ac982b 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -27,7 +27,7 @@ packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in pip-tools==5.2.1 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox -pre-commit==2.5.0 # via -r requirements/dev.in +pre-commit==2.5.1 # via -r requirements/dev.in py==1.8.1 # via pytest, tox pygments==2.6.1 # via sphinx pyparsing==2.4.7 # via packaging From 43e40c05a4b94c6aa497dc7bba62b7d69b7e35ec Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 11 Jun 2020 09:42:40 -0700 Subject: [PATCH 069/293] note about changed quotes in error messages --- CHANGES.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index d4cfb7367..7b0434cd7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -96,6 +96,8 @@ Released 2020-03-09 - Make the warning about old 2-arg parameter callbacks a deprecation warning, to be removed in 8.0. This has been a warning since Click 2.0. :pr:`1492` +- Adjust error messages to standardize the types of quotes used so + they match error messages from Python. Version 7.0 From 76f376cc3776ff3156eb8f9973dc8c53a1c26beb Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 11 Jun 2020 09:46:01 -0700 Subject: [PATCH 070/293] move 7.2 to 8.0 --- CHANGES.rst | 5 ----- 1 file changed, 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8857c7ce9..2256649cb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,11 +12,6 @@ Unreleased parameter. :issue:`1264`, :pr:`1329` - Add an optional parameter to ``ProgressBar.update`` to set the ``current_item``. :issue:`1226`, :pr:`1332` - - -Version 7.2 ------------ - - Include ``--help`` option in completion. :pr:`1504` From 9cfa961deed93af69ab43b679f0a0308fd518ecc Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 11 Jun 2020 13:14:40 -0700 Subject: [PATCH 071/293] update contributing guide (#1584) --- CONTRIBUTING.rst | 224 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 185 insertions(+), 39 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c96223889..7ec5bc95e 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,66 +1,212 @@ -========================== How to contribute to Click ========================== -Thanks for considering contributing to Click. +Thank you for considering contributing to Click! + Support questions -================= +----------------- + +Please, don't use the issue tracker for this. The issue tracker is a +tool to address bugs and feature requests in Click itself. Use one of +the following resources for questions about using Click or issues with +your own code: + +- The ``#get-help`` channel on our Discord chat: + https://discord.gg/t6rrQZH +- The mailing list flask@python.org for long term discussion or larger + issues. +- Ask on `Stack Overflow`_. Search with Google first using: + ``site:stackoverflow.com python click {search term, exception message, etc.}`` -Please, don't use the issue tracker for this. Check whether the -``#pocoo`` IRC channel on Freenode can help with your issue. If your problem -is not strictly Click-specific, ``#python`` on Freenode is generally more -active. Also try searching or asking on `Stack Overflow`_ with the -``python-click`` tag. +.. _Stack Overflow: https://stackoverflow.com/questions/tagged/python-click?sort=linked -.. _Stack Overflow: https://stackoverflow.com/questions/tagged/python-click?sort=votes Reporting issues -================ +---------------- -- Under which versions of Python does this happen? This is even more important - if your issue is encoding related. +Include the following information in your post: + +- Describe what you expected to happen. +- If possible, include a `minimal reproducible example`_ to help us + identify the issue. This also helps check that the issue is not with + your own code. +- Describe what actually happened. Include the full traceback if there + was an exception. +- List your Python and Click. If possible, check if this issue is + already fixed in the latest releases or the latest code in the + repository. + +.. _minimal reproducible example: https://stackoverflow.com/help/minimal-reproducible-example -- Under which versions of Click does this happen? Check if this issue is fixed - in the repository. Submitting patches -================== +------------------ + +If there is not an open issue for what you want to submit, prefer +opening one for discussion before working on a PR. You can work on any +issue that doesn't have an open PR linked to it or a maintainer assigned +to it. These show up in the sidebar. No need to ask if you can work on +an issue that interests you. + +Include the following in your patch: + +- Use `Black`_ to format your code. This and other tools will run + automatically if you install `pre-commit`_ using the instructions + below. +- Include tests if your patch adds or changes code. Make sure the test + fails without your patch. +- Update any relevant docs pages and docstrings. Docs pages and + docstrings should be wrapped at 72 characters. +- Add an entry in ``CHANGES.rst``. Use the same style as other + entries. Also include ``.. versionchanged::`` inline changelogs in + relevant docstrings. + +.. _Black: https://black.readthedocs.io +.. _pre-commit: https://pre-commit.com + + +First time setup +~~~~~~~~~~~~~~~~ + +- Download and install the `latest version of git`_. +- Configure git with your `username`_ and `email`_. + + .. code-block:: text + + $ git config --global user.name 'your name' + $ git config --global user.email 'your email' + +- Make sure you have a `GitHub account`_. +- Fork Click to your GitHub account by clicking the `Fork`_ button. +- `Clone`_ the main repository locally. + + .. code-block:: text + + $ git clone https://github.com/pallets/click + $ cd click + +- Add your fork as a remote to push your work to. Replace + ``{username}`` with your username. + + .. code-block:: text + + git remote add fork https://github.com/{username}/click + +- Create a virtualenv. + + .. code-block:: text + + $ python3 -m venv env + $ . env/bin/activate + + On Windows, activating is different. + + .. code-block:: text + + > env\Scripts\activate + +- Install Click in editable mode with development dependencies. + + .. code-block:: text + + $ pip install -e . -r requirements/dev.txt + +- Install the pre-commit hooks. + + .. code-block:: text + + $ pre-commit install + +.. _latest version of git: https://git-scm.com/downloads +.. _username: https://help.github.com/en/articles/setting-your-username-in-git +.. _email: https://help.github.com/en/articles/setting-your-commit-email-address-in-git +.. _GitHub account: https://github.com/join +.. _Fork: https://github.com/pallets/click/fork +.. _Clone: https://help.github.com/en/articles/fork-a-repo#step-2-create-a-local-clone-of-your-fork + + +Start coding +~~~~~~~~~~~~ + +- Create a branch to identify the issue you would like to work on. If + you're submitting a bug or documentation fix, branch off of the + latest ".x" branch. + + .. code-block:: text + + $ git checkout -b your-branch-name origin/7.x + + If you're submitting a feature addition or change, branch off of the + "master" branch. + + .. code-block:: text + + $ git checkout -b your-branch-name origin/master + +- Using your favorite editor, make your changes, + `committing as you go`_. +- Include tests that cover any code changes you make. Make sure the + test fails without your patch. Run the tests as described below. +- Push your commits to your fork on GitHub and + `create a pull request`_. Link to the issue being addressed with + ``fixes #123`` in the pull request. + + .. code-block:: text + + $ git push --set-upstream fork your-branch-name + +.. _committing as you go: https://dont-be-afraid-to-commit.readthedocs.io/en/latest/git/commandlinegit.html#commit-your-changes +.. _create a pull request: https://help.github.com/en/articles/creating-a-pull-request + + +Running the tests +~~~~~~~~~~~~~~~~~ + +Run the basic test suite with pytest. + +.. code-block:: text + + $ pytest + +This runs the tests for the current environment, which is usually +sufficient. CI will run the full suite when you submit your pull +request. You can run the full test suite with tox if you don't want to +wait. + +.. code-block:: text + + $ tox -- Include tests if your patch is supposed to solve a bug, and explain clearly - under which circumstances the bug happens. Make sure the test fails without - your patch. -- Try to follow `PEP8 `_, but you - may ignore the line-length-limit if following it would make the code uglier. +Running test coverage +~~~~~~~~~~~~~~~~~~~~~ -- For features: Consider whether your feature would be a better fit for an - `external package `_ +Generating a report of lines that do not have test coverage can indicate +where to start contributing. Run ``pytest`` using ``coverage`` and +generate a report. -- For docs and bug fixes: Submit against the latest maintenance branch instead of master! +.. code-block:: text -- Non docs or text related changes need an entry in ``CHANGES.rst``, - and ``.. versionadded`` or ``.. versionchanged`` markers in the docs. + $ pip install coverage + $ coverage run -m pytest + $ coverage html -Running the testsuite ---------------------- +Open ``htmlcov/index.html`` in your browser to explore the report. -You probably want to set up a `virtualenv -`_. +Read more about `coverage `__. -The minimal requirement for running the testsuite is ``py.test``. You can -install it with:: - pip install pytest +Building the docs +~~~~~~~~~~~~~~~~~ -Then you can run the testsuite with:: +Build the docs in the ``docs`` directory using Sphinx. - py.test +.. code-block:: text -For a more isolated test environment, you can also install ``tox`` instead of -``pytest``. You can install it with:: + $ cd docs + $ make html - pip install tox +Open ``_build/html/index.html`` in your browser to view the docs. -The ``tox`` command will then run all tests against multiple combinations of -Python versions and dependency versions. +Read more about `Sphinx `__. From 52a854a451e2d0c14edf3a838427274f6074d05f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2020 08:54:49 +0000 Subject: [PATCH 072/293] Bump sphinx from 3.1.0 to 3.1.1 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.1.0 to 3.1.1. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.1.0...v3.1.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 305ac982b..eb890d636 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -38,7 +38,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging, pip-tools, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.1.0 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.1.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index 4097bb334..7eefde3c2 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -22,7 +22,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.1.0 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.1.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx From 3bd7182b974cc3c90b5e9568168a0b8ef597a55e Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 23 Jun 2020 08:00:46 -0700 Subject: [PATCH 073/293] update CONTRIBUTING.rst --- CONTRIBUTING.rst | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 7ec5bc95e..e2eb460f5 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -87,7 +87,8 @@ First time setup $ cd click - Add your fork as a remote to push your work to. Replace - ``{username}`` with your username. + ``{username}`` with your username. This names the remote "fork", the + default Pallets remote is "origin". .. code-block:: text @@ -106,11 +107,14 @@ First time setup > env\Scripts\activate -- Install Click in editable mode with development dependencies. +- Install the development dependencies, then install Click in editable + mode. This order is important, otherwise you'll get the wrong + version of Click. .. code-block:: text - $ pip install -e . -r requirements/dev.txt + $ pip install -r requirements/dev.txt + $ pip install -e . - Install the pre-commit hooks. @@ -135,6 +139,7 @@ Start coding .. code-block:: text + $ git fetch origin $ git checkout -b your-branch-name origin/7.x If you're submitting a feature addition or change, branch off of the @@ -142,6 +147,7 @@ Start coding .. code-block:: text + $ git fetch origin $ git checkout -b your-branch-name origin/master - Using your favorite editor, make your changes, From 6bdbea0d00008cc6957667c60c504e1bc298c754 Mon Sep 17 00:00:00 2001 From: David Fischer Date: Tue, 23 Jun 2020 10:12:20 -0700 Subject: [PATCH 074/293] Update the docs requirement file path (#1601) --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 1cbed9fa0..190695202 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,7 +1,7 @@ version: 2 python: install: - - requirements: docs/requirements.txt + - requirements: requirements/docs.txt - method: pip path: . sphinx: From cb37854c9b43d9fd3c99a3a830f4580517c1b32e Mon Sep 17 00:00:00 2001 From: Mwiza Simbeye Date: Thu, 11 Jun 2020 00:33:34 +0200 Subject: [PATCH 075/293] use importlib.metadata for version_option --- CHANGES.rst | 3 +++ requirements/dev.txt | 2 ++ requirements/tests.in | 1 + requirements/tests.txt | 2 ++ src/click/decorators.py | 22 +++++++--------------- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2256649cb..efc39145c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,9 @@ Unreleased - Add an optional parameter to ``ProgressBar.update`` to set the ``current_item``. :issue:`1226`, :pr:`1332` - Include ``--help`` option in completion. :pr:`1504` +- ``version_option`` uses ``importlib.metadata`` (or the + ``importlib_metadata`` backport) instead of ``pkg_resources``. + :issue:`1582` Version 7.1.2 diff --git a/requirements/dev.txt b/requirements/dev.txt index eb890d636..cbb09ec9a 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,6 +19,7 @@ filelock==3.0.12 # via tox, virtualenv identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx +importlib-metadata==1.6.1 # via -r requirements/tests.in jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 more-itertools==8.3.0 # via pytest @@ -51,6 +52,7 @@ tox==3.15.2 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox wcwidth==0.1.9 # via pytest +zipp==3.1.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/tests.in b/requirements/tests.in index 808be0535..721be61cf 100644 --- a/requirements/tests.in +++ b/requirements/tests.in @@ -1,2 +1,3 @@ pytest colorama +importlib_metadata diff --git a/requirements/tests.txt b/requirements/tests.txt index f37170203..c40d6c735 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,6 +6,7 @@ # attrs==19.3.0 # via pytest colorama==0.4.3 # via -r requirements/tests.in +importlib-metadata==1.6.1 # via -r requirements/tests.in more-itertools==8.3.0 # via pytest packaging==20.4 # via pytest pluggy==0.13.1 # via pytest @@ -14,3 +15,4 @@ pyparsing==2.4.7 # via packaging pytest==5.4.3 # via -r requirements/tests.in six==1.15.0 # via packaging wcwidth==0.1.9 # via pytest +zipp==3.1.0 # via importlib-metadata diff --git a/src/click/decorators.py b/src/click/decorators.py index 30133051a..ad61111ef 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -1,5 +1,4 @@ import inspect -import sys from functools import update_wrapper from .core import Argument @@ -260,11 +259,6 @@ def version_option(version=None, *param_decls, **attrs): (``'%(prog)s, version %(version)s'``) :param others: everything else is forwarded to :func:`option`. """ - if version is None: - if hasattr(sys, "_getframe"): - module = sys._getframe(1).f_globals.get("__name__") - else: - module = "" def decorator(f): prog_name = attrs.pop("prog_name", None) @@ -279,16 +273,14 @@ def callback(ctx, param, value): ver = version if ver is None: try: - import pkg_resources + from importlib.metadata import version as get_version except ImportError: - pass - else: - for dist in pkg_resources.working_set: - scripts = dist.get_entry_map().get("console_scripts") or {} - for entry_point in scripts.values(): - if entry_point.module_name == module: - ver = dist.version - break + try: + from importlib_metadata import version as get_version + except ImportError: + get_version = None + if get_version is not None: + ver = get_version(prog) if ver is None: raise RuntimeError("Could not determine version") echo(message % {"prog": prog, "version": ver}, color=ctx.color) From 81ff0aea6c814c7fad7ed4e7c76098a97d6a0fbe Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 24 Jun 2020 13:57:03 -0700 Subject: [PATCH 076/293] refactor version_option option() returns a decorator, no need for inner decorator function optional package_name parameter to skip stack frame detection package_name is available in message format use nonlocal keyword for modifying args from callback only detect version if package name was detected show error if importlib_metadata needs to be installed show error if detected package name isn't an installed package name show detected package name in error when version wasn't detected rewrite docs --- src/click/decorators.py | 132 ++++++++++++++++++++++++++++------------ 1 file changed, 92 insertions(+), 40 deletions(-) diff --git a/src/click/decorators.py b/src/click/decorators.py index ad61111ef..1ed727522 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -247,53 +247,105 @@ def decorator(f): return decorator -def version_option(version=None, *param_decls, **attrs): - """Adds a ``--version`` option which immediately ends the program - printing out the version number. This is implemented as an eager - option that prints the version and exits the program in the callback. - - :param version: the version number to show. If not provided Click - attempts an auto discovery via setuptools. - :param prog_name: the name of the program (defaults to autodetection) - :param message: custom message to show instead of the default - (``'%(prog)s, version %(version)s'``) - :param others: everything else is forwarded to :func:`option`. +def version_option( + version=None, + *param_decls, + package_name=None, + prog_name=None, + message="%(prog)s, version %(version)s", + **kwargs, +): + """Add a ``--version`` option which immediately prints the version + number and exits the program. + + If ``version`` is not provided, Click will try to detect it using + :func:`importlib.metadata.version` to get the version for the + ``package_name``. On Python < 3.8, the ``importlib_metadata`` + backport must be installed. + + If ``package_name`` is not provided, Click will try to detect it by + inspecting the stack frames. This will be used to detect the + version, so it must match the name of the installed package. + + :param version: The version number to show. If not provided, Click + will try to detect it. + :param param_decls: One or more option names. Defaults to the single + value ``"--version"``. + :param package_name: The package name to detect the version from. If + not provided, Click will try to detect it. + :param prog_name: The name of the CLI to show in the message. If not + provided, it will be detected from the command. + :param message: The message to show. The values ``%(prog)s``, + ``%(package)s``, and ``%(version)s`` are available. + :param kwargs: Extra arguments are passed to :func:`option`. + :raise RuntimeError: ``version`` could not be detected. + + .. versionchanged:: 8.0 + Add the ``package_name`` parameter, and the ``%(package)s`` + value for messages. + + .. versionchanged:: 8.0 + Use :mod:`importlib.metadata` instead of ``pkg_resources``. """ + if version is None and package_name is None: + frame = inspect.currentframe() - def decorator(f): - prog_name = attrs.pop("prog_name", None) - message = attrs.pop("message", "%(prog)s, version %(version)s") + if frame is not None: + package_name = frame.f_back.f_globals.get("__name__").partition(".")[0] - def callback(ctx, param, value): - if not value or ctx.resilient_parsing: - return - prog = prog_name - if prog is None: - prog = ctx.find_root().info_name - ver = version - if ver is None: + def callback(ctx, param, value): + if not value or ctx.resilient_parsing: + return + + nonlocal prog_name + nonlocal version + + if prog_name is None: + prog_name = ctx.find_root().info_name + + if version is None and package_name is not None: + try: + from importlib import metadata + except ImportError: + # Python < 3.8 try: - from importlib.metadata import version as get_version + import importlib_metadata as metadata except ImportError: - try: - from importlib_metadata import version as get_version - except ImportError: - get_version = None - if get_version is not None: - ver = get_version(prog) - if ver is None: - raise RuntimeError("Could not determine version") - echo(message % {"prog": prog, "version": ver}, color=ctx.color) - ctx.exit() + metadata = None - attrs.setdefault("is_flag", True) - attrs.setdefault("expose_value", False) - attrs.setdefault("is_eager", True) - attrs.setdefault("help", "Show the version and exit.") - attrs["callback"] = callback - return option(*(param_decls or ("--version",)), **attrs)(f) + if metadata is None: + raise RuntimeError( + "Install 'importlib_metadata' to get the version on Python < 3.8." + ) - return decorator + try: + version = metadata.version(package_name) + except metadata.PackageNotFoundError: + raise RuntimeError( + f"{package_name!r} is not installed. Try passing" + " 'package_name' instead." + ) + + if version is None: + raise RuntimeError( + f"Could not determine the version for {package_name!r} automatically." + ) + + echo( + message % {"prog": prog_name, "package": package_name, "version": version}, + color=ctx.color, + ) + ctx.exit() + + if not param_decls: + param_decls = ("--version",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", "Show the version and exit.") + kwargs["callback"] = callback + return option(*param_decls, **kwargs) def help_option(*param_decls, **attrs): From b38cb0e2b1372c933ea42975632ee5792cef08cf Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 24 Jun 2020 15:22:26 -0700 Subject: [PATCH 077/293] version detection works with python -m package Co-authored-by: Claudio Jolowicz --- src/click/decorators.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/click/decorators.py b/src/click/decorators.py index 1ed727522..9ca0804ee 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -289,9 +289,19 @@ def version_option( """ if version is None and package_name is None: frame = inspect.currentframe() + f_globals = frame.f_back.f_globals if frame is not None else None + # break reference cycle + # https://docs.python.org/3/library/inspect.html#the-interpreter-stack + del frame - if frame is not None: - package_name = frame.f_back.f_globals.get("__name__").partition(".")[0] + if f_globals is not None: + package_name = f_globals.get("__name__") + + if package_name == "__main__": + package_name = f_globals.get("__package__") + + if package_name: + package_name = package_name.partition(".")[0] def callback(ctx, param, value): if not value or ctx.resilient_parsing: From a077ed70475e3f68d09e50f511268588378b7fa8 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 24 Jun 2020 15:46:09 -0700 Subject: [PATCH 078/293] refactor other decorators to match version_option option() returns a decorator directly rewrite docs --- src/click/decorators.py | 118 ++++++++++++++++++---------------------- 1 file changed, 54 insertions(+), 64 deletions(-) diff --git a/src/click/decorators.py b/src/click/decorators.py index 9ca0804ee..5742f05e5 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -192,59 +192,45 @@ def decorator(f): return decorator -def confirmation_option(*param_decls, **attrs): - """Shortcut for confirmation prompts that can be ignored by passing - ``--yes`` as parameter. - - This is equivalent to decorating a function with :func:`option` with - the following parameters:: - - def callback(ctx, param, value): - if not value: - ctx.abort() - - @click.command() - @click.option('--yes', is_flag=True, callback=callback, - expose_value=False, prompt='Do you want to continue?') - def dropdb(): - pass - """ +def confirmation_option(*param_decls, **kwargs): + """Add a ``--yes`` option which shows a prompt before continuing if + not passed. If the prompt is declined, the program will exit. - def decorator(f): - def callback(ctx, param, value): - if not value: - ctx.abort() + :param param_decls: One or more option names. Defaults to the single + value ``"--yes"``. + :param kwargs: Extra arguments are passed to :func:`option`. + """ - attrs.setdefault("is_flag", True) - attrs.setdefault("callback", callback) - attrs.setdefault("expose_value", False) - attrs.setdefault("prompt", "Do you want to continue?") - attrs.setdefault("help", "Confirm the action without prompting.") - return option(*(param_decls or ("--yes",)), **attrs)(f) + def callback(ctx, param, value): + if not value: + ctx.abort() - return decorator + if not param_decls: + param_decls = ("--yes",) + kwargs.setdefault("is_flag", True) + kwargs.setdefault("callback", callback) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("prompt", "Do you want to continue?") + kwargs.setdefault("help", "Confirm the action without prompting.") + return option(*param_decls, **kwargs) -def password_option(*param_decls, **attrs): - """Shortcut for password prompts. - This is equivalent to decorating a function with :func:`option` with - the following parameters:: +def password_option(*param_decls, **kwargs): + """Add a ``--password`` option which prompts for a password, hiding + input and asking to enter the value again for confirmation. - @click.command() - @click.option('--password', prompt=True, confirmation_prompt=True, - hide_input=True) - def changeadmin(password): - pass + :param param_decls: One or more option names. Defaults to the single + value ``"--password"``. + :param kwargs: Extra arguments are passed to :func:`option`. """ + if not param_decls: + param_decls = ("--password",) - def decorator(f): - attrs.setdefault("prompt", True) - attrs.setdefault("confirmation_prompt", True) - attrs.setdefault("hide_input", True) - return option(*(param_decls or ("--password",)), **attrs)(f) - - return decorator + kwargs.setdefault("prompt", True) + kwargs.setdefault("confirmation_prompt", True) + kwargs.setdefault("hide_input", True) + return option(*param_decls, **kwargs) def version_option( @@ -358,28 +344,32 @@ def callback(ctx, param, value): return option(*param_decls, **kwargs) -def help_option(*param_decls, **attrs): - """Adds a ``--help`` option which immediately ends the program - printing out the help page. This is usually unnecessary to add as - this is added by default to all commands unless suppressed. +def help_option(*param_decls, **kwargs): + """Add a ``--help`` option which immediately prints the help page + and exits the program. - Like :func:`version_option`, this is implemented as eager option that - prints in the callback and exits. + This is usually unnecessary, as the ``--help`` option is added to + each command automatically unless ``add_help_option=False`` is + passed. - All arguments are forwarded to :func:`option`. + :param param_decls: One or more option names. Defaults to the single + value ``"--help"``. + :param kwargs: Extra arguments are passed to :func:`option`. """ - def decorator(f): - def callback(ctx, param, value): - if value and not ctx.resilient_parsing: - echo(ctx.get_help(), color=ctx.color) - ctx.exit() - - attrs.setdefault("is_flag", True) - attrs.setdefault("expose_value", False) - attrs.setdefault("help", "Show this message and exit.") - attrs.setdefault("is_eager", True) - attrs["callback"] = callback - return option(*(param_decls or ("--help",)), **attrs)(f) + def callback(ctx, param, value): + if not value or ctx.resilient_parsing: + return - return decorator + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + if not param_decls: + param_decls = ("--help",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", "Show this message and exit.") + kwargs["callback"] = callback + return option(*param_decls, **kwargs) From a6211edff44e1401b967fd6686f01ad655fd88f3 Mon Sep 17 00:00:00 2001 From: Cesare De Cal Date: Fri, 19 Jun 2020 18:06:26 +0200 Subject: [PATCH 079/293] omit hidden prompt input from validation error --- CHANGES.rst | 2 ++ src/click/termui.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index efc39145c..5721fd734 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -16,6 +16,8 @@ Unreleased - ``version_option`` uses ``importlib.metadata`` (or the ``importlib_metadata`` backport) instead of ``pkg_resources``. :issue:`1582` +- If validation fails for a prompt with ``hide_input=True``, the value + is not shown in the error message. :issue:`1460` Version 7.1.2 diff --git a/src/click/termui.py b/src/click/termui.py index a1bdf2ab8..fd3d91576 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -153,7 +153,10 @@ def prompt_func(text): try: result = value_proc(value) except UsageError as e: - echo(f"Error: {e.message}", err=err) # noqa: B306 + if hide_input: + echo("Error: the value you entered was invalid", err=err) + else: + echo(f"Error: {e.message}", err=err) # noqa: B306 continue if not confirmation_prompt: return result From a0c73dc3b6ec02f734781bab5bb9647acceacb23 Mon Sep 17 00:00:00 2001 From: Cathal Date: Mon, 15 Jun 2020 16:23:04 +0100 Subject: [PATCH 080/293] show IntRange min and max in --help output --- CHANGES.rst | 2 ++ src/click/core.py | 4 ++++ tests/test_options.py | 10 ++++++++++ 3 files changed, 16 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 5721fd734..6eb180eae 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -18,6 +18,8 @@ Unreleased :issue:`1582` - If validation fails for a prompt with ``hide_input=True``, the value is not shown in the error message. :issue:`1460` +- An ``IntRange`` option shows the accepted range in its help text. + :issue:`1525` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index b7124df4f..87415bdbc 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1936,6 +1936,10 @@ def _write_opts(opts): default_string = self.default extra.append(f"default: {default_string}") + if isinstance(self.type, IntRange): + if self.type.min is not None and self.type.max is not None: + extra.append(f"{self.type.min}-{self.type.max} inclusive") + if self.required: extra.append("required") if extra: diff --git a/tests/test_options.py b/tests/test_options.py index 1b9b27e0e..28990c3a8 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -216,6 +216,16 @@ def cmd(username): assert "(current user)" in result.output +def test_intrange_default_help_text(runner): + @click.command() + @click.option("--count", type=click.IntRange(1, 32), show_default=True, default=1) + def cmd(arg): + click.echo(arg) + + result = runner.invoke(cmd, ["--help"]) + assert "1-32 inclusive" in result.output + + def test_toupper_envvar_prefix(runner): @click.command() @click.option("--arg") From 7de47088cd87a14f0099bc5cae6b773f781a8c53 Mon Sep 17 00:00:00 2001 From: Mwiza Simbeye Date: Thu, 18 Jun 2020 14:44:48 +0200 Subject: [PATCH 081/293] flag option with duplicate names raises ValueError --- CHANGES.rst | 2 ++ src/click/core.py | 5 +++++ tests/test_options.py | 5 +++++ 3 files changed, 12 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 6eb180eae..bcfa72d0c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,6 +20,8 @@ Unreleased is not shown in the error message. :issue:`1460` - An ``IntRange`` option shows the accepted range in its help text. :issue:`1525` +- An option defined with duplicate flag names (``"--foo/--foo"``) + raises a ``ValueError``. :issue:`1465` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 87415bdbc..2b05d3f28 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1840,6 +1840,11 @@ def _parse_decls(self, decls, expose_value): second = second.lstrip() if second: secondary_opts.append(second.lstrip()) + if first == second: + raise ValueError( + f"Boolean option {decl!r} cannot use the" + " same flag for true/false." + ) else: possible_names.append(split_opt(decl)) opts.append(decl) diff --git a/tests/test_options.py b/tests/test_options.py index 28990c3a8..bd66bd2a0 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -565,3 +565,8 @@ def cmd(**kwargs): if form.startswith("-"): result = runner.invoke(cmd, [form]) assert result.output == "True\n" + + +def test_flag_duplicate_names(runner): + with pytest.raises(ValueError, match="cannot use the same flag for true/false"): + click.Option(["--foo/--foo"], default=False) From ce23ce8e9ff407da7411b8d28555eb3004aa37ef Mon Sep 17 00:00:00 2001 From: Andreas Maier Date: Wed, 17 Jun 2020 04:44:57 +0200 Subject: [PATCH 082/293] tolerate UnsupportedOperation in _winconsole._is_console() * On Windows, calling 'f.fileno()' in '_winconsole._is_console()' may raise UnsupportedOperation, for example when the pytest 'capsys' fixture is used which captures stdout for testing purposes. This change tolerates that exception and treats it as False. * Added a testcase test_echo_with_capsys() where the output of click.echo() is captured using the pytest capsys fixture. Signed-off-by: Andreas Maier --- CHANGES.rst | 2 ++ src/click/_winconsole.py | 2 +- tests/test_utils.py | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index bcfa72d0c..4842bd53a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -22,6 +22,8 @@ Unreleased :issue:`1525` - An option defined with duplicate flag names (``"--foo/--foo"``) raises a ``ValueError``. :issue:`1465` +- ``echo()`` will not fail when using pytest's ``capsys`` fixture on + Windows. :issue:`1590` Version 7.1.2 diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index 923fdba65..d10ca5c25 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -284,7 +284,7 @@ def _is_console(f): try: fileno = f.fileno() - except OSError: + except (OSError, io.UnsupportedOperation): return False handle = msvcrt.get_osfhandle(fileno) diff --git a/tests/test_utils.py b/tests/test_utils.py index bf3956d1f..0abd9496a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -261,6 +261,12 @@ def emulate_input(text): assert err == "Pause to stderr\n" +def test_echo_with_capsys(capsys): + click.echo("Capture me.") + out, err = capsys.readouterr() + assert out == "Capture me.\n" + + def test_open_file(runner): @click.command() @click.argument("filename") From ac8b464a367fe51dc83719cf34c1b827b9dc0b42 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2020 08:34:51 +0000 Subject: [PATCH 083/293] Bump importlib-metadata from 1.6.1 to 1.7.0 Bumps [importlib-metadata](http://importlib-metadata.readthedocs.io/) from 1.6.1 to 1.7.0. Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index cbb09ec9a..099e9b45e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ filelock==3.0.12 # via tox, virtualenv identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==1.6.1 # via -r requirements/tests.in +importlib_metadata==1.7.0 # via -r requirements/tests.in jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 more-itertools==8.3.0 # via pytest diff --git a/requirements/tests.txt b/requirements/tests.txt index c40d6c735..7f448852b 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,7 +6,7 @@ # attrs==19.3.0 # via pytest colorama==0.4.3 # via -r requirements/tests.in -importlib-metadata==1.6.1 # via -r requirements/tests.in +importlib_metadata==1.7.0 # via -r requirements/tests.in more-itertools==8.3.0 # via pytest packaging==20.4 # via pytest pluggy==0.13.1 # via pytest From ba434959f959d198d45aa8425cf89f082371d823 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2020 08:35:19 +0000 Subject: [PATCH 084/293] Bump tox from 3.15.2 to 3.16.0 Bumps [tox](https://github.com/tox-dev/tox) from 3.15.2 to 3.16.0. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.15.2...3.16.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index cbb09ec9a..b24896c55 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -48,7 +48,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, tox -tox==3.15.2 # via -r requirements/dev.in +tox==3.16.0 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox wcwidth==0.1.9 # via pytest From 5fb2324446c8d8db6219e9bc38918c06c4fcc9d6 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2020 08:10:32 +0000 Subject: [PATCH 085/293] Bump tox from 3.16.0 to 3.16.1 Bumps [tox](https://github.com/tox-dev/tox) from 3.16.0 to 3.16.1. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.16.0...3.16.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 6f4fb4257..96fa52c74 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ filelock==3.0.12 # via tox, virtualenv identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib_metadata==1.7.0 # via -r requirements/tests.in +importlib-metadata==1.7.0 # via -r requirements/tests.in jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 more-itertools==8.3.0 # via pytest @@ -48,7 +48,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, tox -tox==3.16.0 # via -r requirements/dev.in +tox==3.16.1 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox wcwidth==0.1.9 # via pytest From 4a337ea913515e8e98e425d70701ea4fb091830e Mon Sep 17 00:00:00 2001 From: ovezovs Date: Mon, 29 Jun 2020 15:45:11 -0400 Subject: [PATCH 086/293] use canonical command name instead of matched name --- CHANGES.rst | 4 ++++ src/click/core.py | 2 +- tests/test_commands.py | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4842bd53a..cd40f7891 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -24,6 +24,10 @@ Unreleased raises a ``ValueError``. :issue:`1465` - ``echo()`` will not fail when using pytest's ``capsys`` fixture on Windows. :issue:`1590` +- Resolving commands returns the canonical command name instead of the + matched name. This makes behavior such as help text and + ``Context.invoked_subcommand`` consistent when using patterns like + ``AliasedGroup``. :issue:`1422` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 2b05d3f28..22f742001 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1346,7 +1346,7 @@ def resolve_command(self, ctx, args): self.parse_args(ctx, ctx.args) ctx.fail(f"No such command '{original_cmd_name}'.") - return cmd_name, cmd, args[1:] + return cmd.name, cmd, args[1:] def get_command(self, ctx, cmd_name): """Given a context and a command name, this returns a diff --git a/tests/test_commands.py b/tests/test_commands.py index eace114a4..2467c733c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -258,6 +258,22 @@ def sync(): assert result.output == "no subcommand, use default\nin subcommand\n" +def test_aliased_command_canonical_name(runner): + class AliasedGroup(click.Group): + def get_command(self, ctx, cmd_name): + return push + + cli = AliasedGroup() + + @cli.command() + def push(): + click.echo("push command") + + result = runner.invoke(cli, ["pu", "--help"]) + assert not result.exception + assert result.output.startswith("Usage: root push [OPTIONS]") + + def test_unprocessed_options(runner): @click.command(context_settings=dict(ignore_unknown_options=True)) @click.argument("args", nargs=-1, type=click.UNPROCESSED) From 637ea206c348100582beda3c76a8c51e9d7437ab Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 1 Jul 2020 09:12:54 -0700 Subject: [PATCH 087/293] update metadata --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.cfg b/setup.cfg index c0c4ecf08..51075534e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,6 +8,8 @@ project_urls = Issue tracker = https://github.com/pallets/click/issues license = BSD-3-Clause license_files = LICENSE.rst +author = Armin Ronacher +author_email = armin.ronacher@active-4.com maintainer = Pallets maintainer_email = contact@palletsprojects.com description = Composable command line interface toolkit From d58e8551be4d9279877cd3f7efada710473bcc96 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2020 08:20:24 +0000 Subject: [PATCH 088/293] Bump pre-commit from 2.5.1 to 2.6.0 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.5.1 to 2.6.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.5.1...v2.6.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 96fa52c74..bd6698338 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -28,7 +28,7 @@ packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in pip-tools==5.2.1 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox -pre-commit==2.5.1 # via -r requirements/dev.in +pre-commit==2.6.0 # via -r requirements/dev.in py==1.8.1 # via pytest, tox pygments==2.6.1 # via sphinx pyparsing==2.4.7 # via packaging From 84b3df2d308f35a7298bafd87fda85c67d72f59e Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2020 08:27:53 +0000 Subject: [PATCH 089/293] Bump sphinx from 3.1.1 to 3.1.2 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.1.1 to 3.1.2. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.1.1...v3.1.2) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index bd6698338..1a0ea9ace 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -39,7 +39,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging, pip-tools, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.1.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.1.2 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index 7eefde3c2..39f812149 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -22,7 +22,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.1.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.1.2 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx From 0276c6c65849f5578b2b7e83d2ffeea51f2e8a73 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2020 08:11:32 +0000 Subject: [PATCH 090/293] Bump tox from 3.16.1 to 3.17.0 Bumps [tox](https://github.com/tox-dev/tox) from 3.16.1 to 3.17.0. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/3.17.0/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.16.1...3.17.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 1a0ea9ace..0c2a95e2c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -48,7 +48,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, tox -tox==3.16.1 # via -r requirements/dev.in +tox==3.17.0 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox wcwidth==0.1.9 # via pytest From a3661dacb612de532cbc8ee8e764f38cbc159ca0 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2020 08:23:20 +0000 Subject: [PATCH 091/293] Bump tox from 3.17.0 to 3.17.1 Bumps [tox](https://github.com/tox-dev/tox) from 3.17.0 to 3.17.1. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.17.0...3.17.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 0c2a95e2c..055cc2744 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -48,7 +48,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, tox -tox==3.17.0 # via -r requirements/dev.in +tox==3.17.1 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox wcwidth==0.1.9 # via pytest From 708a7ef7cd7d413cc572d35be006ac69b008f8cd Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2020 08:07:37 +0000 Subject: [PATCH 092/293] Bump tox from 3.17.1 to 3.18.0 Bumps [tox](https://github.com/tox-dev/tox) from 3.17.1 to 3.18.0. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.17.1...3.18.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 055cc2744..4f09fff38 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -48,7 +48,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, tox -tox==3.17.1 # via -r requirements/dev.in +tox==3.18.0 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox wcwidth==0.1.9 # via pytest From 0d220ac7fa52fc06aa4af77e9b5b7fabc955b044 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2020 08:35:43 +0000 Subject: [PATCH 093/293] Bump pip-tools from 5.2.1 to 5.3.0 Bumps [pip-tools](https://github.com/jazzband/pip-tools) from 5.2.1 to 5.3.0. - [Release notes](https://github.com/jazzband/pip-tools/releases) - [Changelog](https://github.com/jazzband/pip-tools/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/pip-tools/compare/5.2.1...5.3.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 4f09fff38..e7eeb073e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -26,7 +26,7 @@ more-itertools==8.3.0 # via pytest nodeenv==1.3.5 # via pre-commit packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pip-tools==5.2.1 # via -r requirements/dev.in +pip-tools==5.3.0 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox pre-commit==2.6.0 # via -r requirements/dev.in py==1.8.1 # via pytest, tox From 233d389fbf9889038fc08e00ebd8b234954d8c9f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2020 08:24:10 +0000 Subject: [PATCH 094/293] Bump tox from 3.18.0 to 3.18.1 Bumps [tox](https://github.com/tox-dev/tox) from 3.18.0 to 3.18.1. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.18.0...3.18.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index e7eeb073e..1addb984f 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -48,7 +48,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, tox -tox==3.18.0 # via -r requirements/dev.in +tox==3.18.1 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox wcwidth==0.1.9 # via pytest From 4a34def7df2f5b3854190e60f2b4d5fda99a39ef Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2020 08:32:09 +0000 Subject: [PATCH 095/293] Bump pytest from 5.4.3 to 6.0.0 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.4.3 to 6.0.0. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.4.3...6.0.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 8 ++++---- requirements/tests.txt | 9 +++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 1addb984f..78b97059a 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -20,6 +20,7 @@ identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx importlib-metadata==1.7.0 # via -r requirements/tests.in +iniconfig==1.0.0 # via pytest jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 more-itertools==8.3.0 # via pytest @@ -29,10 +30,10 @@ pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in pip-tools==5.3.0 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox pre-commit==2.6.0 # via -r requirements/dev.in -py==1.8.1 # via pytest, tox +py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx pyparsing==2.4.7 # via packaging -pytest==5.4.3 # via -r requirements/tests.in +pytest==6.0.0 # via -r requirements/tests.in pytz==2020.1 # via babel pyyaml==5.3.1 # via pre-commit requests==2.23.0 # via sphinx @@ -47,11 +48,10 @@ sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx -toml==0.10.1 # via pre-commit, tox +toml==0.10.1 # via pre-commit, pytest, tox tox==3.18.1 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox -wcwidth==0.1.9 # via pytest zipp==3.1.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/tests.txt b/requirements/tests.txt index 7f448852b..be8aa3610 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,13 +6,14 @@ # attrs==19.3.0 # via pytest colorama==0.4.3 # via -r requirements/tests.in -importlib_metadata==1.7.0 # via -r requirements/tests.in +importlib-metadata==1.7.0 # via -r requirements/tests.in +iniconfig==1.0.0 # via pytest more-itertools==8.3.0 # via pytest packaging==20.4 # via pytest pluggy==0.13.1 # via pytest -py==1.8.1 # via pytest +py==1.9.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==5.4.3 # via -r requirements/tests.in +pytest==6.0.0 # via -r requirements/tests.in six==1.15.0 # via packaging -wcwidth==0.1.9 # via pytest +toml==0.10.1 # via pytest zipp==3.1.0 # via importlib-metadata From bc6009aa81c0549f11e8a697f37dd2455e8925c8 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2020 08:29:17 +0000 Subject: [PATCH 096/293] Bump pytest from 6.0.0 to 6.0.1 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.0.0 to 6.0.1. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.0.0...6.0.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 78b97059a..d9353a782 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -33,7 +33,7 @@ pre-commit==2.6.0 # via -r requirements/dev.in py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx pyparsing==2.4.7 # via packaging -pytest==6.0.0 # via -r requirements/tests.in +pytest==6.0.1 # via -r requirements/tests.in pytz==2020.1 # via babel pyyaml==5.3.1 # via pre-commit requests==2.23.0 # via sphinx diff --git a/requirements/tests.txt b/requirements/tests.txt index be8aa3610..019fd6d30 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -13,7 +13,7 @@ packaging==20.4 # via pytest pluggy==0.13.1 # via pytest py==1.9.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==6.0.0 # via -r requirements/tests.in +pytest==6.0.1 # via -r requirements/tests.in six==1.15.0 # via packaging toml==0.10.1 # via pytest zipp==3.1.0 # via importlib-metadata From 1b7549ab088f6f9f4c8a56b7b9ce505cb6b1f26f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2020 08:55:57 +0000 Subject: [PATCH 097/293] Bump pip-tools from 5.3.0 to 5.3.1 Bumps [pip-tools](https://github.com/jazzband/pip-tools) from 5.3.0 to 5.3.1. - [Release notes](https://github.com/jazzband/pip-tools/releases) - [Changelog](https://github.com/jazzband/pip-tools/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/pip-tools/compare/5.3.0...5.3.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index d9353a782..d8555d2e6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -27,7 +27,7 @@ more-itertools==8.3.0 # via pytest nodeenv==1.3.5 # via pre-commit packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pip-tools==5.3.0 # via -r requirements/dev.in +pip-tools==5.3.1 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox pre-commit==2.6.0 # via -r requirements/dev.in py==1.9.0 # via pytest, tox From ce498eb559e37852e6c69651a5176b4b6fcf28e8 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Fri, 17 Jul 2020 17:35:01 -0400 Subject: [PATCH 098/293] Make ``BOOL`` accept the strings "on" and "off". Closes #1629. --- CHANGES.rst | 1 + docs/parameters.rst | 6 +++--- src/click/types.py | 4 ++-- tests/test_basic.py | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index cd40f7891..667f715f8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -28,6 +28,7 @@ Unreleased matched name. This makes behavior such as help text and ``Context.invoked_subcommand`` consistent when using patterns like ``AliasedGroup``. :issue:`1422` +- The ``BOOL`` type now accepts the strings "on" and "off". :issue:`1629` Version 7.1.2 diff --git a/docs/parameters.rst b/docs/parameters.rst index f5f181fb5..9ac721dda 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -48,9 +48,9 @@ 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``, ``t`` - and ``true`` convert to `True` and ``0``, ``no``, ``n``, ``f`` and ``false`` - convert to `False`. + for boolean flags. If used with string values, ``1``, ``yes``, ``y``, + ``t``, ``true``, and ``on`` convert to `True`, and ``0``, ``no``, ``n``, + ``f``, ``false``, and ``off`` convert to `False`. :data:`click.UUID`: A parameter that accepts UUID values. This is not automatically diff --git a/src/click/types.py b/src/click/types.py index 93cf70195..58bbc9c26 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -387,9 +387,9 @@ def convert(self, value, param, ctx): if isinstance(value, bool): return bool(value) value = value.lower() - if value in ("true", "t", "1", "yes", "y"): + if value in ("true", "t", "1", "yes", "y", "on"): return True - elif value in ("false", "f", "0", "no", "n"): + elif value in ("false", "f", "0", "no", "n", "off"): return False self.fail(f"{value} is not a valid boolean", param, ctx) diff --git a/tests/test_basic.py b/tests/test_basic.py index c33e3a4ac..92e79631f 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -206,12 +206,12 @@ def test_boolean_conversion(runner): def cli(flag): click.echo(flag) - for value in "true", "t", "1", "yes", "y": + for value in "true", "t", "1", "yes", "y", "on": result = runner.invoke(cli, ["--flag", value]) assert not result.exception assert result.output == "True\n" - for value in "false", "f", "0", "no", "n": + for value in "false", "f", "0", "no", "n", "off": result = runner.invoke(cli, ["--flag", value]) assert not result.exception assert result.output == "False\n" From 9537f4c17a1ba00184d7517b835c221715114e63 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 5 Aug 2020 07:35:50 -0700 Subject: [PATCH 099/293] use sets for bool string check update doc, changelog, test --- CHANGES.rst | 2 +- docs/parameters.rst | 8 ++++---- src/click/types.py | 6 +++--- tests/test_basic.py | 38 +++++++++++++++++++------------------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 667f715f8..0fce1bc22 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -28,7 +28,7 @@ Unreleased matched name. This makes behavior such as help text and ``Context.invoked_subcommand`` consistent when using patterns like ``AliasedGroup``. :issue:`1422` -- The ``BOOL`` type now accepts the strings "on" and "off". :issue:`1629` +- The ``BOOL`` type accepts the values "on" and "off". :issue:`1629` Version 7.1.2 diff --git a/docs/parameters.rst b/docs/parameters.rst index 9ac721dda..27c84ea6f 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -47,10 +47,10 @@ different behavior and some are supported out of the box: A parameter that only accepts floating point values. ``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``, - ``t``, ``true``, and ``on`` convert to `True`, and ``0``, ``no``, ``n``, - ``f``, ``false``, and ``off`` convert to `False`. + A parameter that accepts boolean values. This is automatically used + for boolean flags. The string values "1", "true", "t", "yes", "y", + and "on" convert to ``True``. "0", "false", "f", "no", "n", and + "off" convert to ``False``. :data:`click.UUID`: A parameter that accepts UUID values. This is not automatically diff --git a/src/click/types.py b/src/click/types.py index 58bbc9c26..d50786aee 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -387,11 +387,11 @@ def convert(self, value, param, ctx): if isinstance(value, bool): return bool(value) value = value.lower() - if value in ("true", "t", "1", "yes", "y", "on"): + if value in {"1", "true", "t", "yes", "y", "on"}: return True - elif value in ("false", "f", "0", "no", "n", "off"): + elif value in {"0", "false", "f", "no", "n", "off"}: return False - self.fail(f"{value} is not a valid boolean", param, ctx) + self.fail(f"{value!r} is not a valid boolean value.", param, ctx) def __repr__(self): return "BOOL" diff --git a/tests/test_basic.py b/tests/test_basic.py index 92e79631f..9daccc228 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,5 +1,8 @@ import os import uuid +from itertools import chain + +import pytest import click @@ -198,27 +201,24 @@ def cli(flag): assert result.output == f"{default}\n" -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", "on": - result = runner.invoke(cli, ["--flag", value]) - assert not result.exception - assert result.output == "True\n" +@pytest.mark.parametrize( + ("value", "expect"), + chain( + ((x, "True") for x in ("1", "true", "t", "yes", "y", "on")), + ((x, "False") for x in ("0", "false", "f", "no", "n", "off")), + ), +) +def test_boolean_conversion(runner, value, expect): + @click.command() + @click.option("--flag", type=bool) + def cli(flag): + click.echo(flag, nl=False) - for value in "false", "f", "0", "no", "n", "off": - result = runner.invoke(cli, ["--flag", value]) - assert not result.exception - assert result.output == "False\n" + result = runner.invoke(cli, ["--flag", value]) + assert result.output == expect - result = runner.invoke(cli, []) - assert not result.exception - assert result.output == f"{default}\n" + result = runner.invoke(cli, ["--flag", value.title()]) + assert result.output == expect def test_file_option(runner): From 32ddbb11f210dd679439241c6619cad8bcafca67 Mon Sep 17 00:00:00 2001 From: IamCathal Date: Mon, 6 Jul 2020 16:47:51 +0100 Subject: [PATCH 100/293] non-chained group invoked without command invokes result callback --- CHANGES.rst | 2 ++ src/click/core.py | 19 +++++++------------ tests/test_chain.py | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0fce1bc22..adb580532 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -29,6 +29,8 @@ Unreleased ``Context.invoked_subcommand`` consistent when using patterns like ``AliasedGroup``. :issue:`1422` - The ``BOOL`` type accepts the values "on" and "off". :issue:`1629` +- A ``Group`` with ``invoke_without_command=True`` will always invoke + its result callback. :issue:`1178` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 22f742001..9651deb65 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1170,7 +1170,7 @@ def format_options(self, ctx, formatter): self.format_commands(ctx, formatter) def resultcallback(self, replace=False): - """Adds a result callback to the chain command. By default if a + """Adds a result callback to the command. By default if a result callback is already registered this will chain them but this can be disabled with the `replace` parameter. The result callback is invoked with the return value of the subcommand @@ -1189,10 +1189,10 @@ def cli(input): def process_result(result, input): return result + input - .. versionadded:: 3.0 - :param replace: if set to `True` an already existing result callback will be removed. + + .. versionadded:: 3.0 """ def decorator(f): @@ -1258,18 +1258,13 @@ def _process_result(value): return value if not ctx.protected_args: - # If we are invoked without command the chain flag controls - # how this happens. If we are not in chain mode, the return - # value here is the return value of the command. - # If however we are in chain mode, the return value is the - # return value of the result processor invoked with an empty - # list (which means that no subcommand actually was executed). if self.invoke_without_command: - if not self.chain: - return Command.invoke(self, ctx) + # No subcommand was invoked, so the result callback is + # invoked with None for regular groups, or an empty list + # for chained groups. with ctx: Command.invoke(self, ctx) - return _process_result([]) + return _process_result([] if self.chain else None) ctx.fail("Missing command.") # Fetch args back out diff --git a/tests/test_chain.py b/tests/test_chain.py index 046277909..74a04f106 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -88,6 +88,25 @@ def bdist(format): assert result.output.splitlines() == ["bdist called 1", "sdist called 2"] +@pytest.mark.parametrize(("chain", "expect"), [(False, "None"), (True, "[]")]) +def test_no_command_result_callback(runner, chain, expect): + """When a group has ``invoke_without_command=True``, the result + callback is always invoked. A regular group invokes it with + ``None``, a chained group with ``[]``. + """ + + @click.group(invoke_without_command=True, chain=chain) + def cli(): + pass + + @cli.resultcallback() + def process_result(result): + click.echo(str(result), nl=False) + + result = runner.invoke(cli, []) + assert result.output == expect + + def test_chaining_with_arguments(runner): @click.group(chain=True) def cli(): From 3d3ea9c64420fd8978a41ce8b4db3ac2d245849c Mon Sep 17 00:00:00 2001 From: Amy Lei Date: Wed, 24 Jun 2020 17:42:00 -0400 Subject: [PATCH 101/293] nargs=-1 works with envvar nargs > 1 is validated for envvar and default values Co-authored-by: Rachel Liu --- CHANGES.rst | 2 ++ src/click/core.py | 14 ++++++++++ src/click/parser.py | 4 +++ tests/test_arguments.py | 59 +++++++++++++++++++++++++++++------------ 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index adb580532..d7a780deb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -31,6 +31,8 @@ Unreleased - The ``BOOL`` type accepts the values "on" and "off". :issue:`1629` - A ``Group`` with ``invoke_without_command=True`` will always invoke its result callback. :issue:`1178` +- ``nargs == -1`` and ``nargs > 1`` is parsed and validated for + values from environment variables and defaults. :issue:`729` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 9651deb65..346350bab 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1624,12 +1624,26 @@ def full_process_value(self, ctx, value): if value is None and not ctx.resilient_parsing: value = self.get_default(ctx) + if value is not None: ctx.set_parameter_source(self.name, ParameterSource.DEFAULT) if self.required and self.value_is_missing(value): raise MissingParameter(ctx=ctx, param=self) + # For bounded nargs (!= -1), validate the number of values. + if ( + not ctx.resilient_parsing + and self.nargs > 1 + and self.nargs != len(value) + and isinstance(value, (tuple, list)) + ): + were = "was" if len(value) == 1 else "were" + ctx.fail( + f"Argument {self.name!r} takes {self.nargs} values but" + f" {len(value)} {were} given." + ) + return value def resolve_envvar_value(self, ctx): diff --git a/src/click/parser.py b/src/click/parser.py index 158abb0de..b2ed7ad8f 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -183,6 +183,10 @@ def process(self, value, state): raise BadArgumentUsage( f"argument {self.dest} takes {self.nargs} values" ) + + if self.nargs == -1 and self.obj.envvar is not None: + value = None + state.opts[self.dest] = value state.order.append(self.obj) diff --git a/tests/test_arguments.py b/tests/test_arguments.py index c5fee4242..445b8802d 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -18,7 +18,7 @@ def copy(src, dst): assert result.output.splitlines() == ["src=foo.txt|bar.txt", "dst=dir"] -def test_nargs_default(runner): +def test_argument_unbounded_nargs_cant_have_default(runner): with pytest.raises(TypeError, match="nargs=-1"): @click.command() @@ -163,26 +163,31 @@ def inout(output): assert result.output == "Foo bar baz\n" -def test_nargs_envvar(runner): - @click.command() - @click.option("--arg", nargs=2) - def cmd(arg): - click.echo("|".join(arg)) - - result = runner.invoke( - cmd, [], auto_envvar_prefix="TEST", env={"TEST_ARG": "foo bar"} - ) - assert not result.exception - assert result.output == "foo|bar\n" +@pytest.mark.parametrize( + ("nargs", "value", "code", "output"), + [ + (2, "", 2, "Argument 'arg' takes 2 values but 0 were given."), + (2, "a", 2, "Argument 'arg' takes 2 values but 1 was given."), + (2, "a b", 0, "len 2"), + (2, "a b c", 2, "Argument 'arg' takes 2 values but 3 were given."), + (-1, "a b c", 0, "len 3"), + (-1, "", 0, "len 0"), + ], +) +def test_nargs_envvar(runner, nargs, value, code, output): + if nargs == -1: + param = click.argument("arg", envvar="X", nargs=nargs) + else: + param = click.option("--arg", envvar="X", nargs=nargs) @click.command() - @click.option("--arg", envvar="X", nargs=2) + @param def cmd(arg): - click.echo("|".join(arg)) + click.echo(f"len {len(arg)}") - result = runner.invoke(cmd, [], env={"X": "foo bar"}) - assert not result.exception - assert result.output == "foo|bar\n" + result = runner.invoke(cmd, env={"X": value}) + assert result.exit_code == code + assert output in result.output def test_empty_nargs(runner): @@ -303,3 +308,23 @@ def test_multiple_param_decls_not_allowed(runner): @click.argument("x", click.Choice(["a", "b"])) def copy(x): click.echo(x) + + +@pytest.mark.parametrize( + ("value", "code", "output"), + [ + ((), 2, "Argument 'arg' takes 2 values but 0 were given."), + (("a",), 2, "Argument 'arg' takes 2 values but 1 was given."), + (("a", "b"), 0, "len 2"), + (("a", "b", "c"), 2, "Argument 'arg' takes 2 values but 3 were given."), + ], +) +def test_nargs_default(runner, value, code, output): + @click.command() + @click.argument("arg", nargs=2, default=value) + def cmd(arg): + click.echo(f"len {len(arg)}") + + result = runner.invoke(cmd) + assert result.exit_code == code + assert output in result.output From faaec8294eb819e9f8e6f568ae81b711a861ebbe Mon Sep 17 00:00:00 2001 From: kbanc Date: Thu, 25 Jun 2020 15:17:55 -0400 Subject: [PATCH 102/293] detect program name when run with python -m Co-authored-by: Gabriel de Melo Cruz --- CHANGES.rst | 2 ++ src/click/core.py | 5 ++--- src/click/utils.py | 49 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 30 +++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index d7a780deb..79ae5d809 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -33,6 +33,8 @@ Unreleased its result callback. :issue:`1178` - ``nargs == -1`` and ``nargs > 1`` is parsed and validated for values from environment variables and defaults. :issue:`729` +- Detect the program name when executing a module or package with + ``python -m name``. :issue:`1603` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 346350bab..52f9cacb0 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -25,6 +25,7 @@ from .types import BOOL from .types import convert_type from .types import IntRange +from .utils import _detect_program_name from .utils import echo from .utils import make_default_short_help from .utils import make_str @@ -795,9 +796,7 @@ def main( args = list(args) if prog_name is None: - prog_name = make_str( - os.path.basename(sys.argv[0] if sys.argv else __file__) - ) + prog_name = _detect_program_name() # Hook for the Bash completion. This only activates if the Bash # completion is actually enabled, otherwise this is quite a fast diff --git a/src/click/utils.py b/src/click/utils.py index bd9dd8e7a..0bff5c0ff 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -438,3 +438,52 @@ def flush(self): def __getattr__(self, attr): return getattr(self.wrapped, attr) + + +def _detect_program_name(path=None, _main=sys.modules["__main__"]): + """Determine the command used to run the program, for use in help + text. If a file or entry point was executed, the file name is + returned. If ``python -m`` was used to execute a module or package, + ``python -m name`` is returned. + + This doesn't try to be too precise, the goal is to give a concise + name for help text. Files are only shown as their name without the + path. ``python`` is only shown for modules, and the full path to + ``sys.executable`` is not shown. + + :param path: The Python file being executed. Python puts this in + ``sys.argv[0]``, which is used by default. + :param _main: The ``__main__`` module. This should only be passed + during internal testing. + + .. versionadded:: 8.0 + Based on command args detection in the Werkzeug reloader. + + :meta private: + """ + if not path: + path = sys.argv[0] + + # The value of __package__ indicates how Python was called. It may + # not exist if a setuptools script is installed as an egg. It may be + # set incorrectly for entry points created with pip on Windows. + if getattr(_main, "__package__", None) is None or ( + os.name == "nt" + and _main.__package__ == "" + and not os.path.exists(path) + and os.path.exists(f"{path}.exe") + ): + # Executed a file, like "python app.py". + return os.path.basename(path) + + # Executed a module, like "python -m example". + # Rewritten by Python from "-m script" to "/path/to/script.py". + # Need to look at main module to determine how it was executed. + py_module = _main.__package__ + name = os.path.splitext(os.path.basename(path))[0] + + # A submodule like "example.cli". + if name != "__main__": + py_module = f"{py_module}.{name}" + + return f"python -m {py_module.lstrip('.')}" diff --git a/tests/test_utils.py b/tests/test_utils.py index 0abd9496a..8b1277bb7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import os +import pathlib import stat import sys from io import StringIO @@ -382,3 +383,32 @@ def test_iter_lazyfile(tmpdir): with click.utils.LazyFile(f.name) as lf: for e_line, a_line in zip(expected, lf): assert e_line == a_line.strip() + + +class MockMain: + __slots__ = "__package__" + + def __init__(self, package_name): + self.__package__ = package_name + + +@pytest.mark.parametrize( + ("path", "main", "expected"), + [ + ("example.py", None, "example.py"), + (str(pathlib.Path("/foo/bar/example.py")), None, "example.py"), + ("example", None, "example"), + ( + str(pathlib.Path("example/__main__.py")), + MockMain(".example"), + "python -m example", + ), + ( + str(pathlib.Path("example/cli.py")), + MockMain(".example"), + "python -m example.cli", + ), + ], +) +def test_detect_program_name(path, main, expected): + assert click.utils._detect_program_name(path, _main=main) == expected From 190b86b37e4c76af638575c81671f670874265a3 Mon Sep 17 00:00:00 2001 From: Amy Date: Tue, 30 Jun 2020 10:26:02 -0400 Subject: [PATCH 103/293] include parent args in subcommand help synopsis --- CHANGES.rst | 2 ++ src/click/core.py | 5 ++++- tests/test_arguments.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 79ae5d809..2900361f7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -35,6 +35,8 @@ Unreleased values from environment variables and defaults. :issue:`729` - Detect the program name when executing a module or package with ``python -m name``. :issue:`1603` +- Include required parent arguments in help synopsis of subcommands. + :issue:`1475` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 52f9cacb0..71178dcab 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -508,7 +508,10 @@ def command_path(self): if self.info_name is not None: rv = self.info_name if self.parent is not None: - rv = f"{self.parent.command_path} {rv}" + parent_command_path = [self.parent.command_path] + for param in self.parent.command.get_params(self): + parent_command_path.extend(param.get_usage_pieces(self)) + rv = f"{' '.join(parent_command_path)} {rv}" return rv.lstrip() def find_root(self): diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 445b8802d..ca2e406de 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -328,3 +328,44 @@ def cmd(arg): result = runner.invoke(cmd) assert result.exit_code == code assert output in result.output + + +def test_subcommand_help(runner): + @click.group() + @click.argument("name") + @click.argument("val") + @click.option("--opt") + @click.pass_context + def cli(ctx, name, val, opt): + ctx.obj = dict(name=name, val=val) + + @cli.command() + @click.pass_obj + def cmd(obj): + click.echo(f"CMD for {obj['name']} with value {obj['val']}") + + result = runner.invoke(cli, ["foo", "bar", "cmd", "--help"]) + assert not result.exception + assert "Usage: cli NAME VAL cmd [OPTIONS]" in result.output + + +def test_nested_subcommand_help(runner): + @click.group() + @click.argument("arg1") + @click.option("--opt1") + def cli(arg1, opt1): + pass + + @cli.group() + @click.argument("arg2") + @click.option("--opt2") + def cmd(arg2, opt2): + pass + + @cmd.command() + def subcmd(): + click.echo("subcommand") + + result = runner.invoke(cli, ["arg1", "cmd", "arg2", "subcmd", "--help"]) + assert not result.exception + assert "Usage: cli ARG1 cmd ARG2 subcmd [OPTIONS]" in result.output From 8528079be61bd4c4635e6f18d428740dd5032861 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2020 08:02:05 +0000 Subject: [PATCH 104/293] Bump tox from 3.18.1 to 3.19.0 Bumps [tox](https://github.com/tox-dev/tox) from 3.18.1 to 3.19.0. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.18.1...3.19.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index d8555d2e6..570b6850a 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -49,7 +49,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, pytest, tox -tox==3.18.1 # via -r requirements/dev.in +tox==3.19.0 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox zipp==3.1.0 # via importlib-metadata From f1a41de495c57cc85230aa6dbf15ee863220409f Mon Sep 17 00:00:00 2001 From: ovezovs Date: Tue, 30 Jun 2020 16:40:33 -0400 Subject: [PATCH 105/293] show boolean flag name for default --- CHANGES.rst | 2 ++ src/click/core.py | 6 ++++++ tests/test_options.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 2900361f7..041de51fd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -37,6 +37,8 @@ Unreleased ``python -m name``. :issue:`1603` - Include required parent arguments in help synopsis of subcommands. :issue:`1475` +- Help for boolean flags with ``show_default=True`` shows the flag + name instead of ``True`` or ``False``. :issue:`1538` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 71178dcab..726c69d0f 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1948,6 +1948,12 @@ def _write_opts(opts): default_string = ", ".join(str(d) for d in self.default) elif inspect.isfunction(self.default): default_string = "(dynamic)" + elif self.is_bool_flag and self.secondary_opts: + # For boolean flags that have distinct True/False opts, + # use the opt without prefix instead of the value. + default_string = split_opt( + (self.opts if self.default else self.secondary_opts)[0] + )[1] else: default_string = self.default extra.append(f"default: {default_string}") diff --git a/tests/test_options.py b/tests/test_options.py index bd66bd2a0..c22f95dae 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -570,3 +570,32 @@ def cmd(**kwargs): def test_flag_duplicate_names(runner): with pytest.raises(ValueError, match="cannot use the same flag for true/false"): click.Option(["--foo/--foo"], default=False) + + +@pytest.mark.parametrize(("default", "expect"), [(False, "no-cache"), (True, "cache")]) +def test_show_default_boolean_flag_name(runner, default, expect): + """When a boolean flag has distinct True/False opts, it should show + the default opt name instead of the default value. It should only + show one name even if multiple are declared. + """ + opt = click.Option( + ("--cache/--no-cache", "--c/--nc"), + default=default, + show_default=True, + help="Enable/Disable the cache.", + ) + ctx = click.Context(click.Command("test")) + message = opt.get_help_record(ctx)[1] + assert f"[default: {expect}]" in message + + +def test_show_default_boolean_flag_value(runner): + """When a boolean flag only has one opt, it will show the default + value, not the opt name. + """ + opt = click.Option( + ("--cache",), is_flag=True, show_default=True, help="Enable the cache.", + ) + ctx = click.Context(click.Command("test")) + message = opt.get_help_record(ctx)[1] + assert "[default: False]" in message From 1516dc6c80ff9f6aebfaf846126909650e451ad9 Mon Sep 17 00:00:00 2001 From: Segev Finer Date: Sun, 14 Oct 2018 19:38:11 +0300 Subject: [PATCH 106/293] convert objects to string in style/secho --- CHANGES.rst | 2 ++ src/click/termui.py | 19 ++++++++++++++----- tests/test_termui.py | 7 +++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 041de51fd..b6b5842f1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -39,6 +39,8 @@ Unreleased :issue:`1475` - Help for boolean flags with ``show_default=True`` shows the flag name instead of ``True`` or ``False``. :issue:`1538` +- Non-string objects passed to ``style()`` and ``secho()`` will be + converted to string. :pr:`1146` Version 7.1.2 diff --git a/src/click/termui.py b/src/click/termui.py index fd3d91576..a252ca6bc 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -482,11 +482,6 @@ def style( * ``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. @@ -501,7 +496,18 @@ def style( :param reset: by default a reset-all code is added at the end of the string which means that styles do not carry over. This can be disabled to compose styles. + + .. versionchanged:: 8.0 + A non-string ``message`` is converted to a string. + + .. versionchanged:: 7.0 + Added support for bright colors. + + .. versionadded:: 2.0 """ + if not isinstance(text, str): + text = str(text) + bits = [] if fg: try: @@ -551,6 +557,9 @@ def secho(message=None, file=None, nl=True, err=False, color=None, **styles): All keyword arguments are forwarded to the underlying functions depending on which one they go with. + .. versionchanged:: 8.0 + A non-string ``message`` is converted to a string. + .. versionadded:: 2.0 """ if message is not None: diff --git a/tests/test_termui.py b/tests/test_termui.py index 7165023b4..dc34a56bb 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -257,6 +257,13 @@ def test_secho(runner): assert bytes == b"" +def test_secho_non_text(runner): + with runner.isolation() as outstreams: + click.secho(Exception("spam"), nl=False) + bytes = outstreams[0].getvalue() + assert bytes == b"spam" + + def test_progressbar_yields_all_items(runner): with click.progressbar(range(3)) as progress: assert len(list(progress)) == 3 From 353dd895ffa390c127a0ead2ed86f9510b1a33ad Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 7 Aug 2020 19:19:10 -0700 Subject: [PATCH 107/293] pass bytes unstyled from secho to echo --- src/click/termui.py | 12 ++++++++++-- tests/test_termui.py | 15 ++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/click/termui.py b/src/click/termui.py index a252ca6bc..24de36ee4 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -7,6 +7,7 @@ from ._compat import DEFAULT_COLUMNS from ._compat import get_winterm_size +from ._compat import is_bytes from ._compat import isatty from ._compat import strip_ansi from ._compat import WIN @@ -557,13 +558,20 @@ def secho(message=None, file=None, nl=True, err=False, color=None, **styles): All keyword arguments are forwarded to the underlying functions depending on which one they go with. + Non-string types will be converted to :class:`str`. However, + :class:`bytes` are passed directly to :meth:`echo` without applying + style. If you want to style bytes that represent text, call + :meth:`bytes.decode` first. + .. versionchanged:: 8.0 - A non-string ``message`` is converted to a string. + A non-string ``message`` is converted to a string. Bytes are + passed through without style applied. .. versionadded:: 2.0 """ - if message is not None: + if message is not None and not is_bytes(message): message = style(message, **styles) + return echo(message, file=file, nl=nl, err=err, color=color) diff --git a/tests/test_termui.py b/tests/test_termui.py index dc34a56bb..0bcbca5d7 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -1,3 +1,4 @@ +import platform import time import pytest @@ -257,11 +258,15 @@ def test_secho(runner): assert bytes == b"" -def test_secho_non_text(runner): - with runner.isolation() as outstreams: - click.secho(Exception("spam"), nl=False) - bytes = outstreams[0].getvalue() - assert bytes == b"spam" +@pytest.mark.skipif(platform.system() == "Windows", reason="No style on Windows.") +@pytest.mark.parametrize( + ("value", "expect"), [(123, b"\x1b[45m123\x1b[0m"), (b"test", b"test")] +) +def test_secho_non_text(runner, value, expect): + with runner.isolation() as (out, _): + click.secho(value, nl=False, color=True, bg="magenta") + result = out.getvalue() + assert result == expect def test_progressbar_yields_all_items(runner): From 11c286a37d55357b82c7e5db7237544cd79fe25a Mon Sep 17 00:00:00 2001 From: Giovanni Pizzi Date: Wed, 20 Jun 2018 09:34:30 +0200 Subject: [PATCH 108/293] fix edit() require_save check for fast editors If a fast editor is used and the filesystem only provides a resolution of 1 second, edit() returns None if the file is edited and saved very quickly. --- CHANGES.rst | 2 ++ src/click/_termui_impl.py | 7 +++++++ tests/test_termui.py | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index b6b5842f1..420ee3ed6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -41,6 +41,8 @@ Unreleased name instead of ``True`` or ``False``. :issue:`1538` - Non-string objects passed to ``style()`` and ``secho()`` will be converted to string. :pr:`1146` +- ``edit(require_save=True)`` will detect saves for editors that exit + very fast on filesystems with 1 second resolution. :pr:`1050` Version 7.1.2 diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 78372503d..1ef020ffe 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -493,6 +493,13 @@ def edit(self, text): f = os.fdopen(fd, "wb") f.write(text) f.close() + # If the filesystem resolution is 1 second, like Mac OS + # 10.12 Extended, or 2 seconds, like FAT32, and the editor + # closes very fast, require_save can fail. Set the modified + # time to be 2 seconds in the past to work around this. + os.utime(name, (os.path.getatime(name), os.path.getmtime(name) - 2)) + # Depending on the resolution, the exact value might not be + # recorded, so get the new recorded value. timestamp = os.path.getmtime(name) self.edit_file(name) diff --git a/tests/test_termui.py b/tests/test_termui.py index 0bcbca5d7..90a4ce60c 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -379,3 +379,9 @@ def test_getchar_windows_exceptions(runner, monkeypatch, key_char, exc): with pytest.raises(exc): click.getchar() + + +@pytest.mark.skipif(platform.system() == "Windows", reason="No sed on Windows.") +def test_fast_edit(runner): + result = click.edit("a\nb", editor="sed -i~ 's/$/Test/'") + assert result == "aTest\nbTest\n" From 9d4fc53be5997eaf6b7ad099e05749bcded03800 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 8 Aug 2020 10:53:09 -0700 Subject: [PATCH 109/293] clean up Editor.edit code use context manager for opening file move encoding out of try block use isinstance instead of type --- src/click/_termui_impl.py | 43 +++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 1ef020ffe..53941408d 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -474,25 +474,26 @@ def edit_file(self, filename): def edit(self, text): import tempfile - text = text or "" - binary_data = type(text) in [bytes, bytearray] + if not text: + text = "" - if not binary_data and text and not text.endswith("\n"): - text += "\n" + is_bytes = isinstance(text, (bytes, bytearray)) + + if not is_bytes: + if text and not text.endswith("\n"): + text += "\n" + + if WIN: + text = text.replace("\n", "\r\n").encode("utf-8-sig") + else: + text = text.encode("utf-8") fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension) + try: - if not binary_data: - if WIN: - encoding = "utf-8-sig" - text = text.replace("\n", "\r\n") - else: - encoding = "utf-8" - text = text.encode(encoding) - - f = os.fdopen(fd, "wb") - f.write(text) - f.close() + with os.fdopen(fd, "wb") as f: + f.write(text) + # If the filesystem resolution is 1 second, like Mac OS # 10.12 Extended, or 2 seconds, like FAT32, and the editor # closes very fast, require_save can fail. Set the modified @@ -507,15 +508,13 @@ def edit(self, text): if self.require_save and os.path.getmtime(name) == timestamp: return None - f = open(name, "rb") - try: + with open(name, "rb") as f: rv = f.read() - finally: - f.close() - if binary_data: + + if is_bytes: return rv - else: - return rv.decode("utf-8-sig").replace("\r\n", "\n") + + return rv.decode("utf-8-sig").replace("\r\n", "\n") finally: os.unlink(name) From 08a0d691d8105312d4d81b64366a4fffda277a12 Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Sat, 8 Aug 2020 18:29:55 -0700 Subject: [PATCH 110/293] use super() consistently --- src/click/_winconsole.py | 2 +- src/click/core.py | 36 +++++++++++++++++++++--------------- src/click/exceptions.py | 17 ++++++++--------- src/click/types.py | 6 ++++-- tests/test_commands.py | 2 +- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index d10ca5c25..074dff75f 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -113,7 +113,7 @@ def __init__(self, handle): self.handle = handle def isatty(self): - io.RawIOBase.isatty(self) + super().isatty() return True diff --git a/src/click/core.py b/src/click/core.py index 726c69d0f..c37bd987d 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -909,7 +909,7 @@ def __init__( hidden=False, deprecated=False, ): - BaseCommand.__init__(self, name, context_settings) + super().__init__(name, context_settings) #: the callback to execute when the command fires. This might be #: `None` in which case nothing happens. self.callback = callback @@ -1138,7 +1138,7 @@ def __init__( result_callback=None, **attrs, ): - Command.__init__(self, name, **attrs) + super().__init__(name, **attrs) if no_args_is_help is None: no_args_is_help = not invoke_without_command self.no_args_is_help = no_args_is_help @@ -1163,12 +1163,12 @@ def __init__( ) def collect_usage_pieces(self, ctx): - rv = Command.collect_usage_pieces(self, ctx) + rv = super().collect_usage_pieces(ctx) rv.append(self.subcommand_metavar) return rv def format_options(self, ctx, formatter): - Command.format_options(self, ctx, formatter) + super().format_options(ctx, formatter) self.format_commands(ctx, formatter) def resultcallback(self, replace=False): @@ -1244,7 +1244,8 @@ def parse_args(self, ctx, args): echo(ctx.get_help(), color=ctx.color) ctx.exit() - rest = Command.parse_args(self, ctx, args) + rest = super().parse_args(ctx, args) + if self.chain: ctx.protected_args = rest ctx.args = [] @@ -1265,7 +1266,7 @@ def _process_result(value): # invoked with None for regular groups, or an empty list # for chained groups. with ctx: - Command.invoke(self, ctx) + super().invoke(ctx) return _process_result([] if self.chain else None) ctx.fail("Missing command.") @@ -1283,7 +1284,7 @@ def _process_result(value): with ctx: cmd_name, cmd, args = self.resolve_command(ctx, args) ctx.invoked_subcommand = cmd_name - Command.invoke(self, ctx) + super().invoke(ctx) sub_ctx = cmd.make_context(cmd_name, args, parent=ctx) with sub_ctx: return _process_result(sub_ctx.command.invoke(sub_ctx)) @@ -1295,7 +1296,7 @@ def _process_result(value): # but nothing else. with ctx: ctx.invoked_subcommand = "*" if args else None - Command.invoke(self, ctx) + super().invoke(ctx) # Otherwise we make every single context and invoke them in a # chain. In that case the return value to the result processor @@ -1366,7 +1367,7 @@ class Group(MultiCommand): """ def __init__(self, name=None, commands=None, **attrs): - MultiCommand.__init__(self, name, **attrs) + super().__init__(name, **attrs) #: the registered subcommands by their exported names. self.commands = commands or {} @@ -1425,7 +1426,7 @@ class CommandCollection(MultiCommand): """ def __init__(self, name=None, sources=None, **attrs): - MultiCommand.__init__(self, name, **attrs) + super().__init__(name, **attrs) #: The list of registered multi commands. self.sources = sources or [] @@ -1763,7 +1764,7 @@ def __init__( **attrs, ): default_is_missing = attrs.get("default", _missing) is _missing - Parameter.__init__(self, param_decls, type=type, **attrs) + super().__init__(param_decls, type=type, **attrs) if prompt is True: prompt_text = self.name.replace("_", " ").capitalize() @@ -1980,7 +1981,8 @@ def get_default(self, ctx): if param.name == self.name and param.default: return param.flag_value return None - return Parameter.get_default(self, ctx) + + return super().get_default(ctx) def prompt_for_value(self, ctx): """This is an alternative flow that can be activated in the full @@ -2007,7 +2009,8 @@ def prompt_for_value(self, ctx): ) def resolve_envvar_value(self, ctx): - rv = Parameter.resolve_envvar_value(self, ctx) + rv = super().resolve_envvar_value(ctx) + if rv is not None: return rv if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: @@ -2028,7 +2031,8 @@ def value_from_envvar(self, ctx): def full_process_value(self, ctx, value): if value is None and self.prompt is not None and not ctx.resilient_parsing: return self.prompt_for_value(ctx) - return Parameter.full_process_value(self, ctx, value) + + return super().full_process_value(ctx, value) class Argument(Parameter): @@ -2047,7 +2051,9 @@ def __init__(self, param_decls, required=None, **attrs): required = False else: required = attrs.get("nargs", 1) > 0 - Parameter.__init__(self, param_decls, required=required, **attrs) + + super().__init__(param_decls, required=required, **attrs) + if self.default is not None and self.nargs < 0: raise TypeError( "nargs=-1 in combination with a default value is not supported." diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 25b02bb0c..9623cd812 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -43,7 +43,7 @@ class UsageError(ClickException): exit_code = 2 def __init__(self, message, ctx=None): - ClickException.__init__(self, message) + super().__init__(message) self.ctx = ctx self.cmd = self.ctx.command if self.ctx else None @@ -82,7 +82,7 @@ class BadParameter(UsageError): """ def __init__(self, message, ctx=None, param=None, param_hint=None): - UsageError.__init__(self, message, ctx) + super().__init__(message, ctx) self.param = param self.param_hint = param_hint @@ -113,7 +113,7 @@ class MissingParameter(BadParameter): def __init__( self, message=None, ctx=None, param=None, param_hint=None, param_type=None ): - BadParameter.__init__(self, message, ctx, param, param_hint) + super().__init__(message, ctx, param, param_hint) self.param_type = param_type def format_message(self): @@ -159,7 +159,8 @@ class NoSuchOption(UsageError): def __init__(self, option_name, message=None, possibilities=None, ctx=None): if message is None: message = f"no such option: {option_name}" - UsageError.__init__(self, message, ctx) + + super().__init__(message, ctx) self.option_name = option_name self.possibilities = possibilities @@ -185,7 +186,7 @@ class BadOptionUsage(UsageError): """ def __init__(self, option_name, message, ctx=None): - UsageError.__init__(self, message, ctx) + super().__init__(message, ctx) self.option_name = option_name @@ -197,9 +198,6 @@ class BadArgumentUsage(UsageError): .. versionadded:: 6.0 """ - def __init__(self, message, ctx=None): - UsageError.__init__(self, message, ctx) - class FileError(ClickException): """Raised if a file cannot be opened.""" @@ -208,7 +206,8 @@ def __init__(self, filename, hint=None): ui_filename = filename_to_ui(filename) if hint is None: hint = "unknown error" - ClickException.__init__(self, hint) + + super().__init__(hint) self.ui_filename = ui_filename self.filename = filename diff --git a/src/click/types.py b/src/click/types.py index d50786aee..910cd9df9 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -278,7 +278,8 @@ def __init__(self, min=None, max=None, clamp=False): self.clamp = clamp def convert(self, value, param, ctx): - rv = IntParamType.convert(self, value, param, ctx) + rv = super().convert(value, param, ctx) + if self.clamp: if self.min is not None and rv < self.min: return self.min @@ -344,7 +345,8 @@ def __init__(self, min=None, max=None, clamp=False): self.clamp = clamp def convert(self, value, param, ctx): - rv = FloatParamType.convert(self, value, param, ctx) + rv = super().convert(value, param, ctx) + if self.clamp: if self.min is not None and rv < self.min: return self.min diff --git a/tests/test_commands.py b/tests/test_commands.py index 2467c733c..c8da4ceb8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -133,7 +133,7 @@ def cli(): class OptParseCommand(click.BaseCommand): def __init__(self, name, parser, callback): - click.BaseCommand.__init__(self, name) + super().__init__(name) self.parser = parser self.callback = callback From ca4e4d96e59d502176d43ab4203f30f3f9a6c31d Mon Sep 17 00:00:00 2001 From: Javier Domingo Cansino Date: Sat, 8 Aug 2020 18:30:55 -0700 Subject: [PATCH 111/293] add Command.context_class --- src/click/core.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index c37bd987d..29ccdf9c3 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -601,12 +601,15 @@ def invoke(*args, **kwargs): # noqa: B902 if isinstance(callback, Command): other_cmd = callback callback = other_cmd.callback - ctx = Context(other_cmd, info_name=other_cmd.name, parent=self) + if callback is None: raise TypeError( "The given command does not have a callback that can be invoked." ) + # Create a new context of the same type as this context. + ctx = type(self)(other_cmd, info_name=other_cmd.name, parent=self) + for param in other_cmd.params: if param.name not in kwargs and param.expose_value: kwargs[param.name] = param.get_default(ctx) @@ -623,8 +626,7 @@ def forward(*args, **kwargs): # noqa: B902 """ self, cmd = args[:2] - # It's also possible to invoke another command which might or - # might not have a callback. + # Can only forward to other commands, not direct callbacks. if not isinstance(cmd, Command): raise TypeError("Callback is not a command.") @@ -686,6 +688,10 @@ class BaseCommand: passed to the context object. """ + #: The context class to create with :meth:`make_context`. + #: + #: .. versionadded:: 8.0 + context_class = Context #: the default for the :attr:`Context.allow_extra_args` flag. allow_extra_args = False #: the default for the :attr:`Context.allow_interspersed_args` flag. @@ -718,6 +724,9 @@ def make_context(self, info_name, args, parent=None, **extra): off the parsing and create a new :class:`Context`. It does not invoke the actual command callback though. + To quickly customize the context class used without overriding + this method, set the :attr:`context_class` attribute. + :param info_name: the info name for this invokation. Generally this is the most descriptive name for the script or command. For the toplevel script it's usually @@ -727,11 +736,16 @@ def make_context(self, info_name, args, parent=None, **extra): :param parent: the parent context if available. :param extra: extra keyword arguments forwarded to the context constructor. + + .. versionchanged:: 8.0 + Added the :attr:`context_class` attribute. """ for key, value in self.context_settings.items(): if key not in extra: extra[key] = value - ctx = Context(self, info_name=info_name, parent=parent, **extra) + + ctx = self.context_class(self, info_name=info_name, parent=parent, **extra) + with ctx.scope(cleanup=False): self.parse_args(ctx, args) return ctx From dee49e50374abb2d77e98db74576f1f0fb4e4911 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 8 Aug 2020 13:58:28 -0700 Subject: [PATCH 112/293] add Context.formatter_class --- CHANGES.rst | 5 +++++ src/click/core.py | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 420ee3ed6..eb9e8e655 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -43,6 +43,11 @@ Unreleased converted to string. :pr:`1146` - ``edit(require_save=True)`` will detect saves for editors that exit very fast on filesystems with 1 second resolution. :pr:`1050` +- ``Command`` has a ``context_class`` attribute to quickly customize + the ``Context``. ``Context`` has a ``formatter_class`` attribute to + quickly customize the ``HelpFormatter``. ``Context.invoke`` creates + child contexts of the same type as itself. Core objects use + ``super()`` consistently. :pr:`938` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 29ccdf9c3..1c36b6a96 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -245,6 +245,11 @@ class Context: this command-level setting overrides it. """ + #: The formatter class to create with :meth:`make_formatter`. + #: + #: .. versionadded:: 8.0 + formatter_class = HelpFormatter + def __init__( self, command, @@ -475,8 +480,16 @@ def get_language(): return self._meta def make_formatter(self): - """Creates the formatter for the help and usage output.""" - return HelpFormatter( + """Creates the :class:`~click.HelpFormatter` for the help and + usage output. + + To quickly customize the formatter class used without overriding + this method, set the :attr:`formatter_class` attribute. + + .. versionchanged:: 8.0 + Added the :attr:`formatter_class` attribute. + """ + return self.formatter_class( width=self.terminal_width, max_width=self.max_content_width ) From c4659126f7e7c77dcc107faff38d0caa9aa85b50 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 8 Aug 2020 18:21:21 -0700 Subject: [PATCH 113/293] add command_class and group_class to Group --- src/click/core.py | 52 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index 1c36b6a96..310ae6f3d 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1393,6 +1393,25 @@ class Group(MultiCommand): :param commands: a dictionary of commands. """ + #: If set, this is used by the group's :meth:`command` decorator + #: as the default :class:`Command` class. This is useful to make all + #: subcommands use a custom command class. + #: + #: .. versionadded:: 8.0 + command_class = None + + #: If set, this is used by the group's :meth:`group` decorator + #: as the default :class:`Group` class. This is useful to make all + #: subgroups use a custom group class. + #: + #: If set to the special value :class:`type` (literally + #: ``group_class = type``), this group's class will be used as the + #: default class. This makes a custom group class continue to make + #: custom groups. + #: + #: .. versionadded:: 8.0 + group_class = None + def __init__(self, name=None, commands=None, **attrs): super().__init__(name, **attrs) #: the registered subcommands by their exported names. @@ -1410,12 +1429,21 @@ def add_command(self, cmd, name=None): def command(self, *args, **kwargs): """A shortcut decorator for declaring and attaching a command to - the group. This takes the same arguments as :func:`command` but - immediately registers the created command with this instance by - calling into :meth:`add_command`. + the group. This takes the same arguments as :func:`command` and + immediately registers the created command with this group by + calling :meth:`add_command`. + + To customize the command class used, set the + :attr:`command_class` attribute. + + .. versionchanged:: 8.0 + Added the :attr:`command_class` attribute. """ from .decorators import command + if self.command_class is not None and "cls" not in kwargs: + kwargs["cls"] = self.command_class + def decorator(f): cmd = command(*args, **kwargs)(f) self.add_command(cmd) @@ -1425,12 +1453,24 @@ def decorator(f): def group(self, *args, **kwargs): """A shortcut decorator for declaring and attaching a group to - the group. This takes the same arguments as :func:`group` but - immediately registers the created command with this instance by - calling into :meth:`add_command`. + the group. This takes the same arguments as :func:`group` and + immediately registers the created group with this group by + calling :meth:`add_command`. + + To customize the group class used, set the :attr:`group_class` + attribute. + + .. versionchanged:: 8.0 + Added the :attr:`group_class` attribute. """ from .decorators import group + if self.group_class is not None and "cls" not in kwargs: + if self.group_class is type: + kwargs["cls"] = type(self) + else: + kwargs["cls"] = self.group_class + def decorator(f): cmd = group(*args, **kwargs)(f) self.add_command(cmd) From aff51681a7c9862edb1396f8a8272345aacd9ecb Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 8 Aug 2020 19:49:26 -0700 Subject: [PATCH 114/293] tests for custom class attributes --- CHANGES.rst | 21 +++++-- tests/test_custom_classes.py | 112 +++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/test_custom_classes.py diff --git a/CHANGES.rst b/CHANGES.rst index eb9e8e655..df689a8cb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -43,11 +43,22 @@ Unreleased converted to string. :pr:`1146` - ``edit(require_save=True)`` will detect saves for editors that exit very fast on filesystems with 1 second resolution. :pr:`1050` -- ``Command`` has a ``context_class`` attribute to quickly customize - the ``Context``. ``Context`` has a ``formatter_class`` attribute to - quickly customize the ``HelpFormatter``. ``Context.invoke`` creates - child contexts of the same type as itself. Core objects use - ``super()`` consistently. :pr:`938` +- New class attributes make it easier to use custom core objects + throughout an entire application. :pr:`938` + + - ``Command.context_class`` controls the context created when + running the command. + - ``Context.invoke`` creates new contexts of the same type, so a + custom type will persist to invoked subcommands. + - ``Context.formatter_class`` controls the formatter used to + generate help and usage. + - ``Group.command_class`` changes the default type for + subcommands with ``@group.command()``. + - ``Group.group_class`` changes the default type for subgroups + with ``@group.group()``. Setting it to ``type`` will create + subgroups of the same type as the group itself. + - Core objects use ``super()`` consistently for better support of + subclassing. Version 7.1.2 diff --git a/tests/test_custom_classes.py b/tests/test_custom_classes.py new file mode 100644 index 000000000..314842bda --- /dev/null +++ b/tests/test_custom_classes.py @@ -0,0 +1,112 @@ +import click + + +def test_command_context_class(): + """A command with a custom ``context_class`` should produce a + context using that type. + """ + + class CustomContext(click.Context): + pass + + class CustomCommand(click.Command): + context_class = CustomContext + + command = CustomCommand("test") + context = command.make_context("test", []) + assert isinstance(context, CustomContext) + + +def test_context_invoke_type(runner): + """A command invoked from a custom context should have a new + context with the same type. + """ + + class CustomContext(click.Context): + pass + + class CustomCommand(click.Command): + context_class = CustomContext + + @click.command() + @click.argument("first_id", type=int) + @click.pass_context + def second(ctx, first_id): + assert isinstance(ctx, CustomContext) + assert id(ctx) != first_id + + @click.command(cls=CustomCommand) + @click.pass_context + def first(ctx): + assert isinstance(ctx, CustomContext) + ctx.invoke(second, first_id=id(ctx)) + + assert not runner.invoke(first).exception + + +def test_context_formatter_class(): + """A context with a custom ``formatter_class`` should format help + using that type. + """ + + class CustomFormatter(click.HelpFormatter): + def write_heading(self, heading): + heading = click.style(heading, fg="yellow") + return super().write_heading(heading) + + class CustomContext(click.Context): + formatter_class = CustomFormatter + + context = CustomContext( + click.Command("test", params=[click.Option(["--value"])]), color=True + ) + assert "\x1b[33mOptions\x1b[0m:" in context.get_help() + + +def test_group_command_class(runner): + """A group with a custom ``command_class`` should create subcommands + of that type by default. + """ + + class CustomCommand(click.Command): + pass + + class CustomGroup(click.Group): + command_class = CustomCommand + + group = CustomGroup() + subcommand = group.command()(lambda: None) + assert type(subcommand) is CustomCommand + subcommand = group.command(cls=click.Command)(lambda: None) + assert type(subcommand) is click.Command + + +def test_group_group_class(runner): + """A group with a custom ``group_class`` should create subgroups + of that type by default. + """ + + class CustomSubGroup(click.Group): + pass + + class CustomGroup(click.Group): + group_class = CustomSubGroup + + group = CustomGroup() + subgroup = group.group()(lambda: None) + assert type(subgroup) is CustomSubGroup + subgroup = group.command(cls=click.Group)(lambda: None) + assert type(subgroup) is click.Group + + +def test_group_group_class_self(runner): + """A group with ``group_class = type`` should create subgroups of + the same type as itself. + """ + + class CustomGroup(click.Group): + group_class = type + + group = CustomGroup() + subgroup = group.group()(lambda: None) + assert type(subgroup) is CustomGroup From 89349070ae1cb2e6219cce9c0a4907c0f041cc87 Mon Sep 17 00:00:00 2001 From: Matthias Urlichs Date: Fri, 21 Dec 2018 17:54:20 +0100 Subject: [PATCH 115/293] add an ExitStack to the context This makes it possible to open a resource, make it available to callbacks and subcommands, then ensure the resource is cleaned up when the group ends and the context goes out of scope. --- CHANGES.rst | 5 +++ docs/advanced.rst | 74 +++++++++++++++++++++++++++++++++++++++++++ src/click/core.py | 58 ++++++++++++++++++++++++++------- tests/test_context.py | 18 +++++++++++ 4 files changed, 143 insertions(+), 12 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index df689a8cb..1b00e615d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -60,6 +60,11 @@ Unreleased - Core objects use ``super()`` consistently for better support of subclassing. +- Use ``Context.with_resource()`` to manage resources that would + normally be used in a ``with`` statement, allowing them to be used + across subcommands and callbacks, then cleaned up when the context + ends. :pr:`1191` + Version 7.1.2 ------------- diff --git a/docs/advanced.rst b/docs/advanced.rst index 80036111f..e1ffcad07 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -406,3 +406,77 @@ method can be used to find this out. println() invoke(cli, prog_name='cli', args=[]) println() + + +Managing Resources +------------------ + +It can be useful to open a resource in a group, to be made available to +subcommands. Many types of resources need to be closed or otherwise +cleaned up after use. The standard way to do this in Python is by using +a context manager with the ``with`` statement. + +For example, the ``Repo`` class from :doc:`complex` might actually be +defined as a context manager: + +.. code-block:: python + + class Repo: + def __init__(self, home=None): + self.home = os.path.abspath(home or ".") + self.db = None + + def __enter__(self): + path = os.path.join(self.home, "repo.db") + self.db = open_database(path) + + def __exit__(self, exc_type, exc_value, tb): + self.db.close() + +Ordinarily, it would be used with the ``with`` statement: + +.. code-block:: python + + with Repo() as repo: + repo.db.query(...) + +However, a ``with`` block in a group would exit and close the database +before it could be used by a subcommand. + +Instead, use the context's :meth:`~click.Context.with_resource` method +to enter the context manager and return the resource. When the group and +any subcommands finish, the context's resources are cleaned up. + +.. code-block:: python + + @click.group() + @click.option("--repo-home", default=".repo") + @click.pass_context + def cli(ctx, repo_home): + ctx.obj = ctx.with_resource(Repo(repo_home)) + + @cli.command() + @click.pass_obj + def log(obj): + # obj is the repo opened in the cli group + for entry in obj.db.query(...): + click.echo(entry) + +If the resource isn't a context manager, usually it can be wrapped in +one using something from :mod:`contextlib`. If that's not possible, use +the context's :meth:`~click.Context.call_on_close` method to register a +cleanup function. + +.. code-block:: python + + @click.group() + @click.option("--name", default="repo.db") + @click.pass_context + def cli(ctx, repo_home): + ctx.obj = db = open_db(repo_home) + + @ctx.call_on_close + def close_db(): + db.record_use() + db.save() + db.close() diff --git a/src/click/core.py b/src/click/core.py index 310ae6f3d..1de6151bd 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -3,6 +3,7 @@ import os import sys from contextlib import contextmanager +from contextlib import ExitStack from functools import update_wrapper from itertools import repeat @@ -401,6 +402,7 @@ def __init__( self._close_callbacks = [] self._depth = 0 self._source_by_paramname = {} + self._exit_stack = ExitStack() def __enter__(self): self._depth += 1 @@ -493,23 +495,55 @@ def make_formatter(self): width=self.terminal_width, max_width=self.max_content_width ) + def with_resource(self, context_manager): + """Register a resource as if it were used in a ``with`` + statement. The resource will be cleaned up when the context is + popped. + + Uses :meth:`contextlib.ExitStack.enter_context`. It calls the + resource's ``__enter__()`` method and returns the result. When + the context is popped, it closes the stack, which calls the + resource's ``__exit__()`` method. + + To register a cleanup function for something that isn't a + context manager, use :meth:`call_on_close`. Or use something + from :mod:`contextlib` to turn it into a context manager first. + + .. code-block:: python + + @click.group() + @click.option("--name") + @click.pass_context + def cli(ctx): + ctx.obj = ctx.with_resource(connect_db(name)) + + :param context_manager: The context manager to enter. + :return: Whatever ``context_manager.__enter__()`` returns. + + .. versionadded:: 8.0 + """ + return self._exit_stack.enter_context(context_manager) + def call_on_close(self, f): - """This decorator remembers a function as callback that should be - executed when the context tears down. This is most useful to bind - resource handling to the script execution. For instance, file objects - opened by the :class:`File` type will register their close callbacks - here. + """Register a function to be called when the context tears down. - :param f: the function to execute on teardown. + This can be used to close resources opened during the script + execution. Resources that support Python's context manager + protocol which would be used in a ``with`` statement should be + registered with :meth:`with_resource` instead. + + :param f: The function to execute on teardown. """ - self._close_callbacks.append(f) - return f + return self._exit_stack.callback(f) def close(self): - """Invokes all close callbacks.""" - for cb in self._close_callbacks: - cb() - self._close_callbacks = [] + """Invoke all close callbacks registered with + :meth:`call_on_close`, and exit all context managers entered + with :meth:`with_resource`. + """ + self._exit_stack.close() + # In case the context is reused, create a new exit stack. + self._exit_stack = ExitStack() @property def command_path(self): diff --git a/tests/test_context.py b/tests/test_context.py index 44befa584..c1be8f35f 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,3 +1,5 @@ +from contextlib import contextmanager + import pytest import click @@ -212,6 +214,22 @@ def foo(): assert called == [True] +def test_with_resource(): + @contextmanager + def manager(): + val = [1] + yield val + val[0] = 0 + + ctx = click.Context(click.Command("test")) + + with ctx.scope(): + rv = ctx.with_resource(manager()) + assert rv[0] == 1 + + assert rv == [0] + + def test_make_pass_decorator_args(runner): """ Test to check that make_pass_decorator doesn't consume arguments based on From a2b774a8fb048977331fda844cb4375641bf58bd Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2020 08:10:19 +0000 Subject: [PATCH 116/293] Bump sphinx from 3.1.2 to 3.2.0 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.1.2 to 3.2.0. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.1.2...v3.2.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 570b6850a..af78e78a1 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -40,7 +40,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging, pip-tools, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.1.2 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.2.0 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index 39f812149..9b6ca1d55 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -22,7 +22,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.1.2 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.2.0 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx From a7436e89bbebd361d8d97d1261db0bd399c84b31 Mon Sep 17 00:00:00 2001 From: Tom Dalton Date: Wed, 8 May 2019 15:48:36 +0100 Subject: [PATCH 117/293] test result captures command return value --- CHANGES.rst | 3 +++ src/click/testing.py | 41 +++++++++++++++++++++++++++++------------ tests/test_testing.py | 13 +++++++++++++ 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1b00e615d..66895accd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -64,6 +64,9 @@ Unreleased normally be used in a ``with`` statement, allowing them to be used across subcommands and callbacks, then cleaned up when the context ends. :pr:`1191` +- The result object returned by the test runner's ``invoke()`` method + has a ``return_value`` attribute with the value returned by the + invoked command. :pr:`1312` Version 7.1.2 diff --git a/src/click/testing.py b/src/click/testing.py index fd6bf61b1..d7e585662 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -62,7 +62,14 @@ class Result: """Holds the captured result of an invoked CLI script.""" def __init__( - self, runner, stdout_bytes, stderr_bytes, exit_code, exception, exc_info=None + self, + runner, + stdout_bytes, + stderr_bytes, + return_value, + exit_code, + exception, + exc_info=None, ): #: The runner that created the result self.runner = runner @@ -70,6 +77,10 @@ def __init__( self.stdout_bytes = stdout_bytes #: The standard error as bytes, or None if not available self.stderr_bytes = stderr_bytes + #: The value returned from the invoked command. + #: + #: .. versionadded:: 8.0 + self.return_value = return_value #: The exit code as integer. self.exit_code = exit_code #: The exception that happened if one did. @@ -269,16 +280,6 @@ def invoke( This returns a :class:`Result` object. - .. versionadded:: 3.0 - The ``catch_exceptions`` parameter was added. - - .. versionchanged:: 3.0 - The result object now has an `exc_info` attribute with the - traceback if available. - - .. versionadded:: 4.0 - The ``color`` parameter was added. - :param cli: the command to invoke :param args: the arguments to invoke. It may be given as an iterable or a string. When given as string it will be interpreted @@ -291,9 +292,24 @@ def invoke( :param extra: the keyword arguments to pass to :meth:`main`. :param color: whether the output should contain color codes. The application can still override this explicitly. + + .. versionchanged:: 8.0 + The result object has the ``return_value`` attribute with + the value returned from the invoked command. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. + + .. versionchanged:: 3.0 + Added the ``catch_exceptions`` parameter. + + .. versionchanged:: 3.0 + The result object has the ``exc_info`` attribute with the + traceback if available. """ exc_info = None with self.isolation(input=input, env=env, color=color) as outstreams: + return_value = None exception = None exit_code = 0 @@ -306,7 +322,7 @@ def invoke( prog_name = self.get_default_prog_name(cli) try: - cli.main(args=args or (), prog_name=prog_name, **extra) + return_value = cli.main(args=args or (), prog_name=prog_name, **extra) except SystemExit as e: exc_info = sys.exc_info() exit_code = e.code @@ -339,6 +355,7 @@ def invoke( runner=self, stdout_bytes=stdout, stderr_bytes=stderr, + return_value=return_value, exit_code=exit_code, exception=exception, exc_info=exc_info, diff --git a/tests/test_testing.py b/tests/test_testing.py index 7360473f3..86c8708d3 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -295,3 +295,16 @@ def cli(): result = runner.invoke(cli, prog_name="foobar") assert not result.exception assert result.output == "ok\n" + + +def test_command_standalone_mode_returns_value(): + @click.command() + def cli(): + click.echo("ok") + return "Hello, World!" + + runner = CliRunner() + result = runner.invoke(cli, standalone_mode=False) + assert result.output == "ok\n" + assert result.return_value == "Hello, World!" + assert result.exit_code == 0 From 73ed951779e12ab49480c730a13d7201a01c3ca0 Mon Sep 17 00:00:00 2001 From: Nicolas Simonds Date: Tue, 2 Apr 2019 11:48:22 -0700 Subject: [PATCH 118/293] Annotate required choices with braces Display the metavar for Choice options with curly braces on required params, to reduce user confusion. --- CHANGES.rst | 3 +++ src/click/types.py | 9 ++++++++- tests/test_basic.py | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 66895accd..b2ee94586 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -67,6 +67,9 @@ Unreleased - The result object returned by the test runner's ``invoke()`` method has a ``return_value`` attribute with the value returned by the invoked command. :pr:`1312` +- Required arguments with the ``Choice`` type show the choices in + curly braces to indicate that one is required (``{a|b|c}``). + :issue:`1272` Version 7.1.2 diff --git a/src/click/types.py b/src/click/types.py index 910cd9df9..c9f15ce62 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -157,7 +157,14 @@ def __init__(self, choices, case_sensitive=True): self.case_sensitive = case_sensitive def get_metavar(self, param): - return f"[{'|'.join(self.choices)}]" + choices_str = "|".join(self.choices) + + # Use curly braces to indicate a required argument. + if param.required and param.param_type_name == "argument": + return f"{{{choices_str}}}" + + # Use square braces to indicate an option or optional argument. + return f"[{choices_str}]" def get_missing_message(self, param): choice_str = ",\n\t".join(self.choices) diff --git a/tests/test_basic.py b/tests/test_basic.py index 9daccc228..e53353432 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -352,6 +352,27 @@ def cli(method): assert "--method [foo|bar|baz]" in result.output +def test_choice_argument(runner): + @click.command() + @click.argument("method", type=click.Choice(["foo", "bar", "baz"])) + def cli(method): + click.echo(method) + + result = runner.invoke(cli, ["foo"]) + assert not result.exception + assert result.output == "foo\n" + + result = runner.invoke(cli, ["meh"]) + assert result.exit_code == 2 + assert ( + "Invalid value for '{foo|bar|baz}': invalid choice: meh. " + "(choose from foo, bar, baz)" in result.output + ) + + result = runner.invoke(cli, ["--help"]) + assert "{foo|bar|baz}" in result.output + + def test_datetime_option_default(runner): @click.command() @click.option("--start_date", type=click.DateTime()) From 75b708a157b9de67e46d428795ad187097513f4e Mon Sep 17 00:00:00 2001 From: robintully Date: Mon, 6 May 2019 13:59:56 -0400 Subject: [PATCH 119/293] add open intervals to FloatRange --- CHANGES.rst | 1 + src/click/types.py | 13 +++++++++---- tests/test_basic.py | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b2ee94586..9fd552fd2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,6 +20,7 @@ Unreleased is not shown in the error message. :issue:`1460` - An ``IntRange`` option shows the accepted range in its help text. :issue:`1525` +- ``FloatRange`` min and max can be exclusive. :issue:`1100` - An option defined with duplicate flag names (``"--foo/--foo"``) raises a ``ValueError``. :issue:`1465` - ``echo()`` will not fail when using pytest's ``capsys`` fixture on diff --git a/src/click/types.py b/src/click/types.py index c9f15ce62..1edd52880 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -1,4 +1,5 @@ import os +import operator import stat from datetime import datetime @@ -339,17 +340,18 @@ 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. + between the two edges. The exclusive argument permits open intervals. See :ref:`ranges` for an example. """ name = "float range" - def __init__(self, min=None, max=None, clamp=False): + def __init__(self, min=None, max=None, clamp=False, exclusive=False): self.min = min self.max = max self.clamp = clamp + self.exclusive = exclusive def convert(self, value, param, ctx): rv = super().convert(value, param, ctx) @@ -359,11 +361,14 @@ def convert(self, value, param, ctx): return self.min if self.max is not None and rv > self.max: return self.max + less_than = operator.le if self.exclusive else operator.lt + greater_than = operator.ge if self.exclusive else operator.gt + if ( self.min is not None - and rv < self.min + and less_than(rv, self.min) or self.max is not None - and rv > self.max + and greater_than(rv, self.max) ): if self.min is None: self.fail( diff --git a/tests/test_basic.py b/tests/test_basic.py index e53353432..3fce2557f 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -481,6 +481,24 @@ def clamp(x): assert not result.exception assert result.output == "0\n" + @click.command() + @click.option('--x', type=click.FloatRange(0, 5, exclusive=True)) + def exclude(x): + click.echo(x) + + result = runner.invoke(exclude, ['--x=4.99']) + assert not result.exception + assert result.output == '4.99\n' + + result = runner.invoke(exclude, ['--x=5.0']) + assert result.exit_code == 2 + assert 'Invalid value for "--x": 5.0 is not in the valid range of 0 to 5.\n' \ + in result.output + + result = runner.invoke(exclude, ['--x=-3e8']) + assert result.exit_code == 2 + assert 'Invalid value for "--x": -300000000.0 is not in the valid range of 0 to 5.\n' \ + in result.output def test_required_option(runner): @click.command() From 81550db28984dc7af322e6d62529166cf52deacc Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Thu, 6 Aug 2020 23:26:49 -0700 Subject: [PATCH 120/293] Upgrade issue templates --- .../bug-report.md} | 12 ++++++--- .github/ISSUE_TEMPLATE/config.yaml | 11 ++++++++ .github/ISSUE_TEMPLATE/feature-request.md | 15 +++++++++++ .github/pull_request_template.md | 25 +++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) rename .github/{ISSUE_TEMPLATE.md => ISSUE_TEMPLATE/bug-report.md} (79%) create mode 100644 .github/ISSUE_TEMPLATE/config.yaml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.md create mode 100644 .github/pull_request_template.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/bug-report.md similarity index 79% rename from .github/ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE/bug-report.md index f9b0cf9ba..9a9b197e3 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,15 +1,21 @@ -**This issue tracker is a tool to address bugs in Click itself. +--- +name: Bug report +about: Report a bug in Click (not other projects which depend on Click) +--- + + ---- ### Expected Behavior diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 000000000..8d8c9b747 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Security issue + url: security@palletsprojects.com + about: Do not report security issues publicly. Email our security contact. + - name: Questions + url: https://stackoverflow.com/questions/tagged/python-click?tab=Frequent + about: Search for and ask questions about your code on Stack Overflow. + - name: Questions and discussions + url: https://discord.gg/pallets + about: Discuss questions about your code on our Discord chat. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 000000000..3dce4a42e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest a new feature for Click +--- + + + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..79739f6f9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ + + + + + +- Fixes # + + + +Checklist: + +- [ ] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change. +- [ ] Add or update relevant docs, in the docs folder and in code. +- [ ] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue. +- [ ] Add `.. versionchanged::` entries in any relevant code docs. +- [ ] Run `pre-commit` hooks and fix any issues. +- [ ] Run `pytest` and `tox`, no tests failed. From d77a0aefad0ad488d7bfd46cd7cf9ea6dc62c380 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 10 Aug 2020 20:28:41 -0700 Subject: [PATCH 121/293] refactor int, float, and range types with common bases allow specifying min and max as open separately clamp works for open int bounds, fails for open float bounds help output shows range information --- CHANGES.rst | 8 +- docs/options.rst | 37 ++++---- src/click/core.py | 9 +- src/click/types.py | 215 ++++++++++++++++++++++-------------------- tests/test_basic.py | 88 ----------------- tests/test_options.py | 27 +++--- tests/test_types.py | 56 +++++++++++ 7 files changed, 209 insertions(+), 231 deletions(-) create mode 100644 tests/test_types.py diff --git a/CHANGES.rst b/CHANGES.rst index 9fd552fd2..ea5f8b4ff 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -18,9 +18,11 @@ Unreleased :issue:`1582` - If validation fails for a prompt with ``hide_input=True``, the value is not shown in the error message. :issue:`1460` -- An ``IntRange`` option shows the accepted range in its help text. - :issue:`1525` -- ``FloatRange`` min and max can be exclusive. :issue:`1100` +- An ``IntRange`` or ``FloatRange`` option shows the accepted range in + its help text. :issue:`1525`, :pr:`1303` +- ``IntRange`` and ``FloatRange`` bounds can be open (``<``) instead + of closed (``<=``) by setting ``min_open`` and ``max_open``. Error + messages have changed to reflect this. :issue:`1100` - An option defined with duplicate flag names (``"--foo/--foo"``) raises a ``ValueError``. :issue:`1465` - ``echo()`` will not fail when using pytest's ``capsys`` fixture on diff --git a/docs/options.rst b/docs/options.rst index 5adbc54d0..f45312559 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -775,39 +775,34 @@ boolean flag you need to separate it with ``;`` instead of ``/``: Range Options ------------- -A special mention should go to the :class:`IntRange` type, which works very -similarly to the :data:`INT` type, but restricts the value to fall into a -specific range (inclusive on both edges). It has two modes: +The :class:`IntRange` type extends the :data:`INT` type to ensure the +value is contained in the given range. The :class:`FloatRange` type does +the same for :data:`FLOAT`. -- the default mode (non-clamping mode) where a value that falls outside - of the range will cause an error. -- an optional clamping mode where a value that falls outside of the - range will be clamped. This means that a range of ``0-5`` would - return ``5`` for the value ``10`` or ``0`` for the value ``-1`` (for - example). +If ``min`` or ``max`` is omitted, that side is *unbounded*. Any value in +that direction is accepted. By default, both bounds are *closed*, which +means the boundary value is included in the accepted range. ``min_open`` +and ``max_open`` can be used to exclude that boundary from the range. -Example: +If ``clamp`` mode is enabled, a value that is outside the range is set +to the boundary instead of failing. For example, the range ``0, 5`` +would return ``5`` for the value ``10``, or ``0`` for the value ``-1``. +When using :class:`FloatRange`, ``clamp`` can only be enabled if both +bounds are *closed* (the default). .. click:example:: @click.command() - @click.option('--count', type=click.IntRange(0, 20, clamp=True)) - @click.option('--digit', type=click.IntRange(0, 10)) + @click.option("--count", type=click.IntRange(0, 20, clamp=True)) + @click.option("--digit", type=click.IntRange(0, 9)) def repeat(count, digit): click.echo(str(digit) * count) - if __name__ == '__main__': - repeat() - -And from the command line: - .. click:run:: - invoke(repeat, args=['--count=1000', '--digit=5']) - invoke(repeat, args=['--count=1000', '--digit=12']) + invoke(repeat, args=['--count=100', '--digit=5']) + invoke(repeat, args=['--count=6', '--digit=12']) -If you pass ``None`` for any of the edges, it means that the range is open -at that side. Callbacks for Validation ------------------------ diff --git a/src/click/core.py b/src/click/core.py index 1de6151bd..6d6f29926 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -23,6 +23,7 @@ from .termui import confirm from .termui import prompt from .termui import style +from .types import _NumberRangeBase from .types import BOOL from .types import convert_type from .types import IntRange @@ -2060,9 +2061,11 @@ def _write_opts(opts): default_string = self.default extra.append(f"default: {default_string}") - if isinstance(self.type, IntRange): - if self.type.min is not None and self.type.max is not None: - extra.append(f"{self.type.min}-{self.type.max} inclusive") + if isinstance(self.type, _NumberRangeBase): + range_str = self.type._describe_range() + + if range_str: + extra.append(range_str) if self.required: extra.append("required") diff --git a/src/click/types.py b/src/click/types.py index 1edd52880..5649a9c16 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -1,5 +1,4 @@ import os -import operator import stat from datetime import datetime @@ -256,142 +255,150 @@ def __repr__(self): return "DateTime" -class IntParamType(ParamType): - name = "integer" +class _NumberParamTypeBase(ParamType): + _number_class = None def convert(self, value, param, ctx): try: - return int(value) + return self._number_class(value) except ValueError: - self.fail(f"{value} is not a valid integer", param, ctx) - - def __repr__(self): - return "INT" - + self.fail(f"{value} is not a valid {self.name}", param, ctx) -class IntRange(IntParamType): - """A parameter that works similar to :data:`click.INT` 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 = "integer range" - - def __init__(self, min=None, max=None, clamp=False): +class _NumberRangeBase(_NumberParamTypeBase): + def __init__(self, min=None, max=None, min_open=False, max_open=False, clamp=False): self.min = min self.max = max + self.min_open = min_open + self.max_open = max_open self.clamp = clamp def convert(self, value, param, ctx): + import operator + rv = super().convert(value, param, ctx) + lt_min = self.min is not None and ( + operator.le if self.min_open else operator.lt + )(rv, self.min) + gt_max = self.max is not None and ( + operator.ge if self.max_open else operator.gt + )(rv, self.max) 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( - f"{rv} is bigger than the maximum valid value {self.max}.", - param, - ctx, - ) - elif self.max is None: - self.fail( - f"{rv} is smaller than the minimum valid value {self.min}.", - param, - ctx, - ) - else: - self.fail( - f"{rv} is not in the valid range of {self.min} to {self.max}.", - param, - ctx, - ) + if lt_min: + return self._clamp(self.min, 1, self.min_open) + + if gt_max: + return self._clamp(self.max, -1, self.max_open) + + if lt_min or gt_max: + self.fail(f"{rv} is not in the range {self._describe_range()}.", param, ctx) + return rv + def _clamp(self, bound, dir, open): + """Find the valid value to clamp to bound in the given + direction. + + :param bound: The boundary value. + :param dir: 1 or -1 indicating the direction to move. + :param open: If true, the range does not include the bound. + """ + raise NotImplementedError + + def _describe_range(self): + """Describe the range for use in help text.""" + if self.min is None: + op = "<" if self.max_open else "<=" + return f"x{op}{self.max}" + + if self.max is None: + op = ">" if self.min_open else ">=" + return f"x{op}{self.min}" + + lop = "<" if self.min_open else "<=" + rop = "<" if self.max_open else "<=" + return f"{self.min}{lop}x{rop}{self.max}" + def __repr__(self): - return f"IntRange({self.min}, {self.max})" + clamp = " clamped" if self.clamp else "" + return f"<{type(self).__name__} {self._describe_range()}{clamp}>" -class FloatParamType(ParamType): - name = "float" +class IntParamType(_NumberParamTypeBase): + name = "integer" + _number_class = int - def convert(self, value, param, ctx): - try: - return float(value) - except ValueError: - self.fail(f"{value} is not a valid floating point value", param, ctx) + def __repr__(self): + return "INT" + + +class IntRange(_NumberRangeBase, IntParamType): + """Restrict an :data:`click.INT` value to a range of accepted + values. See :ref:`ranges`. + + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. + + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. + + .. versionchanged:: 8.0 + Added the ``min_open`` and ``max_open`` parameters. + """ + + name = "integer range" + + def _clamp(self, bound, dir, open): + if not open: + return bound + + return bound + dir + + +class FloatParamType(_NumberParamTypeBase): + name = "float" + _number_class = float 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. The exclusive argument permits open intervals. +class FloatRange(_NumberRangeBase, FloatParamType): + """Restrict a :data:`click.FLOAT` value to a range of accepted + values. See :ref:`ranges`. + + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. - See :ref:`ranges` for an example. + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. This is not supported if either + boundary is marked ``open``. + + .. versionchanged:: 8.0 + Added the ``min_open`` and ``max_open`` parameters. """ name = "float range" - def __init__(self, min=None, max=None, clamp=False, exclusive=False): - self.min = min - self.max = max - self.clamp = clamp - self.exclusive = exclusive + def __init__(self, min=None, max=None, min_open=False, max_open=False, clamp=False): + super().__init__( + min=min, max=max, min_open=min_open, max_open=max_open, clamp=clamp + ) - def convert(self, value, param, ctx): - rv = super().convert(value, param, ctx) + if (min_open or max_open) and clamp: + raise TypeError("Clamping is not supported for open bounds.") - 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 - less_than = operator.le if self.exclusive else operator.lt - greater_than = operator.ge if self.exclusive else operator.gt - - if ( - self.min is not None - and less_than(rv, self.min) - or self.max is not None - and greater_than(rv, self.max) - ): - if self.min is None: - self.fail( - f"{rv} is bigger than the maximum valid value {self.max}.", - param, - ctx, - ) - elif self.max is None: - self.fail( - f"{rv} is smaller than the minimum valid value {self.min}.", - param, - ctx, - ) - else: - self.fail( - f"{rv} is not in the valid range of {self.min} to {self.max}.", - param, - ctx, - ) - return rv + def _clamp(self, bound, dir, open): + if not open: + return bound - def __repr__(self): - return f"FloatRange({self.min}, {self.max})" + # Could use Python 3.9's math.nextafter here, but clamping an + # open float range doesn't seem to be particularly useful. It's + # left up to the user to write a callback to do it if needed. + raise RuntimeError("Clamping is not supported for open bounds.") class BoolParamType(ParamType): diff --git a/tests/test_basic.py b/tests/test_basic.py index 3fce2557f..8ab94dcf7 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -412,94 +412,6 @@ def cli(start_date): assert result.output == "2010-06-05T00:00:00\n" -def test_int_range_option(runner): - @click.command() - @click.option("--x", type=click.IntRange(0, 5)) - def cli(x): - click.echo(x) - - result = runner.invoke(cli, ["--x=5"]) - assert not result.exception - assert result.output == "5\n" - - result = runner.invoke(cli, ["--x=6"]) - assert result.exit_code == 2 - assert ( - "Invalid value for '--x': 6 is not in the valid range of 0 to 5.\n" - in result.output - ) - - @click.command() - @click.option("--x", type=click.IntRange(0, 5, clamp=True)) - def clamp(x): - click.echo(x) - - result = runner.invoke(clamp, ["--x=5"]) - assert not result.exception - assert result.output == "5\n" - - result = runner.invoke(clamp, ["--x=6"]) - assert not result.exception - assert result.output == "5\n" - - result = runner.invoke(clamp, ["--x=-1"]) - assert not result.exception - 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" - - @click.command() - @click.option('--x', type=click.FloatRange(0, 5, exclusive=True)) - def exclude(x): - click.echo(x) - - result = runner.invoke(exclude, ['--x=4.99']) - assert not result.exception - assert result.output == '4.99\n' - - result = runner.invoke(exclude, ['--x=5.0']) - assert result.exit_code == 2 - assert 'Invalid value for "--x": 5.0 is not in the valid range of 0 to 5.\n' \ - in result.output - - result = runner.invoke(exclude, ['--x=-3e8']) - assert result.exit_code == 2 - assert 'Invalid value for "--x": -300000000.0 is not in the valid range of 0 to 5.\n' \ - in result.output - def test_required_option(runner): @click.command() @click.option("--foo", required=True) diff --git a/tests/test_options.py b/tests/test_options.py index c22f95dae..515af72ec 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -64,10 +64,7 @@ def cli(v): result = runner.invoke(cli, ["-vvvv"]) assert result.exception - assert ( - "Invalid value for '-v': 4 is not in the valid range of 0 to 3." - in result.output - ) + assert "Invalid value for '-v': 4 is not in the range 0<=x<=3." in result.output result = runner.invoke(cli, []) assert not result.exception @@ -216,14 +213,20 @@ def cmd(username): assert "(current user)" in result.output -def test_intrange_default_help_text(runner): - @click.command() - @click.option("--count", type=click.IntRange(1, 32), show_default=True, default=1) - def cmd(arg): - click.echo(arg) - - result = runner.invoke(cmd, ["--help"]) - assert "1-32 inclusive" in result.output +@pytest.mark.parametrize( + ("type", "expect"), + [ + (click.IntRange(1, 32), "1<=x<=32"), + (click.IntRange(1, 32, min_open=True, max_open=True), "1=1"), + (click.IntRange(max=32), "x<=32"), + ], +) +def test_intrange_default_help_text(runner, type, expect): + option = click.Option(["--count"], type=type, show_default=True, default=1) + context = click.Context(click.Command("test")) + result = option.get_help_record(context)[1] + assert expect in result def test_toupper_envvar_prefix(runner): diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 000000000..b76aa805c --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,56 @@ +import pytest + +import click + + +@pytest.mark.parametrize( + ("type", "value", "expect"), + [ + (click.IntRange(0, 5), "3", 3), + (click.IntRange(5), "5", 5), + (click.IntRange(5), "100", 100), + (click.IntRange(max=5), "5", 5), + (click.IntRange(max=5), "-100", -100), + (click.IntRange(0, clamp=True), "-1", 0), + (click.IntRange(max=5, clamp=True), "6", 5), + (click.IntRange(0, min_open=True, clamp=True), "0", 1), + (click.IntRange(max=5, max_open=True, clamp=True), "5", 4), + (click.FloatRange(0.5, 1.5), "1.2", 1.2), + (click.FloatRange(0.5, min_open=True), "0.51", 0.51), + (click.FloatRange(max=1.5, max_open=True), "1.49", 1.49), + (click.FloatRange(0.5, clamp=True), "-0.0", 0.5), + (click.FloatRange(max=1.5, clamp=True), "inf", 1.5), + ], +) +def test_range(type, value, expect): + assert type.convert(value, None, None) == expect + + +@pytest.mark.parametrize( + ("type", "value", "expect"), + [ + (click.IntRange(0, 5), "6", "6 is not in the range 0<=x<=5."), + (click.IntRange(5), "4", "4 is not in the range x>=5."), + (click.IntRange(max=5), "6", "6 is not in the range x<=5."), + (click.IntRange(0, 5, min_open=True), 0, "00.5"), + (click.FloatRange(max=1.5, max_open=True), 1.5, "x<1.5"), + ], +) +def test_range_fail(type, value, expect): + with pytest.raises(click.BadParameter) as exc_info: + type.convert(value, None, None) + + assert expect in exc_info.value.message + + +def test_float_range_no_clamp_open(): + with pytest.raises(TypeError): + click.FloatRange(0, 1, max_open=True, clamp=True) + + sneaky = click.FloatRange(0, 1, max_open=True) + sneaky.clamp = True + + with pytest.raises(RuntimeError): + sneaky.convert("1.5", None, None) From e389b5eeeee68baba1ba099188cfd919a9b318a5 Mon Sep 17 00:00:00 2001 From: jtrakk <43392409+jtrakk@users.noreply.github.com> Date: Sat, 13 Jul 2019 13:15:43 -0700 Subject: [PATCH 122/293] suggest renaming option from 'name' to '--name' --- CHANGES.rst | 2 ++ src/click/core.py | 4 ++-- tests/test_options.py | 11 ++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index ea5f8b4ff..5a7bde257 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -73,6 +73,8 @@ Unreleased - Required arguments with the ``Choice`` type show the choices in curly braces to indicate that one is required (``{a|b|c}``). :issue:`1272` +- If only a name is passed to ``option()``, Click suggests renaming it + to ``--name``. :pr:`1355` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 6d6f29926..e62059e9f 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1977,8 +1977,8 @@ def _parse_decls(self, decls, expose_value): if not opts and not secondary_opts: raise TypeError( f"No options defined but a name was passed ({name})." - " Did you mean to declare an argument instead of an" - " option?" + " Did you mean to declare an argument instead? Did" + f" you mean to pass '--{name}'?" ) return name, opts, secondary_opts diff --git a/tests/test_options.py b/tests/test_options.py index 515af72ec..9f88e34df 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -23,12 +23,13 @@ def cli(foo, bar): def test_invalid_option(runner): - with pytest.raises(TypeError, match="name was passed"): + with pytest.raises(TypeError, match="name was passed") as exc_info: + click.Option(["foo"]) - @click.command() - @click.option("foo") - def cli(foo): - pass + message = str(exc_info.value) + assert "name was passed (foo)" in message + assert "declare an argument" in message + assert "'--foo'" in message def test_invalid_nargs(runner): From a2b13e9b6e405c79f69ff46db0f6b4050352522f Mon Sep 17 00:00:00 2001 From: Joongi Kim Date: Fri, 29 May 2020 18:40:16 +0900 Subject: [PATCH 123/293] context.show_default defaults to parent context --- CHANGES.rst | 2 ++ src/click/core.py | 44 +++++++++++++++++++++++++------------------ tests/test_context.py | 14 ++++++++++++++ 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5a7bde257..8f58527e8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -75,6 +75,8 @@ Unreleased :issue:`1272` - If only a name is passed to ``option()``, Click suggests renaming it to ``--name``. :pr:`1355` +- A context's ``show_default`` parameter defaults to the value from + the parent context. :issue:`1565` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index e62059e9f..a678bd752 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -173,21 +173,6 @@ class Context: A context can be used as context manager in which case it will call :meth:`close` on teardown. - .. versionadded:: 2.0 - Added the `resilient_parsing`, `help_option_names`, - `token_normalize_func` parameters. - - .. versionadded:: 3.0 - Added the `allow_extra_args` and `allow_interspersed_args` - parameters. - - .. versionadded:: 4.0 - Added the `color`, `ignore_unknown_options`, and - `max_content_width` parameters. - - .. versionadded:: 7.1 - Added the `show_default` parameter. - :param command: the command class for this context. :param parent: the parent context. :param info_name: the info name for this invocation. Generally this @@ -242,9 +227,28 @@ class Context: codes are used in texts that Click prints which is by default not the case. This for instance would affect help output. - :param show_default: if True, shows defaults for all options. - Even if an option is later created with show_default=False, - this command-level setting overrides it. + :param show_default: Show defaults for all options. If not set, + defaults to the value from a parent context. Overrides an + option's ``show_default`` argument. + + .. versionchanged:: 8.0 + The ``show_default`` parameter defaults to the value from the + parent context. + + .. versionchanged:: 7.1 + Added the ``show_default`` parameter. + + .. versionchanged:: 4.0 + Added the ``color``, ``ignore_unknown_options``, and + ``max_content_width`` parameters. + + .. versionchanged:: 3.0 + Added the ``allow_extra_args`` and ``allow_interspersed_args`` + parameters. + + .. versionchanged:: 2.0 + Added the ``resilient_parsing``, ``help_option_names``, and + ``token_normalize_func`` parameters. """ #: The formatter class to create with :meth:`make_formatter`. @@ -398,6 +402,10 @@ def __init__( #: Controls if styling output is wanted or not. self.color = color + if show_default is None and parent is not None: + show_default = parent.show_default + + #: Show option default values when formatting help text. self.show_default = show_default self._close_callbacks = [] diff --git a/tests/test_context.py b/tests/test_context.py index c1be8f35f..bfccb85f5 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -267,6 +267,20 @@ def test2(ctx, foo): assert result.output == "foocmd\n" +def test_propagate_show_default_setting(runner): + """A context's ``show_default`` setting defaults to the value from + the parent context. + """ + group = click.Group( + commands={ + "sub": click.Command("sub", params=[click.Option(["-a"], default="a")]), + }, + context_settings={"show_default": True}, + ) + result = runner.invoke(group, ["sub", "--help"]) + assert "[default: a]" in result.output + + def test_exit_not_standalone(): @click.command() @click.pass_context From 5949dad4bd08ae6ba8ca241e5949474fd2fd54be Mon Sep 17 00:00:00 2001 From: lmjohns3 Date: Wed, 20 Nov 2019 16:03:34 -0800 Subject: [PATCH 124/293] click.style supports 256/RGB colors This adds support for true-color text (using the \033[38;2;RRR;GGG;BBB escape sequence) and 256-color palette (using the \033[38;5;NNN escape sequence). It's backwards compatible with the current string-based colors, but adds a type-checking helper to handle integers or tuples/lists of 3 integers. --- CHANGES.rst | 2 ++ src/click/termui.py | 32 ++++++++++++++++++++++++++++++-- tests/test_utils.py | 2 ++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8f58527e8..b4a8b1cc0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -77,6 +77,8 @@ Unreleased to ``--name``. :pr:`1355` - A context's ``show_default`` parameter defaults to the value from the parent context. :issue:`1565` +- ``click.style()`` can output 256 and RGB color codes. Most modern + terminals support these codes. :pr:`1429` Version 7.1.2 diff --git a/src/click/termui.py b/src/click/termui.py index 24de36ee4..44e0b8038 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -441,6 +441,17 @@ def clear(): sys.stdout.write("\033[2J\033[1;1H") +def _interpret_color(color, offset=0): + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + r, g, b = color + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + def style( text, fg=None, @@ -462,6 +473,7 @@ def style( click.echo(click.style('Hello World!', fg='green')) click.echo(click.style('ATTENTION!', blink=True)) click.echo(click.style('Some things', reverse=True, fg='cyan')) + click.echo(click.style('More colors', fg=(255, 12, 128), bg=117)) Supported color names: @@ -483,6 +495,16 @@ def style( * ``bright_white`` * ``reset`` (reset the color code only) + If the terminal supports it, color may also be specified as: + + - An integer in the interval [0, 255]. The terminal must support + 8-bit/256-color mode. + - An RGB tuple of three integers in [0, 255]. The terminal must + support 24-bit/true-color mode. + + See https://en.wikipedia.org/wiki/ANSI_color and + https://gist.github.com/XVilka/8346728 for more information. + :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. @@ -501,6 +523,9 @@ def style( .. versionchanged:: 8.0 A non-string ``message`` is converted to a string. + .. versionchanged:: 8.0 + Added support for 256 and RGB color codes. + .. versionchanged:: 7.0 Added support for bright colors. @@ -510,16 +535,19 @@ def style( text = str(text) bits = [] + if fg: try: - bits.append(f"\033[{_ansi_colors[fg]}m") + bits.append(f"\033[{_interpret_color(fg)}m") except KeyError: raise TypeError(f"Unknown color {fg!r}") + if bg: try: - bits.append(f"\033[{_ansi_colors[bg] + 10}m") + bits.append(f"\033[{_interpret_color(bg, 10)}m") except KeyError: raise TypeError(f"Unknown color {bg!r}") + if bold is not None: bits.append(f"\033[{1 if bold else 22}m") if dim is not None: diff --git a/tests/test_utils.py b/tests/test_utils.py index 8b1277bb7..a58890449 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -62,6 +62,8 @@ def test_echo_custom_file(): ({"bg": "magenta"}, "\x1b[45mx y\x1b[0m"), ({"bg": "cyan"}, "\x1b[46mx y\x1b[0m"), ({"bg": "white"}, "\x1b[47mx y\x1b[0m"), + ({"bg": 91}, "\x1b[48;5;91mx y\x1b[0m"), + ({"bg": (135, 0, 175)}, "\x1b[48;2;135;0;175mx y\x1b[0m"), ({"blink": True}, "\x1b[5mx y\x1b[0m"), ({"underline": True}, "\x1b[4mx y\x1b[0m"), ({"bold": True}, "\x1b[1mx y\x1b[0m"), From a1555bf0ef7366647acfb8f852fde6acd5b48c2e Mon Sep 17 00:00:00 2001 From: grbd Date: Tue, 21 Apr 2020 22:56:09 +0100 Subject: [PATCH 125/293] show group.add_command in quickstart --- docs/quickstart.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index fd6bce4e5..ae23bfd8a 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -226,6 +226,30 @@ other invocations:: if __name__ == '__main__': cli() + +Registering Commands Later +-------------------------- + +Instead of using the ``@group.command()`` decorator, commands can be +decorated with the plain ``@click.command()`` decorator and registered +with a group later with ``group.add_command()``. This could be used to +split commands into multiple Python modules. + +.. code-block:: python + + @click.command() + def greet(): + click.echo("Hello, World!") + +.. code-block:: python + + @click.group() + def group(): + pass + + group.add_command(greet) + + Adding Parameters ----------------- From 7d7809fcd0940c9a272b5aaeba3ec96bc8b682c1 Mon Sep 17 00:00:00 2001 From: Jethro Cao Date: Wed, 11 Mar 2020 13:58:08 +0800 Subject: [PATCH 126/293] CliRunner isolation streams have name and mode --- CHANGES.rst | 3 +++ src/click/testing.py | 34 ++++++++++++++++++++++++++-------- tests/test_testing.py | 11 +++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b4a8b1cc0..1bb2d6cce 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -79,6 +79,9 @@ Unreleased the parent context. :issue:`1565` - ``click.style()`` can output 256 and RGB color codes. Most modern terminals support these codes. :pr:`1429` +- When using ``CliRunner.invoke()``, the replaced ``stdin`` file has + ``name`` and ``mode`` attributes. This lets ``File`` options with + the ``-`` value match non-testing behavior. :issue:`1064` Version 7.1.2 diff --git a/src/click/testing.py b/src/click/testing.py index d7e585662..9443556a4 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -40,6 +40,21 @@ def __repr__(self): return repr(self._input) +class _NamedTextIOWrapper(io.TextIOWrapper): + def __init__(self, buffer, name=None, mode=None, **kwargs): + super().__init__(buffer, **kwargs) + self._name = name + self._mode = mode + + @property + def name(self): + return self._name + + @property + def mode(self): + return self._mode + + def make_input_stream(input, charset): # Is already an input stream. if hasattr(input, "read"): @@ -186,17 +201,20 @@ def isolation(self, input=None, env=None, color=False): if self.echo_stdin: input = EchoingStdin(input, bytes_output) - input = io.TextIOWrapper(input, encoding=self.charset) - 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) + sys.stdin = input = _NamedTextIOWrapper( + input, encoding=self.charset, name="", mode="r" + ) + sys.stdout = _NamedTextIOWrapper( + bytes_output, encoding=self.charset, name="", mode="w" + ) if self.mix_stderr: sys.stderr = sys.stdout - - sys.stdin = input + else: + bytes_error = io.BytesIO() + sys.stderr = _NamedTextIOWrapper( + bytes_error, encoding=self.charset, name="", mode="w" + ) def visible_input(prompt=None): sys.stdout.write(prompt or "") diff --git a/tests/test_testing.py b/tests/test_testing.py index 86c8708d3..758e173f5 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -308,3 +308,14 @@ def cli(): assert result.output == "ok\n" assert result.return_value == "Hello, World!" assert result.exit_code == 0 + + +def test_file_stdin_attrs(runner): + @click.command() + @click.argument("f", type=click.File()) + def cli(f): + click.echo(f.name) + click.echo(f.mode, nl=False) + + result = runner.invoke(cli, ["-"]) + assert result.output == "\nr" From 3f17cd89df636e897cba571f77afd6d80154d604 Mon Sep 17 00:00:00 2001 From: energizah Date: Tue, 8 Oct 2019 23:03:13 +0000 Subject: [PATCH 127/293] support passing a list of commands to Group --- CHANGES.rst | 2 ++ src/click/core.py | 25 ++++++++++++++++++++----- tests/test_basic.py | 24 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1bb2d6cce..d731f9fe3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -82,6 +82,8 @@ Unreleased - When using ``CliRunner.invoke()``, the replaced ``stdin`` file has ``name`` and ``mode`` attributes. This lets ``File`` options with the ``-`` value match non-testing behavior. :issue:`1064` +- When creating a ``Group``, allow passing a list of commands instead + of a dict. :issue:`1339` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index a678bd752..2df179fd6 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1430,10 +1430,19 @@ def list_commands(self, ctx): class Group(MultiCommand): - """A group allows a command to have subcommands attached. This is the - most common way to implement nesting in Click. + """A group allows a command to have subcommands attached. This is + the most common way to implement nesting in Click. - :param commands: a dictionary of commands. + :param name: The name of the group command. + :param commands: A dict mapping names to :class:`Command` objects. + Can also be a list of :class:`Command`, which will use + :attr:`Command.name` to create the dict. + :param attrs: Other command arguments described in + :class:`MultiCommand`, :class:`Command`, and + :class:`BaseCommand`. + + .. versionchanged:: 8.0 + The ``commmands`` argument can be a list of command objects. """ #: If set, this is used by the group's :meth:`command` decorator @@ -1457,8 +1466,14 @@ class Group(MultiCommand): def __init__(self, name=None, commands=None, **attrs): super().__init__(name, **attrs) - #: the registered subcommands by their exported names. - self.commands = commands or {} + + if commands is None: + commands = {} + elif isinstance(commands, (list, tuple)): + commands = {c.name: c for c in commands} + + #: The registered subcommands by their exported names. + self.commands = commands def add_command(self, cmd, name=None): """Registers another :class:`Command` with this group. If the name diff --git a/tests/test_basic.py b/tests/test_basic.py index 8ab94dcf7..cc0d0f245 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -80,6 +80,30 @@ def subcommand(): assert "SUBCOMMAND EXECUTED" in result.output +def test_group_commands_dict(runner): + """A Group can be built with a dict of commands.""" + + @click.command() + def sub(): + click.echo("sub", nl=False) + + cli = click.Group(commands={"other": sub}) + result = runner.invoke(cli, ["other"]) + assert result.output == "sub" + + +def test_group_from_list(runner): + """A Group can be built with a list of commands.""" + + @click.command() + def sub(): + click.echo("sub", nl=False) + + cli = click.Group(commands=[sub]) + result = runner.invoke(cli, ["sub"]) + assert result.output == "sub" + + def test_basic_option(runner): @click.command() @click.option("--foo", default="no value") From 27ec93877b7b93094dba967c636a290807b511b3 Mon Sep 17 00:00:00 2001 From: Narendra N Date: Fri, 17 Jan 2020 18:02:12 +0530 Subject: [PATCH 128/293] use difflib to suggest possible long option names --- CHANGES.rst | 2 ++ src/click/parser.py | 4 +++- tests/test_options.py | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index d731f9fe3..cc6a74494 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -84,6 +84,8 @@ Unreleased the ``-`` value match non-testing behavior. :issue:`1064` - When creating a ``Group``, allow passing a list of commands instead of a dict. :issue:`1339` +- When a long option name isn't valid, use ``difflib`` to make better + suggestions for possible corrections. :issue:`1446` Version 7.1.2 diff --git a/src/click/parser.py b/src/click/parser.py index b2ed7ad8f..f6139477c 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -329,7 +329,9 @@ def _process_args_for_options(self, state): def _match_long_opt(self, opt, explicit_value, state): if opt not in self._long_opt: - possibilities = [word for word in self._long_opt if word.startswith(opt)] + from difflib import get_close_matches + + possibilities = get_close_matches(opt, self._long_opt) raise NoSuchOption(opt, possibilities=possibilities, ctx=self.ctx) option = self._long_opt[opt] diff --git a/tests/test_options.py b/tests/test_options.py index 9f88e34df..c57525c12 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -86,6 +86,22 @@ def cli(): assert f"no such option: {unknown_flag}" in result.output +@pytest.mark.parametrize( + ("value", "expect"), + [ + ("--cat", "Did you mean --count?"), + ("--bounds", "(Possible options: --bound, --count)"), + ("--bount", "(Possible options: --bound, --count)"), + ], +) +def test_suggest_possible_options(runner, value, expect): + cli = click.Command( + "cli", params=[click.Option(["--bound"]), click.Option(["--count"])] + ) + result = runner.invoke(cli, [value]) + assert expect in result.output + + def test_multiple_required(runner): @click.command() @click.option("-m", "--message", multiple=True, required=True) From 183a7f83836fae90affc28d6eb2676014b3077e9 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 14 Aug 2020 10:07:20 -0700 Subject: [PATCH 129/293] document that launch(wait) only works for blocking programs --- src/click/termui.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/click/termui.py b/src/click/termui.py index 44e0b8038..1eb9ed804 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -658,7 +658,9 @@ def launch(url, wait=False, locate=False): .. versionadded:: 2.0 :param url: URL or filename of the thing to launch. - :param wait: waits for the program to stop. + :param wait: Wait for the program to exit before returning. This + only works if the launched program blocks. In particular, + ``xdg-open`` on Linux does not block. :param locate: if this is set to `True` then instead of launching the application associated with the URL it will attempt to launch a file manager with the file located. This From 783d63f34d251bec4659e81f53c9f6cde512e83e Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 14 Aug 2020 14:02:28 -0700 Subject: [PATCH 130/293] update API docs about custom ParamType requirements --- src/click/types.py | 46 +++++++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/click/types.py b/src/click/types.py index 5649a9c16..2b796e776 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -13,17 +13,21 @@ class ParamType: - """Helper for converting values through types. The following is - necessary for a valid type: - - * it needs a name - * it needs to pass through None unchanged - * it needs to convert from a string - * it needs to convert its result type through unchanged - (eg: needs to be idempotent) - * it needs to be able to deal with param and context being `None`. - This can be the case when the object is used with prompt - inputs. + """Represents the type of a parameter. Validates and converts values + from the command line or Python into the correct type. + + To implement a custom type, subclass and implement at least the + following: + + - The :attr:`name` class attribute must be set. + - Calling an instance of the type with ``None`` must return + ``None``. This is already implemented by default. + - :meth:`convert` must convert string values to the correct type. + - :meth:`convert` must accept values that are already the correct + type. + - It must be able to convert a value if the ``ctx`` and ``param`` + arguments are ``None``. This can occur when converting prompt + input. """ is_composite = False @@ -54,8 +58,24 @@ def get_missing_message(self, param): """ def convert(self, value, param, ctx): - """Converts the value. This is not invoked for values that are - `None` (the missing value). + """Convert the value to the correct type. This is not called if + the value is ``None`` (the missing value). + + This must accept string values from the command line, as well as + values that are already the correct type. It may also convert + other compatible types. + + The ``param`` and ``ctx`` arguments may be ``None`` in certain + situations, such as when converting prompt input. + + If the value cannot be converted, call :meth:`fail` with a + descriptive message. + + :param value: The value to convert. + :param param: The parameter that is using this type to convert + its value. May be ``None``. + :param ctx: The current context that arrived at this value. May + be ``None``. """ return value From d874b0a24697ab33151a667e0ff04af7cfb51e69 Mon Sep 17 00:00:00 2001 From: yk396 Date: Fri, 3 Jul 2020 13:45:22 -0400 Subject: [PATCH 131/293] implement export to dictionary Co-authored-by: Chris Nguyen --- src/click/core.py | 52 ++++++++ src/click/types.py | 55 +++++++++ tests/test_info_dict.py | 257 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 tests/test_info_dict.py diff --git a/src/click/core.py b/src/click/core.py index 2df179fd6..397ef7514 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -766,6 +766,9 @@ def __init__(self, name, context_settings=None): #: an optional dictionary with defaults passed to the context. self.context_settings = context_settings + def to_info_dict(self, ctx): + return {"name": self.name} + def __repr__(self): return f"<{self.__class__.__name__} {self.name}>" @@ -1000,6 +1003,18 @@ def __init__( self.hidden = hidden self.deprecated = deprecated + def to_info_dict(self, ctx): + info_dict = super().to_info_dict(ctx) + info_dict.update( + params=[param.to_info_dict() for param in self.get_params(ctx)], + help=self.help, + epilog=self.epilog, + short_help=self.short_help, + hidden=self.hidden, + deprecated=self.deprecated, + ) + return info_dict + def __repr__(self): return f"<{self.__class__.__name__} {self.name}>" @@ -1232,6 +1247,17 @@ def __init__( " optional arguments." ) + def to_info_dict(self, ctx): + info_dict = super().to_info_dict(ctx) + info_dict.update( + commands=[ + self.get_command(ctx, cmd_name).to_info_dict(ctx) + for cmd_name in self.list_commands(ctx) + ], + chain=self.chain, + ) + return info_dict + def collect_usage_pieces(self, ctx): rv = super().collect_usage_pieces(ctx) rv.append(self.subcommand_metavar) @@ -1661,6 +1687,20 @@ def __init__( self.envvar = envvar self.autocompletion = autocompletion + def to_info_dict(self): + return { + "name": self.name, + "param_type_name": self.param_type_name, + "opts": self.opts, + "secondary_opts": self.secondary_opts, + "type": self.type.to_info_dict(), + "required": self.required, + "nargs": self.nargs, + "multiple": self.multiple, + "default": self.default, + "envvar": self.envvar, + } + def __repr__(self): return f"<{self.__class__.__name__} {self.name}>" @@ -1955,6 +1995,18 @@ def __init__( "Options cannot be count and flags at the same time." ) + def to_info_dict(self): + info_dict = super().to_info_dict() + info_dict.update( + help=self.help, + prompt=self.prompt, + is_flag=self.is_flag, + flag_value=self.flag_value, + count=self.count, + hidden=self.hidden, + ) + return info_dict + def _parse_decls(self, decls, expose_value): opts = [] secondary_opts = [] diff --git a/src/click/types.py b/src/click/types.py index 2b796e776..ad882afdd 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -43,6 +43,12 @@ class ParamType: #: Windows). envvar_list_splitter = None + def to_info_dict(self): + # The class name without the "ParamType" suffix. + param_type = type(self).__name__.partition("ParamType")[0] + param_type = param_type.partition("ParameterType")[0] + return {"param_type": param_type, "name": self.name} + def __call__(self, value, param=None, ctx=None): if value is not None: return self.convert(value, param, ctx) @@ -107,6 +113,11 @@ def __init__(self, func): self.name = func.__name__ self.func = func + def to_info_dict(self): + info_dict = super().to_info_dict() + info_dict["func"] = self.func + return info_dict + def convert(self, value, param, ctx): try: return self.func(value) @@ -176,6 +187,12 @@ def __init__(self, choices, case_sensitive=True): self.choices = choices self.case_sensitive = case_sensitive + def to_info_dict(self): + info_dict = super().to_info_dict() + info_dict["choices"] = self.choices + info_dict["case_sensitive"] = self.case_sensitive + return info_dict + def get_metavar(self, param): choices_str = "|".join(self.choices) @@ -251,6 +268,11 @@ class DateTime(ParamType): def __init__(self, formats=None): self.formats = formats or ["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"] + def to_info_dict(self): + info_dict = super().to_info_dict() + info_dict["formats"] = self.formats + return info_dict + def get_metavar(self, param): return f"[{'|'.join(self.formats)}]" @@ -293,6 +315,17 @@ def __init__(self, min=None, max=None, min_open=False, max_open=False, clamp=Fal self.max_open = max_open self.clamp = clamp + def to_info_dict(self): + info_dict = super().to_info_dict() + info_dict.update( + min=self.min, + max=self.max, + min_open=self.min_open, + max_open=self.max_open, + clamp=self.clamp, + ) + return info_dict + def convert(self, value, param, ctx): import operator @@ -492,6 +525,11 @@ def __init__( self.lazy = lazy self.atomic = atomic + def to_info_dict(self): + info_dict = super().to_info_dict() + info_dict.update(mode=self.mode, encoding=self.encoding) + return info_dict + def resolve_lazy_flag(self, value): if self.lazy is not None: return self.lazy @@ -601,6 +639,18 @@ def __init__( self.name = "path" self.path_type = "Path" + def to_info_dict(self): + info_dict = super().to_info_dict() + info_dict.update( + exists=self.exists, + file_okay=self.file_okay, + dir_okay=self.dir_okay, + writable=self.writable, + readable=self.readable, + allow_dash=self.allow_dash, + ) + return info_dict + def coerce_path_result(self, rv): if self.type is not None and not isinstance(rv, self.type): if self.type is str: @@ -674,6 +724,11 @@ class Tuple(CompositeParamType): def __init__(self, types): self.types = [convert_type(ty) for ty in types] + def to_info_dict(self): + info_dict = super().to_info_dict() + info_dict["types"] = [t.to_info_dict() for t in self.types] + return info_dict + @property def name(self): return f"<{' '.join(ty.name for ty in self.types)}>" diff --git a/tests/test_info_dict.py b/tests/test_info_dict.py new file mode 100644 index 000000000..9d5197408 --- /dev/null +++ b/tests/test_info_dict.py @@ -0,0 +1,257 @@ +import pytest + +import click.types + +# Common (obj, expect) pairs used to construct multiple tests. +STRING_PARAM_TYPE = (click.STRING, {"param_type": "String", "name": "text"}) +INT_PARAM_TYPE = (click.INT, {"param_type": "Int", "name": "integer"}) +BOOL_PARAM_TYPE = (click.BOOL, {"param_type": "Bool", "name": "boolean"}) +HELP_OPTION = ( + None, + { + "name": "help", + "param_type_name": "option", + "opts": ["--help"], + "secondary_opts": [], + "type": BOOL_PARAM_TYPE[1], + "required": False, + "nargs": 1, + "multiple": False, + "default": False, + "envvar": None, + "help": "Show this message and exit.", + "prompt": None, + "is_flag": True, + "flag_value": True, + "count": False, + "hidden": False, + }, +) +NAME_ARGUMENT = ( + click.Argument(["name"]), + { + "name": "name", + "param_type_name": "argument", + "opts": ["name"], + "secondary_opts": [], + "type": STRING_PARAM_TYPE[1], + "required": True, + "nargs": 1, + "multiple": False, + "default": None, + "envvar": None, + }, +) +NUMBER_OPTION = ( + click.Option(["-c", "--count", "number"], default=1), + { + "name": "number", + "param_type_name": "option", + "opts": ["-c", "--count"], + "secondary_opts": [], + "type": INT_PARAM_TYPE[1], + "required": False, + "nargs": 1, + "multiple": False, + "default": 1, + "envvar": None, + "help": None, + "prompt": None, + "is_flag": False, + "flag_value": False, + "count": False, + "hidden": False, + }, +) +HELLO_COMMAND = ( + click.Command("hello", params=[NUMBER_OPTION[0]]), + { + "name": "hello", + "params": [NUMBER_OPTION[1], HELP_OPTION[1]], + "help": None, + "epilog": None, + "short_help": None, + "hidden": False, + "deprecated": False, + }, +) +HELLO_GROUP = ( + click.Group("cli", [HELLO_COMMAND[0]]), + { + "name": "cli", + "params": [HELP_OPTION[1]], + "help": None, + "epilog": None, + "short_help": None, + "hidden": False, + "deprecated": False, + "commands": [HELLO_COMMAND[1]], + "chain": False, + }, +) + + +@pytest.mark.parametrize( + ("obj", "expect"), + [ + pytest.param( + click.types.FuncParamType(range), + {"param_type": "Func", "name": "range", "func": range}, + id="Func ParamType", + ), + pytest.param( + click.UNPROCESSED, + {"param_type": "Unprocessed", "name": "text"}, + id="UNPROCESSED ParamType", + ), + pytest.param(*STRING_PARAM_TYPE, id="STRING ParamType"), + pytest.param( + click.Choice(["a", "b"]), + { + "param_type": "Choice", + "name": "choice", + "choices": ["a", "b"], + "case_sensitive": True, + }, + id="Choice ParamType", + ), + pytest.param( + click.DateTime(["%Y-%m-%d"]), + {"param_type": "DateTime", "name": "datetime", "formats": ["%Y-%m-%d"]}, + id="DateTime ParamType", + ), + pytest.param(*INT_PARAM_TYPE, id="INT ParamType"), + pytest.param( + click.IntRange(0, 10, clamp=True), + { + "param_type": "IntRange", + "name": "integer range", + "min": 0, + "max": 10, + "min_open": False, + "max_open": False, + "clamp": True, + }, + id="IntRange ParamType", + ), + pytest.param( + click.FLOAT, {"param_type": "Float", "name": "float"}, id="FLOAT ParamType" + ), + pytest.param( + click.FloatRange(-0.5, 0.5), + { + "param_type": "FloatRange", + "name": "float range", + "min": -0.5, + "max": 0.5, + "min_open": False, + "max_open": False, + "clamp": False, + }, + id="FloatRange ParamType", + ), + pytest.param(*BOOL_PARAM_TYPE, id="Bool ParamType"), + pytest.param( + click.UUID, {"param_type": "UUID", "name": "uuid"}, id="UUID ParamType" + ), + pytest.param( + click.File(), + {"param_type": "File", "name": "filename", "mode": "r", "encoding": None}, + id="File ParamType", + ), + pytest.param( + click.Path(), + { + "param_type": "Path", + "name": "path", + "exists": False, + "file_okay": True, + "dir_okay": True, + "writable": False, + "readable": True, + "allow_dash": False, + }, + id="Path ParamType", + ), + pytest.param( + click.Tuple((click.STRING, click.INT)), + { + "param_type": "Tuple", + "name": "", + "types": [STRING_PARAM_TYPE[1], INT_PARAM_TYPE[1]], + }, + id="Tuple ParamType", + ), + pytest.param(*NUMBER_OPTION, id="Option"), + pytest.param( + click.Option(["--cache/--no-cache", "-c/-u"]), + { + "name": "cache", + "param_type_name": "option", + "opts": ["--cache", "-c"], + "secondary_opts": ["--no-cache", "-u"], + "type": BOOL_PARAM_TYPE[1], + "required": False, + "nargs": 1, + "multiple": False, + "default": False, + "envvar": None, + "help": None, + "prompt": None, + "is_flag": True, + "flag_value": True, + "count": False, + "hidden": False, + }, + id="Flag Option", + ), + pytest.param(*NAME_ARGUMENT, id="Argument"), + ], +) +def test_parameter(obj, expect): + """Test to_info_dict for types and parameters.""" + out = obj.to_info_dict() + assert out == expect + + +@pytest.mark.parametrize( + ("obj", "expect"), + [ + pytest.param(*HELLO_COMMAND, id="Command"), + pytest.param(*HELLO_GROUP, id="Group"), + pytest.param( + click.Group( + "base", + [click.Command("test", params=[NAME_ARGUMENT[0]]), HELLO_GROUP[0]], + ), + { + "name": "base", + "params": [HELP_OPTION[1]], + "help": None, + "epilog": None, + "short_help": None, + "hidden": False, + "deprecated": False, + "commands": [ + HELLO_GROUP[1], + { + "name": "test", + "params": [NAME_ARGUMENT[1], HELP_OPTION[1]], + "help": None, + "epilog": None, + "short_help": None, + "hidden": False, + "deprecated": False, + }, + ], + "chain": False, + }, + id="Nested Group", + ), + ], +) +def test_ctx_obj(obj, expect): + """Test to_info_dict for objects that have a make_context method.""" + ctx = obj.make_context("test", [], resilient_parsing=True) + out = obj.to_info_dict(ctx) + assert out == expect From 4f3844656a66269692492356cc43c51a22f81611 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 16 Aug 2020 16:23:45 -0700 Subject: [PATCH 132/293] implement Context.to_info_dict Context has extra information, and traverses the entire CLI. Group.commands is a dict. Push a new context when traversing subcommands. Add docs and changelog. --- CHANGES.rst | 4 +++ src/click/core.py | 68 +++++++++++++++++++++++++++++++++++------ src/click/types.py | 8 +++++ tests/test_info_dict.py | 29 ++++++++++++------ 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index cc6a74494..8753a5b0d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -86,6 +86,10 @@ Unreleased of a dict. :issue:`1339` - When a long option name isn't valid, use ``difflib`` to make better suggestions for possible corrections. :issue:`1446` +- Core objects have a ``to_info_dict()`` method. This gathers + information about the object's structure that could be useful for a + tool generating user-facing documentation. To get the structure of + an entire CLI, use ``Context(cli).to_info_dict()``. :issue:`461` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 397ef7514..3bbc1200a 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -413,6 +413,27 @@ def __init__( self._source_by_paramname = {} self._exit_stack = ExitStack() + def to_info_dict(self): + """Gather information that could be useful for a tool generating + user-facing documentation. This traverses the entire CLI + structure. + + .. code-block:: python + + with Context(cli) as ctx: + info = ctx.to_info_dict() + + .. versionadded:: 8.0 + """ + return { + "command": self.command.to_info_dict(self), + "info_name": self.info_name, + "allow_extra_args": self.allow_extra_args, + "allow_interspersed_args": self.allow_interspersed_args, + "ignore_unknown_options": self.ignore_unknown_options, + "auto_envvar_prefix": self.auto_envvar_prefix, + } + def __enter__(self): self._depth += 1 push_context(self) @@ -632,6 +653,14 @@ def get_help(self): """ return self.command.get_help(self) + def _make_sub_context(self, command): + """Create a new context of the same type as this context, but + for a new command. + + :meta private: + """ + return type(self)(command, info_name=command.name, parent=self) + def invoke(*args, **kwargs): # noqa: B902 """Invokes a command callback in exactly the way it expects. There are two ways to invoke this method: @@ -663,8 +692,7 @@ def invoke(*args, **kwargs): # noqa: B902 "The given command does not have a callback that can be invoked." ) - # Create a new context of the same type as this context. - ctx = type(self)(other_cmd, info_name=other_cmd.name, parent=self) + ctx = self._make_sub_context(other_cmd) for param in other_cmd.params: if param.name not in kwargs and param.expose_value: @@ -767,6 +795,17 @@ def __init__(self, name, context_settings=None): self.context_settings = context_settings def to_info_dict(self, ctx): + """Gather information that could be useful for a tool generating + user-facing documentation. This traverses the entire structure + below this command. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + :param ctx: A :class:`Context` representing this command. + + .. versionadded:: 8.0 + """ return {"name": self.name} def __repr__(self): @@ -1249,13 +1288,16 @@ def __init__( def to_info_dict(self, ctx): info_dict = super().to_info_dict(ctx) - info_dict.update( - commands=[ - self.get_command(ctx, cmd_name).to_info_dict(ctx) - for cmd_name in self.list_commands(ctx) - ], - chain=self.chain, - ) + commands = {} + + for name in self.list_commands(ctx): + command = self.get_command(ctx, name) + sub_ctx = ctx._make_sub_context(command) + + with sub_ctx.scope(cleanup=False): + commands[name] = command.to_info_dict(sub_ctx) + + info_dict.update(commands=commands, chain=self.chain) return info_dict def collect_usage_pieces(self, ctx): @@ -1688,6 +1730,14 @@ def __init__( self.autocompletion = autocompletion def to_info_dict(self): + """Gather information that could be useful for a tool generating + user-facing documentation. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + .. versionadded:: 8.0 + """ return { "name": self.name, "param_type_name": self.param_type_name, diff --git a/src/click/types.py b/src/click/types.py index ad882afdd..1a70c012b 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -44,6 +44,14 @@ class ParamType: envvar_list_splitter = None def to_info_dict(self): + """Gather information that could be useful for a tool generating + user-facing documentation. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + .. versionadded:: 8.0 + """ # The class name without the "ParamType" suffix. param_type = type(self).__name__.partition("ParamType")[0] param_type = param_type.partition("ParameterType")[0] diff --git a/tests/test_info_dict.py b/tests/test_info_dict.py index 9d5197408..b58ad6efb 100644 --- a/tests/test_info_dict.py +++ b/tests/test_info_dict.py @@ -85,7 +85,7 @@ "short_help": None, "hidden": False, "deprecated": False, - "commands": [HELLO_COMMAND[1]], + "commands": {"hello": HELLO_COMMAND[1]}, "chain": False, }, ) @@ -209,7 +209,6 @@ ], ) def test_parameter(obj, expect): - """Test to_info_dict for types and parameters.""" out = obj.to_info_dict() assert out == expect @@ -232,9 +231,9 @@ def test_parameter(obj, expect): "short_help": None, "hidden": False, "deprecated": False, - "commands": [ - HELLO_GROUP[1], - { + "commands": { + "cli": HELLO_GROUP[1], + "test": { "name": "test", "params": [NAME_ARGUMENT[1], HELP_OPTION[1]], "help": None, @@ -243,15 +242,27 @@ def test_parameter(obj, expect): "hidden": False, "deprecated": False, }, - ], + }, "chain": False, }, id="Nested Group", ), ], ) -def test_ctx_obj(obj, expect): - """Test to_info_dict for objects that have a make_context method.""" - ctx = obj.make_context("test", [], resilient_parsing=True) +def test_command(obj, expect): + ctx = click.Context(obj) out = obj.to_info_dict(ctx) assert out == expect + + +def test_context(): + ctx = click.Context(HELLO_COMMAND[0]) + out = ctx.to_info_dict() + assert out == { + "command": HELLO_COMMAND[1], + "info_name": None, + "allow_extra_args": False, + "allow_interspersed_args": True, + "ignore_unknown_options": False, + "auto_envvar_prefix": None, + } From 0704abd27dac04c05d543db300fa50b241551888 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2020 08:30:33 +0000 Subject: [PATCH 133/293] Bump sphinx from 3.2.0 to 3.2.1 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.2.0 to 3.2.1. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.2.0...v3.2.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index af78e78a1..b83c3baf8 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -40,7 +40,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging, pip-tools, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.2.0 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.2.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index 9b6ca1d55..756b6b0bd 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -22,7 +22,7 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.2.0 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx==3.2.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx From 3400f51ccc6ea977b09d1be26ba18d85bdbd10de Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2020 08:17:34 +0000 Subject: [PATCH 134/293] Bump pre-commit from 2.6.0 to 2.7.1 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.6.0 to 2.7.1. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.6.0...v2.7.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index b83c3baf8..8d1482f69 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -29,7 +29,7 @@ packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in pip-tools==5.3.1 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox -pre-commit==2.6.0 # via -r requirements/dev.in +pre-commit==2.7.1 # via -r requirements/dev.in py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx pyparsing==2.4.7 # via packaging From 144b307909f8dd7d9aee52f9ac73c958d6b65c87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20M=C3=A1t=C3=A9=20R=C3=B3bert?= Date: Tue, 25 Aug 2020 02:45:09 +0200 Subject: [PATCH 135/293] Fixed a copy-paste typo in autocomplete One line had `source_zsh` instead of `source_fish`. --- docs/bashcomplete.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/bashcomplete.rst b/docs/bashcomplete.rst index bc0ba7d42..a4a5f6ee3 100644 --- a/docs/bashcomplete.rst +++ b/docs/bashcomplete.rst @@ -147,7 +147,7 @@ For Fish: .. code-block:: text - _FOO_BAR_COMPLETE=source_zsh foo-bar > foo-bar-complete.sh + _FOO_BAR_COMPLETE=source_fish foo-bar > foo-bar-complete.fish In ``.bashrc`` or ``.zshrc``, source the script instead of the ``eval`` command: From d97bc1a651314aa7ecb3a0cc061552bc00fa7a17 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2020 08:18:45 +0000 Subject: [PATCH 136/293] Bump tox from 3.19.0 to 3.20.0 Bumps [tox](https://github.com/tox-dev/tox) from 3.19.0 to 3.20.0. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.19.0...3.20.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 8d1482f69..339fd1157 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -49,7 +49,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, pytest, tox -tox==3.19.0 # via -r requirements/dev.in +tox==3.20.0 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox zipp==3.1.0 # via importlib-metadata From 0b598cc46b5d443d738564a494ec86a7412d2e44 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2020 08:19:03 +0000 Subject: [PATCH 137/293] Bump pytest from 6.0.1 to 6.0.2 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.0.1 to 6.0.2. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.0.1...6.0.2) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 339fd1157..2122decc2 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -33,7 +33,7 @@ pre-commit==2.7.1 # via -r requirements/dev.in py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx pyparsing==2.4.7 # via packaging -pytest==6.0.1 # via -r requirements/tests.in +pytest==6.0.2 # via -r requirements/tests.in pytz==2020.1 # via babel pyyaml==5.3.1 # via pre-commit requests==2.23.0 # via sphinx diff --git a/requirements/tests.txt b/requirements/tests.txt index 019fd6d30..57cb574bb 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -13,7 +13,7 @@ packaging==20.4 # via pytest pluggy==0.13.1 # via pytest py==1.9.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==6.0.1 # via -r requirements/tests.in +pytest==6.0.2 # via -r requirements/tests.in six==1.15.0 # via packaging toml==0.10.1 # via pytest zipp==3.1.0 # via importlib-metadata From 0fb30a46c50855461ece83a1c2fa0642840f5b54 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2020 08:23:53 +0000 Subject: [PATCH 138/293] Bump pytest from 6.0.2 to 6.1.0 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.0.2 to 6.1.0. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.0.2...6.1.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 3 +-- requirements/tests.txt | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 2122decc2..3ebce66db 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -23,7 +23,6 @@ importlib-metadata==1.7.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 -more-itertools==8.3.0 # via pytest nodeenv==1.3.5 # via pre-commit packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in @@ -33,7 +32,7 @@ pre-commit==2.7.1 # via -r requirements/dev.in py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx pyparsing==2.4.7 # via packaging -pytest==6.0.2 # via -r requirements/tests.in +pytest==6.1.0 # via -r requirements/tests.in pytz==2020.1 # via babel pyyaml==5.3.1 # via pre-commit requests==2.23.0 # via sphinx diff --git a/requirements/tests.txt b/requirements/tests.txt index 57cb574bb..aa542f2d8 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -8,12 +8,11 @@ attrs==19.3.0 # via pytest colorama==0.4.3 # via -r requirements/tests.in importlib-metadata==1.7.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest -more-itertools==8.3.0 # via pytest packaging==20.4 # via pytest pluggy==0.13.1 # via pytest py==1.9.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==6.0.2 # via -r requirements/tests.in +pytest==6.1.0 # via -r requirements/tests.in six==1.15.0 # via packaging toml==0.10.1 # via pytest zipp==3.1.0 # via importlib-metadata From 29ed17259c6f7c0c18f2cc2410743f7111ef3470 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2020 08:27:20 +0000 Subject: [PATCH 139/293] Bump importlib-metadata from 1.7.0 to 2.0.0 Bumps [importlib-metadata](http://importlib-metadata.readthedocs.io/) from 1.7.0 to 2.0.0. Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 3ebce66db..a3770c661 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ filelock==3.0.12 # via tox, virtualenv identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==1.7.0 # via -r requirements/tests.in +importlib_metadata==2.0.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 diff --git a/requirements/tests.txt b/requirements/tests.txt index aa542f2d8..0cf510b43 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,7 +6,7 @@ # attrs==19.3.0 # via pytest colorama==0.4.3 # via -r requirements/tests.in -importlib-metadata==1.7.0 # via -r requirements/tests.in +importlib_metadata==2.0.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest packaging==20.4 # via pytest pluggy==0.13.1 # via pytest From 3c4cacb1bba9f2b6f62628cf1783ce64aed62436 Mon Sep 17 00:00:00 2001 From: Amy Date: Tue, 4 Aug 2020 15:53:44 -0700 Subject: [PATCH 140/293] redesign shell completion system Co-authored-by: Kai Chen Co-authored-by: David Lord --- src/click/_bashcomplete.py | 371 ------------------------- src/click/core.py | 161 +++++++++-- src/click/shell_completion.py | 507 ++++++++++++++++++++++++++++++++++ src/click/types.py | 53 ++++ 4 files changed, 702 insertions(+), 390 deletions(-) delete mode 100644 src/click/_bashcomplete.py create mode 100644 src/click/shell_completion.py diff --git a/src/click/_bashcomplete.py b/src/click/_bashcomplete.py deleted file mode 100644 index 0ef874313..000000000 --- a/src/click/_bashcomplete.py +++ /dev/null @@ -1,371 +0,0 @@ -import copy -import os -import re -from collections import abc - -from .core import Argument -from .core import MultiCommand -from .core import Option -from .parser import split_arg_string -from .types import Choice -from .utils import echo - -WORDBREAK = "=" - -# Note, only BASH version 4.4 and later have the nosort option. -COMPLETION_SCRIPT_BASH = """ -%(complete_func)s() { - local IFS=$'\n' - COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ - COMP_CWORD=$COMP_CWORD \\ - %(autocomplete_var)s=complete $1 ) ) - return 0 -} - -%(complete_func)s_setup() { - local COMPLETION_OPTIONS="" - local BASH_VERSION_ARR=(${BASH_VERSION//./ }) - # Only BASH version 4.4 and later have the nosort option. - if [ ${BASH_VERSION_ARR[0]} -gt 4 ] || ([ ${BASH_VERSION_ARR[0]} -eq 4 ] \ -&& [ ${BASH_VERSION_ARR[1]} -ge 4 ]); then - COMPLETION_OPTIONS="-o nosort" - fi - - complete $COMPLETION_OPTIONS -F %(complete_func)s %(script_names)s -} - -%(complete_func)s_setup -""" - -COMPLETION_SCRIPT_ZSH = """ -#compdef %(script_names)s - -%(complete_func)s() { - local -a completions - local -a completions_with_descriptions - local -a response - (( ! $+commands[%(script_names)s] )) && return 1 - - response=("${(@f)$( env COMP_WORDS=\"${words[*]}\" \\ - COMP_CWORD=$((CURRENT-1)) \\ - %(autocomplete_var)s=\"complete_zsh\" \\ - %(script_names)s )}") - - for key descr in ${(kv)response}; do - if [[ "$descr" == "_" ]]; then - completions+=("$key") - else - completions_with_descriptions+=("$key":"$descr") - fi - done - - if [ -n "$completions_with_descriptions" ]; then - _describe -V unsorted completions_with_descriptions -U - fi - - if [ -n "$completions" ]; then - compadd -U -V unsorted -a completions - fi - compstate[insert]="automenu" -} - -compdef %(complete_func)s %(script_names)s -""" - -COMPLETION_SCRIPT_FISH = ( - "complete --no-files --command %(script_names)s --arguments" - ' "(env %(autocomplete_var)s=complete_fish' - " COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t)" - ' %(script_names)s)"' -) - -_completion_scripts = { - "bash": COMPLETION_SCRIPT_BASH, - "zsh": COMPLETION_SCRIPT_ZSH, - "fish": COMPLETION_SCRIPT_FISH, -} - -_invalid_ident_char_re = re.compile(r"[^a-zA-Z0-9_]") - - -def get_completion_script(prog_name, complete_var, shell): - cf_name = _invalid_ident_char_re.sub("", prog_name.replace("-", "_")) - script = _completion_scripts.get(shell, COMPLETION_SCRIPT_BASH) - return ( - script - % { - "complete_func": f"_{cf_name}_completion", - "script_names": prog_name, - "autocomplete_var": complete_var, - } - ).strip() + ";" - - -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) - args = ctx.protected_args + ctx.args - while args: - if isinstance(ctx.command, MultiCommand): - if not ctx.command.chain: - cmd_name, cmd, args = ctx.command.resolve_command(ctx, args) - if cmd is None: - return ctx - ctx = cmd.make_context( - cmd_name, args, parent=ctx, resilient_parsing=True - ) - args = ctx.protected_args + ctx.args - else: - # Walk chained subcommand contexts saving the last one. - while args: - cmd_name, cmd, args = ctx.command.resolve_command(ctx, args) - if cmd is None: - return ctx - sub_ctx = cmd.make_context( - cmd_name, - args, - parent=ctx, - allow_extra_args=True, - allow_interspersed_args=False, - resilient_parsing=True, - ) - args = sub_ctx.args - ctx = sub_ctx - args = sub_ctx.protected_args + sub_ctx.args - else: - break - 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 parameter - :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, abc.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 - """ - results = [] - if isinstance(cmd_param.type, Choice): - # Choices don't support descriptions. - results = [ - (c, None) for c in cmd_param.type.choices if str(c).startswith(incomplete) - ] - elif cmd_param.autocompletion is not None: - dynamic_completions = cmd_param.autocompletion( - ctx=ctx, args=args, incomplete=incomplete - ) - results = [ - c if isinstance(c, tuple) else (c, None) for c in dynamic_completions - ] - return results - - -def get_visible_commands_starting_with(ctx, starts_with): - """ - :param ctx: context associated with the parsed command - :starts_with: string that visible commands must start with. - :return: all visible (not hidden) commands that start with starts_with. - """ - for c in ctx.command.list_commands(ctx): - if c.startswith(starts_with): - command = ctx.command.get_command(ctx, c) - if not command.hidden: - yield command - - -def add_subcommand_completions(ctx, incomplete, completions_out): - # Add subcommand completions. - if isinstance(ctx.command, MultiCommand): - completions_out.extend( - [ - (c.name, c.get_short_help_str()) - for c in get_visible_commands_starting_with(ctx, 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 = [ - c - for c in get_visible_commands_starting_with(ctx, incomplete) - if c.name not in ctx.protected_args - ] - completions_out.extend( - [(c.name, c.get_short_help_str()) for c in remaining_commands] - ) - - -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 [] - - has_double_dash = "--" in all_args - - # 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 not has_double_dash and start_of_option(incomplete): - # completions for partial options - for param in ctx.command.get_params(ctx): - if isinstance(param, Option) and not param.hidden: - 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( - [(o, param.help) for o in param_opts if o.startswith(incomplete)] - ) - return completions - # completion for option values from user supplied values - for param in ctx.command.get_params(ctx): - 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.get_params(ctx): - if is_incomplete_argument(ctx.params, param): - return get_user_autocompletions(ctx, all_args, incomplete, param) - - add_subcommand_completions(ctx, incomplete, completions) - # Sort before returning so that proper ordering can be enforced in custom types. - return sorted(completions) - - -def do_complete(cli, prog_name, include_descriptions): - cwords = split_arg_string(os.environ["COMP_WORDS"]) - cword = int(os.environ["COMP_CWORD"]) - args = cwords[1:cword] - try: - incomplete = cwords[cword] - except IndexError: - incomplete = "" - - for item in get_choices(cli, prog_name, args, incomplete): - echo(item[0]) - if include_descriptions: - # ZSH has trouble dealing with empty array parameters when - # returned from commands, use '_' to indicate no description - # is present. - echo(item[1] if item[1] else "_") - - return True - - -def do_complete_fish(cli, prog_name): - cwords = split_arg_string(os.environ["COMP_WORDS"]) - incomplete = os.environ["COMP_CWORD"] - args = cwords[1:] - - for item in get_choices(cli, prog_name, args, incomplete): - if item[1]: - echo(f"{item[0]}\t{item[1]}") - else: - echo(item[0]) - - return True - - -def bashcomplete(cli, prog_name, complete_var, complete_instr): - if "_" in complete_instr: - command, shell = complete_instr.split("_", 1) - else: - command = complete_instr - shell = "bash" - - if command == "source": - echo(get_completion_script(prog_name, complete_var, shell)) - return True - elif command == "complete": - if shell == "fish": - return do_complete_fish(cli, prog_name) - elif shell in {"bash", "zsh"}: - return do_complete(cli, prog_name, shell == "zsh") - - return False diff --git a/src/click/core.py b/src/click/core.py index 3bbc1200a..daaab8fff 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -47,27 +47,30 @@ def _maybe_show_deprecated_notice(cmd): echo(style(DEPRECATED_INVOKE_NOTICE.format(name=cmd.name), fg="red"), err=True) -def fast_exit(code): - """Exit without garbage collection, this speeds up exit by about 10ms for - things like bash completion. +def _fast_exit(code): + """Low-level exit that skips Python's cleanup but speeds up exit by + about 10ms for things like shell completion. + + :param code: Exit code. """ 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: - complete_var = f"_{prog_name}_COMPLETE".replace("-", "_").upper() - complete_instr = os.environ.get(complete_var) - if not complete_instr: - return +def _complete_visible_commands(ctx, incomplete): + """List all the subcommands of a group that start with the + incomplete value and aren't hidden. - from ._bashcomplete import bashcomplete + :param ctx: Invocation context for the group. + :param incomplete: Value being completed. May be empty. + """ + for name in ctx.command.list_commands(ctx): + if name.startswith(incomplete): + command = ctx.command.get_command(ctx, name) - if bashcomplete(cmd, prog_name, complete_var, complete_instr): - fast_exit(1) + if not command.hidden: + yield command def _check_multicommand(base_command, cmd_name, cmd, register=False): @@ -861,6 +864,34 @@ def invoke(self, ctx): """ raise NotImplementedError("Base commands are not invokable by default") + def shell_complete(self, ctx, args, incomplete): + """Return a list of completions for the incomplete value. Looks + at the names of chained multi-commands. + + Any command could be part of a chained multi-command, so sibling + commands are valid at any point during command completion. Other + command classes will return more completions. + + :param ctx: Invocation context for this command. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + results = [] + + while ctx.parent is not None: + ctx = ctx.parent + + if isinstance(ctx.command, MultiCommand) and ctx.command.chain: + results.extend( + ("plain", command.name, command.get_short_help_str()) + for command in _complete_visible_commands(ctx, incomplete) + if command.name not in ctx.protected_args + ) + + return results + def main( self, args=None, @@ -913,10 +944,8 @@ def main( if prog_name is None: prog_name = _detect_program_name() - # Hook for the Bash completion. This only activates if the Bash - # completion is actually enabled, otherwise this is quite a fast - # noop. - _bashcomplete(self, prog_name, complete_var) + # Process shell completion requests and exit early. + self._main_shell_completion(prog_name, complete_var) try: try: @@ -966,6 +995,29 @@ def main( echo("Aborted!", file=sys.stderr) sys.exit(1) + def _main_shell_completion(self, prog_name, complete_var=None): + """Check if the shell is asking for tab completion, process + that, then exit early. Called from :meth:`main` before the + program is invoked. + + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. Defaults to + ``_{PROG_NAME}_COMPLETE``. + """ + if complete_var is None: + complete_var = f"_{prog_name}_COMPLETE".replace("-", "_").upper() + + instruction = os.environ.get(complete_var) + + if not instruction: + return + + from .shell_completion import shell_complete + + rv = shell_complete(self, prog_name, complete_var, instruction) + _fast_exit(rv) + def __call__(self, *args, **kwargs): """Alias for :meth:`main`.""" return self.main(*args, **kwargs) @@ -1224,6 +1276,37 @@ def invoke(self, ctx): if self.callback is not None: return ctx.invoke(self.callback, **ctx.params) + def shell_complete(self, ctx, args, incomplete): + """Return a list of completions for the incomplete value. Looks + at the names of options and chained multi-commands. + + :param ctx: Invocation context for this command. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + results = [] + + if incomplete and not incomplete[0].isalnum(): + for param in self.get_params(ctx): + if not isinstance(param, Option) or param.hidden: + continue + + names = ( + name + for name in param.opts + param.secondary_opts + if name not in args or param.multiple + ) + results.extend( + ("plain", name, param.help) + for name in names + if name.startswith(incomplete) + ) + + results.extend(super().shell_complete(ctx, args, incomplete)) + return results + class MultiCommand(Command): """A multi command is the basic implementation of a command that @@ -1481,8 +1564,7 @@ def resolve_command(self, ctx, args): if split_opt(cmd_name)[0]: self.parse_args(ctx, ctx.args) ctx.fail(f"No such command '{original_cmd_name}'.") - - return cmd.name, cmd, args[1:] + return cmd.name if cmd else None, cmd, args[1:] def get_command(self, ctx, cmd_name): """Given a context and a command name, this returns a @@ -1496,6 +1578,24 @@ def list_commands(self, ctx): """ return [] + def shell_complete(self, ctx, args, incomplete): + """Return a list of completions for the incomplete value. Looks + at the names of options, subcommands, and chained + multi-commands. + + :param ctx: Invocation context for this command. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + results = [ + ("plain", command.name, command.get_short_help_str()) + for command in _complete_visible_commands(ctx, incomplete) + ] + results.extend(super().shell_complete(ctx, args, incomplete)) + return results + class Group(MultiCommand): """A group allows a command to have subcommands attached. This is @@ -1677,6 +1777,9 @@ class Parameter: order of processing. :param envvar: a string or list of strings that are environment variables that should be checked. + :param autocompletion: A function that returns custom shell + completions. Used instead of the param's type completion if + given. .. versionchanged:: 7.1 Empty environment variables are ignored rather than taking the @@ -1917,6 +2020,26 @@ def get_error_hint(self, ctx): hint_list = self.opts or [self.human_readable_name] return " / ".join(repr(x) for x in hint_list) + def shell_complete(self, ctx, args, incomplete): + """Return a list of completions for the incomplete value. If an + :attr:`autocompletion` function was given, it is used. + Otherwise, the :attr:`type` + :meth:`~click.types.ParamType.shell_complete` function is used. + + :param ctx: Invocation context for this command. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + if self.autocompletion is not None: + return [ + ("plain",) + c if isinstance(c, tuple) else ("plain", c, None) + for c in self.autocompletion(ctx=ctx, args=args, incomplete=incomplete) + ] + + return self.type.shell_complete(ctx, args, incomplete) + class Option(Parameter): """Options are usually optional values on the command line and diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py new file mode 100644 index 000000000..a8a33b6c1 --- /dev/null +++ b/src/click/shell_completion.py @@ -0,0 +1,507 @@ +import os +import re + +from .core import Argument +from .core import MultiCommand +from .core import Option +from .parser import split_arg_string +from .utils import echo + + +def shell_complete(cli, prog_name, complete_var, instruction): + """Perform shell completion for the given CLI program. + + :param cli: Command being called. + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. + :param instruction: Value of ``complete_var`` with the completion + instruction and shell, in the form ``instruction_shell``. + :return: Status code to exit with. + """ + instruction, _, shell = instruction.partition("_") + comp_cls = get_completion_class(shell) + + if comp_cls is None: + return 1 + + comp = comp_cls(cli, prog_name, complete_var) + + if instruction == "source": + echo(comp.source()) + return 0 + + if instruction == "complete": + echo(comp.complete()) + return 0 + + return 1 + + +# Only Bash >= 4.4 has the nosort option. +_SOURCE_BASH = """\ +%(complete_func)s() { + local IFS=$'\\n' + local response + + response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD \ +%(complete_var)s=complete_bash $1) + + for completion in $response; do + IFS=',' read type value <<< "$completion" + + if [[ $type == 'dir' ]]; then + COMREPLY=() + compopt -o dirnames + elif [[ $type == 'file' ]]; then + COMREPLY=() + compopt -o default + elif [[ $type == 'plain' ]]; then + COMPREPLY+=($value) + fi + done + + return 0 +} + +%(complete_func)s_setup() { + complete -o nosort -F %(complete_func)s %(prog_name)s +} + +%(complete_func)s_setup; +""" + +_SOURCE_ZSH = """\ +#compdef %(prog_name)s + +%(complete_func)s() { + local -a completions + local -a completions_with_descriptions + local -a response + (( ! $+commands[%(prog_name)s] )) && return 1 + + response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \ +%(complete_var)s=complete_zsh %(prog_name)s)}") + + for type key descr in ${response}; do + if [[ "$type" == "plain" ]]; then + if [[ "$descr" == "_" ]]; then + completions+=("$key") + else + completions_with_descriptions+=("$key":"$descr") + fi + elif [[ "$type" == "dir" ]]; then + _path_files -/ + elif [[ "$type" == "file" ]]; then + _path_files -f + fi + done + + if [ -n "$completions_with_descriptions" ]; then + _describe -V unsorted completions_with_descriptions -U + fi + + if [ -n "$completions" ]; then + compadd -U -V unsorted -a completions + fi +} + +compdef %(complete_func)s %(prog_name)s; +""" + +_SOURCE_FISH = """\ +function %(complete_func)s; + set -l response; + + for value in (env %(complete_var)s=complete_fish COMP_WORDS=(commandline -cp) \ +COMP_CWORD=(commandline -t) %(prog_name)s); + set response $response $value; + end; + + for completion in $response; + set -l metadata (string split "," $completion); + + if test $metadata[1] = "dir"; + __fish_complete_directories $metadata[2]; + else if test $metadata[1] = "file"; + __fish_complete_path $metadata[2]; + else if test $metadata[1] = "plain"; + echo $metadata[2]; + end; + end; +end; + +complete --no-files --command %(prog_name)s --arguments \ +"(%(complete_func)s)"; +""" + + +class ShellComplete: + """Base class for providing shell completion support. A subclass for + a given shell will override attributes and methods to implement the + completion instructions (``source`` and ``complete``). + + :param cli: Command being called. + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. + + .. versionadded:: 8.0 + """ + + name = None + """Name to register the shell as with :func:`add_completion_class`. + This is used in completion instructions (``source_{name}`` and + ``complete_{name}``). + """ + source_template = None + """Completion script template formatted by :meth:`source`. This must + be provided by subclasses. + """ + + def __init__(self, cli, prog_name, complete_var): + self.cli = cli + self.prog_name = prog_name + self.complete_var = complete_var + + @property + def func_name(self): + """The name of the shell function defined by the completion + script. + """ + safe_name = re.sub(r"\W*", "", self.prog_name.replace("-", "_"), re.ASCII) + return f"_{safe_name}_completion" + + def source_vars(self): + """Vars for formatting :attr:`source_template`. + + By default this provides ``complete_func``, ``complete_var``, + and ``prog_name``. + """ + return { + "complete_func": self.func_name, + "complete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def source(self): + """Produce the shell script that defines the completion + function. By default this ``%``-style formats + :attr:`source_template` with the dict returned by + :meth:`source_vars`. + """ + return self.source_template % self.source_vars() + + def get_completion_args(self): + """Use the env vars defined by the shell script to return a + tuple of ``args, incomplete``. This must be implemented by + subclasses. + """ + raise NotImplementedError + + def get_completions(self, args, incomplete): + """Determine the context and last complete command or parameter + from the complete args. Call that object's ``shell_complete`` + method to get the completions for the incomplete value. + + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + """ + ctx = _resolve_context(self.cli, self.prog_name, args) + + if ctx is None: + return [] + + obj, incomplete = _resolve_incomplete(ctx, args, incomplete) + return obj.shell_complete(ctx, args, incomplete) + + def format_completion(self, item): + """Format a completion item into the form recognized by the + shell script. This must be implemented by subclasses. + + :param item: Completion item to format. + """ + raise NotImplementedError + + def complete(self): + """Produce the completion data to send back to the shell. + + By default this calls :meth:`get_completion_args`, gets the + completions, then calls `:meth:`format_completion` for each + completion. + """ + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + out = [self.format_completion(item) for item in completions] + return "\n".join(out) + + +class BashComplete(ShellComplete): + """Shell completion for Bash.""" + + name = "bash" + source_template = _SOURCE_BASH + + def source(self): + import subprocess + + output = subprocess.run(["bash", "--version"], stdout=subprocess.PIPE) + match = re.search(r"version (\d)\.(\d)\.\d", output.stdout.decode()) + + if match is not None: + major, minor = match.groups() + + if major < "4" or major == "4" and minor < "4": + raise RuntimeError( + "Shell completion is not supported for Bash" + " versions older than 4.4." + ) + else: + raise RuntimeError( + "Couldn't detect Bash version, shell completion is not supported." + ) + + return super().source() + + def get_completion_args(self): + cwords = split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + return args, incomplete + + def format_completion(self, item): + type, value, _ = item + return f"{type},{value}" + + +class ZshComplete(ShellComplete): + """Shell completion for Zsh.""" + + name = "zsh" + source_template = _SOURCE_ZSH + + def get_completion_args(self): + cwords = split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + return args, incomplete + + def format_completion(self, item): + type, value, desc = item + return f"{type}\n{value}\n{desc if desc else '_'}" + + +class FishComplete(ShellComplete): + """Shell completion for Fish.""" + + name = "fish" + source_template = _SOURCE_FISH + + def get_completion_args(self): + cwords = split_arg_string(os.environ["COMP_WORDS"]) + incomplete = os.environ["COMP_CWORD"] + args = cwords[1:] + + # Fish stores the partial word in both COMP_WORDS and + # COMP_CWORD, remove it from complete args. + if incomplete and args and args[-1] == incomplete: + args.pop() + + return args, incomplete + + def format_completion(self, item): + type, value, desc = item + + if desc: + return f"{type},{value}\t{desc}" + + return f"{type},{value}" + + +_available_shells = { + "bash": BashComplete, + "fish": FishComplete, + "zsh": ZshComplete, +} + + +def add_completion_class(cls, name=None): + """Register a :class:`ShellComplete` subclass under the given name. + The name will be provided by the completion instruction environment + variable during completion. + + :param cls: The completion class that will handle completion for the + shell. + :param name: Name to register the class under. Defaults to the + class's ``name`` attribute. + """ + if name is None: + name = cls.name + + _available_shells[name] = cls + + +def get_completion_class(shell): + """Look up a registered :class:`ShellComplete` subclass by the name + provided by the completion instruction environment variable. If the + name isn't registered, returns ``None``. + + :param shell: Name the class is registered under. + """ + return _available_shells.get(shell) + + +def _is_incomplete_argument(values, param): + """Determine if the given parameter is an argument that can still + accept values. + + :param values: Dict of param names and values parsed from the + command line args. + :param param: Argument object being checked. + """ + if not isinstance(param, Argument): + return False + + value = values[param.name] + + if value is None: + return True + + if param.nargs == -1: + return True + + if isinstance(value, list) and param.nargs > 1 and len(value) < param.nargs: + return True + + return False + + +def _start_of_option(value): + """Check if the value looks like the start of an option.""" + return value and not value[0].isalnum() + + +def _is_incomplete_option(args, param): + """Determine if the given parameter is an option that needs a value. + + :param args: List of complete args before the incomplete value. + :param param: Option object being checked. + """ + if not isinstance(param, Option): + return False + + if param.is_flag: + return False + + last_option = None + + for index, arg in enumerate(reversed(args)): + if index + 1 > param.nargs: + break + + if _start_of_option(arg): + last_option = arg + + return bool(last_option and last_option in param.opts) + + +def _resolve_context(cli, prog_name, args): + """Produce the context hierarchy starting with the command and + traversing the complete arguments. This only follows the commands, + it doesn't trigger input prompts or callbacks. + + :param cli: Command being called. + :param prog_name: Name of the executable in the shell. + :param args: List of complete args before the incomplete value. + """ + ctx = cli.make_context(prog_name, args.copy(), resilient_parsing=True) + args = ctx.protected_args + ctx.args + + while args: + if isinstance(ctx.command, MultiCommand): + if not ctx.command.chain: + name, cmd, args = ctx.command.resolve_command(ctx, args) + + if cmd is None: + return ctx + + ctx = cmd.make_context(name, args, parent=ctx, resilient_parsing=True) + args = ctx.protected_args + ctx.args + else: + while args: + name, cmd, args = ctx.command.resolve_command(ctx, args) + + if cmd is None: + return ctx + + sub_ctx = cmd.make_context( + name, + args, + parent=ctx, + allow_extra_args=True, + allow_interspersed_args=False, + resilient_parsing=True, + ) + args = sub_ctx.args + + ctx = sub_ctx + args = sub_ctx.protected_args + sub_ctx.args + else: + break + + return ctx + + +def _resolve_incomplete(ctx, args, incomplete): + """Find the Click object that will handle the completion of the + incomplete value. Return the object and the incomplete value. + + :param ctx: Invocation context for the command represented by + the parsed complete args. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + """ + # Different shells treat an "=" between a long option name and + # value differently. Might keep the value joined, return the "=" + # as a separate item, or return the split name and value. Always + # split and discard the "=" to make completion easier. + if incomplete == "=": + incomplete = "" + elif "=" in incomplete and _start_of_option(incomplete): + name, _, incomplete = incomplete.partition("=") + args.append(name) + + # The "--" marker tells Click to stop treating values as options + # even if they start with the option character. If it hasn't been + # given and the incomplete arg looks like an option, the current + # command will provide option name completions. + if "--" not in args and _start_of_option(incomplete): + return ctx.command, incomplete + + # If the last complete arg is an option name with an incomplete + # value, the option will provide value completions. + for param in ctx.command.get_params(ctx): + if _is_incomplete_option(args, param): + return param, incomplete + + # It's not an option name or value. The first argument without a + # parsed value will provide value completions. + for param in ctx.command.get_params(ctx): + if _is_incomplete_argument(ctx.params, param): + return param, incomplete + + # There were no unparsed arguments, the command may be a group that + # will provide command name completions. + return ctx.command, incomplete diff --git a/src/click/types.py b/src/click/types.py index 1a70c012b..43afac0d6 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -107,6 +107,19 @@ def fail(self, message, param=None, ctx=None): """Helper method to fail with an invalid value message.""" raise BadParameter(message, ctx=ctx, param=param) + def shell_complete(self, ctx, args, incomplete): + """Return a list of completions for the incomplete value. Most + types do not provide completions, but some do, and this allows + custom types to provide custom completions as well. + + :param ctx: Invocation context for this command. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + return [] + class CompositeParamType(ParamType): is_composite = True @@ -249,6 +262,20 @@ def convert(self, value, param, ctx): def __repr__(self): return f"Choice({list(self.choices)})" + def shell_complete(self, ctx, args, incomplete): + """Return a list of completions for the incomplete value based + on the choices. + + :param ctx: Invocation context for this command. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + return [ + ("plain", c, None) for c in self.choices if str(c).startswith(incomplete) + ] + class DateTime(ParamType): """The DateTime type converts date strings into `datetime` objects. @@ -583,6 +610,18 @@ def convert(self, value, param, ctx): ctx, ) + def shell_complete(self, ctx, args, incomplete): + """Return a special completion marker that tells the completion + system to use the shell to provide file path completions. + + :param ctx: Invocation context for this command. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + return [("file", incomplete, None)] + class Path(ParamType): """The path type is similar to the :class:`File` type but it performs @@ -714,6 +753,20 @@ def convert(self, value, param, ctx): return self.coerce_path_result(rv) + def shell_complete(self, ctx, args, incomplete): + """Return a special completion marker that tells the completion + system to use the shell to provide path completions for only + directories or any paths. + + :param ctx: Invocation context for this command. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + completion_type = "dir" if self.dir_okay and not self.file_okay else "file" + return [(completion_type, incomplete, None)] + class Tuple(CompositeParamType): """The default behavior of Click is to apply a type on a value directly. From 7029307f9ce2ee6757e8d49e3f76ebeec944779e Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Tue, 4 Aug 2020 15:54:25 -0700 Subject: [PATCH 141/293] tests for new shell completion system Co-authored-by: Amy Co-authored-by: David Lord --- src/click/shell_completion.py | 4 +- tests/test_bashcomplete.py | 519 --------------------------------- tests/test_compat.py | 17 -- tests/test_shell_completion.py | 240 +++++++++++++++ 4 files changed, 243 insertions(+), 537 deletions(-) delete mode 100644 tests/test_bashcomplete.py create mode 100644 tests/test_shell_completion.py diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index a8a33b6c1..e165fbc49 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -242,7 +242,7 @@ class BashComplete(ShellComplete): name = "bash" source_template = _SOURCE_BASH - def source(self): + def _check_version(self): import subprocess output = subprocess.run(["bash", "--version"], stdout=subprocess.PIPE) @@ -261,6 +261,8 @@ def source(self): "Couldn't detect Bash version, shell completion is not supported." ) + def source(self): + self._check_version() return super().source() def get_completion_args(self): diff --git a/tests/test_bashcomplete.py b/tests/test_bashcomplete.py deleted file mode 100644 index f34698254..000000000 --- a/tests/test_bashcomplete.py +++ /dev/null @@ -1,519 +0,0 @@ -import pytest - -import click -from click._bashcomplete import get_choices - - -def choices_without_help(cli, args, incomplete): - completions = get_choices(cli, "dummy", args, incomplete) - return [c[0] for c in completions] - - -def choices_with_help(cli, args, incomplete): - return list(get_choices(cli, "dummy", args, incomplete)) - - -def test_single_command(): - @click.command() - @click.option("--local-opt") - def cli(local_opt): - pass - - assert choices_without_help(cli, [], "-") == ["--local-opt", "--help"] - assert choices_without_help(cli, [], "") == [] - - -def test_boolean_flag(): - @click.command() - @click.option("--shout/--no-shout", default=False) - def cli(local_opt): - pass - - assert choices_without_help(cli, [], "-") == ["--shout", "--no-shout", "--help"] - - -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 choices_without_help(cli, [], "-") == ["--pos", "--help"] - assert choices_without_help(cli, ["--pos"], "") == [] - assert choices_without_help(cli, ["--pos", "1.0"], "") == [] - assert choices_without_help(cli, ["--pos", "1.0", "1.0"], "") == ["sub"] - - -def test_multi_option(): - @click.command() - @click.option("--message", "-m", multiple=True) - def cli(local_opt): - pass - - assert choices_without_help(cli, [], "-") == ["--message", "-m", "--help"] - assert choices_without_help(cli, ["-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 choices_without_help(cli, [], "") == ["sub"] - assert choices_without_help(cli, [], "-") == ["--global-opt", "--help"] - assert choices_without_help(cli, ["sub"], "") == [] - assert choices_without_help(cli, ["sub"], "-") == ["--local-opt", "--help"] - - -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 choices_without_help(cli, [], "-") == ["--cli-opt", "--help"] - assert choices_without_help(cli, [], "") == ["asub"] - assert choices_without_help(cli, ["asub"], "-") == ["--asub-opt", "--help"] - assert choices_without_help(cli, ["asub"], "") == ["bsub"] - assert choices_without_help(cli, ["asub", "bsub"], "-") == ["--bsub-opt", "--help"] - assert choices_without_help(cli, ["asub", "bsub"], "") == ["csub"] - assert choices_without_help(cli, ["asub", "bsub", "csub"], "-") == [ - "--csub-opt", - "--csub", - "--search-color", - "--help", - ] - assert ( - choices_without_help(cli, ["asub", "bsub", "csub", "--csub-opt"], "") - == CSUB_OPT_CHOICES - ) - assert choices_without_help(cli, ["asub", "bsub", "csub"], "--csub") == [ - "--csub-opt", - "--csub", - ] - assert ( - choices_without_help(cli, ["asub", "bsub", "csub", "--csub"], "") - == CSUB_CHOICES - ) - assert choices_without_help(cli, ["asub", "bsub", "csub", "--csub-opt"], "f") == [ - "foo" - ] - assert choices_without_help(cli, ["asub", "bsub", "csub"], "") == COLORS - assert choices_without_help(cli, ["asub", "bsub", "csub"], "b") == ["blue"] - assert choices_without_help( - cli, ["asub", "bsub", "csub", "--search-color"], "een" - ) == ["green"] - - -def test_chaining(): - @click.group("cli", chain=True) - @click.option("--cli-opt") - @click.argument("arg", type=click.Choice(["cliarg1", "cliarg2"])) - def cli(cli_opt, arg): - pass - - @cli.command() - @click.option("--asub-opt") - def asub(asub_opt): - pass - - @cli.command(help="bsub help") - @click.option("--bsub-opt") - @click.argument("arg", type=click.Choice(["arg1", "arg2"])) - def bsub(bsub_opt, arg): - pass - - @cli.command() - @click.option("--csub-opt") - @click.argument("arg", type=click.Choice(["carg1", "carg2"]), default="carg1") - def csub(csub_opt, arg): - pass - - assert choices_without_help(cli, [], "-") == ["--cli-opt", "--help"] - assert choices_without_help(cli, [], "") == ["cliarg1", "cliarg2"] - assert choices_without_help(cli, ["cliarg1", "asub"], "-") == [ - "--asub-opt", - "--help", - ] - assert choices_without_help(cli, ["cliarg1", "asub"], "") == ["bsub", "csub"] - assert choices_without_help(cli, ["cliarg1", "bsub"], "") == ["arg1", "arg2"] - assert choices_without_help(cli, ["cliarg1", "asub", "--asub-opt"], "") == [] - assert choices_without_help( - cli, ["cliarg1", "asub", "--asub-opt", "5", "bsub"], "-" - ) == ["--bsub-opt", "--help"] - assert choices_without_help(cli, ["cliarg1", "asub", "bsub"], "-") == [ - "--bsub-opt", - "--help", - ] - assert choices_without_help(cli, ["cliarg1", "asub", "csub"], "") == [ - "carg1", - "carg2", - ] - assert choices_without_help(cli, ["cliarg1", "bsub", "arg1", "csub"], "") == [ - "carg1", - "carg2", - ] - assert choices_without_help(cli, ["cliarg1", "asub", "csub"], "-") == [ - "--csub-opt", - "--help", - ] - assert choices_with_help(cli, ["cliarg1", "asub"], "b") == [("bsub", "bsub help")] - - -def test_argument_choice(): - @click.command() - @click.argument("arg1", required=True, type=click.Choice(["arg11", "arg12"])) - @click.argument("arg2", type=click.Choice(["arg21", "arg22"]), default="arg21") - @click.argument("arg3", type=click.Choice(["arg", "argument"]), default="arg") - def cli(): - pass - - assert choices_without_help(cli, [], "") == ["arg11", "arg12"] - assert choices_without_help(cli, [], "arg") == ["arg11", "arg12"] - assert choices_without_help(cli, ["arg11"], "") == ["arg21", "arg22"] - assert choices_without_help(cli, ["arg12", "arg21"], "") == ["arg", "argument"] - assert choices_without_help(cli, ["arg12", "arg21"], "argu") == ["argument"] - - -def test_option_choice(): - @click.command() - @click.option("--opt1", type=click.Choice(["opt11", "opt12"]), help="opt1 help") - @click.option("--opt2", type=click.Choice(["opt21", "opt22"]), default="opt21") - @click.option("--opt3", type=click.Choice(["opt", "option"])) - def cli(): - pass - - assert choices_with_help(cli, [], "-") == [ - ("--opt1", "opt1 help"), - ("--opt2", None), - ("--opt3", None), - ("--help", "Show this message and exit."), - ] - assert choices_without_help(cli, [], "--opt") == ["--opt1", "--opt2", "--opt3"] - assert choices_without_help(cli, [], "--opt1=") == ["opt11", "opt12"] - assert choices_without_help(cli, [], "--opt2=") == ["opt21", "opt22"] - assert choices_without_help(cli, ["--opt2"], "=") == ["opt21", "opt22"] - assert choices_without_help(cli, ["--opt2", "="], "opt") == ["opt21", "opt22"] - assert choices_without_help(cli, ["--opt1"], "") == ["opt11", "opt12"] - assert choices_without_help(cli, ["--opt2"], "") == ["opt21", "opt22"] - assert choices_without_help(cli, ["--opt1", "opt11", "--opt2"], "") == [ - "opt21", - "opt22", - ] - assert choices_without_help(cli, ["--opt2", "opt21"], "-") == [ - "--opt1", - "--opt3", - "--help", - ] - assert choices_without_help(cli, ["--opt1", "opt11"], "-") == [ - "--opt2", - "--opt3", - "--help", - ] - assert choices_without_help(cli, ["--opt1"], "opt") == ["opt11", "opt12"] - assert choices_without_help(cli, ["--opt3"], "opti") == ["option"] - - assert choices_without_help(cli, ["--opt1", "invalid_opt"], "-") == [ - "--opt2", - "--opt3", - "--help", - ] - - -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 choices_without_help(cli, ["--opt1"], "") == ["opt11", "opt12"] - assert choices_without_help(cli, [""], "--opt1=") == ["opt11", "opt12"] - assert choices_without_help(cli, [], "") == ["arg11", "arg12"] - assert choices_without_help(cli, ["--opt2"], "") == ["opt21", "opt22"] - assert choices_without_help(cli, ["arg11"], "--opt") == ["--opt1", "--opt2"] - assert choices_without_help(cli, [], "--opt") == ["--opt1", "--opt2"] - - -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 choices_without_help(cli, [], "-") == ["--shout", "--no-shout", "--help"] - assert choices_without_help(cli, ["--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 choices_without_help(cli, ["--pos"], "") == ["pos1", "pos2"] - assert choices_without_help(cli, ["--pos", "pos1"], "") == ["pos1", "pos2"] - assert choices_without_help(cli, ["--pos", "pos1", "pos2"], "") == ["arg1", "arg2"] - assert choices_without_help(cli, ["--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 choices_without_help(cli, ["-m"], "") == ["m1", "m2"] - assert choices_without_help(cli, ["-m", "m1", "-m"], "") == ["m1", "m2"] - assert choices_without_help(cli, ["-m", "m1"], "") == ["arg1", "arg2"] - - -def test_variadic_argument_choice(): - @click.command() - @click.option("--opt", type=click.Choice(["opt1", "opt2"])) - @click.argument("src", nargs=-1, type=click.Choice(["src1", "src2"])) - def cli(local_opt): - pass - - assert choices_without_help(cli, ["src1", "src2"], "") == ["src1", "src2"] - assert choices_without_help(cli, ["src1", "src2"], "--o") == ["--opt"] - assert choices_without_help(cli, ["src1", "src2", "--opt"], "") == ["opt1", "opt2"] - assert choices_without_help(cli, ["src1", "src2"], "") == ["src1", "src2"] - - -def test_variadic_argument_complete(): - def _complete(ctx, args, incomplete): - return ["abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz"] - - @click.group() - def entrypoint(): - pass - - @click.command() - @click.option("--opt", autocompletion=_complete) - @click.argument("arg", nargs=-1) - def subcommand(opt, arg): - pass - - entrypoint.add_command(subcommand) - - assert choices_without_help(entrypoint, ["subcommand", "--opt"], "") == _complete( - 0, 0, 0 - ) - assert choices_without_help( - entrypoint, ["subcommand", "whatever", "--opt"], "" - ) == _complete(0, 0, 0) - assert ( - choices_without_help(entrypoint, ["subcommand", "whatever", "--opt", "abc"], "") - == [] - ) - - -def test_long_chain_choice(): - @click.group() - def cli(): - pass - - @cli.group() - @click.option("--sub-opt", type=click.Choice(["subopt1", "subopt2"])) - @click.argument( - "sub-arg", required=False, type=click.Choice(["subarg1", "subarg2"]) - ) - def sub(sub_opt, sub_arg): - pass - - @sub.command(short_help="bsub help") - @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 - - @sub.group("csub") - def csub(): - pass - - @csub.command() - def dsub(): - pass - - assert choices_with_help(cli, ["sub", "subarg1"], "") == [ - ("bsub", "bsub help"), - ("csub", ""), - ] - assert choices_without_help(cli, ["sub"], "") == ["subarg1", "subarg2"] - assert choices_without_help(cli, ["sub", "--sub-opt"], "") == ["subopt1", "subopt2"] - assert choices_without_help(cli, ["sub", "--sub-opt", "subopt1"], "") == [ - "subarg1", - "subarg2", - ] - assert choices_without_help( - cli, ["sub", "--sub-opt", "subopt1", "subarg1", "bsub"], "-" - ) == ["--bsub-opt", "--help"] - assert choices_without_help( - cli, ["sub", "--sub-opt", "subopt1", "subarg1", "bsub"], "" - ) == ["bsubarg1", "bsubarg2"] - assert choices_without_help( - cli, ["sub", "--sub-opt", "subopt1", "subarg1", "bsub", "--bsub-opt"], "" - ) == ["bsubopt1", "bsubopt2"] - assert choices_without_help( - cli, - [ - "sub", - "--sub-opt", - "subopt1", - "subarg1", - "bsub", - "--bsub-opt", - "bsubopt1", - "bsubarg1", - ], - "", - ) == ["bbsubarg1", "bbsubarg2"] - assert choices_without_help( - cli, ["sub", "--sub-opt", "subopt1", "subarg1", "csub"], "" - ) == ["dsub"] - - -def test_chained_multi(): - @click.group() - def cli(): - pass - - @cli.group() - def sub(): - pass - - @sub.group() - def bsub(): - pass - - @sub.group(chain=True) - def csub(): - pass - - @csub.command() - def dsub(): - pass - - @csub.command() - def esub(): - pass - - assert choices_without_help(cli, ["sub"], "") == ["bsub", "csub"] - assert choices_without_help(cli, ["sub"], "c") == ["csub"] - assert choices_without_help(cli, ["sub", "csub"], "") == ["dsub", "esub"] - assert choices_without_help(cli, ["sub", "csub", "dsub"], "") == ["esub"] - - -def test_hidden(): - @click.group() - @click.option("--name", hidden=True) - @click.option("--choices", type=click.Choice([1, 2]), hidden=True) - def cli(name): - pass - - @cli.group(hidden=True) - def hgroup(): - pass - - @hgroup.group() - def hgroupsub(): - pass - - @cli.command() - def asub(): - pass - - @cli.command(hidden=True) - @click.option("--hname") - def hsub(): - pass - - assert choices_without_help(cli, [], "--n") == [] - assert choices_without_help(cli, [], "--c") == [] - # If the user exactly types out the hidden param, complete its options. - assert choices_without_help(cli, ["--choices"], "") == [1, 2] - assert choices_without_help(cli, [], "") == ["asub"] - assert choices_without_help(cli, [], "") == ["asub"] - assert choices_without_help(cli, [], "h") == [] - # If the user exactly types out the hidden command, complete its subcommands. - assert choices_without_help(cli, ["hgroup"], "") == ["hgroupsub"] - assert choices_without_help(cli, ["hsub"], "--h") == ["--hname", "--help"] - - -@pytest.mark.parametrize( - ("args", "part", "expect"), - [ - ([], "-", ["--opt", "--help"]), - (["value"], "--", ["--opt", "--help"]), - ([], "-o", []), - (["--opt"], "-o", []), - (["--"], "", ["name", "-o", "--opt", "--"]), - (["--"], "--o", ["--opt"]), - ], -) -def test_args_with_double_dash_complete(args, part, expect): - def _complete(ctx, args, incomplete): - values = ["name", "-o", "--opt", "--"] - return [x for x in values if x.startswith(incomplete)] - - @click.command() - @click.option("--opt") - @click.argument("args", nargs=-1, autocompletion=_complete) - def cli(opt, args): - pass - - assert choices_without_help(cli, args, part) == expect diff --git a/tests/test_compat.py b/tests/test_compat.py index 3c458e9c7..825e04649 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -4,23 +4,6 @@ from click._compat import WIN -def test_bash_func_name(): - from click._bashcomplete import get_completion_script - - script = get_completion_script("foo-bar baz_blah", "_COMPLETE_VAR", "bash").strip() - assert script.startswith("_foo_barbaz_blah_completion()") - assert "_COMPLETE_VAR=complete $1" in script - - -def test_zsh_func_name(): - from click._bashcomplete import get_completion_script - - script = get_completion_script("foo-bar", "_COMPLETE_VAR", "zsh").strip() - assert script.startswith("#compdef foo-bar") - assert "compdef _foo_bar_completion foo-bar;" in script - assert "(( ! $+commands[foo-bar] )) && return 1" in script - - @pytest.mark.xfail(WIN, reason="Jupyter not tested/supported on Windows") def test_is_jupyter_kernel_output(): class JupyterKernelFakeStream: diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py new file mode 100644 index 000000000..0113b6ea2 --- /dev/null +++ b/tests/test_shell_completion.py @@ -0,0 +1,240 @@ +import sys + +import pytest + +from click.core import Argument +from click.core import Command +from click.core import Group +from click.core import Option +from click.shell_completion import ShellComplete +from click.types import Choice +from click.types import File +from click.types import Path + + +def _get_completions(cli, args, incomplete): + comp = ShellComplete(cli, cli.name, "_CLICK_COMPLETE") + return comp.get_completions(args, incomplete) + + +def _get_words(cli, args, incomplete): + return [c[1] for c in _get_completions(cli, args, incomplete)] + + +def test_command(): + cli = Command("cli", params=[Option(["-t", "--test"])]) + assert _get_words(cli, [], "") == [] + assert _get_words(cli, [], "-") == ["-t", "--test", "--help"] + assert _get_words(cli, [], "--") == ["--test", "--help"] + assert _get_words(cli, [], "--t") == ["--test"] + assert _get_words(cli, ["-t", "a"], "-") == ["--test", "--help"] + + +def test_group(): + cli = Group("cli", params=[Option(["-a"])], commands=[Command("x"), Command("y")]) + assert _get_words(cli, [], "") == ["x", "y"] + assert _get_words(cli, [], "-") == ["-a", "--help"] + + +def test_chained(): + cli = Group( + "cli", + chain=True, + commands=[ + Command("set", params=[Option(["-y"])]), + Command("start"), + Group("get", commands=[Command("full")]), + ], + ) + assert _get_words(cli, [], "") == ["get", "set", "start"] + assert _get_words(cli, [], "s") == ["set", "start"] + assert _get_words(cli, ["set", "start"], "") == ["get"] + # subcommands and parent subcommands + assert _get_words(cli, ["get"], "") == ["full", "set", "start"] + assert _get_words(cli, ["get", "full"], "") == ["set", "start"] + assert _get_words(cli, ["get"], "s") == ["set", "start"] + + +def test_help_option(): + cli = Group("cli", commands=[Command("with"), Command("no", add_help_option=False)]) + assert _get_words(cli, ["with"], "--") == ["--help"] + assert _get_words(cli, ["no"], "--") == [] + + +def test_argument_order(): + cli = Command( + "cli", + params=[ + Argument(["plain"]), + Argument(["c1"], type=Choice(["a1", "a2", "b"])), + Argument(["c2"], type=Choice(["c1", "c2", "d"])), + ], + ) + # first argument has no completions + assert _get_words(cli, [], "") == [] + assert _get_words(cli, [], "a") == [] + # first argument filled, now completion can happen + assert _get_words(cli, ["x"], "a") == ["a1", "a2"] + assert _get_words(cli, ["x", "b"], "d") == ["d"] + + +def test_type_choice(): + cli = Command("cli", params=[Option(["-c"], type=Choice(["a1", "a2", "b"]))]) + assert _get_words(cli, ["-c"], "") == ["a1", "a2", "b"] + assert _get_words(cli, ["-c"], "a") == ["a1", "a2"] + assert _get_words(cli, ["-c"], "a2") == ["a2"] + + +def test_type_file(): + cli = Command("cli", params=[Option(["-f"], type=File())]) + assert _get_completions(cli, ["-f"], "ab") == [("file", "ab", None)] + + +def test_type_path_file(): + cli = Command("cli", params=[Option(["-p"], type=Path())]) + assert _get_completions(cli, ["-p"], "ab") == [("file", "ab", None)] + + +def test_type_path_dir(): + cli = Command("cli", params=[Option(["-d"], type=Path(file_okay=False))]) + assert _get_completions(cli, ["-d"], "ab") == [("dir", "ab", None)] + + +def test_option_flag(): + cli = Command( + "cli", + add_help_option=False, + params=[ + Option(["--on/--off"]), + Argument(["a"], type=Choice(["a1", "a2", "b"])), + ], + ) + assert _get_words(cli, ["type"], "--") == ["--on", "--off"] + # flag option doesn't take value, use choice argument + assert _get_words(cli, ["x", "--on"], "a") == ["a1", "a2"] + + +def test_option_custom(): + def custom(ctx, args, incomplete): + return [incomplete.upper()] + + cli = Command( + "cli", + params=[ + Argument(["x"]), + Argument(["y"]), + Argument(["z"], autocompletion=custom), + ], + ) + assert _get_words(cli, ["a", "b"], "") == [""] + assert _get_words(cli, ["a", "b"], "c") == ["C"] + + +def test_option_multiple(): + cli = Command( + "type", + params=[Option(["-m"], type=Choice(["a", "b"]), multiple=True), Option(["-f"])], + ) + assert _get_words(cli, ["-m"], "") == ["a", "b"] + assert "-m" in _get_words(cli, ["-m", "a"], "-") + assert _get_words(cli, ["-m", "a", "-m"], "") == ["a", "b"] + # used single options aren't suggested again + assert "-c" not in _get_words(cli, ["-c", "f"], "-") + + +def test_option_nargs(): + cli = Command("cli", params=[Option(["-c"], type=Choice(["a", "b"]), nargs=2)]) + assert _get_words(cli, ["-c"], "") == ["a", "b"] + assert _get_words(cli, ["-c", "a"], "") == ["a", "b"] + assert _get_words(cli, ["-c", "a", "b"], "") == [] + + +def test_argument_nargs(): + cli = Command( + "cli", + params=[ + Argument(["x"], type=Choice(["a", "b"]), nargs=2), + Argument(["y"], type=Choice(["c", "d"]), nargs=-1), + Option(["-z"]), + ], + ) + assert _get_words(cli, [], "") == ["a", "b"] + assert _get_words(cli, ["a"], "") == ["a", "b"] + assert _get_words(cli, ["a", "b"], "") == ["c", "d"] + assert _get_words(cli, ["a", "b", "c"], "") == ["c", "d"] + assert _get_words(cli, ["a", "b", "c", "d"], "") == ["c", "d"] + assert _get_words(cli, ["a", "-z", "1"], "") == ["a", "b"] + assert _get_words(cli, ["a", "-z", "1", "b"], "") == ["c", "d"] + + +def test_double_dash(): + cli = Command( + "cli", + add_help_option=False, + params=[ + Option(["--opt"]), + Argument(["name"], type=Choice(["name", "--", "-o", "--opt"])), + ], + ) + assert _get_words(cli, [], "-") == ["--opt"] + assert _get_words(cli, ["value"], "-") == ["--opt"] + assert _get_words(cli, [], "") == ["name", "--", "-o", "--opt"] + assert _get_words(cli, ["--"], "") == ["name", "--", "-o", "--opt"] + + +def test_hidden(): + cli = Group( + "cli", + commands=[ + Command( + "hidden", + add_help_option=False, + hidden=True, + params=[ + Option(["-a"]), + Option(["-b"], type=Choice(["a", "b"]), hidden=True), + ], + ) + ], + ) + assert "hidden" not in _get_words(cli, [], "") + assert "hidden" not in _get_words(cli, [], "hidden") + assert _get_words(cli, ["hidden"], "-") == ["-a"] + assert _get_words(cli, ["hidden", "-b"], "") == ["a", "b"] + + +@pytest.fixture() +def _patch_for_completion(monkeypatch): + monkeypatch.setattr("click.core._fast_exit", sys.exit) + monkeypatch.setattr( + "click.shell_completion.BashComplete._check_version", lambda self: True + ) + + +@pytest.mark.parametrize( + "shell", ["bash", "zsh", "fish"], +) +@pytest.mark.usefixtures("_patch_for_completion") +def test_full_source(runner, shell): + cli = Group("cli", commands=[Command("a"), Command("b")]) + result = runner.invoke(cli, env={"_CLI_COMPLETE": f"source_{shell}"}) + assert f"_CLI_COMPLETE=complete_{shell}" in result.output + + +@pytest.mark.parametrize( + ("shell", "env", "expect"), + [ + ("bash", {"COMP_WORDS": "", "COMP_CWORD": "0"}, "plain,a\nplain,b\n"), + ("bash", {"COMP_WORDS": "a b", "COMP_CWORD": "1"}, "plain,b\n"), + ("zsh", {"COMP_WORDS": "", "COMP_CWORD": "0"}, "plain\na\n_\nplain\nb\nbee\n"), + ("zsh", {"COMP_WORDS": "a b", "COMP_CWORD": "1"}, "plain\nb\nbee\n"), + ("fish", {"COMP_WORDS": "", "COMP_CWORD": ""}, "plain,a\nplain,b\tbee\n"), + ("fish", {"COMP_WORDS": "a b", "COMP_CWORD": "b"}, "plain,b\tbee\n"), + ], +) +@pytest.mark.usefixtures("_patch_for_completion") +def test_full_complete(runner, shell, env, expect): + cli = Group("cli", commands=[Command("a"), Command("b", help="bee")]) + env["_CLI_COMPLETE"] = f"complete_{shell}" + result = runner.invoke(cli, env=env) + assert result.output == expect From de75b40b5f986b4f843a487b2eb6272773b3f304 Mon Sep 17 00:00:00 2001 From: Amy Date: Tue, 4 Aug 2020 15:55:12 -0700 Subject: [PATCH 142/293] documentation for new shell completion system Co-authored-by: Kai Chen Co-authored-by: David Lord --- CHANGES.rst | 6 + docs/api.rst | 16 ++ docs/bashcomplete.rst | 163 ------------------- docs/conf.py | 1 + docs/index.rst | 2 +- docs/shell-completion.rst | 286 ++++++++++++++++++++++++++++++++++ requirements/dev.txt | 5 +- requirements/docs.in | 1 + requirements/docs.txt | 5 +- src/click/shell_completion.py | 2 +- 10 files changed, 318 insertions(+), 169 deletions(-) delete mode 100644 docs/bashcomplete.rst create mode 100644 docs/shell-completion.rst diff --git a/CHANGES.rst b/CHANGES.rst index 8753a5b0d..7660617be 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -90,6 +90,12 @@ Unreleased information about the object's structure that could be useful for a tool generating user-facing documentation. To get the structure of an entire CLI, use ``Context(cli).to_info_dict()``. :issue:`461` +- Redesign the shell completion system. :issue:`1484`, :pr:`1622` + + - Support Bash >= 4.4, Zsh, and Fish, with the ability for + extensions to add support for other shells. + - Allow commands, groups, parameters, and types to override their + completions suggestions. Version 7.1.2 diff --git a/docs/api.rst b/docs/api.rst index 22dd39f4c..70095f358 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -169,6 +169,22 @@ Parsing .. autoclass:: OptionParser :members: + +Shell Completion +---------------- + +See :doc:`/shell-completion` for information about enabling and +customizing Click's shell completion system. + +.. currentmodule:: click.shell_completion + +.. autoclass:: ShellComplete + :members: + :member-order: bysource + +.. autofunction:: add_completion_class + + Testing ------- diff --git a/docs/bashcomplete.rst b/docs/bashcomplete.rst deleted file mode 100644 index a4a5f6ee3..000000000 --- a/docs/bashcomplete.rst +++ /dev/null @@ -1,163 +0,0 @@ -Shell Completion -================ - -.. versionadded:: 2.0 - -Click can provide tab completion for commands, options, and choice -values. Bash, Zsh, and Fish are supported - -Completion is only available if a script is installed and invoked -through an entry point, not through the ``python`` command. See -:ref:`setuptools-integration`. - - -What it Completes ------------------ - -Generally, the shell completion support will complete commands, -options, and any option or argument values where the type is -:class:`click.Choice`. Options are only listed if at least a dash has -been entered. - -.. code-block:: text - - $ repo - clone commit copy delete setuser - $ repo clone - - --deep --help --rev --shallow -r - -Custom completions can be provided for argument and option values by -providing an ``autocompletion`` function that returns a list of strings. -This is useful when the suggestions need to be dynamically generated -completion time. The callback function will be passed 3 keyword -arguments: - -- ``ctx`` - The current command context. -- ``args`` - The list of arguments passed in. -- ``incomplete`` - The partial word that is being completed. 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: - -.. code-block:: python - - import os - - def get_env_vars(ctx, args, incomplete): - return [k for k in os.environ.keys() if incomplete in k] - - @click.command() - @click.argument("envvar", type=click.STRING, autocompletion=get_env_vars) - def cmd1(envvar): - click.echo(f"Environment variable: {envvar}") - click.echo(f"Value: {os.environ[envvar]}") - - -Completion help strings ------------------------ - -ZSH and fish support showing documentation strings for completions. -These are taken from the help parameters of options and subcommands. For -dynamically generated completions a help string can be provided by -returning a tuple instead of a string. The first element of the tuple is -the completion and the second is the help string to display. - -Here is an example of using a callback function to generate dynamic -suggestions with help strings: - -.. code-block:: python - - import os - - def get_colors(ctx, args, incomplete): - colors = [('red', 'a warm color'), - ('blue', 'a cool color'), - ('green', 'the other starter color')] - return [c for c in colors if incomplete in c[0]] - - @click.command() - @click.argument("color", type=click.STRING, autocompletion=get_colors) - def cmd1(color): - click.echo(f"Chosen color is {color}") - - -Activation ----------- - -In order to activate shell completion, you need to inform your shell -that completion is available for your script. Any Click application -automatically provides support for that. If the program is executed with -a special ``__COMPLETE`` variable, the completion mechanism -is triggered instead of the normal command. ```` is the -executable name in uppercase with dashes replaced by underscores. - -If your tool is called ``foo-bar``, then the variable is called -``_FOO_BAR_COMPLETE``. By exporting it with the ``source_{shell}`` -value it will output the activation script to evaluate. - -Here are examples for a ``foo-bar`` script. - -For Bash, add this to ``~/.bashrc``: - -.. code-block:: text - - eval "$(_FOO_BAR_COMPLETE=source_bash foo-bar)" - -For Zsh, add this to ``~/.zshrc``: - -.. code-block:: text - - eval "$(_FOO_BAR_COMPLETE=source_zsh foo-bar)" - -For Fish, add this to ``~/.config/fish/completions/foo-bar.fish``: - -.. code-block:: text - - eval (env _FOO_BAR_COMPLETE=source_fish foo-bar) - -Open a new shell to enable completion. Or run the ``eval`` command -directly in your current shell to enable it temporarily. - - -Activation Script ------------------ - -The above ``eval`` examples will invoke your application every time a -shell is started. This may slow down shell startup time significantly. - -Alternatively, export the generated completion code as a static script -to be executed. You can ship this file with your builds; tools like Git -do this. At least Zsh will also cache the results of completion files, -but not ``eval`` scripts. - -For Bash: - -.. code-block:: text - - _FOO_BAR_COMPLETE=source_bash foo-bar > foo-bar-complete.sh - -For Zsh: - -.. code-block:: text - - _FOO_BAR_COMPLETE=source_zsh foo-bar > foo-bar-complete.sh - -For Fish: - -.. code-block:: text - - _FOO_BAR_COMPLETE=source_fish foo-bar > foo-bar-complete.fish - -In ``.bashrc`` or ``.zshrc``, source the script instead of the ``eval`` -command: - -.. code-block:: text - - . /path/to/foo-bar-complete.sh - -For Fish, add the file to the completions directory: - -.. code-block:: text - - _FOO_BAR_COMPLETE=source_fish foo-bar > ~/.config/fish/completions/foo-bar-complete.fish diff --git a/docs/conf.py b/docs/conf.py index 54c7a4d98..ed2d13f2e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,6 +22,7 @@ "sphinxcontrib.log_cabinet", "pallets_sphinx_themes", "sphinx_issues", + "sphinx_tabs.tabs", ] intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)} issues_github_path = "pallets/click" diff --git a/docs/index.rst b/docs/index.rst index 24afcc83a..1e4ac44ff 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -79,7 +79,7 @@ usage patterns. advanced testing utils - bashcomplete + shell-completion exceptions unicode-support wincmd diff --git a/docs/shell-completion.rst b/docs/shell-completion.rst new file mode 100644 index 000000000..1e45bcdec --- /dev/null +++ b/docs/shell-completion.rst @@ -0,0 +1,286 @@ +.. currentmodule:: click.shell_completion + +Shell Completion +================ + +Click provides tab completion support for Bash (version 4.4 and up), +Zsh, and Fish. It is possible to add support for other shells too, and +suggestions can be customized at multiple levels. + +Shell completion suggests command names, option names, and values for +choice, file, and path parameter types. Options are only listed if at +least a dash has been entered. Hidden commands and options are not +shown. + +.. code-block:: text + + $ repo + clone commit copy delete setuser + $ repo clone - + --deep --help --rev --shallow -r + + +Enabling Completion +------------------- + +Completion is only available if a script is installed and invoked +through an entry point, not through the ``python`` command. See +:doc:`/setuptools`. Once the executable is installed, calling it with +a special environment variable will put Click in completion mode. + +In order for completion to be used, the user needs to register a special +function with their shell. The script is different for every shell, and +Click will output it when called with ``_{PROG_NAME}_COMPLETE`` set to +``source_{shell}``. ``{PROG_NAME}`` is the executable name in uppercase +with dashes replaced by underscores. The built-in shells are ``bash``, +``zsh``, and ``fish``. + +Provide your users with the following instructions customized to your +program name. This uses ``foo-bar`` as an example. + +.. tabs:: + + .. group-tab:: Bash + + Add this to ``~/.bashrc``: + + .. code-block:: bash + + eval "$(_FOO_BAR_COMPLETE=source_bash foo-bar)" + + .. group-tab:: Zsh + + Add this to ``~/.zshrc``: + + .. code-block:: zsh + + eval "$(_FOO_BAR_COMPLETE=source_zsh foo-bar)" + + .. group-tab:: Fish + + Add this to ``~/.config/fish/completions/foo-bar.fish``: + + .. code-block:: fish + + eval (env _FOO_BAR_COMPLETE=source_fish foo-bar) + + This is the same file used for the activation script method + below. For Fish it's probably always easier to use that method. + +Using ``eval`` means that the command is invoked and evaluated every +time a shell is started, which can delay shell responsiveness. To speed +it up, write the generated script to a file, then source that. You can +generate the files ahead of time and distribute them with your program +to save your users a step. + +.. tabs:: + + .. group-tab:: Bash + + Save the script somewhere. + + .. code-block:: bash + + _FOO_BAR_COMPLETE=source_bash foo-bar > ~/.foo-bar-complete.bash + + Source the file in ``~/.bashrc``. + + .. code-block:: bash + + . ~/.foo-bar-complete.bash + + .. group-tab:: Zsh + + Save the script somewhere. + + .. code-block:: bash + + _FOO_BAR_COMPLETE=source_zsh foo-bar > ~/.foo-bar-complete.zsh + + Source the file in ``~/.zshrc``. + + .. code-block:: bash + + . ~/.foo-bar-complete.zsh + + .. group-tab:: Fish + + Save the script to ``~/.config/fish/completions/foo-bar.fish``: + + .. code-block:: fish + + _FOO_BAR_COMPLETE=source_fish foo-bar > ~/.config/fish/completions/foo-bar.fish + +After modifying the shell config, you need to start a new shell in order +for the changes to be loaded. + + +Custom Type Completion +---------------------- + +When creating a custom :class:`~click.ParamType`, override its +:meth:`~click.ParamType.shell_complete` method to provide shell +completion for parameters with the type. The method must return a list +of ``(type, value, help)`` tuples. ``type`` will usually be ``"plain"`` +unless you've implemented a custom shell script. Some shells know how to +display a ``help`` string next to each suggestion. + +In this example, the type will suggest environment variables that start +with the incomplete value. + +.. code-block:: python + + class EnvVarType(ParamType): + def shell_complete(self, ctx, args, incomplete): + return [ + ("plain", k, None) + for k in os.environ + if k.startswith(incomplete) + ] + + @click.command() + @click.option("--ev", type=EnvVarType()) + def cli(ev): + click.echo(os.environ[ev]) + + +Overriding Value Completion +--------------------------- + +Value completions for a parameter can be customized without a custom +type by providing an ``autocompletion`` function. The function is used +instead of any completion provided by the type. It is passed 3 keyword +arguments, and returns a list of strings to be shown. + +- ``ctx`` - The current command context. +- ``args`` - The list of complete args before the incomplete value. +- ``incomplete`` - The partial word that is being completed. May + be an empty string if no characters have been entered yet. + +In this example, the command will suggest environment variables that +start with the incomplete value. + +.. code-block:: python + + def complete_env_vars(ctx, args, incomplete): + return [k for k in os.environ if k.startswith(incomplete)] + + @click.command() + @click.argument("name", autocompletion=complete_env_vars) + def cli(name): + click.echo(f"Name: {name}") + click.echo(f"Value: {os.environ[name]}") + + +Adding Support for a Shell +-------------------------- + +Support can be added for shells that do not come built in. Be sure to +check PyPI to see if there's already a package that adds support for +your shell. This topic is very technical, you'll want to look at Click's +source to study the built-in implementations. + +Shell support is provided by subclasses of :class:`ShellComplete` +registered with :func:`add_completion_class`. When Click is invoked in +completion mode, it calls :meth:`~ShellComplete.source` to output the +completion script, or :meth:`~ShellComplete.complete` to output +completions. The base class provides default implementations that +require implementing some smaller parts. + +First, you'll need to figure out how your shell's completion system +works and write a script to integrate it with Click. It must invoke your +program with the environment variable ``_{PROG_NAME}_COMPLETE`` set to +``complete_{shell}`` and pass the complete args and incomplete value. +How it passes those values, and the format of the completion response +from Click is up to you. + +In your subclass, set :attr:`~ShellComplete.source_template` to the +completion script. The default implementation will perform ``%`` +formatting with the following variables: + +- ``complete_func`` - A safe name for the completion function defined + in the script. +- ``complete_var`` - The environment variable name for passing the + ``complete_{shell}`` value. +- ``prog_name`` - The name of the executable being completed. + +The example code is for a made up shell "My Shell" or "mysh" for short. + +.. code-block:: python + + from click.shell_completion import add_completion_class + from click.shell_completion import ShellComplete + + _mysh_source = """\ + %(complete_func)s { + response=$(%(complete_var)s=complete_mysh %(prog_name)s) + # parse response and set completions somehow + } + call-on-complete %(prog_name)s %(complete_func)s + """ + + @add_completion_class + class MyshComplete(ShellComplete): + name = "mysh" + source_template = _mysh_source + +Next, implement :meth:`~ShellComplete.get_completion_args`. This must +get, parse, and return the complete args and incomplete value from the +completion script. For example, for the Bash implementation the +``COMP_WORDS`` env var contains the command line args as a string, and +the ``COMP_CWORD`` env var contains the index of the incomplete arg. The +method must return a ``(args, incomplete)`` tuple. + +.. code-block:: python + + import os + from click.parser import split_arg_string + + class MyshComplete(ShellComplete): + ... + + def get_completion_args(self): + args = split_arg_string(os.environ["COMP_WORDS"]) + + if os.environ["COMP_PARTIAL"] == "1": + incomplete = args.pop() + return args, incomplete + + return args, "" + +Finally, implement :meth:`~ShellComplete.format_completion`. This is +called to format each ``(type, value, help)`` tuples returned by Click +into a string. For example, the Bash implementation returns +``f"{type},{value}`` (it doesn't support help strings), and the Zsh +implementation returns each part separated by a newline, replacing empty +help with a ``_`` placeholder. This format is entirely up to what you +parse with your completion script. + +The ``type`` value is usually ``plain``, but it can be another value +that the completion script can switch on. For example, ``file`` or +``dir`` can tell the shell to handle path completion, since the shell is +better at that than Click. + +.. code-block:: python + + import os + from click.parser import split_arg_string + + class MyshComplete(ShellComplete): + ... + + def format_completion(self, item): + type, value, _ = item + return f"{type}\t{value}" + +With those three things implemented, the new shell support is ready. In +case those weren't sufficient, there are more parts that can be +overridden, but that probably isn't necessary. + +The activation instructions will again depend on how your shell works. +Use the following to generate the completion script, then load it into +the shell somehow. + +.. code-block:: text + + _FOO_BAR_COMPLETE=source_mysh foo-bar diff --git a/requirements/dev.txt b/requirements/dev.txt index a3770c661..c8cdf181e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -30,7 +30,7 @@ pip-tools==5.3.1 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox pre-commit==2.7.1 # via -r requirements/dev.in py==1.9.0 # via pytest, tox -pygments==2.6.1 # via sphinx +pygments==2.6.1 # via sphinx, sphinx-tabs pyparsing==2.4.7 # via packaging pytest==6.1.0 # via -r requirements/tests.in pytz==2020.1 # via babel @@ -39,7 +39,8 @@ requests==2.23.0 # via sphinx six==1.15.0 # via packaging, pip-tools, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.2.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx-tabs==1.3.0 # via -r requirements/docs.in +sphinx==3.2.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/requirements/docs.in b/requirements/docs.in index 7ec501b6d..3ee050af0 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -2,3 +2,4 @@ Pallets-Sphinx-Themes Sphinx sphinx-issues sphinxcontrib-log-cabinet +sphinx-tabs diff --git a/requirements/docs.txt b/requirements/docs.txt index 756b6b0bd..70c3bef6d 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -15,14 +15,15 @@ jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 packaging==20.4 # via pallets-sphinx-themes, sphinx pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pygments==2.6.1 # via sphinx +pygments==2.6.1 # via sphinx, sphinx-tabs pyparsing==2.4.7 # via packaging pytz==2020.1 # via babel requests==2.23.0 # via sphinx six==1.15.0 # via packaging snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx==3.2.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinxcontrib-log-cabinet +sphinx-tabs==1.3.0 # via -r requirements/docs.in +sphinx==3.2.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index e165fbc49..f0e97cdb2 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -227,7 +227,7 @@ def complete(self): """Produce the completion data to send back to the shell. By default this calls :meth:`get_completion_args`, gets the - completions, then calls `:meth:`format_completion` for each + completions, then calls :meth:`format_completion` for each completion. """ args, incomplete = self.get_completion_args() From 00d5dcc9ede551dc04b20010ba0e65ca4fdbe1ff Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 8 Aug 2020 07:37:21 +0200 Subject: [PATCH 143/293] complete commands using registered names --- CHANGES.rst | 2 ++ src/click/core.py | 12 ++++++------ tests/test_shell_completion.py | 7 +++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7660617be..54bcefec4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -96,6 +96,8 @@ Unreleased extensions to add support for other shells. - Allow commands, groups, parameters, and types to override their completions suggestions. + - Groups complete the names commands were registered with, which + can differ from the name they were created with. Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index daaab8fff..b41faf37c 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -70,7 +70,7 @@ def _complete_visible_commands(ctx, incomplete): command = ctx.command.get_command(ctx, name) if not command.hidden: - yield command + yield name, command def _check_multicommand(base_command, cmd_name, cmd, register=False): @@ -885,9 +885,9 @@ def shell_complete(self, ctx, args, incomplete): if isinstance(ctx.command, MultiCommand) and ctx.command.chain: results.extend( - ("plain", command.name, command.get_short_help_str()) - for command in _complete_visible_commands(ctx, incomplete) - if command.name not in ctx.protected_args + ("plain", name, command.get_short_help_str()) + for name, command in _complete_visible_commands(ctx, incomplete) + if name not in ctx.protected_args ) return results @@ -1590,8 +1590,8 @@ def shell_complete(self, ctx, args, incomplete): .. versionadded:: 8.0 """ results = [ - ("plain", command.name, command.get_short_help_str()) - for command in _complete_visible_commands(ctx, incomplete) + ("plain", name, command.get_short_help_str()) + for name, command in _complete_visible_commands(ctx, incomplete) ] results.extend(super().shell_complete(ctx, args, incomplete)) return results diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 0113b6ea2..fd7322380 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -203,6 +203,13 @@ def test_hidden(): assert _get_words(cli, ["hidden", "-b"], "") == ["a", "b"] +def test_add_different_name(): + cli = Group("cli", commands={"renamed": Command("original")}) + words = _get_words(cli, [], "") + assert "renamed" in words + assert "original" not in words + + @pytest.fixture() def _patch_for_completion(monkeypatch): monkeypatch.setattr("click.core._fast_exit", sys.exit) From cb5c21ee379ff33318dc32cda5483ed516b918f2 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 2 Oct 2020 21:29:26 -0700 Subject: [PATCH 144/293] return list of CompletionItem objects --- docs/api.rst | 2 ++ docs/shell-completion.rst | 17 +++++++----- src/click/core.py | 41 +++++++++++++++++---------- src/click/shell_completion.py | 51 ++++++++++++++++++++++++++-------- src/click/types.py | 28 +++++++++++-------- tests/test_shell_completion.py | 33 ++++++++++++---------- 6 files changed, 114 insertions(+), 58 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 70095f358..dfc144553 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -178,6 +178,8 @@ customizing Click's shell completion system. .. currentmodule:: click.shell_completion +.. autoclass:: CompletionItem + .. autoclass:: ShellComplete :members: :member-order: bysource diff --git a/docs/shell-completion.rst b/docs/shell-completion.rst index 1e45bcdec..739a7c73b 100644 --- a/docs/shell-completion.rst +++ b/docs/shell-completion.rst @@ -121,9 +121,10 @@ Custom Type Completion When creating a custom :class:`~click.ParamType`, override its :meth:`~click.ParamType.shell_complete` method to provide shell completion for parameters with the type. The method must return a list -of ``(type, value, help)`` tuples. ``type`` will usually be ``"plain"`` -unless you've implemented a custom shell script. Some shells know how to -display a ``help`` string next to each suggestion. +of :class:`~CompletionItem` objects. Besides the value, these objects +hold metadata that shell support might use. The built-in implementations +use ``type`` to indicate special handling for paths, and ``help`` for +shells that support showing a help string next to a suggestion. In this example, the type will suggest environment variables that start with the incomplete value. @@ -133,9 +134,8 @@ with the incomplete value. class EnvVarType(ParamType): def shell_complete(self, ctx, args, incomplete): return [ - ("plain", k, None) - for k in os.environ - if k.startswith(incomplete) + CompletionItem(name) + for name in os.environ if name.startswith(incomplete) ] @click.command() @@ -150,13 +150,16 @@ Overriding Value Completion Value completions for a parameter can be customized without a custom type by providing an ``autocompletion`` function. The function is used instead of any completion provided by the type. It is passed 3 keyword -arguments, and returns a list of strings to be shown. +arguments: - ``ctx`` - The current command context. - ``args`` - The list of complete args before the incomplete value. - ``incomplete`` - The partial word that is being completed. May be an empty string if no characters have been entered yet. +It must return a list of :class:`CompletionItem` objects, or as a +shortcut it can return a list of strings. + In this example, the command will suggest environment variables that start with the incomplete value. diff --git a/src/click/core.py b/src/click/core.py index b41faf37c..6e45bebb2 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -878,6 +878,8 @@ def shell_complete(self, ctx, args, incomplete): .. versionadded:: 8.0 """ + from click.shell_completion import CompletionItem + results = [] while ctx.parent is not None: @@ -885,7 +887,7 @@ def shell_complete(self, ctx, args, incomplete): if isinstance(ctx.command, MultiCommand) and ctx.command.chain: results.extend( - ("plain", name, command.get_short_help_str()) + CompletionItem(name, help=command.get_short_help_str()) for name, command in _complete_visible_commands(ctx, incomplete) if name not in ctx.protected_args ) @@ -1286,6 +1288,8 @@ def shell_complete(self, ctx, args, incomplete): .. versionadded:: 8.0 """ + from click.shell_completion import CompletionItem + results = [] if incomplete and not incomplete[0].isalnum(): @@ -1293,15 +1297,13 @@ def shell_complete(self, ctx, args, incomplete): if not isinstance(param, Option) or param.hidden: continue - names = ( - name - for name in param.opts + param.secondary_opts - if name not in args or param.multiple - ) results.extend( - ("plain", name, param.help) - for name in names - if name.startswith(incomplete) + CompletionItem(name, help=param.help) + for name in param.opts + param.secondary_opts + if ( + (name not in args or param.multiple) + and name.startswith(incomplete) + ) ) results.extend(super().shell_complete(ctx, args, incomplete)) @@ -1589,8 +1591,10 @@ def shell_complete(self, ctx, args, incomplete): .. versionadded:: 8.0 """ + from click.shell_completion import CompletionItem + results = [ - ("plain", name, command.get_short_help_str()) + CompletionItem(name, help=command.get_short_help_str()) for name, command in _complete_visible_commands(ctx, incomplete) ] results.extend(super().shell_complete(ctx, args, incomplete)) @@ -2032,11 +2036,20 @@ def shell_complete(self, ctx, args, incomplete): .. versionadded:: 8.0 """ + from click.shell_completion import CompletionItem + if self.autocompletion is not None: - return [ - ("plain",) + c if isinstance(c, tuple) else ("plain", c, None) - for c in self.autocompletion(ctx=ctx, args=args, incomplete=incomplete) - ] + results = [] + + for c in self.autocompletion(ctx=ctx, args=args, incomplete=incomplete): + if isinstance(c, CompletionItem): + results.append(c) + elif isinstance(c, tuple): + results.append(CompletionItem(c[0], help=c[1])) + else: + results.append(CompletionItem(c)) + + return results return self.type.shell_complete(ctx, args, incomplete) diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index f0e97cdb2..8152f0016 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -38,6 +38,37 @@ def shell_complete(cli, prog_name, complete_var, instruction): return 1 +class CompletionItem: + """Represents a completion value and metadata about the value. The + default metadata is ``type`` to indicate special shell handling, + and ``help`` if a shell supports showing a help string next to the + value. + + Arbitrary parameters can be passed when creating the object, and + accessed using ``item.attr``. If an attribute wasn't passed, + accessing it returns ``None``. + + :param value: The completion suggestion. + :param type: Tells the shell script to provide special completion + support for the type. Click uses ``"dir"`` and ``"file"``. + :param help: String shown next to the value if supported. + :param kwargs: Arbitrary metadata. The built-in implementations + don't use this, but custom type completions paired with custom + shell support could use it. + """ + + __slots__ = ("value", "type", "help", "_info") + + def __init__(self, value, type="plain", help=None, **kwargs): + self.value = value + self.type = type + self.help = help + self._info = kwargs + + def __getattr__(self, name): + return self._info.get(name) + + # Only Bash >= 4.4 has the nosort option. _SOURCE_BASH = """\ %(complete_func)s() { @@ -277,9 +308,8 @@ def get_completion_args(self): return args, incomplete - def format_completion(self, item): - type, value, _ = item - return f"{type},{value}" + def format_completion(self, item: CompletionItem): + return f"{item.type},{item.value}" class ZshComplete(ShellComplete): @@ -300,9 +330,8 @@ def get_completion_args(self): return args, incomplete - def format_completion(self, item): - type, value, desc = item - return f"{type}\n{value}\n{desc if desc else '_'}" + def format_completion(self, item: CompletionItem): + return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}" class FishComplete(ShellComplete): @@ -323,13 +352,11 @@ def get_completion_args(self): return args, incomplete - def format_completion(self, item): - type, value, desc = item - - if desc: - return f"{type},{value}\t{desc}" + def format_completion(self, item: CompletionItem): + if item.help: + return f"{item.type},{item.value}\t{item.help}" - return f"{type},{value}" + return f"{item.type},{item.value}" _available_shells = { diff --git a/src/click/types.py b/src/click/types.py index 43afac0d6..4cedc2789 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -108,9 +108,11 @@ def fail(self, message, param=None, ctx=None): raise BadParameter(message, ctx=ctx, param=param) def shell_complete(self, ctx, args, incomplete): - """Return a list of completions for the incomplete value. Most - types do not provide completions, but some do, and this allows - custom types to provide custom completions as well. + """Return a list of + :class:`~click.shell_completion.CompletionItem` objects for the + incomplete value. Most types do not provide completions, but + some do, and this allows custom types to provide custom + completions as well. :param ctx: Invocation context for this command. :param args: List of complete args before the incomplete value. @@ -263,8 +265,7 @@ def __repr__(self): return f"Choice({list(self.choices)})" def shell_complete(self, ctx, args, incomplete): - """Return a list of completions for the incomplete value based - on the choices. + """Complete choices that start with the incomplete value. :param ctx: Invocation context for this command. :param args: List of complete args before the incomplete value. @@ -272,9 +273,10 @@ def shell_complete(self, ctx, args, incomplete): .. versionadded:: 8.0 """ - return [ - ("plain", c, None) for c in self.choices if str(c).startswith(incomplete) - ] + from click.shell_completion import CompletionItem + + str_choices = map(str, self.choices) + return [CompletionItem(c) for c in str_choices if c.startswith(incomplete)] class DateTime(ParamType): @@ -620,7 +622,9 @@ def shell_complete(self, ctx, args, incomplete): .. versionadded:: 8.0 """ - return [("file", incomplete, None)] + from click.shell_completion import CompletionItem + + return [CompletionItem(incomplete, type="file")] class Path(ParamType): @@ -764,8 +768,10 @@ def shell_complete(self, ctx, args, incomplete): .. versionadded:: 8.0 """ - completion_type = "dir" if self.dir_okay and not self.file_okay else "file" - return [(completion_type, incomplete, None)] + from click.shell_completion import CompletionItem + + type = "dir" if self.dir_okay and not self.file_okay else "file" + return [CompletionItem(incomplete, type=type)] class Tuple(CompositeParamType): diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index fd7322380..e40d08af6 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -6,6 +6,7 @@ from click.core import Command from click.core import Group from click.core import Option +from click.shell_completion import CompletionItem from click.shell_completion import ShellComplete from click.types import Choice from click.types import File @@ -18,7 +19,7 @@ def _get_completions(cli, args, incomplete): def _get_words(cli, args, incomplete): - return [c[1] for c in _get_completions(cli, args, incomplete)] + return [c.value for c in _get_completions(cli, args, incomplete)] def test_command(): @@ -85,19 +86,17 @@ def test_type_choice(): assert _get_words(cli, ["-c"], "a2") == ["a2"] -def test_type_file(): - cli = Command("cli", params=[Option(["-f"], type=File())]) - assert _get_completions(cli, ["-f"], "ab") == [("file", "ab", None)] - - -def test_type_path_file(): - cli = Command("cli", params=[Option(["-p"], type=Path())]) - assert _get_completions(cli, ["-p"], "ab") == [("file", "ab", None)] - - -def test_type_path_dir(): - cli = Command("cli", params=[Option(["-d"], type=Path(file_okay=False))]) - assert _get_completions(cli, ["-d"], "ab") == [("dir", "ab", None)] +@pytest.mark.parametrize( + ("type", "expect"), + [(File(), "file"), (Path(), "file"), (Path(file_okay=False), "dir")], +) +def test_path_types(type, expect): + cli = Command("cli", params=[Option(["-f"], type=type)]) + out = _get_completions(cli, ["-f"], "ab") + assert len(out) == 1 + c = out[0] + assert c.value == "ab" + assert c.type == expect def test_option_flag(): @@ -210,6 +209,12 @@ def test_add_different_name(): assert "original" not in words +def test_completion_item_data(): + c = CompletionItem("test", a=1) + assert c.a == 1 + assert c.b is None + + @pytest.fixture() def _patch_for_completion(monkeypatch): monkeypatch.setattr("click.core._fast_exit", sys.exit) From 3faede8b430ef88f36d3efa513f5e4065a3f3a7e Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 3 Oct 2020 09:23:39 -0700 Subject: [PATCH 145/293] rename autocompletion to shell_complete new function takes additional param arg, must return a homogeneous list of strings or CompletionItem, and must perform matching on results --- CHANGES.rst | 6 ++ docs/shell-completion.rst | 7 +- examples/bashcompletion/README | 12 ---- examples/bashcompletion/bashcompletion.py | 45 ------------- examples/completion/README | 28 ++++++++ examples/completion/completion.py | 53 +++++++++++++++ .../{bashcompletion => completion}/setup.py | 6 +- src/click/core.py | 65 ++++++++++++++----- src/click/types.py | 12 ++-- tests/test_shell_completion.py | 17 ++++- 10 files changed, 166 insertions(+), 85 deletions(-) delete mode 100644 examples/bashcompletion/README delete mode 100644 examples/bashcompletion/bashcompletion.py create mode 100644 examples/completion/README create mode 100644 examples/completion/completion.py rename examples/{bashcompletion => completion}/setup.py (60%) diff --git a/CHANGES.rst b/CHANGES.rst index 54bcefec4..e6d5c23f8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -98,6 +98,12 @@ Unreleased completions suggestions. - Groups complete the names commands were registered with, which can differ from the name they were created with. + - The ``autocompletion`` parameter for options and arguments is + renamed to ``shell_complete``. The function must take four + parameters ``ctx, param, args, incomplete``, must do matching + rather than return all values, and must return a list of strings + or a list of ``ShellComplete``. The old name and behavior is + deprecated and will be removed in 8.1. Version 7.1.2 diff --git a/docs/shell-completion.rst b/docs/shell-completion.rst index 739a7c73b..a02c3bebf 100644 --- a/docs/shell-completion.rst +++ b/docs/shell-completion.rst @@ -148,11 +148,12 @@ Overriding Value Completion --------------------------- Value completions for a parameter can be customized without a custom -type by providing an ``autocompletion`` function. The function is used +type by providing a ``shell_complete`` function. The function is used instead of any completion provided by the type. It is passed 3 keyword arguments: - ``ctx`` - The current command context. +- ``param`` - The current parameter requesting completion. - ``args`` - The list of complete args before the incomplete value. - ``incomplete`` - The partial word that is being completed. May be an empty string if no characters have been entered yet. @@ -165,11 +166,11 @@ start with the incomplete value. .. code-block:: python - def complete_env_vars(ctx, args, incomplete): + def complete_env_vars(ctx, param, args, incomplete): return [k for k in os.environ if k.startswith(incomplete)] @click.command() - @click.argument("name", autocompletion=complete_env_vars) + @click.argument("name", shell_complete=complete_env_vars) def cli(name): click.echo(f"Name: {name}") click.echo(f"Value: {os.environ[name]}") diff --git a/examples/bashcompletion/README b/examples/bashcompletion/README deleted file mode 100644 index f8a0d51ef..000000000 --- a/examples/bashcompletion/README +++ /dev/null @@ -1,12 +0,0 @@ -$ 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 deleted file mode 100644 index 3f8c9dfc0..000000000 --- a/examples/bashcompletion/bashcompletion.py +++ /dev/null @@ -1,45 +0,0 @@ -import os - -import click - - -@click.group() -def cli(): - pass - - -def get_env_vars(ctx, args, incomplete): - # Completions returned as strings do not have a description displayed. - for key in os.environ.keys(): - if incomplete in key: - yield key - - -@cli.command(help="A command to print environment variables") -@click.argument("envvar", type=click.STRING, autocompletion=get_env_vars) -def cmd1(envvar): - click.echo(f"Environment variable: {envvar}") - click.echo(f"Value: {os.environ[envvar]}") - - -@click.group(help="A group that holds a subcommand") -def group(): - pass - - -def list_users(ctx, args, incomplete): - # You can generate completions with descriptions by returning - # tuples in the form (completion, description). - users = [("bob", "butcher"), ("alice", "baker"), ("jerry", "candlestick maker")] - # Ths will allow completion matches based on matches within the - # description string too! - return [user for user in users if incomplete in user[0] or incomplete in user[1]] - - -@group.command(help="Choose a user") -@click.argument("user", type=click.STRING, autocompletion=list_users) -def subcmd(user): - click.echo(f"Chosen user is {user}") - - -cli.add_command(group) diff --git a/examples/completion/README b/examples/completion/README new file mode 100644 index 000000000..f15654edd --- /dev/null +++ b/examples/completion/README @@ -0,0 +1,28 @@ +$ completion +============ + +Demonstrates Click's shell completion support. + +.. code-block:: bash + + pip install --editable . + +For Bash: + +.. code-block:: bash + + eval "$(_COMPLETION_COMPLETE=source_bash completion)" + +For Zsh: + +.. code-block:: zsh + + eval "$(_COMPLETION_COMPLETE=source_zsh completion)" + +For Fish: + +.. code-block:: fish + + eval (env _COMPLETION_COMPLETE=source_fish completion) + +Now press tab (maybe twice) after typing something to see completions. diff --git a/examples/completion/completion.py b/examples/completion/completion.py new file mode 100644 index 000000000..92dcc7402 --- /dev/null +++ b/examples/completion/completion.py @@ -0,0 +1,53 @@ +import os + +import click +from click.shell_completion import CompletionItem + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.option("--dir", type=click.Path(file_okay=False)) +def ls(dir): + click.echo("\n".join(os.listdir(dir))) + + +def get_env_vars(ctx, param, args, incomplete): + # Returning a list of values is a shortcut to returning a list of + # CompletionItem(value). + return [k for k in os.environ if incomplete in k] + + +@cli.command(help="A command to print environment variables") +@click.argument("envvar", shell_complete=get_env_vars) +def show_env(envvar): + click.echo(f"Environment variable: {envvar}") + click.echo(f"Value: {os.environ[envvar]}") + + +@cli.group(help="A group that holds a subcommand") +def group(): + pass + + +def list_users(ctx, args, incomplete): + # You can generate completions with help strings by returning a list + # of CompletionItem. You can match on whatever you want, including + # the help. + items = [("bob", "butcher"), ("alice", "baker"), ("jerry", "candlestick maker")] + + for value, help in items: + if incomplete in value or incomplete in help: + yield CompletionItem(value, help=help) + + +@group.command(help="Choose a user") +@click.argument("user", type=click.STRING, autocompletion=list_users) +def select_user(user): + click.echo(f"Chosen user is {user}") + + +cli.add_command(group) diff --git a/examples/bashcompletion/setup.py b/examples/completion/setup.py similarity index 60% rename from examples/bashcompletion/setup.py rename to examples/completion/setup.py index f9a2c2934..a78d14087 100644 --- a/examples/bashcompletion/setup.py +++ b/examples/completion/setup.py @@ -1,13 +1,13 @@ from setuptools import setup setup( - name="click-example-bashcompletion", + name="click-example-completion", version="1.0", - py_modules=["bashcompletion"], + py_modules=["completion"], include_package_data=True, install_requires=["click"], entry_points=""" [console_scripts] - bashcompletion=bashcompletion:cli + completion=completion:cli """, ) diff --git a/src/click/core.py b/src/click/core.py index 6e45bebb2..cccf9398c 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1781,9 +1781,17 @@ class Parameter: order of processing. :param envvar: a string or list of strings that are environment variables that should be checked. - :param autocompletion: A function that returns custom shell + :param shell_complete: A function that returns custom shell completions. Used instead of the param's type completion if - given. + given. Takes ``ctx, param, args, incomplete`` and returns a list + of :class:`~click.shell_completion.CompletionItem` or a list of + strings. + + .. versionchanged:: 8.0 + ``autocompletion`` is renamed to ``shell_complete`` and has new + semantics described in the docs above. The old name is + deprecated and will be removed in 8.1, until then it will be + wrapped to match the new requirements. .. versionchanged:: 7.1 Empty environment variables are ignored rather than taking the @@ -1795,6 +1803,7 @@ class Parameter: parameter. The old callback format will still work, but it will raise a warning to give you a chance to migrate the code easier. """ + param_type_name = "parameter" def __init__( @@ -1809,6 +1818,7 @@ def __init__( expose_value=True, is_eager=False, envvar=None, + shell_complete=None, autocompletion=None, ): self.name, self.opts, self.secondary_opts = self._parse_decls( @@ -1834,7 +1844,35 @@ def __init__( self.is_eager = is_eager self.metavar = metavar self.envvar = envvar - self.autocompletion = autocompletion + + if autocompletion is not None: + import warnings + + warnings.warn( + "'autocompletion' is renamed to 'shell_complete'. The old name is" + " deprecated and will be removed in Click 8.1. See the docs about" + " 'Parameter' for information about new behavior.", + DeprecationWarning, + stacklevel=2, + ) + + def shell_complete(ctx, param, args, incomplete): + from click.shell_completion import CompletionItem + + out = [] + + for c in autocompletion(ctx, args, incomplete): + if isinstance(c, tuple): + c = CompletionItem(c[0], help=c[1]) + elif isinstance(c, str): + c = CompletionItem(c) + + if c.value.startswith(incomplete): + out.append(c) + + return out + + self._custom_shell_complete = shell_complete def to_info_dict(self): """Gather information that could be useful for a tool generating @@ -2025,8 +2063,8 @@ def get_error_hint(self, ctx): return " / ".join(repr(x) for x in hint_list) def shell_complete(self, ctx, args, incomplete): - """Return a list of completions for the incomplete value. If an - :attr:`autocompletion` function was given, it is used. + """Return a list of completions for the incomplete value. If a + ``shell_complete`` function was given during init, it is used. Otherwise, the :attr:`type` :meth:`~click.types.ParamType.shell_complete` function is used. @@ -2036,22 +2074,17 @@ def shell_complete(self, ctx, args, incomplete): .. versionadded:: 8.0 """ - from click.shell_completion import CompletionItem + if self._custom_shell_complete is not None: + results = self._custom_shell_complete(ctx, self, args, incomplete) - if self.autocompletion is not None: - results = [] + if results and isinstance(results[0], str): + from click.shell_completion import CompletionItem - for c in self.autocompletion(ctx=ctx, args=args, incomplete=incomplete): - if isinstance(c, CompletionItem): - results.append(c) - elif isinstance(c, tuple): - results.append(CompletionItem(c[0], help=c[1])) - else: - results.append(CompletionItem(c)) + results = [CompletionItem(c) for c in results] return results - return self.type.shell_complete(ctx, args, incomplete) + return self.type.shell_complete(ctx, self, args, incomplete) class Option(Parameter): diff --git a/src/click/types.py b/src/click/types.py index 4cedc2789..f26b8b50e 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -107,7 +107,7 @@ def fail(self, message, param=None, ctx=None): """Helper method to fail with an invalid value message.""" raise BadParameter(message, ctx=ctx, param=param) - def shell_complete(self, ctx, args, incomplete): + def shell_complete(self, ctx, param, args, incomplete): """Return a list of :class:`~click.shell_completion.CompletionItem` objects for the incomplete value. Most types do not provide completions, but @@ -115,6 +115,7 @@ def shell_complete(self, ctx, args, incomplete): completions as well. :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. @@ -264,10 +265,11 @@ def convert(self, value, param, ctx): def __repr__(self): return f"Choice({list(self.choices)})" - def shell_complete(self, ctx, args, incomplete): + def shell_complete(self, ctx, param, args, incomplete): """Complete choices that start with the incomplete value. :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. @@ -612,11 +614,12 @@ def convert(self, value, param, ctx): ctx, ) - def shell_complete(self, ctx, args, incomplete): + def shell_complete(self, ctx, param, args, incomplete): """Return a special completion marker that tells the completion system to use the shell to provide file path completions. :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. @@ -757,12 +760,13 @@ def convert(self, value, param, ctx): return self.coerce_path_result(rv) - def shell_complete(self, ctx, args, incomplete): + def shell_complete(self, ctx, param, args, incomplete): """Return a special completion marker that tells the completion system to use the shell to provide path completions for only directories or any paths. :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index e40d08af6..e2d865c49 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -114,7 +114,7 @@ def test_option_flag(): def test_option_custom(): - def custom(ctx, args, incomplete): + def custom(ctx, param, args, incomplete): return [incomplete.upper()] cli = Command( @@ -122,13 +122,26 @@ def custom(ctx, args, incomplete): params=[ Argument(["x"]), Argument(["y"]), - Argument(["z"], autocompletion=custom), + Argument(["z"], shell_complete=custom), ], ) assert _get_words(cli, ["a", "b"], "") == [""] assert _get_words(cli, ["a", "b"], "c") == ["C"] +def test_autocompletion_deprecated(): + # old function takes three params, returns all values, can mix + # strings and tuples + def custom(ctx, args, incomplete): + return [("art", "x"), "bat", "cat"] + + with pytest.deprecated_call(): + cli = Command("cli", params=[Argument(["x"], autocompletion=custom)]) + + assert _get_words(cli, [], "") == ["art", "bat", "cat"] + assert _get_words(cli, [], "c") == ["cat"] + + def test_option_multiple(): cli = Command( "type", From 1b9a657efe2de8e1b7084e3c6149d70bc0736d60 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 3 Oct 2020 13:48:08 -0700 Subject: [PATCH 146/293] don't pass all args to shell_complete methods --- CHANGES.rst | 10 +++--- docs/shell-completion.rst | 5 ++- examples/completion/completion.py | 6 ++-- src/click/core.py | 55 +++++++++++++++++-------------- src/click/shell_completion.py | 4 +-- src/click/types.py | 12 +++---- tests/test_shell_completion.py | 18 ++++++++-- 7 files changed, 61 insertions(+), 49 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index e6d5c23f8..7f5bb5faf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -99,11 +99,11 @@ Unreleased - Groups complete the names commands were registered with, which can differ from the name they were created with. - The ``autocompletion`` parameter for options and arguments is - renamed to ``shell_complete``. The function must take four - parameters ``ctx, param, args, incomplete``, must do matching - rather than return all values, and must return a list of strings - or a list of ``ShellComplete``. The old name and behavior is - deprecated and will be removed in 8.1. + renamed to ``shell_complete``. The function must take + ``ctx, param, incomplete``, must do matching rather than return + all values, and must return a list of strings or a list of + ``ShellComplete``. The old name and behavior is deprecated and + will be removed in 8.1. Version 7.1.2 diff --git a/docs/shell-completion.rst b/docs/shell-completion.rst index a02c3bebf..f2c1bb4ff 100644 --- a/docs/shell-completion.rst +++ b/docs/shell-completion.rst @@ -132,7 +132,7 @@ with the incomplete value. .. code-block:: python class EnvVarType(ParamType): - def shell_complete(self, ctx, args, incomplete): + def shell_complete(self, ctx, param, incomplete): return [ CompletionItem(name) for name in os.environ if name.startswith(incomplete) @@ -154,7 +154,6 @@ arguments: - ``ctx`` - The current command context. - ``param`` - The current parameter requesting completion. -- ``args`` - The list of complete args before the incomplete value. - ``incomplete`` - The partial word that is being completed. May be an empty string if no characters have been entered yet. @@ -166,7 +165,7 @@ start with the incomplete value. .. code-block:: python - def complete_env_vars(ctx, param, args, incomplete): + def complete_env_vars(ctx, param, incomplete): return [k for k in os.environ if k.startswith(incomplete)] @click.command() diff --git a/examples/completion/completion.py b/examples/completion/completion.py index 92dcc7402..abd09c000 100644 --- a/examples/completion/completion.py +++ b/examples/completion/completion.py @@ -15,7 +15,7 @@ def ls(dir): click.echo("\n".join(os.listdir(dir))) -def get_env_vars(ctx, param, args, incomplete): +def get_env_vars(ctx, param, incomplete): # Returning a list of values is a shortcut to returning a list of # CompletionItem(value). return [k for k in os.environ if incomplete in k] @@ -33,7 +33,7 @@ def group(): pass -def list_users(ctx, args, incomplete): +def list_users(ctx, param, incomplete): # You can generate completions with help strings by returning a list # of CompletionItem. You can match on whatever you want, including # the help. @@ -45,7 +45,7 @@ def list_users(ctx, args, incomplete): @group.command(help="Choose a user") -@click.argument("user", type=click.STRING, autocompletion=list_users) +@click.argument("user", shell_complete=list_users) def select_user(user): click.echo(f"Chosen user is {user}") diff --git a/src/click/core.py b/src/click/core.py index cccf9398c..b2ccaa85b 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -284,9 +284,13 @@ def __init__( self.command = command #: the descriptive information name self.info_name = info_name - #: the parsed parameters except if the value is hidden in which - #: case it's not remembered. + #: Map of parameter names to their parsed values. Parameters + #: with ``expose_value=False`` are not stored. self.params = {} + # This tracks the actual param objects that were parsed, even if + # they didn't expose a value. Used by completion system to know + # what parameters to exclude. + self._seen_params = set() #: the leftover arguments. self.args = [] #: protected arguments. These are arguments that are prepended @@ -864,7 +868,7 @@ def invoke(self, ctx): """ raise NotImplementedError("Base commands are not invokable by default") - def shell_complete(self, ctx, args, incomplete): + def shell_complete(self, ctx, incomplete): """Return a list of completions for the incomplete value. Looks at the names of chained multi-commands. @@ -873,7 +877,6 @@ def shell_complete(self, ctx, args, incomplete): command classes will return more completions. :param ctx: Invocation context for this command. - :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. .. versionadded:: 8.0 @@ -1278,12 +1281,11 @@ def invoke(self, ctx): if self.callback is not None: return ctx.invoke(self.callback, **ctx.params) - def shell_complete(self, ctx, args, incomplete): + def shell_complete(self, ctx, incomplete): """Return a list of completions for the incomplete value. Looks at the names of options and chained multi-commands. :param ctx: Invocation context for this command. - :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. .. versionadded:: 8.0 @@ -1294,19 +1296,20 @@ def shell_complete(self, ctx, args, incomplete): if incomplete and not incomplete[0].isalnum(): for param in self.get_params(ctx): - if not isinstance(param, Option) or param.hidden: + if ( + not isinstance(param, Option) + or param.hidden + or (not param.multiple and param in ctx._seen_params) + ): continue results.extend( CompletionItem(name, help=param.help) for name in param.opts + param.secondary_opts - if ( - (name not in args or param.multiple) - and name.startswith(incomplete) - ) + if name.startswith(incomplete) ) - results.extend(super().shell_complete(ctx, args, incomplete)) + results.extend(super().shell_complete(ctx, incomplete)) return results @@ -1580,13 +1583,12 @@ def list_commands(self, ctx): """ return [] - def shell_complete(self, ctx, args, incomplete): + def shell_complete(self, ctx, incomplete): """Return a list of completions for the incomplete value. Looks at the names of options, subcommands, and chained multi-commands. :param ctx: Invocation context for this command. - :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. .. versionadded:: 8.0 @@ -1597,7 +1599,7 @@ def shell_complete(self, ctx, args, incomplete): CompletionItem(name, help=command.get_short_help_str()) for name, command in _complete_visible_commands(ctx, incomplete) ] - results.extend(super().shell_complete(ctx, args, incomplete)) + results.extend(super().shell_complete(ctx, incomplete)) return results @@ -1783,15 +1785,15 @@ class Parameter: that should be checked. :param shell_complete: A function that returns custom shell completions. Used instead of the param's type completion if - given. Takes ``ctx, param, args, incomplete`` and returns a list + given. Takes ``ctx, param, incomplete`` and must return a list of :class:`~click.shell_completion.CompletionItem` or a list of strings. .. versionchanged:: 8.0 ``autocompletion`` is renamed to ``shell_complete`` and has new - semantics described in the docs above. The old name is - deprecated and will be removed in 8.1, until then it will be - wrapped to match the new requirements. + semantics described above. The old name is deprecated and will + be removed in 8.1, until then it will be wrapped to match the + new requirements. .. versionchanged:: 7.1 Empty environment variables are ignored rather than taking the @@ -1856,12 +1858,12 @@ def __init__( stacklevel=2, ) - def shell_complete(ctx, param, args, incomplete): + def shell_complete(ctx, param, incomplete): from click.shell_completion import CompletionItem out = [] - for c in autocompletion(ctx, args, incomplete): + for c in autocompletion(ctx, [], incomplete): if isinstance(c, tuple): c = CompletionItem(c[0], help=c[1]) elif isinstance(c, str): @@ -2047,6 +2049,10 @@ def handle_parse_result(self, ctx, opts, args): if self.expose_value: ctx.params[self.name] = value + + if value is not None: + ctx._seen_params.add(self) + return value, args def get_help_record(self, ctx): @@ -2062,20 +2068,19 @@ def get_error_hint(self, ctx): hint_list = self.opts or [self.human_readable_name] return " / ".join(repr(x) for x in hint_list) - def shell_complete(self, ctx, args, incomplete): + def shell_complete(self, ctx, incomplete): """Return a list of completions for the incomplete value. If a ``shell_complete`` function was given during init, it is used. Otherwise, the :attr:`type` :meth:`~click.types.ParamType.shell_complete` function is used. :param ctx: Invocation context for this command. - :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. .. versionadded:: 8.0 """ if self._custom_shell_complete is not None: - results = self._custom_shell_complete(ctx, self, args, incomplete) + results = self._custom_shell_complete(ctx, self, incomplete) if results and isinstance(results[0], str): from click.shell_completion import CompletionItem @@ -2084,7 +2089,7 @@ def shell_complete(self, ctx, args, incomplete): return results - return self.type.shell_complete(ctx, self, args, incomplete) + return self.type.shell_complete(ctx, self, incomplete) class Option(Parameter): diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index 8152f0016..2ce3eb928 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -244,7 +244,7 @@ def get_completions(self, args, incomplete): return [] obj, incomplete = _resolve_incomplete(ctx, args, incomplete) - return obj.shell_complete(ctx, args, incomplete) + return obj.shell_complete(ctx, incomplete) def format_completion(self, item): """Format a completion item into the form recognized by the @@ -443,7 +443,7 @@ def _is_incomplete_option(args, param): if _start_of_option(arg): last_option = arg - return bool(last_option and last_option in param.opts) + return last_option is not None and last_option in param.opts def _resolve_context(cli, prog_name, args): diff --git a/src/click/types.py b/src/click/types.py index f26b8b50e..b7161d923 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -107,7 +107,7 @@ def fail(self, message, param=None, ctx=None): """Helper method to fail with an invalid value message.""" raise BadParameter(message, ctx=ctx, param=param) - def shell_complete(self, ctx, param, args, incomplete): + def shell_complete(self, ctx, param, incomplete): """Return a list of :class:`~click.shell_completion.CompletionItem` objects for the incomplete value. Most types do not provide completions, but @@ -116,7 +116,6 @@ def shell_complete(self, ctx, param, args, incomplete): :param ctx: Invocation context for this command. :param param: The parameter that is requesting completion. - :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. .. versionadded:: 8.0 @@ -265,12 +264,11 @@ def convert(self, value, param, ctx): def __repr__(self): return f"Choice({list(self.choices)})" - def shell_complete(self, ctx, param, args, incomplete): + def shell_complete(self, ctx, param, incomplete): """Complete choices that start with the incomplete value. :param ctx: Invocation context for this command. :param param: The parameter that is requesting completion. - :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. .. versionadded:: 8.0 @@ -614,13 +612,12 @@ def convert(self, value, param, ctx): ctx, ) - def shell_complete(self, ctx, param, args, incomplete): + def shell_complete(self, ctx, param, incomplete): """Return a special completion marker that tells the completion system to use the shell to provide file path completions. :param ctx: Invocation context for this command. :param param: The parameter that is requesting completion. - :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. .. versionadded:: 8.0 @@ -760,14 +757,13 @@ def convert(self, value, param, ctx): return self.coerce_path_result(rv) - def shell_complete(self, ctx, param, args, incomplete): + def shell_complete(self, ctx, param, incomplete): """Return a special completion marker that tells the completion system to use the shell to provide path completions for only directories or any paths. :param ctx: Invocation context for this command. :param param: The parameter that is requesting completion. - :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. .. versionadded:: 8.0 diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index e2d865c49..2357cb78d 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -28,7 +28,8 @@ def test_command(): assert _get_words(cli, [], "-") == ["-t", "--test", "--help"] assert _get_words(cli, [], "--") == ["--test", "--help"] assert _get_words(cli, [], "--t") == ["--test"] - assert _get_words(cli, ["-t", "a"], "-") == ["--test", "--help"] + # -t has been seen, so --test isn't suggested + assert _get_words(cli, ["-t", "a"], "-") == ["--help"] def test_group(): @@ -37,6 +38,16 @@ def test_group(): assert _get_words(cli, [], "-") == ["-a", "--help"] +def test_group_command_same_option(): + cli = Group( + "cli", params=[Option(["-a"])], commands=[Command("x", params=[Option(["-a"])])] + ) + assert _get_words(cli, [], "-") == ["-a", "--help"] + assert _get_words(cli, ["-a", "a"], "-") == ["--help"] + assert _get_words(cli, ["-a", "a", "x"], "-") == ["-a", "--help"] + assert _get_words(cli, ["-a", "a", "x", "-a", "a"], "-") == ["--help"] + + def test_chained(): cli = Group( "cli", @@ -114,7 +125,7 @@ def test_option_flag(): def test_option_custom(): - def custom(ctx, param, args, incomplete): + def custom(ctx, param, incomplete): return [incomplete.upper()] cli = Command( @@ -130,9 +141,10 @@ def custom(ctx, param, args, incomplete): def test_autocompletion_deprecated(): - # old function takes three params, returns all values, can mix + # old function takes args and not param, returns all values, can mix # strings and tuples def custom(ctx, args, incomplete): + assert isinstance(args, list) return [("art", "x"), "bat", "cat"] with pytest.deprecated_call(): From 8981a95d4032fb752ffd0b87059d9048e8c996f1 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 3 Oct 2020 14:35:08 -0700 Subject: [PATCH 147/293] pass extra context settings to completion --- CHANGES.rst | 5 ++++- src/click/core.py | 6 +++--- src/click/shell_completion.py | 16 ++++++++++------ tests/test_shell_completion.py | 16 +++++++++++++++- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7f5bb5faf..f7dd40577 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,7 +12,6 @@ Unreleased parameter. :issue:`1264`, :pr:`1329` - Add an optional parameter to ``ProgressBar.update`` to set the ``current_item``. :issue:`1226`, :pr:`1332` -- Include ``--help`` option in completion. :pr:`1504` - ``version_option`` uses ``importlib.metadata`` (or the ``importlib_metadata`` backport) instead of ``pkg_resources``. :issue:`1582` @@ -105,6 +104,10 @@ Unreleased ``ShellComplete``. The old name and behavior is deprecated and will be removed in 8.1. +- Extra context settings (``obj=...``, etc.) are passed on to the + completion system. :issue:`942` +- Include ``--help`` option in completion. :pr:`1504` + Version 7.1.2 ------------- diff --git a/src/click/core.py b/src/click/core.py index b2ccaa85b..3a24f44ed 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -950,7 +950,7 @@ def main( prog_name = _detect_program_name() # Process shell completion requests and exit early. - self._main_shell_completion(prog_name, complete_var) + self._main_shell_completion(extra, prog_name, complete_var) try: try: @@ -1000,7 +1000,7 @@ def main( echo("Aborted!", file=sys.stderr) sys.exit(1) - def _main_shell_completion(self, prog_name, complete_var=None): + def _main_shell_completion(self, ctx_args, prog_name, complete_var=None): """Check if the shell is asking for tab completion, process that, then exit early. Called from :meth:`main` before the program is invoked. @@ -1020,7 +1020,7 @@ def _main_shell_completion(self, prog_name, complete_var=None): from .shell_completion import shell_complete - rv = shell_complete(self, prog_name, complete_var, instruction) + rv = shell_complete(self, ctx_args, prog_name, complete_var, instruction) _fast_exit(rv) def __call__(self, *args, **kwargs): diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index 2ce3eb928..c9664ad07 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -8,10 +8,12 @@ from .utils import echo -def shell_complete(cli, prog_name, complete_var, instruction): +def shell_complete(cli, ctx_args, prog_name, complete_var, instruction): """Perform shell completion for the given CLI program. :param cli: Command being called. + :param ctx_args: Extra arguments to pass to + ``cli.make_context``. :param prog_name: Name of the executable in the shell. :param complete_var: Name of the environment variable that holds the completion instruction. @@ -25,7 +27,7 @@ def shell_complete(cli, prog_name, complete_var, instruction): if comp_cls is None: return 1 - comp = comp_cls(cli, prog_name, complete_var) + comp = comp_cls(cli, ctx_args, prog_name, complete_var) if instruction == "source": echo(comp.source()) @@ -190,8 +192,9 @@ class ShellComplete: be provided by subclasses. """ - def __init__(self, cli, prog_name, complete_var): + def __init__(self, cli, ctx_args, prog_name, complete_var): self.cli = cli + self.ctx_args = ctx_args self.prog_name = prog_name self.complete_var = complete_var @@ -238,7 +241,7 @@ def get_completions(self, args, incomplete): :param args: List of complete args before the incomplete value. :param incomplete: Value being completed. May be empty. """ - ctx = _resolve_context(self.cli, self.prog_name, args) + ctx = _resolve_context(self.cli, self.ctx_args, self.prog_name, args) if ctx is None: return [] @@ -446,7 +449,7 @@ def _is_incomplete_option(args, param): return last_option is not None and last_option in param.opts -def _resolve_context(cli, prog_name, args): +def _resolve_context(cli, ctx_args, prog_name, args): """Produce the context hierarchy starting with the command and traversing the complete arguments. This only follows the commands, it doesn't trigger input prompts or callbacks. @@ -455,7 +458,8 @@ def _resolve_context(cli, prog_name, args): :param prog_name: Name of the executable in the shell. :param args: List of complete args before the incomplete value. """ - ctx = cli.make_context(prog_name, args.copy(), resilient_parsing=True) + ctx_args["resilient_parsing"] = True + ctx = cli.make_context(prog_name, args.copy(), **ctx_args) args = ctx.protected_args + ctx.args while args: diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 2357cb78d..39c016121 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -14,7 +14,7 @@ def _get_completions(cli, args, incomplete): - comp = ShellComplete(cli, cli.name, "_CLICK_COMPLETE") + comp = ShellComplete(cli, {}, cli.name, "_CLICK_COMPLETE") return comp.get_completions(args, incomplete) @@ -275,3 +275,17 @@ def test_full_complete(runner, shell, env, expect): env["_CLI_COMPLETE"] = f"complete_{shell}" result = runner.invoke(cli, env=env) assert result.output == expect + + +@pytest.mark.usefixtures("_patch_for_completion") +def test_context_settings(runner): + def complete(ctx, param, incomplete): + return ctx.obj["choices"] + + cli = Command("cli", params=[Argument("x", shell_complete=complete)]) + result = runner.invoke( + cli, + obj={"choices": ["a", "b"]}, + env={"COMP_WORDS": "", "COMP_CWORD": "0", "_CLI_COMPLETE": "complete_bash"}, + ) + assert result.output == "plain,a\nplain,b\n" From 7e4a27fc273ceb2af3b790434e6b5470237c6168 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 7 Oct 2020 06:30:38 -0700 Subject: [PATCH 148/293] swap order of instruction string --- CHANGES.rst | 3 +++ docs/shell-completion.rst | 40 +++++++++++++++------------------- src/click/shell_completion.py | 12 +++++----- tests/test_shell_completion.py | 8 +++---- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f7dd40577..7429e5ecb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -103,6 +103,9 @@ Unreleased all values, and must return a list of strings or a list of ``ShellComplete``. The old name and behavior is deprecated and will be removed in 8.1. + - The env var values used to start completion have changed order. + The shell now comes first, such as ``{shell}_source`` rather + than ``source_{shell}``, and is always required. - Extra context settings (``obj=...``, etc.) are passed on to the completion system. :issue:`942` diff --git a/docs/shell-completion.rst b/docs/shell-completion.rst index f2c1bb4ff..699d25d9b 100644 --- a/docs/shell-completion.rst +++ b/docs/shell-completion.rst @@ -31,7 +31,7 @@ a special environment variable will put Click in completion mode. In order for completion to be used, the user needs to register a special function with their shell. The script is different for every shell, and Click will output it when called with ``_{PROG_NAME}_COMPLETE`` set to -``source_{shell}``. ``{PROG_NAME}`` is the executable name in uppercase +``{shell}_source``. ``{PROG_NAME}`` is the executable name in uppercase with dashes replaced by underscores. The built-in shells are ``bash``, ``zsh``, and ``fish``. @@ -46,7 +46,7 @@ program name. This uses ``foo-bar`` as an example. .. code-block:: bash - eval "$(_FOO_BAR_COMPLETE=source_bash foo-bar)" + eval "$(_FOO_BAR_COMPLETE=bash_source foo-bar)" .. group-tab:: Zsh @@ -54,7 +54,7 @@ program name. This uses ``foo-bar`` as an example. .. code-block:: zsh - eval "$(_FOO_BAR_COMPLETE=source_zsh foo-bar)" + eval "$(_FOO_BAR_COMPLETE=zsh_source foo-bar)" .. group-tab:: Fish @@ -62,7 +62,7 @@ program name. This uses ``foo-bar`` as an example. .. code-block:: fish - eval (env _FOO_BAR_COMPLETE=source_fish foo-bar) + eval (env _FOO_BAR_COMPLETE=fish_source foo-bar) This is the same file used for the activation script method below. For Fish it's probably always easier to use that method. @@ -81,7 +81,7 @@ to save your users a step. .. code-block:: bash - _FOO_BAR_COMPLETE=source_bash foo-bar > ~/.foo-bar-complete.bash + _FOO_BAR_COMPLETE=bash_source foo-bar > ~/.foo-bar-complete.bash Source the file in ``~/.bashrc``. @@ -95,7 +95,7 @@ to save your users a step. .. code-block:: bash - _FOO_BAR_COMPLETE=source_zsh foo-bar > ~/.foo-bar-complete.zsh + _FOO_BAR_COMPLETE=zsh_source foo-bar > ~/.foo-bar-complete.zsh Source the file in ``~/.zshrc``. @@ -109,7 +109,7 @@ to save your users a step. .. code-block:: fish - _FOO_BAR_COMPLETE=source_fish foo-bar > ~/.config/fish/completions/foo-bar.fish + _FOO_BAR_COMPLETE=fish_source foo-bar > ~/.config/fish/completions/foo-bar.fish After modifying the shell config, you need to start a new shell in order for the changes to be loaded. @@ -193,7 +193,7 @@ require implementing some smaller parts. First, you'll need to figure out how your shell's completion system works and write a script to integrate it with Click. It must invoke your program with the environment variable ``_{PROG_NAME}_COMPLETE`` set to -``complete_{shell}`` and pass the complete args and incomplete value. +``{shell}_complete`` and pass the complete args and incomplete value. How it passes those values, and the format of the completion response from Click is up to you. @@ -204,7 +204,7 @@ formatting with the following variables: - ``complete_func`` - A safe name for the completion function defined in the script. - ``complete_var`` - The environment variable name for passing the - ``complete_{shell}`` value. + ``{shell}_complete`` instruction. - ``prog_name`` - The name of the executable being completed. The example code is for a made up shell "My Shell" or "mysh" for short. @@ -216,7 +216,7 @@ The example code is for a made up shell "My Shell" or "mysh" for short. _mysh_source = """\ %(complete_func)s { - response=$(%(complete_var)s=complete_mysh %(prog_name)s) + response=$(%(complete_var)s=mysh_complete %(prog_name)s) # parse response and set completions somehow } call-on-complete %(prog_name)s %(complete_func)s @@ -252,12 +252,12 @@ method must return a ``(args, incomplete)`` tuple. return args, "" Finally, implement :meth:`~ShellComplete.format_completion`. This is -called to format each ``(type, value, help)`` tuples returned by Click -into a string. For example, the Bash implementation returns -``f"{type},{value}`` (it doesn't support help strings), and the Zsh -implementation returns each part separated by a newline, replacing empty -help with a ``_`` placeholder. This format is entirely up to what you -parse with your completion script. +called to format each :class:`CompletionItem` into a string. For +example, the Bash implementation returns ``f"{item.type},{item.value}`` +(it doesn't support help strings), and the Zsh implementation returns +each part separated by a newline, replacing empty help with a ``_`` +placeholder. This format is entirely up to what you parse with your +completion script. The ``type`` value is usually ``plain``, but it can be another value that the completion script can switch on. For example, ``file`` or @@ -266,15 +266,11 @@ better at that than Click. .. code-block:: python - import os - from click.parser import split_arg_string - class MyshComplete(ShellComplete): ... def format_completion(self, item): - type, value, _ = item - return f"{type}\t{value}" + return f"{item.type}\t{item.value}" With those three things implemented, the new shell support is ready. In case those weren't sufficient, there are more parts that can be @@ -286,4 +282,4 @@ the shell somehow. .. code-block:: text - _FOO_BAR_COMPLETE=source_mysh foo-bar + _FOO_BAR_COMPLETE=mysh_source foo-bar diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index c9664ad07..efefba10d 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -21,7 +21,7 @@ def shell_complete(cli, ctx_args, prog_name, complete_var, instruction): instruction and shell, in the form ``instruction_shell``. :return: Status code to exit with. """ - instruction, _, shell = instruction.partition("_") + shell, _, instruction = instruction.partition("_") comp_cls = get_completion_class(shell) if comp_cls is None: @@ -78,7 +78,7 @@ def __getattr__(self, name): local response response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD \ -%(complete_var)s=complete_bash $1) +%(complete_var)s=bash_complete $1) for completion in $response; do IFS=',' read type value <<< "$completion" @@ -114,7 +114,7 @@ def __getattr__(self, name): (( ! $+commands[%(prog_name)s] )) && return 1 response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \ -%(complete_var)s=complete_zsh %(prog_name)s)}") +%(complete_var)s=zsh_complete %(prog_name)s)}") for type key descr in ${response}; do if [[ "$type" == "plain" ]]; then @@ -146,7 +146,7 @@ def __getattr__(self, name): function %(complete_func)s; set -l response; - for value in (env %(complete_var)s=complete_fish COMP_WORDS=(commandline -cp) \ + for value in (env %(complete_var)s=fish_complete COMP_WORDS=(commandline -cp) \ COMP_CWORD=(commandline -t) %(prog_name)s); set response $response $value; end; @@ -184,8 +184,8 @@ class ShellComplete: name = None """Name to register the shell as with :func:`add_completion_class`. - This is used in completion instructions (``source_{name}`` and - ``complete_{name}``). + This is used in completion instructions (``{name}_source`` and + ``{name}_complete``). """ source_template = None """Completion script template formatted by :meth:`source`. This must diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 39c016121..5c169a907 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -254,8 +254,8 @@ def _patch_for_completion(monkeypatch): @pytest.mark.usefixtures("_patch_for_completion") def test_full_source(runner, shell): cli = Group("cli", commands=[Command("a"), Command("b")]) - result = runner.invoke(cli, env={"_CLI_COMPLETE": f"source_{shell}"}) - assert f"_CLI_COMPLETE=complete_{shell}" in result.output + result = runner.invoke(cli, env={"_CLI_COMPLETE": f"{shell}_source"}) + assert f"_CLI_COMPLETE={shell}_complete" in result.output @pytest.mark.parametrize( @@ -272,7 +272,7 @@ def test_full_source(runner, shell): @pytest.mark.usefixtures("_patch_for_completion") def test_full_complete(runner, shell, env, expect): cli = Group("cli", commands=[Command("a"), Command("b", help="bee")]) - env["_CLI_COMPLETE"] = f"complete_{shell}" + env["_CLI_COMPLETE"] = f"{shell}_complete" result = runner.invoke(cli, env=env) assert result.output == expect @@ -286,6 +286,6 @@ def complete(ctx, param, incomplete): result = runner.invoke( cli, obj={"choices": ["a", "b"]}, - env={"COMP_WORDS": "", "COMP_CWORD": "0", "_CLI_COMPLETE": "complete_bash"}, + env={"COMP_WORDS": "", "COMP_CWORD": "0", "_CLI_COMPLETE": "bash_complete"}, ) assert result.output == "plain,a\nplain,b\n" From 3852f410d6227bed2872768aa316d96a769382bb Mon Sep 17 00:00:00 2001 From: Alan Velasco Date: Mon, 20 Apr 2020 11:55:09 -0600 Subject: [PATCH 149/293] ParameterSource is an Enum subclass Co-authored-by: David Lord --- CHANGES.rst | 1 + docs/advanced.rst | 8 ++-- docs/api.rst | 5 +++ src/click/core.py | 82 ++++++++++++++++-------------------- tests/test_context.py | 97 ++++++++++++++++++------------------------- tests/test_imports.py | 2 +- 6 files changed, 88 insertions(+), 107 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7429e5ecb..548ac134b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -110,6 +110,7 @@ Unreleased - Extra context settings (``obj=...``, etc.) are passed on to the completion system. :issue:`942` - Include ``--help`` option in completion. :pr:`1504` +- ``ParameterSource`` is an ``enum.Enum`` subclass. :issue:`1530` Version 7.1.2 diff --git a/docs/advanced.rst b/docs/advanced.rst index e1ffcad07..3ea5a155c 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -386,8 +386,10 @@ Detecting the Source of a Parameter In some situations it's helpful to understand whether or not an option or parameter came from the command line, the environment, the default -value, or the default_map. The :meth:`Context.get_parameter_source` -method can be used to find this out. +value, or :attr:`Context.default_map`. The +:meth:`Context.get_parameter_source` method can be used to find this +out. It will return a member of the :class:`~click.core.ParameterSource` +enum. .. click:example:: @@ -396,7 +398,7 @@ method can be used to find this out. @click.pass_context def cli(ctx, port): source = ctx.get_parameter_source("port") - click.echo(f"Port came from {source}") + click.echo(f"Port came from {source.name}") .. click:run:: diff --git a/docs/api.rst b/docs/api.rst index dfc144553..59d38d969 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -108,6 +108,11 @@ Context .. autofunction:: get_current_context +.. autoclass:: click.core.ParameterSource + :members: + :member-order: bysource + + Types ----- diff --git a/src/click/core.py b/src/click/core.py index 3a24f44ed..fae95db80 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1,3 +1,4 @@ +import enum import errno import inspect import os @@ -132,36 +133,25 @@ def sort_key(item): return sorted(declaration_order, key=sort_key) -class ParameterSource: - """This is an enum that indicates the source of a command line parameter. +class ParameterSource(enum.Enum): + """This is an :class:`~enum.Enum` that indicates the source of a + parameter's value. - The enum has one of the following values: COMMANDLINE, - ENVIRONMENT, DEFAULT, DEFAULT_MAP. The DEFAULT indicates that the - default value in the decorator was used. This class should be - converted to an enum when Python 2 support is dropped. - """ - - COMMANDLINE = "COMMANDLINE" - ENVIRONMENT = "ENVIRONMENT" - DEFAULT = "DEFAULT" - DEFAULT_MAP = "DEFAULT_MAP" + Use :meth:`click.Context.get_parameter_source` to get the + source for a parameter by name. - VALUES = {COMMANDLINE, ENVIRONMENT, DEFAULT, DEFAULT_MAP} - - @classmethod - def validate(cls, value): - """Validate that the specified value is a valid enum. + .. versionchanged:: 8.0 + Use :class:`~enum.Enum` and drop the ``validate`` method. + """ - This method will raise a ValueError if the value is - not a valid enum. - - :param value: the string value to verify - """ - if value not in cls.VALUES: - raise ValueError( - f"Invalid ParameterSource value: {value!r}. Valid" - f" values are: {','.join(cls.VALUES)}" - ) + COMMANDLINE = enum.auto() + """The value was provided by the command line args.""" + ENVIRONMENT = enum.auto() + """The value was provided with an environment variable.""" + DEFAULT = enum.auto() + """Used the default specified by the parameter.""" + DEFAULT_MAP = enum.auto() + """Used a default provided by :attr:`Context.default_map`.""" class Context: @@ -417,7 +407,7 @@ def __init__( self._close_callbacks = [] self._depth = 0 - self._source_by_paramname = {} + self._parameter_source = {} self._exit_stack = ExitStack() def to_info_dict(self): @@ -728,33 +718,27 @@ def forward(*args, **kwargs): # noqa: B902 return self.invoke(cmd, **kwargs) def set_parameter_source(self, name, source): - """Set the source of a parameter. - - This indicates the location from which the value of the - parameter was obtained. + """Set the source of a parameter. This indicates the location + from which the value of the parameter was obtained. - :param name: the name of the command line parameter - :param source: the source of the command line parameter, which - should be a valid ParameterSource value + :param name: The name of the parameter. + :param source: A member of :class:`~click.core.ParameterSource`. """ - ParameterSource.validate(source) - self._source_by_paramname[name] = source + self._parameter_source[name] = source def get_parameter_source(self, name): - """Get the source of a parameter. + """Get the source of a parameter. This indicates the location + from which the value of the parameter was obtained. - This indicates the location from which the value of the - parameter was obtained. This can be useful for determining - when a user specified an option on the command line that is - the same as the default. In that case, the source would be - ParameterSource.COMMANDLINE, even though the value of the - parameter was equivalent to the default. + This can be useful for determining when a user specified a value + on the command line that is the same as the default value. It + will be :attr:`~click.core.ParameterSource.DEFAULT` only if the + value was actually taken from the default. - :param name: the name of the command line parameter - :returns: the source + :param name: The name of the parameter. :rtype: ParameterSource """ - return self._source_by_paramname[name] + return self._parameter_source[name] class BaseCommand: @@ -1933,14 +1917,18 @@ def add_to_parser(self, parser, ctx): def consume_value(self, ctx, opts): value = opts.get(self.name) source = ParameterSource.COMMANDLINE + if value is None: value = self.value_from_envvar(ctx) source = ParameterSource.ENVIRONMENT + if value is None: value = ctx.lookup_default(self.name) source = ParameterSource.DEFAULT_MAP + if value is not None: ctx.set_parameter_source(self.name, source) + return value def type_cast_value(self, ctx, value): diff --git a/tests/test_context.py b/tests/test_context.py index bfccb85f5..c6aee3c09 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -297,63 +297,48 @@ def cli(ctx): assert cli.main([], "test_exit_not_standalone", standalone_mode=False) == 0 -def test_parameter_source_default(runner): +@pytest.mark.parametrize( + ("option_args", "invoke_args", "expect"), + [ + pytest.param({}, {}, ParameterSource.DEFAULT, id="default"), + pytest.param( + {}, + {"default_map": {"option": 1}}, + ParameterSource.DEFAULT_MAP, + id="default_map", + ), + pytest.param( + {}, + {"args": ["-o", "1"]}, + ParameterSource.COMMANDLINE, + id="commandline short", + ), + pytest.param( + {}, + {"args": ["--option", "1"]}, + ParameterSource.COMMANDLINE, + id="commandline long", + ), + pytest.param( + {}, + {"auto_envvar_prefix": "TEST", "env": {"TEST_OPTION": "1"}}, + ParameterSource.ENVIRONMENT, + id="environment auto", + ), + pytest.param( + {"envvar": "NAME"}, + {"env": {"NAME": "1"}}, + ParameterSource.ENVIRONMENT, + id="environment manual", + ), + ], +) +def test_parameter_source(runner, option_args, invoke_args, expect): @click.command() @click.pass_context - @click.option("-o", "--option", default=1) + @click.option("-o", "--option", default=1, **option_args) def cli(ctx, option): - click.echo(ctx.get_parameter_source("option")) + return ctx.get_parameter_source("option") - rv = runner.invoke(cli) - assert rv.output.rstrip() == ParameterSource.DEFAULT - - -def test_parameter_source_default_map(runner): - @click.command() - @click.pass_context - @click.option("-o", "--option", default=1) - def cli(ctx, option): - click.echo(ctx.get_parameter_source("option")) - - rv = runner.invoke(cli, default_map={"option": 1}) - assert rv.output.rstrip() == ParameterSource.DEFAULT_MAP - - -def test_parameter_source_commandline(runner): - @click.command() - @click.pass_context - @click.option("-o", "--option", default=1) - def cli(ctx, option): - click.echo(ctx.get_parameter_source("option")) - - rv = runner.invoke(cli, ["-o", "1"]) - assert rv.output.rstrip() == ParameterSource.COMMANDLINE - rv = runner.invoke(cli, ["--option", "1"]) - assert rv.output.rstrip() == ParameterSource.COMMANDLINE - - -def test_parameter_source_environment(runner): - @click.command() - @click.pass_context - @click.option("-o", "--option", default=1) - def cli(ctx, option): - click.echo(ctx.get_parameter_source("option")) - - rv = runner.invoke(cli, auto_envvar_prefix="TEST", env={"TEST_OPTION": "1"}) - assert rv.output.rstrip() == ParameterSource.ENVIRONMENT - - -def test_parameter_source_environment_variable_specified(runner): - @click.command() - @click.pass_context - @click.option("-o", "--option", default=1, envvar="NAME") - def cli(ctx, option): - click.echo(ctx.get_parameter_source("option")) - - rv = runner.invoke(cli, env={"NAME": "1"}) - assert rv.output.rstrip() == ParameterSource.ENVIRONMENT - - -def test_validate_parameter_source(): - with pytest.raises(ValueError): - ParameterSource.validate("NOT_A_VALID_PARAMETER_SOURCE") + rv = runner.invoke(cli, standalone_mode=False, **invoke_args) + assert rv.return_value == expect diff --git a/tests/test_imports.py b/tests/test_imports.py index 993dbfdb1..54d9559fb 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -45,7 +45,7 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, "errno", "fcntl", "datetime", - "pipes", + "enum", } if WIN: From c291543d9f0863b8f77b4426230c9535ba8e64eb Mon Sep 17 00:00:00 2001 From: Karthikeyan Singaravelan Date: Sat, 10 Oct 2020 05:44:55 +0000 Subject: [PATCH 150/293] Add python 3.9 to CI. --- .github/workflows/tests.yaml | 11 ++++++----- tox.ini | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4c457e176..0cd5fda2e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -16,14 +16,15 @@ jobs: fail-fast: false matrix: include: - - {name: Linux, python: '3.8', os: ubuntu-latest, tox: py38} - - {name: Windows, python: '3.8', os: windows-latest, tox: py38} - - {name: Mac, python: '3.8', os: macos-latest, tox: py38} + - {name: Linux, python: '3.9', os: ubuntu-latest, tox: py39} + - {name: Windows, python: '3.9', os: windows-latest, tox: py39} + - {name: Mac, python: '3.9', os: macos-latest, tox: py39} + - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} - {name: 'PyPy', python: pypy3, os: ubuntu-latest, tox: pypy3} - - {name: Style, python: '3.8', os: ubuntu-latest, tox: style} - - {name: Docs, python: '3.8', os: ubuntu-latest, tox: docs} + - {name: Style, python: '3.9', os: ubuntu-latest, tox: style} + - {name: Docs, python: '3.9', os: ubuntu-latest, tox: docs} steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/tox.ini b/tox.ini index 9b6d47135..9ec517ba3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{38,37,36,py3} + py{39,38,37,36,py3} style docs skip_missing_interpreters = true From 0c1f7f096a3399d50da22856890f1e42b365344a Mon Sep 17 00:00:00 2001 From: Andreas Maier Date: Sun, 28 Jun 2020 22:12:57 +0200 Subject: [PATCH 151/293] boolean type strips space before converting --- CHANGES.rst | 2 ++ src/click/types.py | 10 +++++++--- tests/test_options.py | 11 +++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 548ac134b..c27b10306 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -111,6 +111,8 @@ Unreleased completion system. :issue:`942` - Include ``--help`` option in completion. :pr:`1504` - ``ParameterSource`` is an ``enum.Enum`` subclass. :issue:`1530` +- Boolean type strips surrounding space before converting. + :issue:`1605` Version 7.1.2 diff --git a/src/click/types.py b/src/click/types.py index b7161d923..721c556e8 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -497,11 +497,15 @@ class BoolParamType(ParamType): def convert(self, value, param, ctx): if isinstance(value, bool): return bool(value) - value = value.lower() - if value in {"1", "true", "t", "yes", "y", "on"}: + + norm = value.strip().lower() + + if norm in {"1", "true", "t", "yes", "y", "on"}: return True - elif value in {"0", "false", "f", "no", "n", "off"}: + + if norm in {"0", "false", "f", "no", "n", "off"}: return False + self.fail(f"{value!r} is not a valid boolean value.", param, ctx) def __repr__(self): diff --git a/tests/test_options.py b/tests/test_options.py index c57525c12..e86a82a58 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -164,6 +164,17 @@ def cmd(arg): assert result.output == "foo|bar\n" +def test_trailing_blanks_boolean_envvar(runner): + @click.command() + @click.option("--shout/--no-shout", envvar="SHOUT") + def cli(shout): + click.echo(f"shout: {shout!r}") + + result = runner.invoke(cli, [], env={"SHOUT": " true "}) + assert result.exit_code == 0 + assert result.output == "shout: True\n" + + def test_multiple_default_help(runner): @click.command() @click.option("--arg1", multiple=True, default=("foo", "bar"), show_default=True) From 48b4cf5fdefc6181b2d156aa27bf691295556f8e Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 10 Oct 2020 17:51:59 -0700 Subject: [PATCH 152/293] Revert "Add python 3.9 to CI." --- .github/workflows/tests.yaml | 11 +++++------ tox.ini | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0cd5fda2e..4c457e176 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -16,15 +16,14 @@ jobs: fail-fast: false matrix: include: - - {name: Linux, python: '3.9', os: ubuntu-latest, tox: py39} - - {name: Windows, python: '3.9', os: windows-latest, tox: py39} - - {name: Mac, python: '3.9', os: macos-latest, tox: py39} - - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} + - {name: Linux, python: '3.8', os: ubuntu-latest, tox: py38} + - {name: Windows, python: '3.8', os: windows-latest, tox: py38} + - {name: Mac, python: '3.8', os: macos-latest, tox: py38} - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} - {name: 'PyPy', python: pypy3, os: ubuntu-latest, tox: pypy3} - - {name: Style, python: '3.9', os: ubuntu-latest, tox: style} - - {name: Docs, python: '3.9', os: ubuntu-latest, tox: docs} + - {name: Style, python: '3.8', os: ubuntu-latest, tox: style} + - {name: Docs, python: '3.8', os: ubuntu-latest, tox: docs} steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/tox.ini b/tox.ini index 9ec517ba3..9b6d47135 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{39,38,37,36,py3} + py{38,37,36,py3} style docs skip_missing_interpreters = true From 94cc293e1bc6899cead8f8051375969a41658cf3 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 7 Oct 2020 12:31:15 -0700 Subject: [PATCH 153/293] correct interaction of default=tuple with multiple=True --- src/click/core.py | 6 +++++- src/click/types.py | 42 ++++++++++++++++++++++++++++++----------- tests/test_options.py | 44 +++++++++++++++++++++++++++++-------------- 3 files changed, 66 insertions(+), 26 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index fae95db80..0f76156ed 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1988,8 +1988,12 @@ def full_process_value(self, ctx, value): if ( not ctx.resilient_parsing and self.nargs > 1 - and self.nargs != len(value) and isinstance(value, (tuple, list)) + and ( + any(len(v) != self.nargs for v in value) + if self.multiple + else len(value) != self.nargs + ) ): were = "was" if len(value) == 1 else "were" ctx.fail( diff --git a/src/click/types.py b/src/click/types.py index 721c556e8..658f13c18 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -818,37 +818,55 @@ def convert(self, value, param, ctx): def convert_type(ty, default=None): - """Converts a callable or python type into the most appropriate - param type. + """Find the most appropriate :class:`ParamType` for the given Python + type. If the type isn't provided, it can be inferred from a default + value. """ guessed_type = False + if ty is None and default is not None: - if isinstance(default, tuple): - ty = tuple(map(type, default)) + if isinstance(default, (tuple, list)): + # If the default is empty, ty will remain None and will + # return STRING. + if default: + item = default[0] + + # A tuple of tuples needs to detect the inner types. + # Can't call convert recursively because that would + # incorrectly unwind the tuple to a single type. + if isinstance(item, (tuple, list)): + ty = tuple(map(type, item)) + else: + ty = type(item) else: ty = type(default) + guessed_type = True if isinstance(ty, tuple): return Tuple(ty) + if isinstance(ty, ParamType): return ty + if ty is str or ty is None: return STRING + if ty is int: return INT - # Booleans are only okay if not guessed. This is done because for - # flags the default value is actually a bit of a lie in that it - # indicates which of the flags is the one we want. See get_default() - # for more information. - if ty is bool and not guessed_type: - return BOOL + if ty is float: return FLOAT + + # Booleans are only okay if not guessed. For is_flag options with + # flag_value, default=True indicates which flag_value is the + # default. + if ty is bool and not guessed_type: + return BOOL + if guessed_type: return STRING - # Catch a common mistake if __debug__: try: if issubclass(ty, ParamType): @@ -856,7 +874,9 @@ def convert_type(ty, default=None): f"Attempted to use an uninstantiated parameter type ({ty})." ) except TypeError: + # ty is an instance (correct), so issubclass fails. pass + return FuncParamType(ty) diff --git a/tests/test_options.py b/tests/test_options.py index e86a82a58..2e5059455 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -188,21 +188,37 @@ def cmd(arg, arg2): assert "1, 2" in result.output -def test_multiple_default_type(runner): - @click.command() - @click.option("--arg1", multiple=True, default=("foo", "bar")) - @click.option("--arg2", multiple=True, default=(1, "a")) - def cmd(arg1, arg2): - assert all(isinstance(e[0], str) for e in arg1) - assert all(isinstance(e[1], str) for e in arg1) +def test_multiple_default_type(): + opt = click.Option(["-a"], multiple=True, default=(1, 2)) + assert opt.nargs == 1 + assert opt.multiple + assert opt.type is click.INT + ctx = click.Context(click.Command("test")) + assert opt.get_default(ctx) == (1, 2) - assert all(isinstance(e[0], int) for e in arg2) - assert all(isinstance(e[1], str) for e in arg2) - result = runner.invoke( - cmd, "--arg1 a b --arg1 test 1 --arg2 2 two --arg2 4 four".split() - ) - assert not result.exception +def test_multiple_default_composite_type(): + opt = click.Option(["-a"], multiple=True, default=[(1, "a")]) + assert opt.nargs == 2 + assert opt.multiple + assert isinstance(opt.type, click.Tuple) + assert opt.type.types == [click.INT, click.STRING] + ctx = click.Context(click.Command("test")) + assert opt.get_default(ctx) == ((1, "a"),) + + +def test_parse_multiple_default_composite_type(runner): + @click.command() + @click.option("-a", multiple=True, default=("a", "b")) + @click.option("-b", multiple=True, default=[(1, "a")]) + def cmd(a, b): + click.echo(a) + click.echo(b) + + # result = runner.invoke(cmd, "-a c -a 1 -a d -b 2 two -b 4 four".split()) + # assert result.output == "('c', '1', 'd')\n((2, 'two'), (4, 'four'))\n" + result = runner.invoke(cmd) + assert result.output == "('a', 'b')\n((1, 'a'),)\n" def test_dynamic_default_help_unset(runner): @@ -251,7 +267,7 @@ def cmd(username): ], ) def test_intrange_default_help_text(runner, type, expect): - option = click.Option(["--count"], type=type, show_default=True, default=1) + option = click.Option(["--count"], type=type, show_default=True, default=2) context = click.Context(click.Command("test")) result = option.get_help_record(context)[1] assert expect in result From e79c2b47aa5b456e6c70f980ecf0f883447eb340 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 10 Oct 2020 17:37:55 -0700 Subject: [PATCH 154/293] get default before processing value This ensures the default value is processed like other values. Some type converters were adjusted to accept values that are already the correct type. Use parameter source instead of value to determine if argument was supplied on the command line during completion. Add a parameter source for values from prompt. --- CHANGES.rst | 16 ++++++++- src/click/core.py | 61 ++++++++++++++++++---------------- src/click/shell_completion.py | 37 +++++++++++---------- src/click/types.py | 44 +++++++++++++----------- tests/test_basic.py | 24 ++++++------- tests/test_formatting.py | 2 +- tests/test_shell_completion.py | 19 +++++++++-- 7 files changed, 118 insertions(+), 85 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index c27b10306..af2ec1073 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -111,8 +111,22 @@ Unreleased completion system. :issue:`942` - Include ``--help`` option in completion. :pr:`1504` - ``ParameterSource`` is an ``enum.Enum`` subclass. :issue:`1530` -- Boolean type strips surrounding space before converting. +- Boolean and UUID types strip surrounding space before converting. :issue:`1605` +- Adjusted error message from parameter type validation to be more + consistent. Quotes are used to distinguish the invalid value. + :issue:`1605` +- The default value for a parameter with ``nargs`` > 1 and + ``multiple=True`` must be a list of tuples. :issue:`1649` +- When getting the value for a parameter, the default is tried in the + same section as other sources to ensure consistent processing. + :issue:`1649` +- All parameter types accept a value that is already the correct type. + :issue:`1649` +- For shell completion, an argument is considered incomplete if its + value did not come from the command line args. :issue:`1649` +- Added ``ParameterSource.PROMPT`` to track parameter values that were + prompted for. :issue:`1649` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 0f76156ed..ddd99908e 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -152,6 +152,8 @@ class ParameterSource(enum.Enum): """Used the default specified by the parameter.""" DEFAULT_MAP = enum.auto() """Used a default provided by :attr:`Context.default_map`.""" + PROMPT = enum.auto() + """Used a prompt to confirm a default or provide a value.""" class Context: @@ -277,10 +279,6 @@ def __init__( #: Map of parameter names to their parsed values. Parameters #: with ``expose_value=False`` are not stored. self.params = {} - # This tracks the actual param objects that were parsed, even if - # they didn't expose a value. Used by completion system to know - # what parameters to exclude. - self._seen_params = set() #: the leftover arguments. self.args = [] #: protected arguments. These are arguments that are prepended @@ -738,7 +736,7 @@ def get_parameter_source(self, name): :param name: The name of the parameter. :rtype: ParameterSource """ - return self._parameter_source[name] + return self._parameter_source.get(name) class BaseCommand: @@ -1283,7 +1281,11 @@ def shell_complete(self, ctx, incomplete): if ( not isinstance(param, Option) or param.hidden - or (not param.multiple and param in ctx._seen_params) + or ( + not param.multiple + and ctx.get_parameter_source(param.name) + is ParameterSource.COMMANDLINE + ) ): continue @@ -1926,10 +1928,11 @@ def consume_value(self, ctx, opts): value = ctx.lookup_default(self.name) source = ParameterSource.DEFAULT_MAP - if value is not None: - ctx.set_parameter_source(self.name, source) + if value is None: + value = self.get_default(ctx) + source = ParameterSource.DEFAULT - return value + return value, source def type_cast_value(self, ctx, value): """Given a value this runs it properly through the type system. @@ -1975,12 +1978,6 @@ def value_is_missing(self, value): def full_process_value(self, ctx, value): value = self.process_value(ctx, value) - if value is None and not ctx.resilient_parsing: - value = self.get_default(ctx) - - if value is not None: - ctx.set_parameter_source(self.name, ParameterSource.DEFAULT) - if self.required and self.value_is_missing(value): raise MissingParameter(ctx=ctx, param=self) @@ -2025,26 +2022,23 @@ def value_from_envvar(self, ctx): def handle_parse_result(self, ctx, opts, args): with augment_usage_errors(ctx, param=self): - value = self.consume_value(ctx, opts) + value, source = self.consume_value(ctx, opts) + ctx.set_parameter_source(self.name, source) + try: value = self.full_process_value(ctx, value) + + if self.callback is not None: + value = self.callback(ctx, self, value) except Exception: if not ctx.resilient_parsing: raise + value = None - if self.callback is not None: - try: - value = self.callback(ctx, self, value) - except Exception: - if not ctx.resilient_parsing: - raise if self.expose_value: ctx.params[self.name] = value - if value is not None: - ctx._seen_params.add(self) - return value, args def get_help_record(self, ctx): @@ -2423,11 +2417,20 @@ def value_from_envvar(self, ctx): rv = batch(rv, self.nargs) return rv - def full_process_value(self, ctx, value): - if value is None and self.prompt is not None and not ctx.resilient_parsing: - return self.prompt_for_value(ctx) + def consume_value(self, ctx, opts): + value, source = super().consume_value(ctx, opts) + + # The value wasn't set, or used the param's default, prompt if + # prompting is enabled. + if ( + source in {None, ParameterSource.DEFAULT} + and self.prompt is not None + and not ctx.resilient_parsing + ): + value = self.prompt_for_value(ctx) + source = ParameterSource.PROMPT - return super().full_process_value(ctx, value) + return value, source class Argument(Parameter): diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index efefba10d..9b10e25cf 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -4,6 +4,7 @@ from .core import Argument from .core import MultiCommand from .core import Option +from .core import ParameterSource from .parser import split_arg_string from .utils import echo @@ -395,29 +396,27 @@ def get_completion_class(shell): return _available_shells.get(shell) -def _is_incomplete_argument(values, param): +def _is_incomplete_argument(ctx, param): """Determine if the given parameter is an argument that can still accept values. - :param values: Dict of param names and values parsed from the - command line args. + :param ctx: Invocation context for the command represented by the + parsed complete args. :param param: Argument object being checked. """ if not isinstance(param, Argument): return False - value = values[param.name] - - if value is None: - return True - - if param.nargs == -1: - return True - - if isinstance(value, list) and param.nargs > 1 and len(value) < param.nargs: - return True - - return False + value = ctx.params[param.name] + return ( + param.nargs == -1 + or ctx.get_parameter_source(param.name) is not ParameterSource.COMMANDLINE + or ( + param.nargs > 1 + and isinstance(value, (tuple, list)) + and len(value) < param.nargs + ) + ) def _start_of_option(value): @@ -523,16 +522,18 @@ def _resolve_incomplete(ctx, args, incomplete): if "--" not in args and _start_of_option(incomplete): return ctx.command, incomplete + params = ctx.command.get_params(ctx) + # If the last complete arg is an option name with an incomplete # value, the option will provide value completions. - for param in ctx.command.get_params(ctx): + for param in params: if _is_incomplete_option(args, param): return param, incomplete # It's not an option name or value. The first argument without a # parsed value will provide value completions. - for param in ctx.command.get_params(ctx): - if _is_incomplete_argument(ctx.params, param): + for param in params: + if _is_incomplete_argument(ctx, param): return param, incomplete # There were no unparsed arguments, the command may be a group that diff --git a/src/click/types.py b/src/click/types.py index 658f13c18..8c886d6b5 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -181,7 +181,7 @@ def convert(self, value, param, ctx): else: value = value.decode("utf-8", "replace") return value - return value + return str(value) def __repr__(self): return "STRING" @@ -255,11 +255,9 @@ def convert(self, value, param, ctx): if normed_value in normed_choices: return normed_choices[normed_value] - self.fail( - f"invalid choice: {value}. (choose from {', '.join(self.choices)})", - param, - ctx, - ) + one_of = "one of " if len(self.choices) > 1 else "" + choices_str = ", ".join(repr(c) for c in self.choices) + self.fail(f"{value!r} is not {one_of}{choices_str}.", param, ctx) def __repr__(self): return f"Choice({list(self.choices)})" @@ -320,14 +318,19 @@ def _try_to_convert_date(self, value, format): return None def convert(self, value, param, ctx): - # Exact match + if isinstance(value, datetime): + return value + for format in self.formats: - dtime = self._try_to_convert_date(value, format) - if dtime: - return dtime + converted = self._try_to_convert_date(value, format) + if converted is not None: + return converted + + plural = "s" if len(self.formats) > 1 else "" + formats_str = ", ".join(repr(f) for f in self.formats) self.fail( - f"invalid datetime format: {value}. (choose from {', '.join(self.formats)})" + f"{value!r} does not match the format{plural} {formats_str}.", param, ctx ) def __repr__(self): @@ -341,7 +344,7 @@ def convert(self, value, param, ctx): try: return self._number_class(value) except ValueError: - self.fail(f"{value} is not a valid {self.name}", param, ctx) + self.fail(f"{value!r} is not a valid {self.name}.", param, ctx) class _NumberRangeBase(_NumberParamTypeBase): @@ -495,7 +498,7 @@ class BoolParamType(ParamType): name = "boolean" def convert(self, value, param, ctx): - if isinstance(value, bool): + if value in {False, True}: return bool(value) norm = value.strip().lower() @@ -506,7 +509,7 @@ def convert(self, value, param, ctx): if norm in {"0", "false", "f", "no", "n", "off"}: return False - self.fail(f"{value!r} is not a valid boolean value.", param, ctx) + self.fail(f"{value!r} is not a valid boolean.", param, ctx) def __repr__(self): return "BOOL" @@ -518,10 +521,15 @@ class UUIDParameterType(ParamType): def convert(self, value, param, ctx): import uuid + if isinstance(value, uuid.UUID): + return value + + value = value.strip() + try: return uuid.UUID(value) except ValueError: - self.fail(f"{value} is not a valid UUID value", param, ctx) + self.fail(f"{value!r} is not a valid UUID.", param, ctx) def __repr__(self): return "UUID" @@ -610,11 +618,7 @@ def convert(self, value, param, ctx): ctx.call_on_close(safecall(f.flush)) return f except OSError as e: # noqa: B014 - self.fail( - f"Could not open file: {filename_to_ui(value)}: {get_strerror(e)}", - param, - ctx, - ) + self.fail(f"{filename_to_ui(value)!r}: {get_strerror(e)}", param, ctx) def shell_complete(self, ctx, param, incomplete): """Return a special completion marker that tells the completion diff --git a/tests/test_basic.py b/tests/test_basic.py index cc0d0f245..de9b0acea 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -147,7 +147,7 @@ def cli(foo): result = runner.invoke(cli, ["--foo=bar"]) assert result.exception - assert "Invalid value for '--foo': bar is not a valid integer" in result.output + assert "Invalid value for '--foo': 'bar' is not a valid integer." in result.output def test_uuid_option(runner): @@ -169,7 +169,7 @@ def cli(u): result = runner.invoke(cli, ["--u=bar"]) assert result.exception - assert "Invalid value for '--u': bar is not a valid UUID value" in result.output + assert "Invalid value for '--u': 'bar' is not a valid UUID." in result.output def test_float_option(runner): @@ -189,7 +189,7 @@ def cli(foo): result = runner.invoke(cli, ["--foo=bar"]) assert result.exception - assert "Invalid value for '--foo': bar is not a valid float" in result.output + assert "Invalid value for '--foo': 'bar' is not a valid float." in result.output def test_boolean_option(runner): @@ -303,10 +303,7 @@ def input_non_lazy(file): os.mkdir("example.txt") result_in = runner.invoke(input_non_lazy, ["--file=example.txt"]) assert result_in.exit_code == 2 - assert ( - "Invalid value for '--file': Could not open file: example.txt" - in result_in.output - ) + assert "Invalid value for '--file': 'example.txt'" in result_in.output def test_path_option(runner): @@ -368,8 +365,8 @@ def cli(method): result = runner.invoke(cli, ["--method=meh"]) assert result.exit_code == 2 assert ( - "Invalid value for '--method': invalid choice: meh." - " (choose from foo, bar, baz)" in result.output + "Invalid value for '--method': 'meh' is not one of 'foo', 'bar', 'baz'." + in result.output ) result = runner.invoke(cli, ["--help"]) @@ -389,8 +386,8 @@ def cli(method): result = runner.invoke(cli, ["meh"]) assert result.exit_code == 2 assert ( - "Invalid value for '{foo|bar|baz}': invalid choice: meh. " - "(choose from foo, bar, baz)" in result.output + "Invalid value for '{foo|bar|baz}': 'meh' is not one of 'foo'," + " 'bar', 'baz'." in result.output ) result = runner.invoke(cli, ["--help"]) @@ -414,9 +411,8 @@ def cli(start_date): result = runner.invoke(cli, ["--start_date=2015-09"]) assert result.exit_code == 2 assert ( - "Invalid value for '--start_date':" - " invalid datetime format: 2015-09." - " (choose from %Y-%m-%d, %Y-%m-%dT%H:%M:%S, %Y-%m-%d %H:%M:%S)" + "Invalid value for '--start_date': '2015-09' does not match the formats" + " '%Y-%m-%d', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S'." ) in result.output result = runner.invoke(cli, ["--help"]) diff --git a/tests/test_formatting.py b/tests/test_formatting.py index b2f72b7d2..407f0aa09 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -195,7 +195,7 @@ def cmd(arg): "Usage: cmd [OPTIONS] metavar", "Try 'cmd --help' for help.", "", - "Error: Invalid value for 'metavar': 3.14 is not a valid integer", + "Error: Invalid value for 'metavar': '3.14' is not a valid integer.", ] diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 5c169a907..e85dc322f 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -90,6 +90,21 @@ def test_argument_order(): assert _get_words(cli, ["x", "b"], "d") == ["d"] +def test_argument_default(): + cli = Command( + "cli", + add_help_option=False, + params=[ + Argument(["a"], type=Choice(["a"]), default="a"), + Argument(["b"], type=Choice(["b"]), default="b"), + ], + ) + assert _get_words(cli, [], "") == ["a"] + assert _get_words(cli, ["a"], "b") == ["b"] + # ignore type validation + assert _get_words(cli, ["x"], "b") == ["b"] + + def test_type_choice(): cli = Command("cli", params=[Option(["-c"], type=Choice(["a1", "a2", "b"]))]) assert _get_words(cli, ["-c"], "") == ["a1", "a2", "b"] @@ -119,9 +134,9 @@ def test_option_flag(): Argument(["a"], type=Choice(["a1", "a2", "b"])), ], ) - assert _get_words(cli, ["type"], "--") == ["--on", "--off"] + assert _get_words(cli, [], "--") == ["--on", "--off"] # flag option doesn't take value, use choice argument - assert _get_words(cli, ["x", "--on"], "a") == ["a1", "a2"] + assert _get_words(cli, ["--on"], "a") == ["a1", "a2"] def test_option_custom(): From 25a88794c10bb35e62802171af6346f9791da0f7 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 11 Oct 2020 16:50:56 -0700 Subject: [PATCH 155/293] handle default=None for nargs and multiple If a default isn't given, return () for multiple or nargs=-1, or return None for nargs > 1. --- CHANGES.rst | 4 ++++ src/click/core.py | 28 +++++++++++++++++++++++++--- tests/test_arguments.py | 27 +++++++++++++++------------ tests/test_types.py | 24 ++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index af2ec1073..486694348 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -127,6 +127,10 @@ Unreleased value did not come from the command line args. :issue:`1649` - Added ``ParameterSource.PROMPT`` to track parameter values that were prompted for. :issue:`1649` +- Options with ``nargs`` > 1 no longer raise an error if a default is + not given. Parameters with ``nargs`` > 1 default to ``None``, and + parameters with ``multiple=True`` or ``nargs=-1`` default to an + empty tuple. :issue:`472` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index ddd99908e..39f99fb78 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -142,6 +142,9 @@ class ParameterSource(enum.Enum): .. versionchanged:: 8.0 Use :class:`~enum.Enum` and drop the ``validate`` method. + + .. versionchanged:: 8.0 + Added the ``PROMPT`` value. """ COMMANDLINE = enum.auto() @@ -735,6 +738,10 @@ def get_parameter_source(self, name): :param name: The name of the parameter. :rtype: ParameterSource + + .. versionchanged:: 8.0 + Returns ``None`` if the parameter was not provided from any + source. """ return self._parameter_source.get(name) @@ -1781,6 +1788,15 @@ class Parameter: be removed in 8.1, until then it will be wrapped to match the new requirements. + .. versionchanged:: 8.0 + For ``multiple=True, nargs>1``, the default must be a list of + tuples. + + .. versionchanged:: 8.0 + Setting a default is no longer required for ``nargs>1``, it will + default to ``None``. ``multiple=True`` or ``nargs=-1`` will + default to ``()``. + .. versionchanged:: 7.1 Empty environment variables are ignored rather than taking the empty string value. This makes it possible for scripts to clear @@ -1939,6 +1955,9 @@ def type_cast_value(self, ctx, value): This automatically handles things like `nargs` and `multiple` as well as composite types. """ + if value is None: + return () if self.multiple or self.nargs == -1 else None + if self.type.is_composite: if self.nargs <= 1: raise TypeError( @@ -1946,14 +1965,17 @@ def type_cast_value(self, ctx, value): f" been set to {self.nargs}. This is not supported;" " nargs needs to be set to a fixed value > 1." ) + if self.multiple: - return tuple(self.type(x or (), self, ctx) for x in value or ()) - return self.type(value or (), self, ctx) + return tuple(self.type(x, self, ctx) for x in value) + + return self.type(value, self, ctx) def _convert(value, level): if level == 0: return self.type(value, self, ctx) - return tuple(_convert(x, level - 1) for x in value or ()) + + return tuple(_convert(x, level - 1) for x in value) return _convert(value, (self.nargs != 1) + bool(self.multiple)) diff --git a/tests/test_arguments.py b/tests/test_arguments.py index ca2e406de..5851d2253 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -164,17 +164,17 @@ def inout(output): @pytest.mark.parametrize( - ("nargs", "value", "code", "output"), + ("nargs", "value", "expect"), [ - (2, "", 2, "Argument 'arg' takes 2 values but 0 were given."), - (2, "a", 2, "Argument 'arg' takes 2 values but 1 was given."), - (2, "a b", 0, "len 2"), - (2, "a b c", 2, "Argument 'arg' takes 2 values but 3 were given."), - (-1, "a b c", 0, "len 3"), - (-1, "", 0, "len 0"), + (2, "", None), + (2, "a", "Argument 'arg' takes 2 values but 1 was given."), + (2, "a b", ("a", "b")), + (2, "a b c", "Argument 'arg' takes 2 values but 3 were given."), + (-1, "a b c", ("a", "b", "c")), + (-1, "", ()), ], ) -def test_nargs_envvar(runner, nargs, value, code, output): +def test_nargs_envvar(runner, nargs, value, expect): if nargs == -1: param = click.argument("arg", envvar="X", nargs=nargs) else: @@ -183,11 +183,14 @@ def test_nargs_envvar(runner, nargs, value, code, output): @click.command() @param def cmd(arg): - click.echo(f"len {len(arg)}") + return arg - result = runner.invoke(cmd, env={"X": value}) - assert result.exit_code == code - assert output in result.output + result = runner.invoke(cmd, env={"X": value}, standalone_mode=False) + + if isinstance(expect, str): + assert expect in str(result.exception) + else: + assert result.return_value == expect def test_empty_nargs(runner): diff --git a/tests/test_types.py b/tests/test_types.py index b76aa805c..4e995f123 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -54,3 +54,27 @@ def test_float_range_no_clamp_open(): with pytest.raises(RuntimeError): sneaky.convert("1.5", None, None) + + +@pytest.mark.parametrize( + ("nargs", "multiple", "default", "expect"), + [ + (2, False, None, None), + (2, False, (None, None), (None, None)), + (None, True, None, ()), + (None, True, (None, None), (None, None)), + (2, True, None, ()), + (2, True, [(None, None)], ((None, None),)), + (-1, None, None, ()), + ], +) +def test_cast_multi_default(runner, nargs, multiple, default, expect): + if nargs == -1: + param = click.Argument(["a"], nargs=nargs, default=default) + else: + param = click.Option(["-a"], nargs=nargs, multiple=multiple, default=default) + + cli = click.Command("cli", params=[param], callback=lambda a: a) + result = runner.invoke(cli, standalone_mode=False) + assert result.exception is None + assert result.return_value == expect From ffe7a3bc80c15c2ceaa301784eac5c1bff3b3614 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 11 Oct 2020 17:23:59 -0700 Subject: [PATCH 156/293] ignore empty env vars consistently --- CHANGES.rst | 3 +++ src/click/core.py | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 486694348..95d16b792 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -131,6 +131,9 @@ Unreleased not given. Parameters with ``nargs`` > 1 default to ``None``, and parameters with ``multiple=True`` or ``nargs=-1`` default to an empty tuple. :issue:`472` +- Handle empty env vars as though the option were not passed. This + extends the change introduced in 7.1 to be consistent in more cases. + :issue:`1285` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 39f99fb78..53b175b1d 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2025,21 +2025,25 @@ def full_process_value(self, ctx, value): def resolve_envvar_value(self, ctx): if self.envvar is None: return + if isinstance(self.envvar, (tuple, list)): for envvar in self.envvar: rv = os.environ.get(envvar) - if rv is not None: + + if rv: return rv else: rv = os.environ.get(self.envvar) - if rv != "": + if rv: return rv def value_from_envvar(self, ctx): rv = self.resolve_envvar_value(ctx) + if rv is not None and self.nargs != 1: rv = self.type.split_envvar_value(rv) + return rv def handle_parse_result(self, ctx, opts, args): @@ -2424,19 +2428,28 @@ def resolve_envvar_value(self, ctx): if rv is not None: return rv + if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" - return os.environ.get(envvar) + rv = os.environ.get(envvar) + + if rv: + return rv def value_from_envvar(self, ctx): rv = self.resolve_envvar_value(ctx) + if rv is None: return None + value_depth = (self.nargs != 1) + bool(self.multiple) + if value_depth > 0 and rv is not None: rv = self.type.split_envvar_value(rv) + if self.multiple and self.nargs != 1: rv = batch(rv, self.nargs) + return rv def consume_value(self, ctx, opts): From 54102a439da9da4a2c84cd893b12020865b9afd3 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Sun, 10 May 2020 00:53:26 +0200 Subject: [PATCH 157/293] show_default uses default_map --- CHANGES.rst | 4 +++- src/click/core.py | 12 +++++++----- tests/test_options.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 95d16b792..a8cd03518 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -134,6 +134,8 @@ Unreleased - Handle empty env vars as though the option were not passed. This extends the change introduced in 7.1 to be consistent in more cases. :issue:`1285` +- ``show_default`` considers ``default_map`` to display the + proper default value. :issue:`1548` Version 7.1.2 @@ -182,7 +184,7 @@ Released 2020-03-09 :issue:`1277`, :pr:`1318` - Add ``no_args_is_help`` option to ``click.Command``, defaults to False :pr:`1167` -- Add ``show_defaults`` parameter to ``Context`` to enable showing +- Add ``show_default`` parameter to ``Context`` to enable showing defaults globally. :issue:`1018` - Handle ``env MYPATH=''`` as though the option were not passed. :issue:`1196` diff --git a/src/click/core.py b/src/click/core.py index 53b175b1d..cb15f2cc9 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2355,12 +2355,14 @@ def _write_opts(opts): else envvar ) extra.append(f"env var: {var_str}") - if self.default is not None and (self.show_default or ctx.show_default): + + default_value = ctx.lookup_default(self.name) or self.default + if default_value is not None and (self.show_default or ctx.show_default): if isinstance(self.show_default, str): default_string = f"({self.show_default})" - elif isinstance(self.default, (list, tuple)): - default_string = ", ".join(str(d) for d in self.default) - elif inspect.isfunction(self.default): + elif isinstance(default_value, (list, tuple)): + default_string = ", ".join(str(d) for d in default_value) + elif inspect.isfunction(default_value): default_string = "(dynamic)" elif self.is_bool_flag and self.secondary_opts: # For boolean flags that have distinct True/False opts, @@ -2369,7 +2371,7 @@ def _write_opts(opts): (self.opts if self.default else self.secondary_opts)[0] )[1] else: - default_string = self.default + default_string = default_value extra.append(f"default: {default_string}") if isinstance(self.type, _NumberRangeBase): diff --git a/tests/test_options.py b/tests/test_options.py index 2e5059455..eea68c1e9 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -188,6 +188,18 @@ def cmd(arg, arg2): assert "1, 2" in result.output +def test_show_default_default_map(runner): + @click.command() + @click.option("--arg", default="a", show_default=True) + def cmd(arg): + click.echo(arg) + + result = runner.invoke(cmd, ["--help"], default_map={"arg": "b"}) + + assert not result.exception + assert "[default: b]" in result.output + + def test_multiple_default_type(): opt = click.Option(["-a"], multiple=True, default=(1, 2)) assert opt.nargs == 1 From 78a62b37c956e71b32689a0d5c75a684aa9ab56d Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 11 Oct 2020 19:12:45 -0700 Subject: [PATCH 158/293] check default_map in param.get_default Applies overrides consistently for defaults, help text, prompts, and invoke(). --- CHANGES.rst | 5 ++-- src/click/core.py | 69 +++++++++++++++++++++++++++++++++-------------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a8cd03518..04bf4c2f0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -134,8 +134,9 @@ Unreleased - Handle empty env vars as though the option were not passed. This extends the change introduced in 7.1 to be consistent in more cases. :issue:`1285` -- ``show_default`` considers ``default_map`` to display the - proper default value. :issue:`1548` +- ``Parameter.get_default()`` checks ``Context.default_map`` to + handle overrides consistently in help text, ``invoke()``, and + prompts. :issue:`1548` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index cb15f2cc9..b62a59194 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1,6 +1,5 @@ import enum import errno -import inspect import os import sys from contextlib import contextmanager @@ -613,15 +612,23 @@ def ensure_object(self, object_type): self.obj = rv = object_type() return rv - def lookup_default(self, name): - """Looks up the default for a parameter name. This by default - looks into the :attr:`default_map` if available. + def lookup_default(self, name, call=True): + """Get the default for a parameter from :attr:`default_map`. + + :param name: Name of the parameter. + :param call: If the default is a callable, call it. Disable to + return the callable instead. + + .. versionchanged:: 8.0 + Added the ``call`` parameter. """ if self.default_map is not None: - rv = self.default_map.get(name) - if callable(rv): - rv = rv() - return rv + value = self.default_map.get(name) + + if call and callable(value): + return value() + + return value def fail(self, message): """Aborts the execution of the program with a specific error @@ -1920,14 +1927,33 @@ def make_metavar(self): metavar += "..." return metavar - def get_default(self, ctx): - """Given a context variable this calculates the default value.""" - # Otherwise go with the regular default. - if callable(self.default): - rv = self.default() - else: - rv = self.default - return self.type_cast_value(ctx, rv) + def get_default(self, ctx, call=True): + """Get the default for the parameter. Tries + :meth:`Context.lookup_value` first, then the local default. + + :param ctx: Current context. + :param call: If the default is a callable, call it. Disable to + return the callable instead. + + .. versionchanged:: 8.0 + Looks at ``ctx.default_map`` first. + + .. versionchanged:: 8.0 + Added the ``call`` parameter. + """ + value = ctx.lookup_default(self.name, call=False) + + if value is None: + value = self.default + + if callable(value): + if not call: + # Don't type cast the callable. + return value + + value = value() + + return self.type_cast_value(ctx, value) def add_to_parser(self, parser, ctx): pass @@ -2356,13 +2382,14 @@ def _write_opts(opts): ) extra.append(f"env var: {var_str}") - default_value = ctx.lookup_default(self.name) or self.default + default_value = self.get_default(ctx, call=False) + if default_value is not None and (self.show_default or ctx.show_default): if isinstance(self.show_default, str): default_string = f"({self.show_default})" elif isinstance(default_value, (list, tuple)): default_string = ", ".join(str(d) for d in default_value) - elif inspect.isfunction(default_value): + elif callable(default_value): default_string = "(dynamic)" elif self.is_bool_flag and self.secondary_opts: # For boolean flags that have distinct True/False opts, @@ -2372,6 +2399,7 @@ def _write_opts(opts): )[1] else: default_string = default_value + extra.append(f"default: {default_string}") if isinstance(self.type, _NumberRangeBase): @@ -2388,7 +2416,7 @@ def _write_opts(opts): return ("; " if any_prefix_is_slash else " / ").join(rv), help - def get_default(self, ctx): + def get_default(self, ctx, call=True): # If we're a non boolean flag our default is more complex because # we need to look at all flags in the same group to figure out # if we're the the default one in which case we return the flag @@ -2397,9 +2425,10 @@ def get_default(self, ctx): for param in ctx.command.params: if param.name == self.name and param.default: return param.flag_value + return None - return super().get_default(ctx) + return super().get_default(ctx, call=call) def prompt_for_value(self, ctx): """This is an alternative flow that can be activated in the full From d314f45b8e9e59e0e82604868de41624ec8d13ed Mon Sep 17 00:00:00 2001 From: Amy Date: Wed, 1 Jul 2020 13:33:59 -0400 Subject: [PATCH 159/293] add ability to provide non-flag option without value use flag_value or prompt if an option is given without a value --- CHANGES.rst | 7 +++++ docs/options.rst | 51 ++++++++++++++++++++++++++++++++++++ src/click/core.py | 38 +++++++++++++++++++++++++-- src/click/parser.py | 61 +++++++++++++++++++++++++++---------------- tests/test_options.py | 31 ++++++++++++++++++++++ tests/test_termui.py | 51 ++++++++++++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 24 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 04bf4c2f0..4b70796a2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -137,6 +137,13 @@ Unreleased - ``Parameter.get_default()`` checks ``Context.default_map`` to handle overrides consistently in help text, ``invoke()``, and prompts. :issue:`1548` +- Add ``prompt_required`` param to ``Option``. When set to ``False``, + the user will only be prompted for an input if no value was passed. + :issue:`736` +- Providing the value to an option can be made optional through + ``is_flag=False``, and the value can instead be prompted for or + passed in as a default value. + :issue:`549, 736, 764, 921, 1015, 1618` Version 7.1.2 diff --git a/docs/options.rst b/docs/options.rst index f45312559..ccd8bee4a 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -433,6 +433,10 @@ What it looks like: It is advised that prompt not be used in conjunction with the multiple flag set to True. Instead, prompt in the function interactively. +By default, the user will be prompted for an input if one was not passed +through the command line. To turn this behavior off, see +:ref:`optional-value`. + Password Prompts ---------------- @@ -845,3 +849,50 @@ And what it looks like: invoke(roll, args=['--rolls=42']) println() invoke(roll, args=['--rolls=2d12']) + + +.. _optional-value: + +Optional Value +-------------- + +Providing the value to an option can be made optional, in which case +providing only the option's flag without a value will either show a +prompt or use its ``flag_value``. + +Setting ``is_flag=False, flag_value=value`` tells Click that the option +can still be passed a value, but if only the flag is given the +``flag_value`` is used. + +.. click:example:: + + @click.command() + @click.option("--name", is_flag=False, flag_value="Flag", default="Default") + def hello(name): + click.echo(f"Hello, {name}!") + +.. click:run:: + + invoke(hello, args=[]) + invoke(hello, args=["--name", "Value"]) + invoke(hello, args=["--name"]) + +If the option has ``prompt`` enabled, then setting +``prompt_required=False`` tells Click to only show the prompt if the +option's flag is given, instead of if the option is not provided at all. + +.. click:example:: + + @click.command() + @click.option('--name', prompt=True, prompt_required=False, default="Default") + def hello(name): + click.echo(f"Hello {name}!") + +.. click:run:: + + invoke(hello) + invoke(hello, args=["--name", "Value"]) + invoke(hello, args=["--name"], input="Prompt") + +If ``required=True``, then the option will still prompt if it is not +given, but it will also prompt if only the flag is given. diff --git a/src/click/core.py b/src/click/core.py index b62a59194..403a2826a 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -18,6 +18,7 @@ from .formatting import join_options from .globals import pop_context from .globals import push_context +from .parser import _flag_needs_value from .parser import OptionParser from .parser import split_opt from .termui import confirm @@ -2148,6 +2149,9 @@ class Option(Parameter): option name capitalized. :param confirmation_prompt: if set then the value will need to be confirmed if it was prompted for. + :param prompt_required: If set to ``False``, the user will be + prompted for input only when the option was specified as a flag + without a value. :param hide_input: if this is `True` then the input on the prompt will be hidden from the user. This is useful for password input. @@ -2177,6 +2181,7 @@ def __init__( show_default=False, prompt=False, confirmation_prompt=False, + prompt_required=True, hide_input=False, is_flag=None, flag_value=None, @@ -2201,21 +2206,38 @@ def __init__( prompt_text = prompt self.prompt = prompt_text self.confirmation_prompt = confirmation_prompt + self.prompt_required = prompt_required self.hide_input = hide_input self.hidden = hidden - # Flags + # If prompt is enabled but not required, then the option can be + # used as a flag to indicate using prompt or flag_value. + self._flag_needs_value = self.prompt is not None and not self.prompt_required + if is_flag is None: if flag_value is not None: + # Implicitly a flag because flag_value was set. is_flag = True + elif self._flag_needs_value: + # Not a flag, but when used as a flag it shows a prompt. + is_flag = False else: + # Implicitly a flag because flag options were given. is_flag = bool(self.secondary_opts) + elif is_flag is False and not self._flag_needs_value: + # Not a flag, and prompt is not enabled, can be used as a + # flag if flag_value is set. + self._flag_needs_value = flag_value is not None + if is_flag and default_is_missing: self.default = False + if flag_value is None: flag_value = not self.default + self.is_flag = is_flag self.flag_value = flag_value + if self.is_flag and isinstance(self.flag_value, bool) and type in [None, bool]: self.type = BOOL self.is_bool_flag = True @@ -2486,11 +2508,23 @@ def value_from_envvar(self, ctx): def consume_value(self, ctx, opts): value, source = super().consume_value(ctx, opts) + # The parser will emit a sentinel value if the option can be + # given as a flag without a value. This is different from None + # to distinguish from the flag not being given at all. + if value is _flag_needs_value: + if self.prompt is not None and not ctx.resilient_parsing: + value = self.prompt_for_value(ctx) + source = ParameterSource.PROMPT + else: + value = self.flag_value + source = ParameterSource.COMMANDLINE + # The value wasn't set, or used the param's default, prompt if # prompting is enabled. - if ( + elif ( source in {None, ParameterSource.DEFAULT} and self.prompt is not None + and (self.required or self.prompt_required) and not ctx.resilient_parsing ): value = self.prompt_for_value(ctx) diff --git a/src/click/parser.py b/src/click/parser.py index f6139477c..92c935460 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -29,6 +29,11 @@ from .exceptions import NoSuchOption from .exceptions import UsageError +# Sentinel value that indicates an option was passed as a flag without a +# value but is not a flag option. Option.consume_value uses this to +# prompt or use the flag_value. +_flag_needs_value = object() + def _unpack_args(args, nargs_spec): """Given an iterable of arguments and an iterable of nargs specifications, @@ -81,12 +86,6 @@ def _fetch(c): return tuple(rv), list(args) -def _error_opt_args(nargs, opt): - if nargs == 1: - raise BadOptionUsage(opt, f"{opt} option requires an argument") - raise BadOptionUsage(opt, f"{opt} option requires {nargs} arguments") - - def split_opt(opt): first = opt[:1] if first.isalnum(): @@ -343,14 +342,7 @@ def _match_long_opt(self, opt, explicit_value, state): if explicit_value is not None: state.rargs.insert(0, explicit_value) - nargs = option.nargs - if len(state.rargs) < nargs: - _error_opt_args(nargs, opt) - elif nargs == 1: - value = state.rargs.pop(0) - else: - value = tuple(state.rargs[:nargs]) - del state.rargs[:nargs] + value = self._get_value_from_state(opt, option, state) elif explicit_value is not None: raise BadOptionUsage(opt, f"{opt} option does not take a value") @@ -383,14 +375,7 @@ def _match_short_opt(self, arg, state): state.rargs.insert(0, arg[i:]) stop = True - nargs = option.nargs - if len(state.rargs) < nargs: - _error_opt_args(nargs, opt) - elif nargs == 1: - value = state.rargs.pop(0) - else: - value = tuple(state.rargs[:nargs]) - del state.rargs[:nargs] + value = self._get_value_from_state(opt, option, state) else: value = None @@ -407,6 +392,38 @@ def _match_short_opt(self, arg, state): if self.ignore_unknown_options and unknown_options: state.largs.append(f"{prefix}{''.join(unknown_options)}") + def _get_value_from_state(self, option_name, option, state): + nargs = option.nargs + + if len(state.rargs) < nargs: + if option.obj._flag_needs_value: + # Option allows omitting the value. + value = _flag_needs_value + else: + n_str = "an argument" if nargs == 1 else f"{nargs} arguments" + raise BadOptionUsage( + option_name, f"{option_name} option requires {n_str}." + ) + elif nargs == 1: + next_rarg = state.rargs[0] + + if ( + option.obj._flag_needs_value + and isinstance(next_rarg, str) + and next_rarg[:1] in self._opt_prefixes + and len(next_rarg) > 1 + ): + # The next arg looks like the start of an option, don't + # use it as the value if omitting the value is allowed. + value = _flag_needs_value + else: + value = state.rargs.pop(0) + else: + value = tuple(state.rargs[:nargs]) + del state.rargs[:nargs] + + return value + def _process_opts(self, arg, state): explicit_value = None # Long option handling happens in two parts. The first part is diff --git a/tests/test_options.py b/tests/test_options.py index eea68c1e9..f26761659 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -658,3 +658,34 @@ def test_show_default_boolean_flag_value(runner): ctx = click.Context(click.Command("test")) message = opt.get_help_record(ctx)[1] assert "[default: False]" in message + + +@pytest.mark.parametrize( + ("args", "expect"), + [ + (None, (None, None, ())), + (["--opt"], ("flag", None, ())), + (["--opt", "-a", 42], ("flag", "42", ())), + (["--opt", "test", "-a", 42], ("test", "42", ())), + (["--opt=test", "-a", 42], ("test", "42", ())), + (["-o"], ("flag", None, ())), + (["-o", "-a", 42], ("flag", "42", ())), + (["-o", "test", "-a", 42], ("test", "42", ())), + (["-otest", "-a", 42], ("test", "42", ())), + (["a", "b", "c"], (None, None, ("a", "b", "c"))), + (["--opt", "a", "b", "c"], ("a", None, ("b", "c"))), + (["--opt", "test"], ("test", None, ())), + (["-otest", "a", "b", "c"], ("test", None, ("a", "b", "c"))), + (["--opt=test", "a", "b", "c"], ("test", None, ("a", "b", "c"))), + ], +) +def test_option_with_optional_value(runner, args, expect): + @click.command() + @click.option("-o", "--opt", is_flag=False, flag_value="flag") + @click.option("-a") + @click.argument("b", nargs=-1) + def cli(opt, a, b): + return opt, a, b + + result = runner.invoke(cli, args, standalone_mode=False, catch_exceptions=False) + assert result.return_value == expect diff --git a/tests/test_termui.py b/tests/test_termui.py index 90a4ce60c..e199516d3 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -385,3 +385,54 @@ def test_getchar_windows_exceptions(runner, monkeypatch, key_char, exc): def test_fast_edit(runner): result = click.edit("a\nb", editor="sed -i~ 's/$/Test/'") assert result == "aTest\nbTest\n" + + +@pytest.mark.parametrize( + ("prompt_required", "required", "args", "expect"), + [ + (True, False, None, "prompt"), + (True, False, ["-v"], "-v option requires an argument"), + (False, True, None, "prompt"), + (False, True, ["-v"], "prompt"), + ], +) +def test_prompt_required_with_required(runner, prompt_required, required, args, expect): + @click.command() + @click.option("-v", prompt=True, prompt_required=prompt_required, required=required) + def cli(v): + click.echo(str(v)) + + result = runner.invoke(cli, args, input="prompt") + assert expect in result.output + + +@pytest.mark.parametrize( + ("args", "expect"), + [ + # Flag not passed, don't prompt. + pytest.param(None, None, id="no flag"), + # Flag and value passed, don't prompt. + pytest.param(["-v", "value"], "value", id="short sep value"), + pytest.param(["--value", "value"], "value", id="long sep value"), + pytest.param(["-vvalue"], "value", id="short join value"), + pytest.param(["--value=value"], "value", id="long join value"), + # Flag without value passed, prompt. + pytest.param(["-v"], "prompt", id="short no value"), + pytest.param(["--value"], "prompt", id="long no value"), + # Don't use next option flag as value. + pytest.param(["-v", "-o", "42"], ("prompt", "42"), id="no value opt"), + ], +) +def test_prompt_required_false(runner, args, expect): + @click.command() + @click.option("-v", "--value", prompt=True, prompt_required=False) + @click.option("-o") + def cli(value, o): + if o is not None: + return value, o + + return value + + result = runner.invoke(cli, args=args, input="prompt", standalone_mode=False) + assert result.exception is None + assert result.return_value == expect From 42ee8a1f6011e0f4a8a19c34a545f3eb7ae978fa Mon Sep 17 00:00:00 2001 From: rarnal Date: Sun, 10 May 2020 01:31:30 +0200 Subject: [PATCH 160/293] fix formatting with empty options_metavar --- CHANGES.rst | 2 +- src/click/core.py | 2 +- tests/test_formatting.py | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4b70796a2..03d1cf941 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -144,7 +144,7 @@ Unreleased ``is_flag=False``, and the value can instead be prompted for or passed in as a default value. :issue:`549, 736, 764, 921, 1015, 1618` - +- Fix formatting when ``Command.options_metavar`` is empty. :pr:`1551` Version 7.1.2 ------------- diff --git a/src/click/core.py b/src/click/core.py index 403a2826a..227025dc0 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1139,7 +1139,7 @@ def collect_usage_pieces(self, ctx): """Returns all the pieces that go into the usage line and returns it as a list of strings. """ - rv = [self.options_metavar] + rv = [self.options_metavar] if self.options_metavar else [] for param in self.get_params(ctx): rv.extend(param.get_usage_pieces(ctx)) return rv diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 407f0aa09..f292c1671 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -350,3 +350,9 @@ def cli(): " --bar TEXT This help message will be padded if it wraps.", " --help Show this message and exit.", ] + + +def test_formatting_with_options_metavar_empty(runner): + cli = click.Command("cli", options_metavar="", params=[click.Argument(["var"])]) + result = runner.invoke(cli, ["--help"]) + assert "Usage: cli VAR\n" in result.output From 6472e3ee92f9f091ac0e22a870f6ece1a7d272b0 Mon Sep 17 00:00:00 2001 From: Steve Graham Date: Mon, 30 Mar 2020 11:21:24 -0700 Subject: [PATCH 161/293] ensure prompt default is cast to type --- CHANGES.rst | 3 +++ src/click/termui.py | 8 ++------ tests/test_utils.py | 7 +++++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 03d1cf941..c11d9108d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -145,6 +145,9 @@ Unreleased passed in as a default value. :issue:`549, 736, 764, 921, 1015, 1618` - Fix formatting when ``Command.options_metavar`` is empty. :pr:`1551` +- The default value passed to ``prompt`` will be cast to the correct + type like an input value would be. :pr:`1517` + Version 7.1.2 ------------- diff --git a/src/click/termui.py b/src/click/termui.py index 1eb9ed804..84442ee8d 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -16,7 +16,6 @@ from .globals import resolve_color_default from .types import Choice from .types import convert_type -from .types import Path from .utils import echo from .utils import LazyFile @@ -146,11 +145,8 @@ def prompt_func(text): if value: break elif default is not None: - if isinstance(value_proc, Path): - # validate Path default value(exists, dir_okay etc.) - value = default - break - return default + value = default + break try: result = value_proc(value) except UsageError as e: diff --git a/tests/test_utils.py b/tests/test_utils.py index a58890449..a5dfbb8b9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -210,6 +210,13 @@ def test_echo_color_flag(monkeypatch, capfd): assert out == f"{text}\n" +def test_prompt_cast_default(capfd, monkeypatch): + monkeypatch.setattr(sys, "stdin", StringIO("\n")) + value = click.prompt("value", default="100", type=int) + capfd.readouterr() + assert type(value) is int + + @pytest.mark.skipif(WIN, reason="Test too complex to make work windows.") def test_echo_writing_to_standard_error(capfd, monkeypatch): def emulate_input(text): From 297150c7566a38fb672828a36b681792513d99b0 Mon Sep 17 00:00:00 2001 From: lrjball <50599110+lrjball@users.noreply.github.com> Date: Sat, 24 Oct 2020 02:40:44 +0100 Subject: [PATCH 162/293] Added examples of default to boolean flags section (#1697) I've found myself a few times double checking what the default option is when a boolean flag is use i.e. does `default=False` refer to the first flag or the second flag. Thought adding an example of the calls without any flags to the docs would make this a bit clearer. --- docs/options.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/options.rst b/docs/options.rst index ccd8bee4a..fafa6fdac 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -253,6 +253,7 @@ And on the command line: invoke(info, args=['--shout']) invoke(info, args=['--no-shout']) + invoke(info) If you really don't want an off-switch, you can just define one and manually inform Click that something is a flag: @@ -274,6 +275,7 @@ And on the command line: .. click:run:: invoke(info, args=['--shout']) + invoke(info) Note that if a slash is contained in your option already (for instance, if you use Windows-style parameters where ``/`` is the prefix character), you From 17a82fc9255ed52f42bb84d9a6649780d8438bdc Mon Sep 17 00:00:00 2001 From: srafi1 Date: Thu, 22 Oct 2020 19:28:14 -0400 Subject: [PATCH 163/293] account for linebreak in help summary --- CHANGES.rst | 2 ++ src/click/utils.py | 3 +++ tests/test_basic.py | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index c11d9108d..d86e7cede 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -147,6 +147,8 @@ Unreleased - Fix formatting when ``Command.options_metavar`` is empty. :pr:`1551` - The default value passed to ``prompt`` will be cast to the correct type like an input value would be. :pr:`1517` +- Automatically generated short help messages will stop at the first + ending of a phrase or double linebreak. :issue:`1082` Version 7.1.2 diff --git a/src/click/utils.py b/src/click/utils.py index 0bff5c0ff..dfb84ce33 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -49,6 +49,9 @@ def make_str(value): def make_default_short_help(help, max_length=45): """Return a condensed version of help string.""" + line_ending = help.find("\n\n") + if line_ending != -1: + help = help[:line_ending] words = help.split() total_length = 0 result = [] diff --git a/tests/test_basic.py b/tests/test_basic.py index de9b0acea..47356277b 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -528,3 +528,22 @@ def nope(): assert result.exit_code == 0 assert "subgroup" not in result.output assert "nope" not in result.output + + +def test_summary_line(runner): + @click.group() + def cli(): + pass + + @cli.command() + def cmd(): + """ + Summary line without period + + Here is a sentence. And here too. + """ + pass + + result = runner.invoke(cli, ["--help"]) + assert "Summary line without period" in result.output + assert "Here is a sentence." not in result.output From acc91bc4f47e38f43277fcdfd8ca855734c4fbbc Mon Sep 17 00:00:00 2001 From: Shakil Rafi Date: Sat, 31 Oct 2020 22:33:19 -0400 Subject: [PATCH 164/293] skip frequent progress bar renders (#1698) --- CHANGES.rst | 2 ++ src/click/_termui_impl.py | 23 ++++++++++++++++++----- src/click/termui.py | 19 +++++++++++++------ tests/test_termui.py | 10 ++++++++++ 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index d86e7cede..4845673de 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -149,6 +149,8 @@ Unreleased type like an input value would be. :pr:`1517` - Automatically generated short help messages will stop at the first ending of a phrase or double linebreak. :issue:`1082` +- Skip progress bar render steps for efficiency with very fast + iterators by setting ``update_min_steps``. :issue:`676` Version 7.1.2 diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 53941408d..3104409d9 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -62,6 +62,7 @@ def __init__( label=None, file=None, color=None, + update_min_steps=1, width=30, ): self.fill_char = fill_char @@ -77,6 +78,8 @@ def __init__( file = _default_text_stdout() self.file = file self.color = color + self.update_min_steps = update_min_steps + self._completed_intervals = 0 self.width = width self.autowidth = width == 0 @@ -290,13 +293,23 @@ def update(self, n_steps, current_item=None): :param current_item: Optional item to set as ``current_item`` for the updated position. - .. versionadded:: 8.0 + .. versionchanged:: 8.0 Added the ``current_item`` optional parameter. + + .. versionchanged:: 8.0 + Only render when the number of steps meets the + ``update_min_steps`` threshold. """ - self.make_step(n_steps) - if current_item is not None: - self.current_item = current_item - self.render_progress() + self._completed_intervals += n_steps + + if self._completed_intervals >= self.update_min_steps: + self.make_step(self._completed_intervals) + + if current_item is not None: + self.current_item = current_item + + self.render_progress() + self._completed_intervals = 0 def finish(self): self.eta_known = 0 diff --git a/src/click/termui.py b/src/click/termui.py index 84442ee8d..0801af33c 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -299,6 +299,7 @@ def progressbar( width=36, file=None, color=None, + update_min_steps=1, ): """This function creates an iterable context manager that can be used to iterate over something while showing a progress bar. It will @@ -353,12 +354,6 @@ def progressbar( archive.extract() bar.update(archive.size, archive) - .. versionadded:: 2.0 - - .. versionadded:: 4.0 - Added the `color` parameter. Added a `update` method to the - progressbar object. - :param iterable: an iterable to iterate over. If not provided the length is required. :param length: the number of items to iterate over. By default the @@ -397,6 +392,17 @@ def progressbar( default is autodetection. This is only needed if ANSI codes are included anywhere in the progress bar output which is not the case by default. + :param update_min_steps: Render only when this many updates have + completed. This allows tuning for very fast iterators. + + .. versionadded:: 8.0 + Added the ``update_min_steps`` parameter. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. Added the ``update`` method to + the object. + + .. versionadded:: 2.0 """ from ._termui_impl import ProgressBar @@ -416,6 +422,7 @@ def progressbar( label=label, width=width, color=color, + update_min_steps=update_min_steps, ) diff --git a/tests/test_termui.py b/tests/test_termui.py index e199516d3..956de778c 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -344,6 +344,16 @@ def cli(): assert "Custom 4" in lines[2] +def test_progress_bar_update_min_steps(runner): + bar = _create_progress(update_min_steps=5) + bar.update(3) + assert bar._completed_intervals == 3 + assert bar.pos == 0 + bar.update(2) + assert bar._completed_intervals == 0 + assert bar.pos == 5 + + @pytest.mark.parametrize("key_char", ("h", "H", "é", "À", " ", "字", "àH", "àR")) @pytest.mark.parametrize("echo", [True, False]) @pytest.mark.skipif(not WIN, reason="Tests user-input using the msvcrt module.") From b964c1e2cba3915983a6e2f5fcb7f489acd9b33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Nord=C3=A9n?= Date: Sat, 17 Oct 2020 12:17:10 +0200 Subject: [PATCH 165/293] handle case_sensitive=False when completing choices Co-authored-by: David Lord --- CHANGES.rst | 2 ++ src/click/types.py | 9 ++++++++- tests/test_shell_completion.py | 10 ++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4845673de..cb6cd134b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -151,6 +151,8 @@ Unreleased ending of a phrase or double linebreak. :issue:`1082` - Skip progress bar render steps for efficiency with very fast iterators by setting ``update_min_steps``. :issue:`676` +- Respect ``case_sensitive=False`` when doing shell completion for + ``Choice`` :issue:`1692` Version 7.1.2 diff --git a/src/click/types.py b/src/click/types.py index 8c886d6b5..626287d30 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -274,7 +274,14 @@ def shell_complete(self, ctx, param, incomplete): from click.shell_completion import CompletionItem str_choices = map(str, self.choices) - return [CompletionItem(c) for c in str_choices if c.startswith(incomplete)] + + if self.case_sensitive: + matched = (c for c in str_choices if c.startswith(incomplete)) + else: + incomplete = incomplete.lower() + matched = (c for c in str_choices if c.lower().startswith(incomplete)) + + return [CompletionItem(c) for c in matched] class DateTime(ParamType): diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index e85dc322f..40cb88f12 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -304,3 +304,13 @@ def complete(ctx, param, incomplete): env={"COMP_WORDS": "", "COMP_CWORD": "0", "_CLI_COMPLETE": "bash_complete"}, ) assert result.output == "plain,a\nplain,b\n" + + +@pytest.mark.parametrize(("value", "expect"), [(False, ["Au", "al"]), (True, ["al"])]) +def test_choice_case_sensitive(value, expect): + cli = Command( + "cli", + params=[Option(["-a"], type=Choice(["Au", "al", "Bc"], case_sensitive=value))], + ) + completions = _get_words(cli, ["-a"], "a") + assert completions == expect From 7046c90c46d41f9147f3ff25ead052e20332971d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2020 08:19:02 +0000 Subject: [PATCH 166/293] Bump pytest from 6.1.0 to 6.1.2 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.1.0 to 6.1.2. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.1.0...6.1.2) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 4 ++-- requirements/tests.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index c8cdf181e..f1b073175 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ filelock==3.0.12 # via tox, virtualenv identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib_metadata==2.0.0 # via -r requirements/tests.in +importlib-metadata==2.0.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 @@ -32,7 +32,7 @@ pre-commit==2.7.1 # via -r requirements/dev.in py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx, sphinx-tabs pyparsing==2.4.7 # via packaging -pytest==6.1.0 # via -r requirements/tests.in +pytest==6.1.2 # via -r requirements/tests.in pytz==2020.1 # via babel pyyaml==5.3.1 # via pre-commit requests==2.23.0 # via sphinx diff --git a/requirements/tests.txt b/requirements/tests.txt index 0cf510b43..91a68feb9 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,13 +6,13 @@ # attrs==19.3.0 # via pytest colorama==0.4.3 # via -r requirements/tests.in -importlib_metadata==2.0.0 # via -r requirements/tests.in +importlib-metadata==2.0.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest packaging==20.4 # via pytest pluggy==0.13.1 # via pytest py==1.9.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==6.1.0 # via -r requirements/tests.in +pytest==6.1.2 # via -r requirements/tests.in six==1.15.0 # via packaging toml==0.10.1 # via pytest zipp==3.1.0 # via importlib-metadata From 7b842828c1b196af142f03ac5f9c91453b6300f4 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2020 08:23:00 +0000 Subject: [PATCH 167/293] Bump tox from 3.20.0 to 3.20.1 Bumps [tox](https://github.com/tox-dev/tox) from 3.20.0 to 3.20.1. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.20.0...3.20.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index f1b073175..fec12c664 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -49,7 +49,7 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.1 # via pre-commit, pytest, tox -tox==3.20.0 # via -r requirements/dev.in +tox==3.20.1 # via -r requirements/dev.in urllib3==1.25.9 # via requests virtualenv==20.0.21 # via pre-commit, tox zipp==3.1.0 # via importlib-metadata From eb74a9778ee0acaa688e6d0a4872ba064553eab2 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2020 08:23:28 +0000 Subject: [PATCH 168/293] Bump colorama from 0.4.3 to 0.4.4 Bumps [colorama](https://github.com/tartley/colorama) from 0.4.3 to 0.4.4. - [Release notes](https://github.com/tartley/colorama/releases) - [Changelog](https://github.com/tartley/colorama/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tartley/colorama/compare/0.4.3...0.4.4) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index f1b073175..bb47fd3e6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -12,7 +12,7 @@ certifi==2020.4.5.1 # via requests cfgv==3.1.0 # via pre-commit chardet==3.0.4 # via requests click==7.1.2 # via pip-tools -colorama==0.4.3 # via -r requirements/tests.in +colorama==0.4.4 # via -r requirements/tests.in distlib==0.3.0 # via virtualenv docutils==0.16 # via sphinx filelock==3.0.12 # via tox, virtualenv diff --git a/requirements/tests.txt b/requirements/tests.txt index 91a68feb9..9b11c9c31 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -5,7 +5,7 @@ # pip-compile requirements/tests.in # attrs==19.3.0 # via pytest -colorama==0.4.3 # via -r requirements/tests.in +colorama==0.4.4 # via -r requirements/tests.in importlib-metadata==2.0.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest packaging==20.4 # via pytest From 325cd1b9471a9e00d4134f638fc1ca1c58d8034b Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2020 08:26:41 +0000 Subject: [PATCH 169/293] Bump pre-commit from 2.7.1 to 2.8.2 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.7.1 to 2.8.2. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.7.1...v2.8.2) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index fec12c664..bc709e539 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -28,7 +28,7 @@ packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in pip-tools==5.3.1 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox -pre-commit==2.7.1 # via -r requirements/dev.in +pre-commit==2.8.2 # via -r requirements/dev.in py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx, sphinx-tabs pyparsing==2.4.7 # via packaging From f0cc87d491e98b8770af0f3be13fba4b688298b3 Mon Sep 17 00:00:00 2001 From: Patrick <4002194+askpatrickw@users.noreply.github.com> Date: Thu, 5 Nov 2020 11:45:51 -0800 Subject: [PATCH 170/293] test Python 3.9 (#1707) --- .github/workflows/tests.yaml | 11 ++++++----- tox.ini | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4c457e176..0cd5fda2e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -16,14 +16,15 @@ jobs: fail-fast: false matrix: include: - - {name: Linux, python: '3.8', os: ubuntu-latest, tox: py38} - - {name: Windows, python: '3.8', os: windows-latest, tox: py38} - - {name: Mac, python: '3.8', os: macos-latest, tox: py38} + - {name: Linux, python: '3.9', os: ubuntu-latest, tox: py39} + - {name: Windows, python: '3.9', os: windows-latest, tox: py39} + - {name: Mac, python: '3.9', os: macos-latest, tox: py39} + - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} - {name: 'PyPy', python: pypy3, os: ubuntu-latest, tox: pypy3} - - {name: Style, python: '3.8', os: ubuntu-latest, tox: style} - - {name: Docs, python: '3.8', os: ubuntu-latest, tox: docs} + - {name: Style, python: '3.9', os: ubuntu-latest, tox: style} + - {name: Docs, python: '3.9', os: ubuntu-latest, tox: docs} steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/tox.ini b/tox.ini index 9b6d47135..9ec517ba3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{38,37,36,py3} + py{39,38,37,36,py3} style docs skip_missing_interpreters = true From f7cd0ff65eaf1c5cf194fe5a8104f61fd69fbc39 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Tue, 10 Nov 2020 09:41:32 +0000 Subject: [PATCH 171/293] use shlex to split completion argument string Co-authored-by: David Lord --- CHANGES.rst | 2 ++ src/click/parser.py | 48 ++++++++++++++++++++++++++++---------------- tests/test_parser.py | 17 ++++++++++++++++ 3 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 tests/test_parser.py diff --git a/CHANGES.rst b/CHANGES.rst index cb6cd134b..6faaa89b6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -107,6 +107,8 @@ Unreleased The shell now comes first, such as ``{shell}_source`` rather than ``source_{shell}``, and is always required. +- Completion correctly parses command line strings with incomplete + quoting or escape sequences. :issue:`1708` - Extra context settings (``obj=...``, etc.) are passed on to the completion system. :issue:`942` - Include ``--help`` option in completion. :pr:`1504` diff --git a/src/click/parser.py b/src/click/parser.py index 92c935460..d730e0106 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -21,7 +21,6 @@ # maintained by the Python Software Foundation. # Copyright 2001-2006 Gregory P. Ward # Copyright 2002-2006 Python Software Foundation -import re from collections import deque from .exceptions import BadArgumentUsage @@ -103,22 +102,37 @@ def normalize_opt(opt, ctx): def split_arg_string(string): - """Given an argument string this attempts to split it into small parts.""" - rv = [] - for match in re.finditer( - r"('([^'\\]*(?:\\.[^'\\]*)*)'|\"([^\"\\]*(?:\\.[^\"\\]*)*)\"|\S+)\s*", - string, - re.S, - ): - arg = match.group().strip() - if arg[:1] == arg[-1:] and arg[:1] in "\"'": - arg = arg[1:-1].encode("ascii", "backslashreplace").decode("unicode-escape") - try: - arg = type(string)(arg) - except UnicodeError: - pass - rv.append(arg) - return rv + """Split an argument string as with :func:`shlex.split`, but don't + fail if the string is incomplete. Ignores a missing closing quote or + incomplete escape sequence and uses the partial token as-is. + + .. code-block:: python + + split_arg_string("example 'my file") + ["example", "my file"] + + split_arg_string("example my\\") + ["example", "my"] + + :param string: String to split. + """ + import shlex + + lex = shlex.shlex(string, posix=True) + lex.whitespace_split = True + lex.commenters = "" + out = [] + + try: + for token in lex: + out.append(token) + except ValueError: + # Raised when end-of-string is reached in an invalid state. Use + # the partial token as-is. The quote or escape character is in + # lex.state, not lex.token. + out.append(lex.token) + + return out class Option: diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 000000000..964f9c826 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,17 @@ +import pytest + +from click.parser import split_arg_string + + +@pytest.mark.parametrize( + ("value", "expect"), + [ + ("cli a b c", ["cli", "a", "b", "c"]), + ("cli 'my file", ["cli", "my file"]), + ("cli 'my file'", ["cli", "my file"]), + ("cli my\\", ["cli", "my"]), + ("cli my\\ file", ["cli", "my file"]), + ], +) +def test_split_arg_string(value, expect): + assert split_arg_string(value) == expect From 38bedbf4c4fe46108b0ccbe81ac97c017d81c3c0 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 12 Nov 2020 22:56:43 -0800 Subject: [PATCH 172/293] add lock threads workflow --- .github/workflows/lock.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/lock.yml diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml new file mode 100644 index 000000000..4f502be75 --- /dev/null +++ b/.github/workflows/lock.yml @@ -0,0 +1,15 @@ +name: 'Lock threads' + +on: + schedule: + - cron: '0/15 * * * *' + +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v2 + with: + github-token: ${{ github.token }} + issue-lock-inactive-days: 14 + pr-lock-inactive-days: 14 From 72e7844330c532b4adeb4c6493ca987219424b6a Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 13 Nov 2020 06:35:24 -0800 Subject: [PATCH 173/293] reduce lock schedule to daily --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 4f502be75..7128f382b 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -2,7 +2,7 @@ name: 'Lock threads' on: schedule: - - cron: '0/15 * * * *' + - cron: '0 0 * * *' jobs: lock: From ebcc7cbe579504cd33c6fd7ba97ccb3b5025afbd Mon Sep 17 00:00:00 2001 From: Amy Date: Wed, 25 Nov 2020 13:21:43 -0500 Subject: [PATCH 174/293] release version 8.0.0a1 --- .DS_Store | Bin 0 -> 8196 bytes docs/.DS_Store | Bin 0 -> 8196 bytes src/click/__init__.py | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 .DS_Store create mode 100644 docs/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7a4fb5d5925e485310af4069a9299d9de1bd8ef2 GIT binary patch literal 8196 zcmeHM-EI;=6h2d^U8qK4jP2##n0V1Nt+lO*F+qM*5?hU+UZ`o=vbGzSU4*5yB8D5^ zL45-s#V7DVd;tB<%t+V;?u=>dOfqwZ+3%a3`R1Hm4iJ$jbQ|kL%S2?Mvdt}`nNs*V z*NHNxN3OvN@QFfd)7y$49ytC4+6)*4i~>dhqkvJsC~y`Oz&o3ZHRHXnuA0^;U=%o) z3h?v6MrE7VcBrg;bfA$b0Bjn=vY?MVKyqwt^V$xTl~l~BvIn85LZ=u)(Q(~oIBZ_q zp|XljLeWX+I}4qm2zht#YzZfkSJt#f0i(dA0-U=qP#t-DM7{C+J#=t|gXs?HjbE~$ zM>J0kb;%>28Y2+F;FO~T2 zw0y4n4*VUu2SB>gXw^OLMU-G9X_2|ga{aP6Mqki3a?zz3a z+|^#k(UJ31)dkDSSed=ta=*WMdws>;*t$7bvHRQG zYb*Axjjh2TV=Y}@zgyXFp1$vY7<~MKO`{0?M5W8>^Qirb-e`lz)pp>A!66g$ct{7- zi617h3nT@Sxc^xoxxs`$7P)kSxN1@hk>VzWl3qV9)FM5ChZf=WyNHzX3_oQ*kF$1U zd{q!99AO+C9tH735$xULtSq9U3|@(LX5EQBU07qDei5 zC~|@8LWfm2%o{pPtPb%XV^}dpi%t`GXb{7vfN#@50);)tQ1e&=PGQQ5d8a31J6!-K z#MwzD$&1g+4jx0N=~yap9LMqb#&9?SQ;Y&y4UywMosHM;%A*3jjLk~zuL4X9PjpHXB z!aD$Oz@zX4yaIO~pnNlHik;XMCxn#UXm>XDd^cgU>GnA7zPXj{{sVfW^=JJp8NW)X$=F0 zfvIGG&j%ZoWhs&)E#>GyBPjsn94<>iA9;Xa>`0a(Inq*~7*l5tDuSvg#ZVfK`!=gX zmLfUQQo~7UIH}08iZT=>)`7D{om5Fn(;5a01CtDJ?tYo}$)O&##`1UGO7~H3o=FjK zLf7ec!YJ4O=7e6W)7{wku4ZQEE?v%9R>3MP6kb=FLBGO5=J`}<4F4+FnjZTa=KfLm@IDzBcmd^hNqgP!kpWSok=VOe=AzrVCP7~H+L zxn^%|-yW{ngPom?HT%xi_HdZDR&H!Q*gH5neK&YN{P0=aAQhb>bVYwZZ@-|IaPqj) z_PxMw;5uV+Age=Ns-m{27JJ>nbbUpQ(=`u0myXf;RHHijx2VndBu=f2;c`HP7(ZS6 zZkC}BckUuAoHEPQ28bEp!V3bsw5(uASfiMq$0@ynew3}s77>VPG z5iW$aI-R6j6F%aooS=Lacymbd1!+}C&LDF5l3Cc>IAU!hef@{0T|8X zL|HLT7R4i8=TM@9kr^w&yd===2&FA zSt#ZqY4NQbc{TW^_j4QQbc@VM63cvdfoF>WK3$5ti#RbB>#gT`OG0QP(hMU!o?ESX;*k>_rI6+5R(k@lh58(~$LYFPpHI>|9! z8wRF?0X4T<*)7A_-@l2+{pMWTMm<2~!g?bu1%gJ>aY#wWA Date: Wed, 25 Nov 2020 13:24:53 -0500 Subject: [PATCH 175/293] start version 8.0.0a2 --- src/click/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/click/__init__.py b/src/click/__init__.py index 988e3f435..b13afb4a4 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -72,4 +72,4 @@ from .utils import get_text_stream from .utils import open_file -__version__ = "8.0.0a1" +__version__ = "8.0.0a2" From 829f3831554f42972200cdff79213ae781833021 Mon Sep 17 00:00:00 2001 From: Elias Rosendahl Jensen Date: Mon, 30 Nov 2020 16:40:38 +0100 Subject: [PATCH 176/293] add FloatRange to API docs (#1719) --- docs/api.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api.rst b/docs/api.rst index 59d38d969..5e8c765c4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -136,6 +136,8 @@ Types .. autoclass:: IntRange +.. autoclass:: FloatRange + .. autoclass:: Tuple .. autoclass:: ParamType From de3790bc142b14a0bdcfb4c5fb9038490e096d13 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 1 Dec 2020 08:54:34 +0000 Subject: [PATCH 177/293] Bump importlib-metadata from 2.0.0 to 3.1.0 Bumps [importlib-metadata](https://github.com/python/importlib_metadata) from 2.0.0 to 3.1.0. - [Release notes](https://github.com/python/importlib_metadata/releases) - [Changelog](https://github.com/python/importlib_metadata/blob/master/CHANGES.rst) - [Commits](https://github.com/python/importlib_metadata/compare/v2.0.0...v3.1.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index c65dba481..9681d1c33 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ filelock==3.0.12 # via tox, virtualenv identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==2.0.0 # via -r requirements/tests.in +importlib_metadata==3.1.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 diff --git a/requirements/tests.txt b/requirements/tests.txt index 9b11c9c31..945d82bd2 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,7 +6,7 @@ # attrs==19.3.0 # via pytest colorama==0.4.4 # via -r requirements/tests.in -importlib-metadata==2.0.0 # via -r requirements/tests.in +importlib_metadata==3.1.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest packaging==20.4 # via pytest pluggy==0.13.1 # via pytest From 7f7c1f443aba967cb1298485407bca2a72caeffa Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 1 Dec 2020 08:56:11 +0000 Subject: [PATCH 178/293] Bump pip-tools from 5.3.1 to 5.4.0 Bumps [pip-tools](https://github.com/jazzband/pip-tools) from 5.3.1 to 5.4.0. - [Release notes](https://github.com/jazzband/pip-tools/releases) - [Changelog](https://github.com/jazzband/pip-tools/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/pip-tools/compare/5.3.1...5.4.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index c65dba481..592c35208 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -26,7 +26,7 @@ markupsafe==1.1.1 # via jinja2 nodeenv==1.3.5 # via pre-commit packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pip-tools==5.3.1 # via -r requirements/dev.in +pip-tools==5.4.0 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox pre-commit==2.8.2 # via -r requirements/dev.in py==1.9.0 # via pytest, tox From cd05813241e8ada6870159f8c7dd9107a12be2e2 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 1 Dec 2020 09:08:15 +0000 Subject: [PATCH 179/293] Bump sphinx from 3.2.1 to 3.3.1 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.2.1 to 3.3.1. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.2.1...v3.3.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 4 ++-- requirements/docs.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 47bd1a10d..b9e55effa 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ filelock==3.0.12 # via tox, virtualenv identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib_metadata==3.1.0 # via -r requirements/tests.in +importlib-metadata==3.1.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 @@ -40,7 +40,7 @@ six==1.15.0 # via packaging, pip-tools, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in sphinx-tabs==1.3.0 # via -r requirements/docs.in -sphinx==3.2.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet +sphinx==3.3.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index 70c3bef6d..97b29c9a4 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -23,7 +23,7 @@ six==1.15.0 # via packaging snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in sphinx-tabs==1.3.0 # via -r requirements/docs.in -sphinx==3.2.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet +sphinx==3.3.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx From 8b7806aece872655120a96feef58868e121dd3cf Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 1 Dec 2020 09:21:57 +0000 Subject: [PATCH 180/293] Bump pre-commit from 2.8.2 to 2.9.2 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.8.2 to 2.9.2. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.8.2...v2.9.2) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index b9e55effa..8f06e4294 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -28,7 +28,7 @@ packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in pip-tools==5.4.0 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox -pre-commit==2.8.2 # via -r requirements/dev.in +pre-commit==2.9.2 # via -r requirements/dev.in py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx, sphinx-tabs pyparsing==2.4.7 # via packaging From 4ae303e4cd8486954906e20f0900f57e7370cd31 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 1 Jan 2021 08:29:01 +0000 Subject: [PATCH 181/293] Bump pytest from 6.1.2 to 6.2.1 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.1.2 to 6.2.1. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.1.2...6.2.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 8f06e4294..fa0c1ffa3 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -32,7 +32,7 @@ pre-commit==2.9.2 # via -r requirements/dev.in py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx, sphinx-tabs pyparsing==2.4.7 # via packaging -pytest==6.1.2 # via -r requirements/tests.in +pytest==6.2.1 # via -r requirements/tests.in pytz==2020.1 # via babel pyyaml==5.3.1 # via pre-commit requests==2.23.0 # via sphinx diff --git a/requirements/tests.txt b/requirements/tests.txt index 945d82bd2..0a2e7f643 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,13 +6,13 @@ # attrs==19.3.0 # via pytest colorama==0.4.4 # via -r requirements/tests.in -importlib_metadata==3.1.0 # via -r requirements/tests.in +importlib-metadata==3.1.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest packaging==20.4 # via pytest pluggy==0.13.1 # via pytest py==1.9.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==6.1.2 # via -r requirements/tests.in +pytest==6.2.1 # via -r requirements/tests.in six==1.15.0 # via packaging toml==0.10.1 # via pytest zipp==3.1.0 # via importlib-metadata From d64be346747d1563c4a5df48d40b875144e84c53 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 1 Jan 2021 08:30:36 +0000 Subject: [PATCH 182/293] Bump pip-tools from 5.4.0 to 5.5.0 Bumps [pip-tools](https://github.com/jazzband/pip-tools) from 5.4.0 to 5.5.0. - [Release notes](https://github.com/jazzband/pip-tools/releases) - [Changelog](https://github.com/jazzband/pip-tools/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/pip-tools/compare/5.4.0...5.5.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 8f06e4294..782324885 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -26,7 +26,7 @@ markupsafe==1.1.1 # via jinja2 nodeenv==1.3.5 # via pre-commit packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pip-tools==5.4.0 # via -r requirements/dev.in +pip-tools==5.5.0 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox pre-commit==2.9.2 # via -r requirements/dev.in py==1.9.0 # via pytest, tox @@ -36,7 +36,7 @@ pytest==6.1.2 # via -r requirements/tests.in pytz==2020.1 # via babel pyyaml==5.3.1 # via pre-commit requests==2.23.0 # via sphinx -six==1.15.0 # via packaging, pip-tools, tox, virtualenv +six==1.15.0 # via packaging, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in sphinx-tabs==1.3.0 # via -r requirements/docs.in From 5e2833256dd7d0e6a22fda84412317614c9315fe Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 1 Jan 2021 08:34:03 +0000 Subject: [PATCH 183/293] Bump importlib-metadata from 3.1.0 to 3.3.0 Bumps [importlib-metadata](https://github.com/python/importlib_metadata) from 3.1.0 to 3.3.0. - [Release notes](https://github.com/python/importlib_metadata/releases) - [Changelog](https://github.com/python/importlib_metadata/blob/main/CHANGES.rst) - [Commits](https://github.com/python/importlib_metadata/compare/v3.1.0...v3.3.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index fa0c1ffa3..bdcab64aa 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ filelock==3.0.12 # via tox, virtualenv identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==3.1.0 # via -r requirements/tests.in +importlib_metadata==3.3.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 diff --git a/requirements/tests.txt b/requirements/tests.txt index 0a2e7f643..34b4e81ff 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,7 +6,7 @@ # attrs==19.3.0 # via pytest colorama==0.4.4 # via -r requirements/tests.in -importlib-metadata==3.1.0 # via -r requirements/tests.in +importlib_metadata==3.3.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest packaging==20.4 # via pytest pluggy==0.13.1 # via pytest From 81524eb9e12adcb8540b8cceeaef8f737c69e775 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 1 Jan 2021 08:44:37 +0000 Subject: [PATCH 184/293] Bump pre-commit from 2.9.2 to 2.9.3 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.9.2 to 2.9.3. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.9.2...v2.9.3) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index ff252003d..77c1c27e8 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ filelock==3.0.12 # via tox, virtualenv identify==1.4.16 # via pre-commit idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib_metadata==3.3.0 # via -r requirements/tests.in +importlib-metadata==3.3.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest jinja2==2.11.2 # via sphinx markupsafe==1.1.1 # via jinja2 @@ -28,7 +28,7 @@ packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in pip-tools==5.5.0 # via -r requirements/dev.in pluggy==0.13.1 # via pytest, tox -pre-commit==2.9.2 # via -r requirements/dev.in +pre-commit==2.9.3 # via -r requirements/dev.in py==1.9.0 # via pytest, tox pygments==2.6.1 # via sphinx, sphinx-tabs pyparsing==2.4.7 # via packaging From 7661a5777d0188268dd78bc988c4d2489521a7a3 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 1 Jan 2021 08:49:09 +0000 Subject: [PATCH 185/293] Bump sphinx from 3.3.1 to 3.4.1 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.3.1 to 3.4.1. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.3.1...v3.4.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 77c1c27e8..a5abdebb3 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -40,7 +40,7 @@ six==1.15.0 # via packaging, tox, virtualenv snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in sphinx-tabs==1.3.0 # via -r requirements/docs.in -sphinx==3.3.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet +sphinx==3.4.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index 97b29c9a4..a3918a7f6 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -23,7 +23,7 @@ six==1.15.0 # via packaging snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in sphinx-tabs==1.3.0 # via -r requirements/docs.in -sphinx==3.3.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet +sphinx==3.4.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx From 2fc486c880eda9fdb746ed8baa49416acab9ea6d Mon Sep 17 00:00:00 2001 From: MIS-Alex <56587100+MIS-Alex@users.noreply.github.com> Date: Thu, 14 Jan 2021 16:00:00 +0000 Subject: [PATCH 186/293] Replaced redundant mktemp() with mkstemp() (#1754) --- CHANGES.rst | 2 ++ src/click/_termui_impl.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6faaa89b6..343c817ff 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -155,6 +155,8 @@ Unreleased iterators by setting ``update_min_steps``. :issue:`676` - Respect ``case_sensitive=False`` when doing shell completion for ``Choice`` :issue:`1692` +- Use ``mkstemp()`` instead of ``mktemp()`` in pager implementation. + :issue:`1752` Version 7.1.2 diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 3104409d9..82277db3b 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -424,7 +424,7 @@ def _tempfilepager(generator, cmd, color): """Page through text by invoking a program on a temporary file.""" import tempfile - filename = tempfile.mktemp() + filename = tempfile.mkstemp() # TODO: This never terminates if the passed generator never terminates. text = "".join(generator) if not color: From c485b112b8cb95000b0c4ff450da8ea24946cca4 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Feb 2021 09:46:12 +0000 Subject: [PATCH 187/293] Bump importlib-metadata from 3.3.0 to 3.4.0 Bumps [importlib-metadata](https://github.com/python/importlib_metadata) from 3.3.0 to 3.4.0. - [Release notes](https://github.com/python/importlib_metadata/releases) - [Changelog](https://github.com/python/importlib_metadata/blob/main/CHANGES.rst) - [Commits](https://github.com/python/importlib_metadata/compare/v3.3.0...v3.4.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 172 +++++++++++++++++++++++++++++------------ requirements/tests.txt | 36 ++++++--- 2 files changed, 147 insertions(+), 61 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index a5abdebb3..a46ffec0c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,55 +4,129 @@ # # pip-compile requirements/dev.in # -alabaster==0.7.12 # via sphinx -appdirs==1.4.4 # via virtualenv -attrs==19.3.0 # via pytest -babel==2.8.0 # via sphinx -certifi==2020.4.5.1 # via requests -cfgv==3.1.0 # via pre-commit -chardet==3.0.4 # via requests -click==7.1.2 # via pip-tools -colorama==0.4.4 # via -r requirements/tests.in -distlib==0.3.0 # via virtualenv -docutils==0.16 # via sphinx -filelock==3.0.12 # via tox, virtualenv -identify==1.4.16 # via pre-commit -idna==2.9 # via requests -imagesize==1.2.0 # via sphinx -importlib-metadata==3.3.0 # via -r requirements/tests.in -iniconfig==1.0.0 # via pytest -jinja2==2.11.2 # via sphinx -markupsafe==1.1.1 # via jinja2 -nodeenv==1.3.5 # via pre-commit -packaging==20.4 # via pallets-sphinx-themes, pytest, sphinx, tox -pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pip-tools==5.5.0 # via -r requirements/dev.in -pluggy==0.13.1 # via pytest, tox -pre-commit==2.9.3 # via -r requirements/dev.in -py==1.9.0 # via pytest, tox -pygments==2.6.1 # via sphinx, sphinx-tabs -pyparsing==2.4.7 # via packaging -pytest==6.2.1 # via -r requirements/tests.in -pytz==2020.1 # via babel -pyyaml==5.3.1 # via pre-commit -requests==2.23.0 # via sphinx -six==1.15.0 # via packaging, tox, virtualenv -snowballstemmer==2.0.0 # via sphinx -sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx-tabs==1.3.0 # via -r requirements/docs.in -sphinx==3.4.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet -sphinxcontrib-applehelp==1.0.2 # via sphinx -sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==1.0.3 # via sphinx -sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in -sphinxcontrib-qthelp==1.0.3 # via sphinx -sphinxcontrib-serializinghtml==1.1.4 # via sphinx -toml==0.10.1 # via pre-commit, pytest, tox -tox==3.20.1 # via -r requirements/dev.in -urllib3==1.25.9 # via requests -virtualenv==20.0.21 # via pre-commit, tox -zipp==3.1.0 # via importlib-metadata +alabaster==0.7.12 + # via sphinx +appdirs==1.4.4 + # via virtualenv +attrs==19.3.0 + # via pytest +babel==2.8.0 + # via sphinx +certifi==2020.4.5.1 + # via requests +cfgv==3.1.0 + # via pre-commit +chardet==3.0.4 + # via requests +click==7.1.2 + # via pip-tools +colorama==0.4.4 + # via -r requirements/tests.in +distlib==0.3.0 + # via virtualenv +docutils==0.16 + # via sphinx +filelock==3.0.12 + # via + # tox + # virtualenv +identify==1.4.16 + # via pre-commit +idna==2.9 + # via requests +imagesize==1.2.0 + # via sphinx +importlib_metadata==3.4.0 + # via -r requirements/tests.in +iniconfig==1.0.0 + # via pytest +jinja2==2.11.2 + # via sphinx +markupsafe==1.1.1 + # via jinja2 +nodeenv==1.3.5 + # via pre-commit +packaging==20.4 + # via + # pallets-sphinx-themes + # pytest + # sphinx + # tox +pallets-sphinx-themes==1.2.3 + # via -r requirements/docs.in +pip-tools==5.5.0 + # via -r requirements/dev.in +pluggy==0.13.1 + # via + # pytest + # tox +pre-commit==2.9.3 + # via -r requirements/dev.in +py==1.9.0 + # via + # pytest + # tox +pygments==2.6.1 + # via + # sphinx + # sphinx-tabs +pyparsing==2.4.7 + # via packaging +pytest==6.2.1 + # via -r requirements/tests.in +pytz==2020.1 + # via babel +pyyaml==5.3.1 + # via pre-commit +requests==2.23.0 + # via sphinx +six==1.15.0 + # via + # packaging + # tox + # virtualenv +snowballstemmer==2.0.0 + # via sphinx +sphinx-issues==1.2.0 + # via -r requirements/docs.in +sphinx-tabs==1.3.0 + # via -r requirements/docs.in +sphinx==3.4.1 + # via + # -r requirements/docs.in + # pallets-sphinx-themes + # sphinx-issues + # sphinx-tabs + # sphinxcontrib-log-cabinet +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==1.0.3 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-log-cabinet==1.0.1 + # via -r requirements/docs.in +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.4 + # via sphinx +toml==0.10.1 + # via + # pre-commit + # pytest + # tox +tox==3.20.1 + # via -r requirements/dev.in +urllib3==1.25.9 + # via requests +virtualenv==20.0.21 + # via + # pre-commit + # tox +zipp==3.1.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/tests.txt b/requirements/tests.txt index 34b4e81ff..a5dc18ca7 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -4,15 +4,27 @@ # # pip-compile requirements/tests.in # -attrs==19.3.0 # via pytest -colorama==0.4.4 # via -r requirements/tests.in -importlib_metadata==3.3.0 # via -r requirements/tests.in -iniconfig==1.0.0 # via pytest -packaging==20.4 # via pytest -pluggy==0.13.1 # via pytest -py==1.9.0 # via pytest -pyparsing==2.4.7 # via packaging -pytest==6.2.1 # via -r requirements/tests.in -six==1.15.0 # via packaging -toml==0.10.1 # via pytest -zipp==3.1.0 # via importlib-metadata +attrs==19.3.0 + # via pytest +colorama==0.4.4 + # via -r requirements/tests.in +importlib_metadata==3.4.0 + # via -r requirements/tests.in +iniconfig==1.0.0 + # via pytest +packaging==20.4 + # via pytest +pluggy==0.13.1 + # via pytest +py==1.9.0 + # via pytest +pyparsing==2.4.7 + # via packaging +pytest==6.2.1 + # via -r requirements/tests.in +six==1.15.0 + # via packaging +toml==0.10.1 + # via pytest +zipp==3.1.0 + # via importlib-metadata From c3f7690c7e90130f32edc2884786fad3c50eb65d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Feb 2021 09:50:22 +0000 Subject: [PATCH 188/293] Bump sphinx-tabs from 1.3.0 to 2.0.0 Bumps [sphinx-tabs](https://github.com/executablebooks/sphinx-tabs) from 1.3.0 to 2.0.0. - [Release notes](https://github.com/executablebooks/sphinx-tabs/releases) - [Changelog](https://github.com/executablebooks/sphinx-tabs/blob/master/CHANGELOG.md) - [Commits](https://github.com/executablebooks/sphinx-tabs/compare/v1.3.0...v2.0.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 4 +- requirements/docs.txt | 93 ++++++++++++++++++++++++++++++------------- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index a46ffec0c..cef1b9db6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -36,7 +36,7 @@ idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib_metadata==3.4.0 +importlib-metadata==3.4.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest @@ -89,7 +89,7 @@ snowballstemmer==2.0.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx-tabs==1.3.0 +sphinx-tabs==2.0.0 # via -r requirements/docs.in sphinx==3.4.1 # via diff --git a/requirements/docs.txt b/requirements/docs.txt index a3918a7f6..d2e58d021 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -4,34 +4,71 @@ # # pip-compile requirements/docs.in # -alabaster==0.7.12 # via sphinx -babel==2.8.0 # via sphinx -certifi==2020.4.5.1 # via requests -chardet==3.0.4 # via requests -docutils==0.16 # via sphinx -idna==2.9 # via requests -imagesize==1.2.0 # via sphinx -jinja2==2.11.2 # via sphinx -markupsafe==1.1.1 # via jinja2 -packaging==20.4 # via pallets-sphinx-themes, sphinx -pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pygments==2.6.1 # via sphinx, sphinx-tabs -pyparsing==2.4.7 # via packaging -pytz==2020.1 # via babel -requests==2.23.0 # via sphinx -six==1.15.0 # via packaging -snowballstemmer==2.0.0 # via sphinx -sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx-tabs==1.3.0 # via -r requirements/docs.in -sphinx==3.4.1 # via -r requirements/docs.in, pallets-sphinx-themes, sphinx-issues, sphinx-tabs, sphinxcontrib-log-cabinet -sphinxcontrib-applehelp==1.0.2 # via sphinx -sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==1.0.3 # via sphinx -sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in -sphinxcontrib-qthelp==1.0.3 # via sphinx -sphinxcontrib-serializinghtml==1.1.4 # via sphinx -urllib3==1.25.9 # via requests +alabaster==0.7.12 + # via sphinx +babel==2.8.0 + # via sphinx +certifi==2020.4.5.1 + # via requests +chardet==3.0.4 + # via requests +docutils==0.16 + # via sphinx +idna==2.9 + # via requests +imagesize==1.2.0 + # via sphinx +jinja2==2.11.2 + # via sphinx +markupsafe==1.1.1 + # via jinja2 +packaging==20.4 + # via + # pallets-sphinx-themes + # sphinx +pallets-sphinx-themes==1.2.3 + # via -r requirements/docs.in +pygments==2.6.1 + # via + # sphinx + # sphinx-tabs +pyparsing==2.4.7 + # via packaging +pytz==2020.1 + # via babel +requests==2.23.0 + # via sphinx +six==1.15.0 + # via packaging +snowballstemmer==2.0.0 + # via sphinx +sphinx-issues==1.2.0 + # via -r requirements/docs.in +sphinx-tabs==2.0.0 + # via -r requirements/docs.in +sphinx==3.4.1 + # via + # -r requirements/docs.in + # pallets-sphinx-themes + # sphinx-issues + # sphinx-tabs + # sphinxcontrib-log-cabinet +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==1.0.3 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-log-cabinet==1.0.1 + # via -r requirements/docs.in +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.4 + # via sphinx +urllib3==1.25.9 + # via requests # The following packages are considered to be unsafe in a requirements file: # setuptools From 322018c048ce6afb0f703b15bc8d9845089d9102 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Feb 2021 09:50:36 +0000 Subject: [PATCH 189/293] Bump tox from 3.20.1 to 3.21.3 Bumps [tox](https://github.com/tox-dev/tox) from 3.20.1 to 3.21.3. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.20.1...3.21.3) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index a46ffec0c..b560a8a9f 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -36,7 +36,7 @@ idna==2.9 # via requests imagesize==1.2.0 # via sphinx -importlib_metadata==3.4.0 +importlib-metadata==3.4.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest @@ -117,7 +117,7 @@ toml==0.10.1 # pre-commit # pytest # tox -tox==3.20.1 +tox==3.21.3 # via -r requirements/dev.in urllib3==1.25.9 # via requests From 6103f1eb0aadcc6340ba17bb9116d163e55e8e11 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Feb 2021 09:54:46 +0000 Subject: [PATCH 190/293] Bump sphinx from 3.4.1 to 3.4.3 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.4.1 to 3.4.3. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.4.1...v3.4.3) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index cef1b9db6..ca1159019 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -91,7 +91,7 @@ sphinx-issues==1.2.0 # via -r requirements/docs.in sphinx-tabs==2.0.0 # via -r requirements/docs.in -sphinx==3.4.1 +sphinx==3.4.3 # via # -r requirements/docs.in # pallets-sphinx-themes diff --git a/requirements/docs.txt b/requirements/docs.txt index d2e58d021..7a91940b8 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -46,7 +46,7 @@ sphinx-issues==1.2.0 # via -r requirements/docs.in sphinx-tabs==2.0.0 # via -r requirements/docs.in -sphinx==3.4.1 +sphinx==3.4.3 # via # -r requirements/docs.in # pallets-sphinx-themes From d23c9a27ff39059a93e12abf5b6bfd10dc4c9753 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Feb 2021 09:57:11 +0000 Subject: [PATCH 191/293] Bump pre-commit from 2.9.3 to 2.10.0 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.9.3 to 2.10.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.9.3...v2.10.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 2000c3af6..3b4976c33 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -60,7 +60,7 @@ pluggy==0.13.1 # via # pytest # tox -pre-commit==2.9.3 +pre-commit==2.10.0 # via -r requirements/dev.in py==1.9.0 # via From ae1f71c851c01f1d76974ee0835eceeb44c77849 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Feb 2021 10:12:22 +0000 Subject: [PATCH 192/293] Bump pytest from 6.2.1 to 6.2.2 Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.2.1 to 6.2.2. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.2.1...6.2.2) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 942cf4cf7..17cda50dc 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -72,7 +72,7 @@ pygments==2.6.1 # sphinx-tabs pyparsing==2.4.7 # via packaging -pytest==6.2.1 +pytest==6.2.2 # via -r requirements/tests.in pytz==2020.1 # via babel diff --git a/requirements/tests.txt b/requirements/tests.txt index a5dc18ca7..1632629fd 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -8,7 +8,7 @@ attrs==19.3.0 # via pytest colorama==0.4.4 # via -r requirements/tests.in -importlib_metadata==3.4.0 +importlib-metadata==3.4.0 # via -r requirements/tests.in iniconfig==1.0.0 # via pytest @@ -20,7 +20,7 @@ py==1.9.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==6.2.1 +pytest==6.2.2 # via -r requirements/tests.in six==1.15.0 # via packaging From 9cd022fb2d2859662f6ad7dbba64ebb59fa528ab Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 8 Feb 2021 12:32:31 -0800 Subject: [PATCH 193/293] update issue templates and contributing guide --- .github/ISSUE_TEMPLATE/bug-report.md | 40 ++++++++++------------------ .github/pull_request_template.md | 17 +++++++----- CONTRIBUTING.rst | 36 ++++++++++++------------- 3 files changed, 42 insertions(+), 51 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 9a9b197e3..09535a685 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -4,36 +4,24 @@ about: Report a bug in Click (not other projects which depend on Click) --- + -```pytb -Paste the full traceback if there was an exception. -``` + -### Environment +Environment: -* Python version: -* Click version: +- Python version: +- Click version: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 79739f6f9..29fd35f85 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,17 +1,22 @@ - -- Fixes # +- fixes # diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e2eb460f5..30d8dcb45 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -7,19 +7,19 @@ Thank you for considering contributing to Click! Support questions ----------------- -Please, don't use the issue tracker for this. The issue tracker is a -tool to address bugs and feature requests in Click itself. Use one of -the following resources for questions about using Click or issues with -your own code: +Please don't use the issue tracker for this. The issue tracker is a tool +to address bugs and feature requests in Click itself. Use one of the +following resources for questions about using Click or issues with your +own code: - The ``#get-help`` channel on our Discord chat: - https://discord.gg/t6rrQZH + https://discord.gg/pallets - The mailing list flask@python.org for long term discussion or larger issues. - Ask on `Stack Overflow`_. Search with Google first using: ``site:stackoverflow.com python click {search term, exception message, etc.}`` -.. _Stack Overflow: https://stackoverflow.com/questions/tagged/python-click?sort=linked +.. _Stack Overflow: https://stackoverflow.com/questions/tagged/python-click?tab=Frequent Reporting issues @@ -33,9 +33,9 @@ Include the following information in your post: your own code. - Describe what actually happened. Include the full traceback if there was an exception. -- List your Python and Click. If possible, check if this issue is - already fixed in the latest releases or the latest code in the - repository. +- List your Python and Click versions. If possible, check if this + issue is already fixed in the latest releases or the latest code in + the repository. .. _minimal reproducible example: https://stackoverflow.com/help/minimal-reproducible-example @@ -107,14 +107,12 @@ First time setup > env\Scripts\activate -- Install the development dependencies, then install Click in editable - mode. This order is important, otherwise you'll get the wrong - version of Click. +- Install the development dependencies, then install Click in + editable mode. .. code-block:: text - $ pip install -r requirements/dev.txt - $ pip install -e . + $ pip install -r requirements/dev.txt && pip install -e . - Install the pre-commit hooks. @@ -123,11 +121,11 @@ First time setup $ pre-commit install .. _latest version of git: https://git-scm.com/downloads -.. _username: https://help.github.com/en/articles/setting-your-username-in-git -.. _email: https://help.github.com/en/articles/setting-your-commit-email-address-in-git +.. _username: https://docs.github.com/en/github/using-git/setting-your-username-in-git +.. _email: https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/setting-your-commit-email-address .. _GitHub account: https://github.com/join -.. _Fork: https://github.com/pallets/click/fork -.. _Clone: https://help.github.com/en/articles/fork-a-repo#step-2-create-a-local-clone-of-your-fork +.. _Fork: https://github.com/pallets/jinja/fork +.. _Clone: https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#step-2-create-a-local-clone-of-your-fork Start coding @@ -163,7 +161,7 @@ Start coding $ git push --set-upstream fork your-branch-name .. _committing as you go: https://dont-be-afraid-to-commit.readthedocs.io/en/latest/git/commandlinegit.html#commit-your-changes -.. _create a pull request: https://help.github.com/en/articles/creating-a-pull-request +.. _create a pull request: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request Running the tests From 5421fe6801630bea21a27e28251c140f51a69c91 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 8 Feb 2021 12:36:31 -0800 Subject: [PATCH 194/293] update requirements --- requirements/dev.txt | 51 +++++++++++++++++++++--------------------- requirements/docs.txt | 26 ++++++++++----------- requirements/tests.txt | 14 +++++------- 3 files changed, 43 insertions(+), 48 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 17cda50dc..cc6d59ced 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,21 +8,21 @@ alabaster==0.7.12 # via sphinx appdirs==1.4.4 # via virtualenv -attrs==19.3.0 +attrs==20.3.0 # via pytest -babel==2.8.0 +babel==2.9.0 # via sphinx -certifi==2020.4.5.1 +certifi==2020.12.5 # via requests -cfgv==3.1.0 +cfgv==3.2.0 # via pre-commit -chardet==3.0.4 +chardet==4.0.0 # via requests click==7.1.2 # via pip-tools colorama==0.4.4 # via -r requirements/tests.in -distlib==0.3.0 +distlib==0.3.1 # via virtualenv docutils==0.16 # via sphinx @@ -30,23 +30,23 @@ filelock==3.0.12 # via # tox # virtualenv -identify==1.4.16 +identify==1.5.13 # via pre-commit -idna==2.9 +idna==2.10 # via requests imagesize==1.2.0 # via sphinx importlib-metadata==3.4.0 # via -r requirements/tests.in -iniconfig==1.0.0 +iniconfig==1.1.1 # via pytest -jinja2==2.11.2 +jinja2==2.11.3 # via sphinx markupsafe==1.1.1 # via jinja2 -nodeenv==1.3.5 +nodeenv==1.5.0 # via pre-commit -packaging==20.4 +packaging==20.9 # via # pallets-sphinx-themes # pytest @@ -60,13 +60,13 @@ pluggy==0.13.1 # via # pytest # tox -pre-commit==2.10.0 +pre-commit==2.10.1 # via -r requirements/dev.in -py==1.9.0 +py==1.10.0 # via # pytest # tox -pygments==2.6.1 +pygments==2.7.4 # via # sphinx # sphinx-tabs @@ -74,22 +74,21 @@ pyparsing==2.4.7 # via packaging pytest==6.2.2 # via -r requirements/tests.in -pytz==2020.1 +pytz==2021.1 # via babel -pyyaml==5.3.1 +pyyaml==5.4.1 # via pre-commit -requests==2.23.0 +requests==2.25.1 # via sphinx six==1.15.0 # via - # packaging # tox # virtualenv -snowballstemmer==2.0.0 +snowballstemmer==2.1.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx-tabs==2.0.0 +sphinx-tabs==2.0.1 # via -r requirements/docs.in sphinx==3.4.3 # via @@ -112,20 +111,20 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx -toml==0.10.1 +toml==0.10.2 # via # pre-commit # pytest # tox -tox==3.21.3 +tox==3.21.4 # via -r requirements/dev.in -urllib3==1.25.9 +urllib3==1.26.3 # via requests -virtualenv==20.0.21 +virtualenv==20.4.2 # via # pre-commit # tox -zipp==3.1.0 +zipp==3.4.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/docs.txt b/requirements/docs.txt index 7a91940b8..3c0417121 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -6,45 +6,43 @@ # alabaster==0.7.12 # via sphinx -babel==2.8.0 +babel==2.9.0 # via sphinx -certifi==2020.4.5.1 +certifi==2020.12.5 # via requests -chardet==3.0.4 +chardet==4.0.0 # via requests docutils==0.16 # via sphinx -idna==2.9 +idna==2.10 # via requests imagesize==1.2.0 # via sphinx -jinja2==2.11.2 +jinja2==2.11.3 # via sphinx markupsafe==1.1.1 # via jinja2 -packaging==20.4 +packaging==20.9 # via # pallets-sphinx-themes # sphinx pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in -pygments==2.6.1 +pygments==2.7.4 # via # sphinx # sphinx-tabs pyparsing==2.4.7 # via packaging -pytz==2020.1 +pytz==2021.1 # via babel -requests==2.23.0 +requests==2.25.1 # via sphinx -six==1.15.0 - # via packaging -snowballstemmer==2.0.0 +snowballstemmer==2.1.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx-tabs==2.0.0 +sphinx-tabs==2.0.1 # via -r requirements/docs.in sphinx==3.4.3 # via @@ -67,7 +65,7 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx -urllib3==1.25.9 +urllib3==1.26.3 # via requests # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/tests.txt b/requirements/tests.txt index 1632629fd..6e6e2241c 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -4,27 +4,25 @@ # # pip-compile requirements/tests.in # -attrs==19.3.0 +attrs==20.3.0 # via pytest colorama==0.4.4 # via -r requirements/tests.in importlib-metadata==3.4.0 # via -r requirements/tests.in -iniconfig==1.0.0 +iniconfig==1.1.1 # via pytest -packaging==20.4 +packaging==20.9 # via pytest pluggy==0.13.1 # via pytest -py==1.9.0 +py==1.10.0 # via pytest pyparsing==2.4.7 # via packaging pytest==6.2.2 # via -r requirements/tests.in -six==1.15.0 - # via packaging -toml==0.10.1 +toml==0.10.2 # via pytest -zipp==3.1.0 +zipp==3.4.0 # via importlib-metadata From cc54113894d97a188e9be3c2febe3b199d46ae15 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 8 Feb 2021 12:41:31 -0800 Subject: [PATCH 195/293] remove pre-commit ci env, using pre-commit.ci --- .github/workflows/tests.yaml | 6 ------ tox.ini | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0cd5fda2e..2291f4414 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -23,7 +23,6 @@ jobs: - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} - {name: 'PyPy', python: pypy3, os: ubuntu-latest, tox: pypy3} - - {name: Style, python: '3.9', os: ubuntu-latest, tox: style} - {name: Docs, python: '3.9', os: ubuntu-latest, tox: docs} steps: - uses: actions/checkout@v2 @@ -43,11 +42,6 @@ jobs: with: path: ${{ steps.pip-cache.outputs.dir }} key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('setup.py') }}|${{ hashFiles('requirements/*.txt') }} - - name: cache pre-commit - uses: actions/cache@v1 - with: - path: ~/.cache/pre-commit - key: pre-commit|${{ matrix.python }}|${{ hashFiles('.pre-commit-config.yaml') }} if: matrix.tox == 'style' - run: pip install tox - run: tox -e ${{ matrix.tox }} diff --git a/tox.ini b/tox.ini index 9ec517ba3..2ca13c904 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ skip_missing_interpreters = true [testenv] deps = -r requirements/tests.txt -commands = pytest --tb=short --basetemp={envtmpdir} {posargs} +commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs} [testenv:style] deps = pre-commit From ff06d50b56baa0ea8bf604cdd107c2c4c637ecd7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 8 Feb 2021 16:50:41 +0000 Subject: [PATCH 196/293] [pre-commit.ci] pre-commit autoupdate --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bcdd24762..3ba87e9a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,27 +1,27 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.4.3 + rev: v2.10.0 hooks: - id: pyupgrade args: ["--py36-plus"] - repo: https://github.com/asottile/reorder_python_imports - rev: v2.3.0 + rev: v2.4.0 hooks: - id: reorder-python-imports args: ["--application-directories", "src"] - - repo: https://github.com/ambv/black - rev: 19.10b0 + - repo: https://github.com/psf/black + rev: 20.8b1 hooks: - id: black - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.2 + rev: 3.8.4 hooks: - id: flake8 additional_dependencies: - flake8-bugbear - flake8-implicit-str-concat - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.1.0 + rev: v3.4.0 hooks: - id: check-byte-order-marker - id: trailing-whitespace From 3f44bac66138570ec9bfb7cefb8b90a5e4c9f91c Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 8 Feb 2021 17:31:44 -0800 Subject: [PATCH 197/293] apply updated pre-commit hooks --- src/click/globals.py | 2 +- tests/test_formatting.py | 22 +++++++++------------- tests/test_options.py | 4 ++-- tests/test_shell_completion.py | 4 +--- tests/test_termui.py | 2 +- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/click/globals.py b/src/click/globals.py index 1649f9a0b..2bdd178ba 100644 --- a/src/click/globals.py +++ b/src/click/globals.py @@ -36,7 +36,7 @@ def pop_context(): def resolve_color_default(color=None): - """"Internal helper to get the default value of the color flag. If a + """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. """ diff --git a/tests/test_formatting.py b/tests/test_formatting.py index f292c1671..9b501b1b6 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -52,13 +52,11 @@ def cli(): def test_wrapping_long_options_strings(runner): @click.group() def cli(): - """Top level command - """ + """Top level command""" @cli.group() def a_very_long(): - """Second level - """ + """Second level""" @a_very_long.command() @click.argument("first") @@ -68,8 +66,7 @@ def a_very_long(): @click.argument("fifth") @click.argument("sixth") def command(): - """A command. - """ + """A command.""" # 54 is chosen as a length where the second line is one character # longer than the maximum length. @@ -90,13 +87,11 @@ def command(): def test_wrapping_long_command_name(runner): @click.group() def cli(): - """Top level command - """ + """Top level command""" @cli.group() def a_very_very_very_long(): - """Second level - """ + """Second level""" @a_very_very_very_long.command() @click.argument("first") @@ -106,8 +101,7 @@ def a_very_very_very_long(): @click.argument("fifth") @click.argument("sixth") def command(): - """A command. - """ + """A command.""" result = runner.invoke( cli, ["a-very-very-very-long", "command", "--help"], terminal_width=54 @@ -128,9 +122,11 @@ def command(): def test_formatting_empty_help_lines(runner): @click.command() def cli(): + # fmt: off """Top level command """ + # fmt: on result = runner.invoke(cli, ["--help"]) assert not result.exception @@ -306,7 +302,7 @@ def test_global_show_default(runner): def cli(): pass - result = runner.invoke(cli, ["--help"],) + result = runner.invoke(cli, ["--help"]) assert result.output.splitlines() == [ "Usage: cli [OPTIONS]", "", diff --git a/tests/test_options.py b/tests/test_options.py index f26761659..2c049414e 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -488,7 +488,7 @@ def test_option_help_preserve_paragraphs(runner): def cmd(config): pass - result = runner.invoke(cmd, ["--help"],) + result = runner.invoke(cmd, ["--help"]) assert result.exit_code == 0 i = " " * 21 assert ( @@ -653,7 +653,7 @@ def test_show_default_boolean_flag_value(runner): value, not the opt name. """ opt = click.Option( - ("--cache",), is_flag=True, show_default=True, help="Enable the cache.", + ("--cache",), is_flag=True, show_default=True, help="Enable the cache." ) ctx = click.Context(click.Command("test")) message = opt.get_help_record(ctx)[1] diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 40cb88f12..ac34ef96c 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -263,9 +263,7 @@ def _patch_for_completion(monkeypatch): ) -@pytest.mark.parametrize( - "shell", ["bash", "zsh", "fish"], -) +@pytest.mark.parametrize("shell", ["bash", "zsh", "fish"]) @pytest.mark.usefixtures("_patch_for_completion") def test_full_source(runner, shell): cli = Group("cli", commands=[Command("a"), Command("b")]) diff --git a/tests/test_termui.py b/tests/test_termui.py index 956de778c..2b0380e88 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -380,7 +380,7 @@ def test_getchar_special_key_windows(runner, monkeypatch, special_key_char, key_ @pytest.mark.parametrize( - ("key_char", "exc"), [("\x03", KeyboardInterrupt), ("\x1a", EOFError)], + ("key_char", "exc"), [("\x03", KeyboardInterrupt), ("\x1a", EOFError)] ) @pytest.mark.skipif(not WIN, reason="Tests user-input using the msvcrt module.") def test_getchar_windows_exceptions(runner, monkeypatch, key_char, exc): From 7520f9e9b0737d8b14aa50244c7dad330cbfcd0f Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 8 Feb 2021 17:55:06 -0800 Subject: [PATCH 198/293] Rename config.yaml to config.yml --- .github/ISSUE_TEMPLATE/{config.yaml => config.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/ISSUE_TEMPLATE/{config.yaml => config.yml} (100%) diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/config.yaml rename to .github/ISSUE_TEMPLATE/config.yml From 7dd9b12b0d22e5a8a8674488684fd2bb799f2945 Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 9 Feb 2021 10:37:08 -0800 Subject: [PATCH 199/293] Rename lock.yml to lock.yaml --- .github/workflows/{lock.yml => lock.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{lock.yml => lock.yaml} (100%) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yaml similarity index 100% rename from .github/workflows/lock.yml rename to .github/workflows/lock.yaml From 32eec714347a47e15cd2f009674dc591d4064401 Mon Sep 17 00:00:00 2001 From: Benjamin Akhras Date: Fri, 15 Jan 2021 11:47:26 +0100 Subject: [PATCH 200/293] show_default string is shown if default is None --- CHANGES.rst | 2 ++ src/click/core.py | 7 +++++-- tests/test_options.py | 8 ++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 343c817ff..ea2b00b7f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -157,6 +157,8 @@ Unreleased ``Choice`` :issue:`1692` - Use ``mkstemp()`` instead of ``mktemp()`` in pager implementation. :issue:`1752` +- If ``Option.show_default`` is a string, it is displayed even if + ``default`` is ``None``. :issue:`1732` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 227025dc0..d04becbe8 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2405,9 +2405,12 @@ def _write_opts(opts): extra.append(f"env var: {var_str}") default_value = self.get_default(ctx, call=False) + show_default_is_str = isinstance(self.show_default, str) - if default_value is not None and (self.show_default or ctx.show_default): - if isinstance(self.show_default, str): + if show_default_is_str or ( + default_value is not None and (self.show_default or ctx.show_default) + ): + if show_default_is_str: default_string = f"({self.show_default})" elif isinstance(default_value, (list, tuple)): default_string = ", ".join(str(d) for d in default_value) diff --git a/tests/test_options.py b/tests/test_options.py index 2c049414e..e9801b1ee 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -660,6 +660,14 @@ def test_show_default_boolean_flag_value(runner): assert "[default: False]" in message +def test_show_default_string(runner): + """When show_default is a string show that value as default.""" + opt = click.Option(["--limit"], show_default="unlimited") + ctx = click.Context(click.Command("cli")) + message = opt.get_help_record(ctx)[1] + assert "[default: (unlimited)]" in message + + @pytest.mark.parametrize( ("args", "expect"), [ From 5ab1ac229558b40ac87a16ac80603d0330c2e6e1 Mon Sep 17 00:00:00 2001 From: Jon Dufresne Date: Tue, 22 Dec 2020 15:01:06 -0800 Subject: [PATCH 201/293] Deprecate click.get_terminal_size() in favor of stdlib shutil. Since Python 3.3, the stdlib provides the function shutil.get_terminal_size(). Now that the project has dropped Python 2 support, the compatibility shim is no longer necessary. The stdlib version returns a named tuple. So rather than indexing "0", can use the more self-documenting attribute "columns". Docs available at: https://docs.python.org/3/library/shutil.html#shutil.get_terminal_size --- CHANGES.rst | 2 ++ src/click/_compat.py | 11 --------- src/click/_termui_impl.py | 4 ++-- src/click/formatting.py | 5 ++-- src/click/termui.py | 50 ++++++++++----------------------------- 5 files changed, 19 insertions(+), 53 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index ea2b00b7f..9d8d778b9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -159,6 +159,8 @@ Unreleased :issue:`1752` - If ``Option.show_default`` is a string, it is displayed even if ``default`` is ``None``. :issue:`1732` +- ``click.get_terminal_size()`` is deprecated and will be removed in + 8.1. Use :func:`shutil.get_terminal_size` instead. :issue:`1736` Version 7.1.2 diff --git a/src/click/_compat.py b/src/click/_compat.py index 85568ca3e..3cf54326c 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -12,10 +12,8 @@ "SERVER_SOFTWARE", "" ) WIN = sys.platform.startswith("win") and not APP_ENGINE and not MSYS2 -DEFAULT_COLUMNS = 80 auto_wrap_for_ansi = None colorama = None -get_winterm_size = None _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") @@ -496,9 +494,6 @@ def should_strip_ansi(stream=None, color=None): # colorama. This will make ANSI colors through the echo function # work automatically. if WIN: - # Windows has a smaller terminal - DEFAULT_COLUMNS = 79 - from ._winconsole import _get_windows_console_stream def _get_argv_encoding(): @@ -544,12 +539,6 @@ def _safe_write(s): pass return rv - def get_winterm_size(): - win = colorama.win32.GetConsoleScreenBufferInfo( - colorama.win32.STDOUT - ).srWindow - return win.Right - win.Left, win.Bottom - win.Top - else: diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 82277db3b..d4e3e88b2 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -224,7 +224,7 @@ def format_progress_line(self): ).rstrip() def render_progress(self): - from .termui import get_terminal_size + import shutil if self.is_hidden: return @@ -235,7 +235,7 @@ def render_progress(self): 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) + new_width = max(0, shutil.get_terminal_size().columns - clutter_length) if new_width < old_width: buf.append(BEFORE_BAR) buf.append(" " * self.max_width) diff --git a/src/click/formatting.py b/src/click/formatting.py index a298c2e65..3edb09f32 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -2,7 +2,6 @@ from ._compat import term_len from .parser import split_opt -from .termui import get_terminal_size # Can force a width. This is used by the test system FORCED_WIDTH = None @@ -104,13 +103,15 @@ class HelpFormatter: """ def __init__(self, indent_increment=2, width=None, max_width=None): + import shutil + self.indent_increment = indent_increment if max_width is None: max_width = 80 if width is None: width = FORCED_WIDTH if width is None: - width = max(min(get_terminal_size()[0], max_width) - 2, 50) + width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50) self.width = width self.current_indent = 0 self.buffer = [] diff --git a/src/click/termui.py b/src/click/termui.py index 0801af33c..c71711e66 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -2,11 +2,8 @@ import io import itertools import os -import struct import sys -from ._compat import DEFAULT_COLUMNS -from ._compat import get_winterm_size from ._compat import is_bytes from ._compat import isatty from ._compat import strip_ansi @@ -215,44 +212,21 @@ def confirm( def get_terminal_size(): """Returns the current size of the terminal as tuple in the form ``(width, height)`` in columns and rows. + + .. deprecated:: 8.0 + Will be removed in Click 8.1. Use + :func:`shutil.get_terminal_size` instead. """ import shutil + import warnings - if hasattr(shutil, "get_terminal_size"): - return shutil.get_terminal_size() - - # 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: - size = get_winterm_size() - if size == (0, 0): - return (79, 24) - else: - return size - - def ioctl_gwinsz(fd): - try: - import fcntl - import termios - - cr = struct.unpack("hh", fcntl.ioctl(fd, termios.TIOCGWINSZ, "1234")) - except Exception: - return - return cr - - cr = ioctl_gwinsz(0) or ioctl_gwinsz(1) or ioctl_gwinsz(2) - if not cr: - try: - fd = os.open(os.ctermid(), os.O_RDONLY) - try: - cr = ioctl_gwinsz(fd) - finally: - os.close(fd) - except Exception: - pass - if not cr or not cr[0] or not cr[1]: - cr = (os.environ.get("LINES", 25), os.environ.get("COLUMNS", DEFAULT_COLUMNS)) - return int(cr[1]), int(cr[0]) + warnings.warn( + "'click.get_terminal_size()' is deprecated and will be removed" + " in Click 8.1. Use 'shutil.get_terminal_size()' instead.", + DeprecationWarning, + stacklevel=2, + ) + return shutil.get_terminal_size() def echo_via_pager(text_or_generator, color=None): From 9a0b219c0aa2c92ad93c91301f95dc925ae68261 Mon Sep 17 00:00:00 2001 From: Benjamin Akhras Date: Mon, 15 Feb 2021 21:48:08 +0100 Subject: [PATCH 202/293] test for empty default with show_default=True --- tests/test_options.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_options.py b/tests/test_options.py index e9801b1ee..611ba38c1 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -668,6 +668,14 @@ def test_show_default_string(runner): assert "[default: (unlimited)]" in message +def test_do_not_show_no_default(runner): + """When show_default is True and no default is set do not show None.""" + opt = click.Option(["--limit"], show_default=True) + ctx = click.Context(click.Command("cli")) + message = opt.get_help_record(ctx)[1] + assert "[default: None]" not in message + + @pytest.mark.parametrize( ("args", "expect"), [ From a9711c50be5ee37104068b309431c5ab02968b8f Mon Sep 17 00:00:00 2001 From: Diaga Date: Mon, 23 Nov 2020 16:25:12 +0500 Subject: [PATCH 203/293] custom temp directory with CLIRunner.isolated_filesystem Co-authored-by: David Lord --- CHANGES.rst | 3 +++ docs/testing.rst | 13 +++++++++++++ src/click/testing.py | 28 ++++++++++++++++++++-------- tests/test_testing.py | 15 +++++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9d8d778b9..a346edc58 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -161,6 +161,9 @@ Unreleased ``default`` is ``None``. :issue:`1732` - ``click.get_terminal_size()`` is deprecated and will be removed in 8.1. Use :func:`shutil.get_terminal_size` instead. :issue:`1736` +- Control the location of the temporary directory created by + ``CLIRunner.isolated_filesystem`` by passing ``temp_dir``. A custom + directory will not be removed automatically. :issue:`395` Version 7.1.2 diff --git a/docs/testing.rst b/docs/testing.rst index 57acaf2bb..baacefc01 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -112,6 +112,19 @@ current working directory to a new, empty folder. assert result.exit_code == 0 assert result.output == 'Hello World!\n' +Pass ``temp_dir`` to control where the temporary directory is created. +The directory will not be removed by Click in this case. This is useful +to integrate with a framework like Pytest that manages temporary files. + +.. code-block:: python + + def test_keep_dir(tmp_path): + runner = CliRunner() + + with runner.isolated_filesystem(temp_dir=tmp_path) as td: + ... + + Input Streams ------------- diff --git a/src/click/testing.py b/src/click/testing.py index 9443556a4..8cd6a1d55 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -380,18 +380,30 @@ def invoke( ) @contextlib.contextmanager - def isolated_filesystem(self): - """A context manager that creates a temporary folder and changes - the current working directory to it for isolated filesystem tests. + def isolated_filesystem(self, temp_dir=None): + """A context manager that creates a temporary directory and + changes the current working directory to it. This isolates tests + that affect the contents of the CWD to prevent them from + interfering with each other. + + :param temp_dir: Create the temporary directory under this + directory. If given, the created directory is not removed + when exiting. + + .. versionchanged:: 8.0 + Added the ``temp_dir`` parameter. """ cwd = os.getcwd() - t = tempfile.mkdtemp() + t = tempfile.mkdtemp(dir=temp_dir) os.chdir(t) + try: yield t finally: os.chdir(cwd) - try: - shutil.rmtree(t) - except OSError: # noqa: B014 - pass + + if temp_dir is None: + try: + shutil.rmtree(t) + except OSError: # noqa: B014 + pass diff --git a/tests/test_testing.py b/tests/test_testing.py index 758e173f5..8b4059635 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -319,3 +319,18 @@ def cli(f): result = runner.invoke(cli, ["-"]) assert result.output == "\nr" + + +def test_isolated_runner(runner): + with runner.isolated_filesystem() as d: + assert os.path.exists(d) + + assert not os.path.exists(d) + + +def test_isolated_runner_custom_tempdir(runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path) as d: + assert os.path.exists(d) + + assert os.path.exists(d) + os.rmdir(d) From 5a77d69224936898e81010bb1c78fcb28e534318 Mon Sep 17 00:00:00 2001 From: Iwan Aucamp Date: Mon, 1 Feb 2021 00:43:14 +0100 Subject: [PATCH 204/293] initial type checking configuration add minimal annotations to pass checks --- .github/workflows/tests.yaml | 1 + requirements/dev.in | 1 + requirements/dev.txt | 8 ++++ requirements/typing.in | 1 + requirements/typing.txt | 14 +++++++ setup.cfg | 20 ++++++++++ src/click/_compat.py | 15 +++++--- src/click/_winconsole.py | 69 ++++++++++++++++------------------- src/click/core.py | 6 ++- src/click/formatting.py | 3 +- src/click/shell_completion.py | 11 +++--- src/click/termui.py | 3 +- src/click/types.py | 7 ++-- tests/test_imports.py | 2 + tox.ini | 5 +++ 15 files changed, 111 insertions(+), 55 deletions(-) create mode 100644 requirements/typing.in create mode 100644 requirements/typing.txt diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 2291f4414..f7535c704 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -23,6 +23,7 @@ jobs: - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} - {name: 'PyPy', python: pypy3, os: ubuntu-latest, tox: pypy3} + - {name: Typing, python: '3.9', os: ubuntu-latest, tox: typing} - {name: Docs, python: '3.9', os: ubuntu-latest, tox: docs} steps: - uses: actions/checkout@v2 diff --git a/requirements/dev.in b/requirements/dev.in index c854000e4..2588467c1 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -1,5 +1,6 @@ -r docs.in -r tests.in +-r typing.in pip-tools pre-commit tox diff --git a/requirements/dev.txt b/requirements/dev.txt index cc6d59ced..2f25e5c0b 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -44,6 +44,10 @@ jinja2==2.11.3 # via sphinx markupsafe==1.1.1 # via jinja2 +mypy-extensions==0.4.3 + # via mypy +mypy==0.800 + # via -r requirements/typing.in nodeenv==1.5.0 # via pre-commit packaging==20.9 @@ -118,6 +122,10 @@ toml==0.10.2 # tox tox==3.21.4 # via -r requirements/dev.in +typed-ast==1.4.2 + # via mypy +typing-extensions==3.7.4.3 + # via mypy urllib3==1.26.3 # via requests virtualenv==20.4.2 diff --git a/requirements/typing.in b/requirements/typing.in new file mode 100644 index 000000000..f0aa93ac8 --- /dev/null +++ b/requirements/typing.in @@ -0,0 +1 @@ +mypy diff --git a/requirements/typing.txt b/requirements/typing.txt new file mode 100644 index 000000000..2530301a5 --- /dev/null +++ b/requirements/typing.txt @@ -0,0 +1,14 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/typing.in +# +mypy-extensions==0.4.3 + # via mypy +mypy==0.800 + # via -r requirements/typing.in +typed-ast==1.4.2 + # via mypy +typing-extensions==3.7.4.3 + # via mypy diff --git a/setup.cfg b/setup.cfg index 51075534e..1f98dc5ac 100644 --- a/setup.cfg +++ b/setup.cfg @@ -70,3 +70,23 @@ max-line-length = 80 per-file-ignores = # __init__ module exports names src/click/__init__.py: F401 + +[mypy] +files = src/click +python_version = 3.6 +disallow_subclassing_any = True +# disallow_untyped_calls = True +# disallow_untyped_defs = True +disallow_incomplete_defs = True +no_implicit_optional = True +local_partial_types = True +# no_implicit_reexport = True +strict_equality = True +warn_redundant_casts = True +warn_unused_configs = True +warn_unused_ignores = True +warn_return_any = True +warn_unreachable = True + +[mypy-importlib_metadata.*] +ignore_missing_imports = True diff --git a/src/click/_compat.py b/src/click/_compat.py index 3cf54326c..e0acca3fd 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -3,6 +3,7 @@ import os import re import sys +import typing as t from weakref import WeakKeyDictionary CYGWIN = sys.platform.startswith("cygwin") @@ -12,8 +13,7 @@ "SERVER_SOFTWARE", "" ) WIN = sys.platform.startswith("win") and not APP_ENGINE and not MSYS2 -auto_wrap_for_ansi = None -colorama = None +auto_wrap_for_ansi: t.Optional[t.Callable[[t.TextIO], t.TextIO]] = None _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") @@ -493,7 +493,8 @@ def should_strip_ansi(stream=None, color=None): # If we're on Windows, we provide transparent integration through # colorama. This will make ANSI colors through the echo function # work automatically. -if WIN: +# NOTE: double check is needed so mypy does not analyze this on Linux +if sys.platform.startswith("win") and WIN: from ._winconsole import _get_windows_console_stream def _get_argv_encoding(): @@ -506,9 +507,13 @@ def _get_argv_encoding(): except ImportError: pass else: - _ansi_stream_wrappers = WeakKeyDictionary() + _ansi_stream_wrappers: t.MutableMapping[ + t.TextIO, t.TextIO + ] = WeakKeyDictionary() - def auto_wrap_for_ansi(stream, color=None): + def auto_wrap_for_ansi( + stream: t.TextIO, color: t.Optional[bool] = None + ) -> t.TextIO: """This function wraps a stream so that calls through colorama are issued to the win32 console API to recolor on demand. It also ensures to reset the colors if a write call is interrupted diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index 074dff75f..cd5d01234 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -6,8 +6,8 @@ # compared to the original patches as we do not need to patch # the entire interpreter but just work in our little world of # echo and prompt. -import ctypes import io +import sys import time from ctypes import byref from ctypes import c_char @@ -18,25 +18,18 @@ from ctypes import c_void_p from ctypes import POINTER from ctypes import py_object -from ctypes import windll -from ctypes import WINFUNCTYPE +from ctypes import Structure from ctypes.wintypes import DWORD from ctypes.wintypes import HANDLE from ctypes.wintypes import LPCWSTR from ctypes.wintypes import LPWSTR -import msvcrt - from ._compat import _NonClosingTextIOWrapper -try: - from ctypes import pythonapi -except ImportError: - pythonapi = None -else: - PyObject_GetBuffer = pythonapi.PyObject_GetBuffer - PyBuffer_Release = pythonapi.PyBuffer_Release - +assert sys.platform == "win32" +import msvcrt # noqa: E402 +from ctypes import windll # noqa: E402 +from ctypes import WINFUNCTYPE # noqa: E402 c_ssize_p = POINTER(c_ssize_t) @@ -50,16 +43,12 @@ CommandLineToArgvW = WINFUNCTYPE(POINTER(LPWSTR), LPCWSTR, POINTER(c_int))( ("CommandLineToArgvW", windll.shell32) ) -LocalFree = WINFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p)( - ("LocalFree", windll.kernel32) -) - +LocalFree = WINFUNCTYPE(c_void_p, c_void_p)(("LocalFree", windll.kernel32)) STDIN_HANDLE = GetStdHandle(-10) STDOUT_HANDLE = GetStdHandle(-11) STDERR_HANDLE = GetStdHandle(-12) - PyBUF_SIMPLE = 0 PyBUF_WRITABLE = 1 @@ -74,33 +63,37 @@ EOF = b"\x1a" MAX_BYTES_WRITTEN = 32767 - -class Py_buffer(ctypes.Structure): - _fields_ = [ - ("buf", c_void_p), - ("obj", py_object), - ("len", c_ssize_t), - ("itemsize", c_ssize_t), - ("readonly", c_int), - ("ndim", c_int), - ("format", c_char_p), - ("shape", c_ssize_p), - ("strides", c_ssize_p), - ("suboffsets", c_ssize_p), - ("internal", c_void_p), - ] - - -# On PyPy we cannot get buffers so our ability to operate here is -# severely limited. -if pythonapi is None: +try: + from ctypes import pythonapi +except ImportError: + # On PyPy we cannot get buffers so our ability to operate here is + # severely limited. get_buffer = None else: + class Py_buffer(Structure): + _fields_ = [ + ("buf", c_void_p), + ("obj", py_object), + ("len", c_ssize_t), + ("itemsize", c_ssize_t), + ("readonly", c_int), + ("ndim", c_int), + ("format", c_char_p), + ("shape", c_ssize_p), + ("strides", c_ssize_p), + ("suboffsets", c_ssize_p), + ("internal", c_void_p), + ] + + PyObject_GetBuffer = pythonapi.PyObject_GetBuffer + PyBuffer_Release = pythonapi.PyBuffer_Release + 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) diff --git a/src/click/core.py b/src/click/core.py index d04becbe8..7e78b5793 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2,6 +2,7 @@ import errno import os import sys +import typing as t from contextlib import contextmanager from contextlib import ExitStack from functools import update_wrapper @@ -1625,7 +1626,7 @@ class Group(MultiCommand): #: subcommands use a custom command class. #: #: .. versionadded:: 8.0 - command_class = None + command_class: t.Optional[t.Type[BaseCommand]] = None #: If set, this is used by the group's :meth:`group` decorator #: as the default :class:`Group` class. This is useful to make all @@ -1637,7 +1638,8 @@ class Group(MultiCommand): #: custom groups. #: #: .. versionadded:: 8.0 - group_class = None + group_class: t.Optional[t.Union[t.Type["Group"], t.Type[type]]] = None + # Literal[type] isn't valid, so use Type[type] def __init__(self, name=None, commands=None, **attrs): super().__init__(name, **attrs) diff --git a/src/click/formatting.py b/src/click/formatting.py index 3edb09f32..0d3c65e83 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -1,10 +1,11 @@ +import typing as t from contextlib import contextmanager from ._compat import term_len from .parser import split_opt # Can force a width. This is used by the test system -FORCED_WIDTH = None +FORCED_WIDTH: t.Optional[int] = None def measure_table(rows): diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index 9b10e25cf..d87e0c84d 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -1,5 +1,6 @@ import os import re +import typing as t from .core import Argument from .core import MultiCommand @@ -183,12 +184,12 @@ class ShellComplete: .. versionadded:: 8.0 """ - name = None + name: t.ClassVar[t.Optional[str]] = None """Name to register the shell as with :func:`add_completion_class`. This is used in completion instructions (``{name}_source`` and ``{name}_complete``). """ - source_template = None + source_template: t.ClassVar[t.Optional[str]] = None """Completion script template formatted by :meth:`source`. This must be provided by subclasses. """ @@ -312,7 +313,7 @@ def get_completion_args(self): return args, incomplete - def format_completion(self, item: CompletionItem): + def format_completion(self, item: CompletionItem) -> str: return f"{item.type},{item.value}" @@ -334,7 +335,7 @@ def get_completion_args(self): return args, incomplete - def format_completion(self, item: CompletionItem): + def format_completion(self, item: CompletionItem) -> str: return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}" @@ -356,7 +357,7 @@ def get_completion_args(self): return args, incomplete - def format_completion(self, item: CompletionItem): + def format_completion(self, item: CompletionItem) -> str: if item.help: return f"{item.type},{item.value}\t{item.help}" diff --git a/src/click/termui.py b/src/click/termui.py index c71711e66..d3c61c015 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -3,6 +3,7 @@ import itertools import os import sys +import typing as t from ._compat import is_bytes from ._compat import isatty @@ -651,7 +652,7 @@ def launch(url, wait=False, locate=False): # If this is provided, getchar() calls into this instead. This is used # for unittesting purposes. -_getchar = None +_getchar: t.Optional[t.Callable[[bool], str]] = None def getchar(echo=False): diff --git a/src/click/types.py b/src/click/types.py index 626287d30..6ca72c9db 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -1,5 +1,6 @@ import os import stat +import typing as t from datetime import datetime from ._compat import _get_argv_encoding @@ -33,7 +34,7 @@ class ParamType: is_composite = False #: the descriptive name of this type - name = None + name: t.Optional[str] = None #: if a list of this type is expected and the value is pulled from a #: string environment variable, this is what splits it up. `None` @@ -41,7 +42,7 @@ class ParamType: #: whitespace splits them up. The exception are paths and files which #: are split by ``os.path.pathsep`` by default (":" on Unix and ";" on #: Windows). - envvar_list_splitter = None + envvar_list_splitter: t.ClassVar[t.Optional[str]] = None def to_info_dict(self): """Gather information that could be useful for a tool generating @@ -345,7 +346,7 @@ def __repr__(self): class _NumberParamTypeBase(ParamType): - _number_class = None + _number_class: t.ClassVar[t.Optional[t.Type]] = None def convert(self, value, param, ctx): try: diff --git a/tests/test_imports.py b/tests/test_imports.py index 54d9559fb..d13b085a6 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -46,6 +46,8 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, "fcntl", "datetime", "enum", + "typing", + "types", } if WIN: diff --git a/tox.ini b/tox.ini index 2ca13c904..de68730f2 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py{39,38,37,36,py3} style + typing docs skip_missing_interpreters = true @@ -14,6 +15,10 @@ deps = pre-commit skip_install = true commands = pre-commit run --all-files --show-diff-on-failure +[testenv:typing] +deps = -r requirements/typing.txt +commands = mypy + [testenv:docs] deps = -r requirements/docs.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html From c2a5bb011c17ef18ecb9d8f96102fc6779c32900 Mon Sep 17 00:00:00 2001 From: yurihs Date: Sat, 14 Dec 2019 12:33:35 -0300 Subject: [PATCH 205/293] click.confirm with default=None repeats prompt --- CHANGES.rst | 2 ++ src/click/termui.py | 20 ++++++++++++++------ tests/test_utils.py | 8 ++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a346edc58..b8f63f993 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -164,6 +164,8 @@ Unreleased - Control the location of the temporary directory created by ``CLIRunner.isolated_filesystem`` by passing ``temp_dir``. A custom directory will not be removed automatically. :issue:`395` +- ``click.confirm()`` will prompt until input is given if called with + ``default=None``. :issue:`#1381` Version 7.1.2 diff --git a/src/click/termui.py b/src/click/termui.py index d3c61c015..d9a45d9c9 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -172,21 +172,29 @@ def confirm( If the user aborts the input by sending a interrupt signal this function will catch it and raise a :exc:`Abort` exception. - .. versionadded:: 4.0 - Added the `err` parameter. - :param text: the question to ask. - :param default: the default for the prompt. + :param default: The default value to use when no input is given. If + ``None``, repeat until input is given. :param abort: if this is set to `True` a negative answer aborts the exception by raising :exc:`Abort`. :param prompt_suffix: a suffix that should be added to the prompt. :param show_default: shows or hides the default value in the prompt. :param err: if set to true the file defaults to ``stderr`` instead of ``stdout``, the same as with echo. + + .. versionchanged:: 8.0 + Repeat until input is given if ``default`` is ``None``. + + .. versionadded:: 4.0 + Added the ``err`` parameter. """ prompt = _build_prompt( - text, prompt_suffix, show_default, "Y/n" if default else "y/N" + text, + prompt_suffix, + show_default, + "y/n" if default is None else ("Y/n" if default else "y/N"), ) + while 1: try: # Write the prompt separately so that we get nice @@ -199,7 +207,7 @@ def confirm( rv = True elif value in ("n", "no"): rv = False - elif value == "": + elif default is not None and value == "": rv = default else: echo("Error: invalid input", err=err) diff --git a/tests/test_utils.py b/tests/test_utils.py index a5dfbb8b9..cc0893dab 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -131,6 +131,14 @@ def test_no(): assert result.output == "Foo [Y/n]: n\nno :(\n" +def test_confirm_repeat(runner): + cli = click.Command( + "cli", params=[click.Option(["--a/--no-a"], default=None, prompt=True)] + ) + result = runner.invoke(cli, input="\ny\n") + assert result.output == "A [y/n]: \nError: invalid input\nA [y/n]: y\n" + + @pytest.mark.skipif(WIN, reason="Different behavior on windows.") def test_prompts_abort(monkeypatch, capsys): def f(_): From 116f32eb0b154aafc57e2d3d534cfcf65738c409 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 15 Feb 2021 16:28:56 -0800 Subject: [PATCH 206/293] use rtd to build docs for prs --- .github/workflows/tests.yaml | 1 - .readthedocs.yaml | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f7535c704..97d9b0e42 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -24,7 +24,6 @@ jobs: - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} - {name: 'PyPy', python: pypy3, os: ubuntu-latest, tox: pypy3} - {name: Typing, python: '3.9', os: ubuntu-latest, tox: typing} - - {name: Docs, python: '3.9', os: ubuntu-latest, tox: docs} steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 190695202..0c363636f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,3 +6,4 @@ python: path: . sphinx: builder: dirhtml + fail_on_warning: true From 336a73a85ed26229f15576855d29740b1b7ccf92 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 15 Feb 2021 16:38:41 -0800 Subject: [PATCH 207/293] skip code tests when only docs change --- .github/workflows/tests.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 97d9b0e42..bfef7d4f8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -4,10 +4,18 @@ on: branches: - master - '*.x' + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' pull_request: branches: - master - '*.x' + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' jobs: tests: name: ${{ matrix.name }} From f7e6f25531b6feaea241b0f0d16c3134c65173bf Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 15 Feb 2021 17:08:06 -0800 Subject: [PATCH 208/293] fix some examples in options docs --- docs/options.rst | 63 +++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/docs/options.rst b/docs/options.rst index fafa6fdac..1c7a178dc 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -448,27 +448,30 @@ useful for password input: .. click:example:: - @click.command() - @click.option('--password', prompt=True, hide_input=True, - confirmation_prompt=True) - def encrypt(password): - click.echo(f"Encrypting password to {password.encode('rot13')}") + import codecs -What it looks like: + @click.command() + @click.option( + "--password", prompt=True, hide_input=True, + confirmation_prompt=True + ) + def encode(password): + click.echo(f"encoded: {codecs.encode(password, 'rot13')}") .. click:run:: - invoke(encrypt, input=['secret', 'secret']) + invoke(encode, input=['secret', 'secret']) Because this combination of parameters is quite common, this can also be replaced with the :func:`password_option` decorator: -.. click:example:: +.. code-block:: python @click.command() @click.password_option() def encrypt(password): - click.echo(f"Encrypting password to {password.encode('rot13')}") + click.echo(f"encoded: to {codecs.encode(password, 'rot13')}") + Dynamic Defaults for Prompts ---------------------------- @@ -483,28 +486,37 @@ 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:: +.. code-block:: python + + import os @click.command() - @click.option('--username', prompt=True, - default=lambda: os.environ.get('USER', '')) + @click.option( + "--username", prompt=True, + default=lambda: os.environ.get("USER", "") + ) def hello(username): - print("Hello,", username) + click.echo(f"Hello, {username}!") To describe what the default value will be, set it in ``show_default``. .. click:example:: + import os + @click.command() - @click.option('--username', prompt=True, - default=lambda: os.environ.get('USER', ''), - show_default='current user') + @click.option( + "--username", prompt=True, + default=lambda: os.environ.get("USER", ""), + show_default="current user" + ) def hello(username): - print("Hello,", username) + click.echo(f"Hello, {username}!") .. click:run:: - invoke(hello, args=['--help']) + invoke(hello, args=["--help"]) + Callbacks and Eager Options --------------------------- @@ -830,27 +842,24 @@ Example: def validate_rolls(ctx, param, value): try: - rolls, dice = map(int, value.split('d', 2)) - return (dice, rolls) + rolls, _, dice = value.partition("d") + return int(dice), int(rolls) except ValueError: - raise click.BadParameter('rolls need to be in format NdM') + raise click.BadParameter("format must be 'NdM'") @click.command() - @click.option('--rolls', callback=validate_rolls, default='1d6') + @click.option("--rolls", callback=validate_rolls, default="1d6") def roll(rolls): sides, times = rolls click.echo(f"Rolling a {sides}-sided dice {times} time(s)") - if __name__ == '__main__': - roll() - And what it looks like: .. click:run:: - invoke(roll, args=['--rolls=42']) + invoke(roll, args=["--rolls=42"]) println() - invoke(roll, args=['--rolls=2d12']) + invoke(roll, args=["--rolls=2d12"]) .. _optional-value: From d7fac6e16099005c642f4dcb71d84a091a787073 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 15 Feb 2021 18:22:21 -0800 Subject: [PATCH 209/293] always install colorama on Windows --- CHANGES.rst | 2 + docs/quickstart.rst | 10 ++--- docs/utils.rst | 14 ++----- examples/colors/README | 2 +- examples/colors/colors.py | 5 +-- examples/colors/setup.py | 6 +-- examples/termui/setup.py | 6 +-- requirements/dev.txt | 2 - requirements/tests.in | 1 - requirements/tests.txt | 2 - setup.py | 2 +- src/click/_compat.py | 80 +++++++++++++++++++-------------------- src/click/termui.py | 3 -- src/click/utils.py | 77 ++++++++++++++++--------------------- tests/test_imports.py | 3 +- 15 files changed, 89 insertions(+), 126 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b8f63f993..dd1d45ff9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,8 @@ Version 8.0 Unreleased - Drop support for Python 2 and 3.5. +- Colorama is always installed on Windows in order to provide style + and color support. :pr:`1784` - Adds a repr to Command, showing the command name for friendlier debugging. :issue:`1267`, :pr:`1295` - Add support for distinguishing the source of a command line diff --git a/docs/quickstart.rst b/docs/quickstart.rst index ae23bfd8a..3ea39998a 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -108,7 +108,7 @@ as in the GitHub repository together with readme files: `_ * ``validation``: `Custom parameter validation example `_ -* ``colors``: `Colorama ANSI color support +* ``colors``: `Color support demo `_ * ``termui``: `Terminal UI functions demo `_ @@ -166,10 +166,10 @@ What this means is that the :func:`echo` function applies some error correction in case the terminal is misconfigured instead of dying with an :exc:`UnicodeError`. -As an added benefit, starting with Click 2.0, the echo function also -has good support for ANSI colors. It will automatically strip ANSI codes -if the output stream is a file and if colorama is supported, ANSI colors -will also work on Windows. See :ref:`ansi-colors`. +The echo function also supports color and other styles in output. It +will automatically remove styles if the output stream is a file. On +Windows, colorama is automatically installed and used. See +:ref:`ansi-colors`. If you don't need this, you can also use the `print()` construct / function. diff --git a/docs/utils.rst b/docs/utils.rst index 6338df941..fb7d3c2bd 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -52,10 +52,8 @@ ANSI Colors .. versionadded:: 2.0 -The :func:`echo` function gained extra functionality to deal with ANSI -colors and styles. Note that on Windows, this functionality is only -available if `colorama`_ is installed. If it is installed, then ANSI -codes are intelligently handled. +The :func:`echo` function supports ANSI colors and styles. On Windows +this uses `colorama`_. Primarily this means that: @@ -66,12 +64,8 @@ Primarily this means that: that colors will work on Windows the same way they do on other operating systems. -Note for `colorama` support: Click will automatically detect when `colorama` -is available and use it. Do *not* call ``colorama.init()``! - -To install `colorama`, run this command:: - - $ pip install colorama +On Windows, Click uses colorama without calling ``colorama.init()``. You +can still call that in your code, but it's not required for Click. For styling a string, the :func:`style` function can be used:: diff --git a/examples/colors/README b/examples/colors/README index 4b5b44f69..7aec8efb5 100644 --- a/examples/colors/README +++ b/examples/colors/README @@ -3,7 +3,7 @@ $ colors_ colors is a simple example that shows how you can colorize text. - For this to work on Windows, colorama is required. + Uses colorama on Windows. Usage: diff --git a/examples/colors/colors.py b/examples/colors/colors.py index cb7af3170..1d9441703 100644 --- a/examples/colors/colors.py +++ b/examples/colors/colors.py @@ -23,9 +23,8 @@ @click.command() def cli(): - """This script prints some colors. If colorama is installed this will - also work on Windows. It will also automatically remove all ANSI - styles if data is piped into a file. + """This script prints some colors. It will also automatically remove + all ANSI styles if data is piped into a file. Give it a try! """ diff --git a/examples/colors/setup.py b/examples/colors/setup.py index 6d892ddcb..3e1a59492 100644 --- a/examples/colors/setup.py +++ b/examples/colors/setup.py @@ -5,11 +5,7 @@ version="1.0", py_modules=["colors"], include_package_data=True, - install_requires=[ - "click", - # Colorama is only required for Windows. - "colorama", - ], + install_requires=["click"], entry_points=""" [console_scripts] colors=colors:cli diff --git a/examples/termui/setup.py b/examples/termui/setup.py index 7791baec2..c1ac109fe 100644 --- a/examples/termui/setup.py +++ b/examples/termui/setup.py @@ -5,11 +5,7 @@ version="1.0", py_modules=["termui"], include_package_data=True, - install_requires=[ - "click", - # Colorama is only required for Windows. - "colorama", - ], + install_requires=["click"], entry_points=""" [console_scripts] termui=termui:cli diff --git a/requirements/dev.txt b/requirements/dev.txt index 2f25e5c0b..76b6cb4d3 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -20,8 +20,6 @@ chardet==4.0.0 # via requests click==7.1.2 # via pip-tools -colorama==0.4.4 - # via -r requirements/tests.in distlib==0.3.1 # via virtualenv docutils==0.16 diff --git a/requirements/tests.in b/requirements/tests.in index 721be61cf..dfeb6f4a3 100644 --- a/requirements/tests.in +++ b/requirements/tests.in @@ -1,3 +1,2 @@ pytest -colorama importlib_metadata diff --git a/requirements/tests.txt b/requirements/tests.txt index 6e6e2241c..d9628ac60 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,8 +6,6 @@ # attrs==20.3.0 # via pytest -colorama==0.4.4 - # via -r requirements/tests.in importlib-metadata==3.4.0 # via -r requirements/tests.in iniconfig==1.1.1 diff --git a/setup.py b/setup.py index 48bf7c047..c5cde9863 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,3 @@ from setuptools import setup -setup(name="click") +setup(name="click", install_requires=["colorama; platform_system == 'Windows'"]) diff --git a/src/click/_compat.py b/src/click/_compat.py index e0acca3fd..276a9e987 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -490,9 +490,8 @@ def should_strip_ansi(stream=None, color=None): return not color -# If we're on Windows, we provide transparent integration through -# colorama. This will make ANSI colors through the echo function -# work automatically. +# On Windows, wrap the output streams with colorama to support ANSI +# color codes. # NOTE: double check is needed so mypy does not analyze this on Linux if sys.platform.startswith("win") and WIN: from ._winconsole import _get_windows_console_stream @@ -502,47 +501,44 @@ def _get_argv_encoding(): return locale.getpreferredencoding() - try: + _ansi_stream_wrappers: t.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() + + def auto_wrap_for_ansi( + stream: t.TextIO, color: t.Optional[bool] = None + ) -> t.TextIO: + """Support ANSI color and style codes on Windows by wrapping a + stream with colorama. + """ + try: + cached = _ansi_stream_wrappers.get(stream) + except Exception: + cached = None + + if cached is not None: + return cached + import colorama - except ImportError: - pass - else: - _ansi_stream_wrappers: t.MutableMapping[ - t.TextIO, t.TextIO - ] = WeakKeyDictionary() - - def auto_wrap_for_ansi( - stream: t.TextIO, color: t.Optional[bool] = None - ) -> t.TextIO: - """This function wraps a stream so that calls through colorama - are issued to the win32 console API to recolor on demand. It - also ensures to reset the colors if a write call is interrupted - to not destroy the console afterwards. - """ - try: - cached = _ansi_stream_wrappers.get(stream) - except Exception: - cached = None - if cached is not None: - return cached - strip = should_strip_ansi(stream, color) - ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) - rv = ansi_wrapper.stream - _write = rv.write - - def _safe_write(s): - try: - return _write(s) - except BaseException: - ansi_wrapper.reset_all() - raise - - rv.write = _safe_write + + strip = should_strip_ansi(stream, color) + ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) + rv = ansi_wrapper.stream + _write = rv.write + + def _safe_write(s): try: - _ansi_stream_wrappers[stream] = rv - except Exception: - pass - return rv + return _write(s) + except BaseException: + ansi_wrapper.reset_all() + raise + + rv.write = _safe_write + + try: + _ansi_stream_wrappers[stream] = rv + except Exception: + pass + + return rv else: diff --git a/src/click/termui.py b/src/click/termui.py index d9a45d9c9..eb559af1e 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -418,9 +418,6 @@ def clear(): """ if not isatty(sys.stdout): return - # If we're on Windows and we don't have colorama available, then we - # clear the screen by shelling out. Otherwise we can use an escape - # sequence. if WIN: os.system("cls") else: diff --git a/src/click/utils.py b/src/click/utils.py index dfb84ce33..e889a4e15 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -172,51 +172,43 @@ def __iter__(self): def echo(message=None, file=None, nl=True, err=False, color=None): - """Prints a message plus a newline to the given file or stdout. On - first sight, this looks like the print function, but it has improved - support for handling Unicode and binary data that does not fail no - matter how badly configured the system is. - - 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 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. - - In addition to that, if `colorama`_ is installed, the echo function will - also support clever handling of ANSI codes. Essentially it will then - do the following: - - - add transparent handling of ANSI color codes on Windows. - - hide ANSI codes automatically if the destination file is not a - terminal. - - .. _colorama: https://pypi.org/project/colorama/ + """Print a message and newline to stdout or a file. This should be + used instead of :func:`print` because it provides better support + for different data, files, and environments. + + Compared to :func:`print`, this does the following: + + - Ensures that the output encoding is not misconfigured on Linux. + - Supports Unicode in the Windows console. + - Supports writing to binary outputs, and supports writing bytes + to text outputs. + - Supports colors and styles on Windows. + - Removes ANSI color and style codes if the output does not look + like an interactive terminal. + - Always flushes the output. + + :param message: The string or bytes to output. Other objects are + converted to strings. + :param file: The file to write to. Defaults to ``stdout``. + :param err: Write to ``stderr`` instead of ``stdout``. + :param nl: Print a newline after the message. Enabled by default. + :param color: Force showing or hiding colors and other styles. By + default Click will remove color if the output does not look like + an interactive terminal. .. versionchanged:: 6.0 - As of Click 6.0 the echo function will properly support unicode - output on the windows console. Not that click does not modify - the interpreter in any way which means that `sys.stdout` or the - print statement or function will still not provide unicode support. + Support Unicode output on the Windows console. Click does not + modify ``sys.stdout``, so ``sys.stdout.write()`` and ``print()`` + will still not support Unicode. - .. versionchanged:: 2.0 - Starting with version 2.0 of Click, the echo function will work - with colorama if it's installed. + .. versionchanged:: 4.0 + Added the ``color`` parameter. .. versionadded:: 3.0 - The `err` parameter was added. + Added the ``err`` parameter. - .. versionchanged:: 4.0 - Added the `color` flag. - - :param message: the message to print - :param file: the file to write to (defaults to ``stdout``) - :param err: if set to true the file defaults to ``stderr`` instead of - ``stdout``. This is faster and easier than calling - :func:`get_text_stderr` yourself. - :param nl: if set to `True` (the default) a newline is printed afterwards. - :param color: controls if the terminal supports ANSI colors or not. The - default is autodetection. + .. versionchanged:: 2.0 + Support colors on Windows if colorama is installed. """ if file is None: if err: @@ -247,11 +239,8 @@ def echo(message=None, file=None, nl=True, err=False, color=None): binary_file.flush() return - # ANSI-style support. If there is no message or we are dealing with - # bytes nothing is happening. If we are connected to a file we want - # to strip colors. If we are on windows we either wrap the stream - # to strip the color or we use the colorama support to translate the - # ansi codes to API calls. + # ANSI style code support. For no message or bytes, nothing happens. + # When outputting to a file instead of a terminal, strip codes. if message and not is_bytes(message): color = resolve_color_default(color) if should_strip_ansi(file, color): diff --git a/tests/test_imports.py b/tests/test_imports.py index d13b085a6..dd26972ab 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -41,7 +41,6 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, "itertools", "io", "threading", - "colorama", "errno", "fcntl", "datetime", @@ -51,7 +50,7 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, } if WIN: - ALLOWED_IMPORTS.update(["ctypes", "ctypes.wintypes", "msvcrt", "time", "zlib"]) + ALLOWED_IMPORTS.update(["ctypes", "ctypes.wintypes", "msvcrt", "time"]) def test_light_imports(): From 493e8ed459ba37c04efd6364d968276cc025df47 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 24 Feb 2021 09:54:51 -0800 Subject: [PATCH 210/293] update project links --- README.rst | 13 +++++++------ docs/conf.py | 8 +++++--- setup.cfg | 8 ++++++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index 8c4fea9cb..95b467a47 100644 --- a/README.rst +++ b/README.rst @@ -70,10 +70,11 @@ donate today`_. Links ----- -- Website: https://palletsprojects.com/p/click/ - Documentation: https://click.palletsprojects.com/ -- Releases: https://pypi.org/project/click/ -- Code: https://github.com/pallets/click -- Issue tracker: https://github.com/pallets/click/issues -- Test status: https://dev.azure.com/pallets/click/_build -- Official chat: https://discord.gg/t6rrQZH +- Changes: https://click.palletsprojects.com/changes/ +- PyPI Releases: https://pypi.org/project/click/ +- Source Code: https://github.com/pallets/click +- Issue Tracker: https://github.com/pallets/click/issues +- Website: https://palletsprojects.com/p/click +- Twitter: https://twitter.com/PalletsTeam +- Chat: https://discord.gg/pallets diff --git a/docs/conf.py b/docs/conf.py index ed2d13f2e..63956c50d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,11 +33,13 @@ html_theme_options = {"index_sidebar_logo": False} html_context = { "project_links": [ - ProjectLink("Donate to Pallets", "https://palletsprojects.com/donate"), - ProjectLink("Click Website", "https://palletsprojects.com/p/click/"), - ProjectLink("PyPI releases", "https://pypi.org/project/click/"), + ProjectLink("Donate", "https://palletsprojects.com/donate"), + ProjectLink("PyPI Releases", "https://pypi.org/project/click/"), ProjectLink("Source Code", "https://github.com/pallets/click/"), ProjectLink("Issue Tracker", "https://github.com/pallets/click/issues/"), + ProjectLink("Website", "https://palletsprojects.com/"), + ProjectLink("Twitter", "https://twitter.com/PalletsTeam"), + ProjectLink("Chat", "https://discord.gg/pallets"), ] } html_sidebars = { diff --git a/setup.cfg b/setup.cfg index 1f98dc5ac..f928c1124 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,9 +3,13 @@ name = click version = attr: click.__version__ url = https://palletsprojects.com/p/click/ project_urls = + Donate = https://palletsprojects.com/donate Documentation = https://click.palletsprojects.com/ - Code = https://github.com/pallets/click - Issue tracker = https://github.com/pallets/click/issues + Changes = https://click.palletsprojects.com/changes/ + Source Code = https://github.com/pallets/click/ + Issue Tracker = https://github.com/pallets/click/issues/ + Twitter = https://twitter.com/PalletsTeam + Chat = https://discord.gg/pallets license = BSD-3-Clause license_files = LICENSE.rst author = Armin Ronacher From 139071d1d314a4fe24ba6aed67906d94064b5632 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 24 Feb 2021 09:55:14 -0800 Subject: [PATCH 211/293] docs rename changelog to changes --- docs/{changelog.rst => changes.rst} | 0 docs/index.rst | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/{changelog.rst => changes.rst} (100%) diff --git a/docs/changelog.rst b/docs/changes.rst similarity index 100% rename from docs/changelog.rst rename to docs/changes.rst diff --git a/docs/index.rst b/docs/index.rst index 1e4ac44ff..ad965a36b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -102,6 +102,6 @@ Miscellaneous Pages :maxdepth: 2 contrib - changelog upgrading license + changes From 5ff34f8944b7881751e211df4af0b9b248d28cb7 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 24 Feb 2021 09:55:33 -0800 Subject: [PATCH 212/293] consistent typing config --- setup.cfg | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index f928c1124..314b16544 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,7 +44,7 @@ filterwarnings = [coverage:run] branch = true source = - src + click tests [coverage:paths] @@ -84,7 +84,7 @@ disallow_subclassing_any = True disallow_incomplete_defs = True no_implicit_optional = True local_partial_types = True -# no_implicit_reexport = True +no_implicit_reexport = True strict_equality = True warn_redundant_casts = True warn_unused_configs = True @@ -92,5 +92,8 @@ warn_unused_ignores = True warn_return_any = True warn_unreachable = True +[mypy-click] +no_implicit_reexport = False + [mypy-importlib_metadata.*] ignore_missing_imports = True From ac82caf031d41377f9b3b95116b0d3338c3e1d27 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 24 Feb 2021 09:55:49 -0800 Subject: [PATCH 213/293] cache mypy in ci --- .github/workflows/tests.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index bfef7d4f8..d52088f92 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -46,10 +46,15 @@ jobs: id: pip-cache run: echo "::set-output name=dir::$(pip cache dir)" - name: cache pip - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ${{ steps.pip-cache.outputs.dir }} key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('setup.py') }}|${{ hashFiles('requirements/*.txt') }} - if: matrix.tox == 'style' + - name: cache mypy + uses: actions/cache@v2 + with: + path: ./.mypy_cache + key: mypy|${{ matrix.python }}|${{ hashFiles('setup.cfg') }} + if: matrix.tox == 'typing' - run: pip install tox - run: tox -e ${{ matrix.tox }} From 05c38db227a84e1d92d814003f1339a9a2fae0d1 Mon Sep 17 00:00:00 2001 From: Saif807380 Date: Tue, 16 Feb 2021 18:44:45 +0530 Subject: [PATCH 214/293] option callback is invoked when validating prompt value --- CHANGES.rst | 4 +++- docs/options.rst | 19 ++++++++++++------- src/click/core.py | 37 +++++++++++++++++-------------------- tests/test_arguments.py | 2 +- tests/test_options.py | 18 +++++++++++++++++- 5 files changed, 50 insertions(+), 30 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index dd1d45ff9..4e0243ab6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -167,7 +167,9 @@ Unreleased ``CLIRunner.isolated_filesystem`` by passing ``temp_dir``. A custom directory will not be removed automatically. :issue:`395` - ``click.confirm()`` will prompt until input is given if called with - ``default=None``. :issue:`#1381` + ``default=None``. :issue:`1381` +- Option prompts validate the value with the option's callback in + addition to its type. :issue:`457` Version 7.1.2 diff --git a/docs/options.rst b/docs/options.rst index 1c7a178dc..277a98baa 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -828,19 +828,21 @@ Callbacks for Validation .. versionchanged:: 2.0 If you want to apply custom validation logic, you can do this in the -parameter callbacks. These callbacks can both modify values as well as -raise errors if the validation does not work. +parameter callbacks. These callbacks can both modify values as well as +raise errors if the validation does not work. The callback runs after +type conversion. It is called for all sources, including prompts. In Click 1.0, you can only raise the :exc:`UsageError` but starting with Click 2.0, you can also raise the :exc:`BadParameter` error, which has the added advantage that it will automatically format the error message to also contain the parameter name. -Example: - .. click:example:: def validate_rolls(ctx, param, value): + if isinstance(value, tuple): + return value + try: rolls, _, dice = value.partition("d") return int(dice), int(rolls) @@ -848,18 +850,21 @@ Example: raise click.BadParameter("format must be 'NdM'") @click.command() - @click.option("--rolls", callback=validate_rolls, default="1d6") + @click.option( + "--rolls", type=click.UNPROCESSED, callback=validate_rolls, + default="1d6", prompt=True, + ) def roll(rolls): sides, times = rolls click.echo(f"Rolling a {sides}-sided dice {times} time(s)") -And what it looks like: - .. click:run:: invoke(roll, args=["--rolls=42"]) println() invoke(roll, args=["--rolls=2d12"]) + println() + invoke(roll, input=["42", "2d12"]) .. _optional-value: diff --git a/src/click/core.py b/src/click/core.py index 7e78b5793..557012440 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1769,9 +1769,10 @@ class Parameter: :param default: the default value if omitted. This can also be a callable, in which case it's invoked when the default is needed without any arguments. - :param callback: a callback that should be executed after the parameter - was matched. This is called as ``fn(ctx, param, - value)`` and needs to return the value. + :param callback: A function to further process or validate the value + after type conversion. It is called as ``f(ctx, param, value)`` + and must return the value. It is called for all sources, + including prompts. :param nargs: the number of arguments to match. If not ``1`` the return value is a tuple instead of single value. The default for nargs is ``1`` (except if the type is a tuple, then it's @@ -1792,6 +1793,12 @@ class Parameter: of :class:`~click.shell_completion.CompletionItem` or a list of strings. + .. versionchanged:: 8.0 + ``process_value`` validates required parameters and bounded + ``nargs``, and invokes the parameter callback before returning + the value. This allows the callback to validate prompts. + ``full_process_value`` is removed. + .. versionchanged:: 8.0 ``autocompletion`` is renamed to ``shell_complete`` and has new semantics described above. The old name is deprecated and will @@ -2008,17 +2015,6 @@ def _convert(value, level): return _convert(value, (self.nargs != 1) + bool(self.multiple)) - def process_value(self, ctx, value): - """Given a value and context this runs the logic to convert the - value as necessary. - """ - # If the value we were given is None we do nothing. This way - # code that calls this can easily figure out if something was - # not provided. Otherwise it would be converted into an empty - # tuple for multiple invocations which is inconvenient. - if value is not None: - return self.type_cast_value(ctx, value) - def value_is_missing(self, value): if value is None: return True @@ -2026,8 +2022,9 @@ def value_is_missing(self, value): return True return False - def full_process_value(self, ctx, value): - value = self.process_value(ctx, value) + def process_value(self, ctx, value): + if value is not None: + value = self.type_cast_value(ctx, value) if self.required and self.value_is_missing(value): raise MissingParameter(ctx=ctx, param=self) @@ -2049,6 +2046,9 @@ def full_process_value(self, ctx, value): f" {len(value)} {were} given." ) + if self.callback is not None: + value = self.callback(ctx, self, value) + return value def resolve_envvar_value(self, ctx): @@ -2081,10 +2081,7 @@ def handle_parse_result(self, ctx, opts, args): ctx.set_parameter_source(self.name, source) try: - value = self.full_process_value(ctx, value) - - if self.callback is not None: - value = self.callback(ctx, self, value) + value = self.process_value(ctx, value) except Exception: if not ctx.resilient_parsing: raise diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 5851d2253..607a636fe 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -228,7 +228,7 @@ def test_missing_argument_string_cast(): ctx = click.Context(click.Command("")) with pytest.raises(click.MissingParameter) as excinfo: - click.Argument(["a"], required=True).full_process_value(ctx, None) + click.Argument(["a"], required=True).process_value(ctx, None) assert str(excinfo.value) == "missing parameter: a" diff --git a/tests/test_options.py b/tests/test_options.py index 611ba38c1..128b86034 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -376,6 +376,22 @@ def cmd(foo): assert result.output == "42\n" +def test_callback_validates_prompt(runner, monkeypatch): + def validate(ctx, param, value): + if value < 0: + raise click.BadParameter("should be positive") + + return value + + @click.command() + @click.option("-a", type=int, callback=validate, prompt=True) + def cli(a): + click.echo(a) + + result = runner.invoke(cli, input="-12\n60\n") + assert result.output == "A: -12\nError: should be positive\nA: 60\n60\n" + + def test_winstyle_options(runner): @click.command() @click.option("/debug;/no-debug", help="Enables or disables debug mode.") @@ -409,7 +425,7 @@ def test_missing_option_string_cast(): ctx = click.Context(click.Command("")) with pytest.raises(click.MissingParameter) as excinfo: - click.Option(["-a"], required=True).full_process_value(ctx, None) + click.Option(["-a"], required=True).process_value(ctx, None) assert str(excinfo.value) == "missing parameter: a" From 43d1bcf2011cd6b8a8004550ab9d86f7a51c61f1 Mon Sep 17 00:00:00 2001 From: leshna balara Date: Thu, 25 Feb 2021 02:25:31 +0530 Subject: [PATCH 215/293] allow style support for Jupyter on Windows --- CHANGES.rst | 1 + setup.cfg | 3 +++ src/click/_compat.py | 7 +------ tests/test_compat.py | 4 ---- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4e0243ab6..55ec465cd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -170,6 +170,7 @@ Unreleased ``default=None``. :issue:`1381` - Option prompts validate the value with the option's callback in addition to its type. :issue:`457` +- Allow styled output in Jupyter on Windows. :issue:`1271` Version 7.1.2 diff --git a/setup.cfg b/setup.cfg index 314b16544..40061f3ad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -95,5 +95,8 @@ warn_unreachable = True [mypy-click] no_implicit_reexport = False +[mypy-colorama.*] +ignore_missing_imports = True + [mypy-importlib_metadata.*] ignore_missing_imports = True diff --git a/src/click/_compat.py b/src/click/_compat.py index 276a9e987..a5e4a875a 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -471,11 +471,6 @@ def strip_ansi(value): def _is_jupyter_kernel_output(stream): - if WIN: - # TODO: Couldn't test on Windows, should't try to support until - # someone tests the details wrt colorama. - return - while isinstance(stream, (_FixupStream, _NonClosingTextIOWrapper)): stream = stream._stream @@ -521,7 +516,7 @@ def auto_wrap_for_ansi( strip = should_strip_ansi(stream, color) ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) - rv = ansi_wrapper.stream + rv = t.cast(t.TextIO, ansi_wrapper.stream) _write = rv.write def _safe_write(s): diff --git a/tests/test_compat.py b/tests/test_compat.py index 825e04649..0e2e424cb 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1,10 +1,6 @@ -import pytest - from click._compat import should_strip_ansi -from click._compat import WIN -@pytest.mark.xfail(WIN, reason="Jupyter not tested/supported on Windows") def test_is_jupyter_kernel_output(): class JupyterKernelFakeStream: pass From 06aa862c4530925188436a4481155b09051cf990 Mon Sep 17 00:00:00 2001 From: Saif807380 Date: Thu, 18 Feb 2021 22:50:19 +0530 Subject: [PATCH 216/293] customize confirmation prompt --- CHANGES.rst | 1 + src/click/core.py | 6 ++++-- src/click/termui.py | 34 +++++++++++++++++++++++----------- tests/test_termui.py | 24 ++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 55ec465cd..dbb4fd539 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -170,6 +170,7 @@ Unreleased ``default=None``. :issue:`1381` - Option prompts validate the value with the option's callback in addition to its type. :issue:`457` +- ``confirmation_prompt`` can be set to a custom string. :issue:`723` - Allow styled output in Jupyter on Windows. :issue:`1271` diff --git a/src/click/core.py b/src/click/core.py index 557012440..cc3aa141c 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2146,8 +2146,9 @@ class Option(Parameter): :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 confirmation_prompt: Prompt a second time to confirm the + value if it was prompted for. Can be set to a string instead of + ``True`` to customize the message. :param prompt_required: If set to ``False``, the user will be prompted for input only when the option was specified as a flag without a value. @@ -2203,6 +2204,7 @@ def __init__( prompt_text = None else: prompt_text = prompt + self.prompt = prompt_text self.confirmation_prompt = confirmation_prompt self.prompt_required = prompt_required diff --git a/src/click/termui.py b/src/click/termui.py index eb559af1e..95bf66870 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -85,21 +85,14 @@ def prompt( 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. - - .. versionadded:: 4.0 - Added the `err` parameter. - :param text: the text to show for the prompt. :param default: the default value to use if no input happens. If this is not given it will prompt until it's aborted. :param hide_input: if this is set to true then the input value will be hidden. - :param confirmation_prompt: asks for confirmation for the value. + :param confirmation_prompt: Prompt a second time to confirm the + value. Can be set to a string instead of ``True`` to customize + the message. :param type: the type to use to check the value against. :param value_proc: if this parameter is provided it's a function that is invoked instead of the type conversion to @@ -112,6 +105,19 @@ def prompt( 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): ". + + .. versionadded:: 8.0 + ``confirmation_prompt`` can be a custom string. + + .. versionadded:: 7.0 + Added the ``show_choices`` parameter. + + .. versionadded:: 6.0 + Added unicode support for cmd.exe on Windows. + + .. versionadded:: 4.0 + Added the `err` parameter. + """ result = None @@ -137,6 +143,12 @@ def prompt_func(text): text, prompt_suffix, show_default, default, show_choices, type ) + if confirmation_prompt: + if confirmation_prompt is True: + confirmation_prompt = "Repeat for confirmation" + + confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) + while 1: while 1: value = prompt_func(prompt) @@ -156,7 +168,7 @@ def prompt_func(text): if not confirmation_prompt: return result while 1: - value2 = prompt_func("Repeat for confirmation: ") + value2 = prompt_func(confirmation_prompt) if value2: break if value == value2: diff --git a/tests/test_termui.py b/tests/test_termui.py index 2b0380e88..3958161e5 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -446,3 +446,27 @@ def cli(value, o): result = runner.invoke(cli, args=args, input="prompt", standalone_mode=False) assert result.exception is None assert result.return_value == expect + + +@pytest.mark.parametrize( + ("prompt", "input", "expect"), + [ + (True, "password\npassword", "password"), + ("Confirm Password", "password\npassword\n", "password"), + (False, None, None), + ], +) +def test_confirmation_prompt(runner, prompt, input, expect): + @click.command() + @click.option( + "--password", prompt=prompt, hide_input=True, confirmation_prompt=prompt + ) + def cli(password): + return password + + result = runner.invoke(cli, input=input, standalone_mode=False) + assert result.exception is None + assert result.return_value == expect + + if prompt == "Confirm Password": + assert "Confirm Password: " in result.output From 24c6b8ab15dd488f35add7ceff1e296c2f852897 Mon Sep 17 00:00:00 2001 From: Saif807380 Date: Thu, 25 Feb 2021 23:46:54 +0530 Subject: [PATCH 217/293] clirunner.isolation returns bytes_error or none --- src/click/testing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/click/testing.py b/src/click/testing.py index 8cd6a1d55..557fbefad 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -208,6 +208,7 @@ def isolation(self, input=None, env=None, color=False): bytes_output, encoding=self.charset, name="", mode="w" ) + bytes_error = None if self.mix_stderr: sys.stderr = sys.stdout else: @@ -262,7 +263,7 @@ def should_strip_ansi(stream=None, color=None): pass else: os.environ[key] = value - yield (bytes_output, not self.mix_stderr and bytes_error) + yield (bytes_output, bytes_error) finally: for key, value in old_env.items(): if value is None: From 79e0b2d980d0e2b6e45eebcf6f8d5d6b0d5e2b4f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Mar 2021 12:27:09 +0000 Subject: [PATCH 218/293] Bump importlib-metadata from 3.4.0 to 3.7.0 Bumps [importlib-metadata](https://github.com/python/importlib_metadata) from 3.4.0 to 3.7.0. - [Release notes](https://github.com/python/importlib_metadata/releases) - [Changelog](https://github.com/python/importlib_metadata/blob/main/CHANGES.rst) - [Commits](https://github.com/python/importlib_metadata/compare/v3.4.0...v3.7.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 76b6cb4d3..2934aad4c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -34,7 +34,7 @@ idna==2.10 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==3.4.0 +importlib_metadata==3.7.0 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest diff --git a/requirements/tests.txt b/requirements/tests.txt index d9628ac60..95a42e68d 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,7 +6,7 @@ # attrs==20.3.0 # via pytest -importlib-metadata==3.4.0 +importlib_metadata==3.7.0 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest From ca75d0a55e09ccaa1514d325a846e490ef0af3bc Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Mar 2021 12:28:56 +0000 Subject: [PATCH 219/293] Bump mypy from 0.800 to 0.812 Bumps [mypy](https://github.com/python/mypy) from 0.800 to 0.812. - [Release notes](https://github.com/python/mypy/releases) - [Commits](https://github.com/python/mypy/compare/v0.800...v0.812) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/typing.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 76b6cb4d3..b26e084b7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -44,7 +44,7 @@ markupsafe==1.1.1 # via jinja2 mypy-extensions==0.4.3 # via mypy -mypy==0.800 +mypy==0.812 # via -r requirements/typing.in nodeenv==1.5.0 # via pre-commit diff --git a/requirements/typing.txt b/requirements/typing.txt index 2530301a5..2b3f58327 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -6,7 +6,7 @@ # mypy-extensions==0.4.3 # via mypy -mypy==0.800 +mypy==0.812 # via -r requirements/typing.in typed-ast==1.4.2 # via mypy From 016bbb38e960c7636f04a83ea1367387faa777ad Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Mar 2021 12:29:48 +0000 Subject: [PATCH 220/293] Bump tox from 3.21.4 to 3.22.0 Bumps [tox](https://github.com/tox-dev/tox) from 3.21.4 to 3.22.0. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.21.4...3.22.0) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 76b6cb4d3..7ef70e7a6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -118,7 +118,7 @@ toml==0.10.2 # pre-commit # pytest # tox -tox==3.21.4 +tox==3.22.0 # via -r requirements/dev.in typed-ast==1.4.2 # via mypy From cafcacde6740fa90e796a196523bca0563ae419b Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 1 Mar 2021 12:31:36 +0000 Subject: [PATCH 221/293] Bump sphinx from 3.4.3 to 3.5.1 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.4.3 to 3.5.1. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.4.3...v3.5.1) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 4 ++-- requirements/docs.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 2934aad4c..81dc92187 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -34,7 +34,7 @@ idna==2.10 # via requests imagesize==1.2.0 # via sphinx -importlib_metadata==3.7.0 +importlib-metadata==3.7.0 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest @@ -92,7 +92,7 @@ sphinx-issues==1.2.0 # via -r requirements/docs.in sphinx-tabs==2.0.1 # via -r requirements/docs.in -sphinx==3.4.3 +sphinx==3.5.1 # via # -r requirements/docs.in # pallets-sphinx-themes diff --git a/requirements/docs.txt b/requirements/docs.txt index 3c0417121..50e0c8b85 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -44,7 +44,7 @@ sphinx-issues==1.2.0 # via -r requirements/docs.in sphinx-tabs==2.0.1 # via -r requirements/docs.in -sphinx==3.4.3 +sphinx==3.5.1 # via # -r requirements/docs.in # pallets-sphinx-themes From 6f705ddc21ede17c3427a8f624537a8ce5586701 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 2 Mar 2021 21:28:23 +0530 Subject: [PATCH 222/293] use lowercase click name --- docs/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 3ea39998a..d971f132e 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -79,7 +79,7 @@ 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:: - $ pip install Click + $ pip install click A few seconds later and you are good to go. From 3a10d8236cd098a7c88b76f6b18d1c73b4beae87 Mon Sep 17 00:00:00 2001 From: BALaka-18 Date: Tue, 2 Mar 2021 16:49:53 +0530 Subject: [PATCH 223/293] Added strikethrough support for style --- CHANGES.rst | 1 + src/click/termui.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index dbb4fd539..ca99f93e5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -172,6 +172,7 @@ Unreleased addition to its type. :issue:`457` - ``confirmation_prompt`` can be set to a custom string. :issue:`723` - Allow styled output in Jupyter on Windows. :issue:`1271` +- ``style()`` supports the ``strikethrough`` style. :issue:`805` Version 7.1.2 diff --git a/src/click/termui.py b/src/click/termui.py index 95bf66870..5cbeae8c6 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -456,6 +456,7 @@ def style( underline=None, blink=None, reverse=None, + strikethrough=None, reset=True, ): """Styles a text with ANSI styles and returns the new string. By @@ -511,6 +512,8 @@ def style( :param reverse: if provided this will enable or disable inverse rendering (foreground becomes background and the other way round). + :param strikethrough: if provided this will enable or disable + striking through text. :param reset: by default a reset-all code is added at the end of the string which means that styles do not carry over. This can be disabled to compose styles. @@ -521,6 +524,9 @@ def style( .. versionchanged:: 8.0 Added support for 256 and RGB color codes. + .. versionchanged:: 8.0 + Added the ``strikethrough`` parameter. + .. versionchanged:: 7.0 Added support for bright colors. @@ -553,6 +559,8 @@ def style( bits.append(f"\033[{5 if blink else 25}m") if reverse is not None: bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") bits.append(text) if reset: bits.append(_ansi_reset_all) From 244d914364e447175549dab8c2414cdb73d86a3c Mon Sep 17 00:00:00 2001 From: Saif807380 Date: Mon, 1 Mar 2021 14:36:36 +0530 Subject: [PATCH 224/293] remove multiline marker from short help text --- CHANGES.rst | 1 + src/click/utils.py | 3 +++ tests/test_formatting.py | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index ca99f93e5..e8d52450d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -173,6 +173,7 @@ Unreleased - ``confirmation_prompt`` can be set to a custom string. :issue:`723` - Allow styled output in Jupyter on Windows. :issue:`1271` - ``style()`` supports the ``strikethrough`` style. :issue:`805` +- Multiline marker is removed from short help text. :issue:`1597` Version 7.1.2 diff --git a/src/click/utils.py b/src/click/utils.py index e889a4e15..eccdfd9f5 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -57,6 +57,9 @@ def make_default_short_help(help, max_length=45): result = [] done = False + if words[0] == "\b": + words = words[1:] + for word in words: if word[-1:] == ".": done = True diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 9b501b1b6..bb2bb8fdc 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -296,6 +296,25 @@ def cli(ctx): ] +def test_removing_multiline_marker(runner): + @click.group() + def cli(): + pass + + @cli.command() + def cmd1(): + """\b + This is command with a multiline help text + which should not be rewrapped. + The output of the short help text should + not contain the multiline marker. + """ + pass + + result = runner.invoke(cli, ["--help"]) + assert "\b" not in result.output + + def test_global_show_default(runner): @click.command(context_settings=dict(show_default=True)) @click.option("-f", "in_file", default="out.txt", help="Output file name") From e74508119a1e8ba59bf96a45fa1a149cd292a79d Mon Sep 17 00:00:00 2001 From: Nicolas F Date: Wed, 10 Oct 2018 15:38:04 +0200 Subject: [PATCH 225/293] progressbar: fix regression with hidden bars The documentation of the progress bar states that the progress bar will output only the label of the bar if it is outputting to a file that is not a tty. However, this was broken a few versions ago, without the documentation being adjusted, and even a test added. Restore this behaviour so we follow the documentation again, and adjust the test to match. Fixes #1138. --- CHANGES.rst | 2 ++ src/click/_termui_impl.py | 6 ++++++ src/click/termui.py | 8 ++++++-- tests/test_termui.py | 5 ++--- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index e8d52450d..f2adc660c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -174,6 +174,8 @@ Unreleased - Allow styled output in Jupyter on Windows. :issue:`1271` - ``style()`` supports the ``strikethrough`` style. :issue:`805` - Multiline marker is removed from short help text. :issue:`1597` +- Restore progress bar behavior of echoing only the label if the file + is not a TTY. :issue:`1138` Version 7.1.2 diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index d4e3e88b2..7029a0876 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -227,6 +227,12 @@ def render_progress(self): import shutil if self.is_hidden: + # Only output the label as it changes if the output is not a + # TTY. Use file=stderr if you expect to be piping stdout. + if self._last_line != self.label: + self._last_line = self.label + echo(self.label, file=self.file, color=self.color) + return buf = [] diff --git a/src/click/termui.py b/src/click/termui.py index 5cbeae8c6..b6855fabb 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -381,8 +381,8 @@ def progressbar( :param info_sep: the separator between multiple info items (eta etc.) :param width: the width of the progress bar in characters, 0 means full terminal width - :param file: the file to write to. If this is not a terminal then - only the label is printed. + :param file: The file to write to. If this is not a terminal then + only the label is printed. :param color: controls if the terminal supports ANSI colors or not. The default is autodetection. This is only needed if ANSI codes are included anywhere in the progress bar output @@ -390,6 +390,10 @@ def progressbar( :param update_min_steps: Render only when this many updates have completed. This allows tuning for very fast iterators. + .. versionchanged:: 8.0 + Labels are echoed if the output is not a TTY. Reverts a change + in 7.0 that removed all output. + .. versionadded:: 8.0 Added the ``update_min_steps`` parameter. diff --git a/tests/test_termui.py b/tests/test_termui.py index 3958161e5..a4ae76f33 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -76,17 +76,16 @@ def cli(): def test_progressbar_hidden(runner, monkeypatch): fake_clock = FakeClock() - label = "whatever" @click.command() def cli(): - with _create_progress(label=label) as progress: + with _create_progress(label="working") as progress: for _ in progress: fake_clock.advance_time() monkeypatch.setattr(time, "time", fake_clock.time) monkeypatch.setattr(click._termui_impl, "isatty", lambda _: False) - assert runner.invoke(cli, []).output == "" + assert runner.invoke(cli, []).output == "working\n" @pytest.mark.parametrize("avg, expected", [([], 0.0), ([1, 4], 2.5)]) From 3cc31b10e8187035d81f5c08163ecf15dad0e5ef Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 3 Mar 2021 18:30:40 -0800 Subject: [PATCH 226/293] don't hide fast progress bars --- CHANGES.rst | 2 ++ src/click/_termui_impl.py | 8 ++---- src/click/termui.py | 3 +++ tests/test_termui.py | 55 ++++++++++++--------------------------- 4 files changed, 24 insertions(+), 44 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f2adc660c..6d596b591 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -176,6 +176,8 @@ Unreleased - Multiline marker is removed from short help text. :issue:`1597` - Restore progress bar behavior of echoing only the label if the file is not a TTY. :issue:`1138` +- Progress bar output is shown even if execution time is less than 0.5 + seconds. :issue:`1648` Version 7.1.2 diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 7029a0876..c1cc936d0 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -102,7 +102,6 @@ def __init__( 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 @@ -126,11 +125,8 @@ def __next__(self): # twice works and does "what you want". return next(iter(self)) - def is_fast(self): - return time.time() - self.start <= self.short_limit - def render_finish(self): - if self.is_hidden or self.is_fast(): + if self.is_hidden: return self.file.write(AFTER_BAR) self.file.flush() @@ -263,7 +259,7 @@ def render_progress(self): line = "".join(buf) # Render the line only if it changed. - if line != self._last_line and not self.is_fast(): + if line != self._last_line: self._last_line = line echo(line, file=self.file, color=self.color, nl=False) self.file.flush() diff --git a/src/click/termui.py b/src/click/termui.py index b6855fabb..4fc722916 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -390,6 +390,9 @@ def progressbar( :param update_min_steps: Render only when this many updates have completed. This allows tuning for very fast iterators. + .. versionchanged:: 8.0 + Output is shown even if execution time is less than 0.5 seconds. + .. versionchanged:: 8.0 Labels are echoed if the output is not a TTY. Reverts a change in 7.0 that removed all output. diff --git a/tests/test_termui.py b/tests/test_termui.py index a4ae76f33..da586af6c 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -27,16 +27,14 @@ def _create_progress(length=10, length_known=True, **kwargs): def test_progressbar_strip_regression(runner, monkeypatch): - fake_clock = FakeClock() label = " padded line" @click.command() def cli(): with _create_progress(label=label) as progress: for _ in progress: - fake_clock.advance_time() + pass - monkeypatch.setattr(time, "time", fake_clock.time) monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) assert label in runner.invoke(cli, []).output @@ -60,30 +58,24 @@ def __next__(self): next = __next__ - fake_clock = FakeClock() - @click.command() def cli(): with click.progressbar(Hinted(10), label="test") as progress: for _ in progress: - fake_clock.advance_time() + pass - 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() - @click.command() def cli(): with _create_progress(label="working") as progress: for _ in progress: - fake_clock.advance_time() + pass - monkeypatch.setattr(time, "time", fake_clock.time) monkeypatch.setattr(click._termui_impl, "isatty", lambda _: False) assert runner.invoke(cli, []).output == "working\n" @@ -193,21 +185,16 @@ def test_progressbar_iter_outside_with_exceptions(runner): def test_progressbar_is_iterator(runner, monkeypatch): - fake_clock = FakeClock() - @click.command() def cli(): with click.progressbar(range(10), label="test") as progress: while True: try: next(progress) - fake_clock.advance_time() except StopIteration: break - monkeypatch.setattr(time, "time", fake_clock.time) monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) - result = runner.invoke(cli, []) assert result.exception is None @@ -289,50 +276,42 @@ def cli(): 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] + assert " 0%" in lines[0] + assert " 25% 00:00:03" in lines[1] + assert " 50% 00:00:02" in lines[2] + assert " 75% 00:00:01" in lines[3] + assert "100% " in lines[4] def test_progressbar_item_show_func(runner, monkeypatch): - fake_clock = FakeClock() - @click.command() def cli(): with click.progressbar( - range(4), item_show_func=lambda x: f"Custom {x}" + range(3), item_show_func=lambda x: f"Custom {x}" ) as progress: for _ in progress: - fake_clock.advance_time() - print("") + click.echo() - 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 "Custom 0" in lines[0] - assert "Custom 1" in lines[1] - assert "Custom 2" in lines[2] - assert "Custom None" in lines[3] + lines = [line for line in output.splitlines() if "[" in line] + assert "Custom None" in lines[0] + assert "Custom 0" in lines[1] + assert "Custom 1" in lines[2] + assert "Custom 2" in lines[3] + assert "Custom None" in lines[4] def test_progressbar_update_with_item_show_func(runner, monkeypatch): - fake_clock = FakeClock() - @click.command() def cli(): with click.progressbar( length=6, item_show_func=lambda x: f"Custom {x}" ) as progress: while not progress.finished: - fake_clock.advance_time() progress.update(2, progress.pos) - print("") + click.echo() - monkeypatch.setattr(time, "time", fake_clock.time) monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) output = runner.invoke(cli, []).output From 2ba2fe0cf80a76e09a6e44c3750034574acd33d0 Mon Sep 17 00:00:00 2001 From: Joel Eager Date: Fri, 12 Jul 2019 21:08:08 -0500 Subject: [PATCH 227/293] progress bar shows current item Co-authored-by: David Lord --- CHANGES.rst | 2 ++ src/click/_termui_impl.py | 15 +++++++++++---- src/click/termui.py | 11 +++++++---- tests/test_termui.py | 21 +++++++++------------ 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6d596b591..c1649ce81 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -178,6 +178,8 @@ Unreleased is not a TTY. :issue:`1138` - Progress bar output is shown even if execution time is less than 0.5 seconds. :issue:`1648` +- Progress bar ``item_show_func`` shows the current item, not the + previous item. :issue:`1353` Version 7.1.2 diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index c1cc936d0..3c7d57f22 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -302,14 +302,13 @@ def update(self, n_steps, current_item=None): Only render when the number of steps meets the ``update_min_steps`` threshold. """ + if current_item is not None: + self.current_item = current_item + self._completed_intervals += n_steps if self._completed_intervals >= self.update_min_steps: self.make_step(self._completed_intervals) - - if current_item is not None: - self.current_item = current_item - self.render_progress() self._completed_intervals = 0 @@ -338,8 +337,16 @@ def generator(self): else: for rv in self.iter: self.current_item = rv + + # This allows show_item_func to be updated before the + # item is processed. Only trigger at the beginning of + # the update interval. + if self._completed_intervals == 0: + self.render_progress() + yield rv self.update(1) + self.finish() self.render_progress() diff --git a/src/click/termui.py b/src/click/termui.py index 4fc722916..4ea76d805 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -366,10 +366,10 @@ def progressbar( `False` if not. :param show_pos: enables or disables the absolute position display. The default is `False`. - :param item_show_func: a function called with the current item which - can return a string to show the current item - next to the progress bar. Note that the current - item can be `None`! + :param item_show_func: A function called with the current item which + can return a string to show next to the progress bar. If the + function returns ``None`` nothing is shown. The current item can + be ``None``, such as when entering and exiting the bar. :param fill_char: the character to use to show the filled part of the progress bar. :param empty_char: the character to use to show the non-filled part of @@ -393,6 +393,9 @@ def progressbar( .. versionchanged:: 8.0 Output is shown even if execution time is less than 0.5 seconds. + .. versionchanged:: 8.0 + ``item_show_func`` shows the current item, not the previous one. + .. versionchanged:: 8.0 Labels are echoed if the output is not a TTY. Reverts a change in 7.0 that removed all output. diff --git a/tests/test_termui.py b/tests/test_termui.py index da586af6c..05e3bf8fb 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -284,22 +284,19 @@ def cli(): def test_progressbar_item_show_func(runner, monkeypatch): + """item_show_func should show the current item being yielded.""" + @click.command() def cli(): - with click.progressbar( - range(3), item_show_func=lambda x: f"Custom {x}" - ) as progress: - for _ in progress: - click.echo() + with click.progressbar(range(3), item_show_func=lambda x: str(x)) as progress: + for item in progress: + click.echo(f" item {item}") monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) - output = runner.invoke(cli, []).output - lines = [line for line in output.splitlines() if "[" in line] - assert "Custom None" in lines[0] - assert "Custom 0" in lines[1] - assert "Custom 1" in lines[2] - assert "Custom 2" in lines[3] - assert "Custom None" in lines[4] + lines = runner.invoke(cli).output.splitlines() + + for i, line in enumerate(x for x in lines if "item" in x): + assert f"{i} item {i}" in line def test_progressbar_update_with_item_show_func(runner, monkeypatch): From 6d64ca5d0eab6df9a66e1c732dc52d1b68a41ae4 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 4 Mar 2021 20:28:01 -0800 Subject: [PATCH 228/293] path type supports pathlib --- CHANGES.rst | 2 ++ src/click/types.py | 37 +++++++++++++++++++------------------ tests/test_types.py | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index c1649ce81..d3795e173 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -180,6 +180,8 @@ Unreleased seconds. :issue:`1648` - Progress bar ``item_show_func`` shows the current item, not the previous item. :issue:`1353` +- The ``Path`` param type can be passed ``path_type=pathlib.Path`` to + return a path object instead of a string. :issue:`405` Version 7.1.2 diff --git a/src/click/types.py b/src/click/types.py index 6ca72c9db..0ed73c099 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -649,9 +649,6 @@ class Path(ParamType): handle it returns just the filename. Secondly, it can perform various basic checks about what the file or directory should be. - .. versionchanged:: 6.0 - `allow_dash` was added. - :param exists: if set to true, the file or directory needs to exist for this value to be valid. If this is not required and a file does indeed not exist, then all further checks are @@ -667,11 +664,15 @@ class Path(ParamType): 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 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. + :param path_type: Convert the incoming path value to this type. If + ``None``, keep Python's default, which is ``str``. Useful to + convert to :class:`pathlib.Path`. + + .. versionchanged:: 8.0 + Allow passing ``type=pathlib.Path``. + + .. versionchanged:: 6.0 + Added the ``allow_dash`` parameter. """ envvar_list_splitter = os.path.pathsep @@ -698,13 +699,10 @@ def __init__( if self.file_okay and not self.dir_okay: self.name = "file" - self.path_type = "File" elif self.dir_okay and not self.file_okay: self.name = "directory" - self.path_type = "Directory" else: self.name = "path" - self.path_type = "Path" def to_info_dict(self): info_dict = super().to_info_dict() @@ -721,9 +719,12 @@ def to_info_dict(self): def coerce_path_result(self, rv): if self.type is not None and not isinstance(rv, self.type): if self.type is str: - rv = rv.decode(get_filesystem_encoding()) + rv = os.fsdecode(rv) + elif self.type is bytes: + rv = os.fsencode(rv) else: - rv = rv.encode(get_filesystem_encoding()) + rv = self.type(rv) + return rv def convert(self, value, param, ctx): @@ -741,32 +742,32 @@ def convert(self, value, param, ctx): if not self.exists: return self.coerce_path_result(rv) self.fail( - f"{self.path_type} {filename_to_ui(value)!r} does not exist.", + f"{self.name.title()} {filename_to_ui(value)!r} does not exist.", param, ctx, ) if not self.file_okay and stat.S_ISREG(st.st_mode): self.fail( - f"{self.path_type} {filename_to_ui(value)!r} is a file.", + f"{self.name.title()} {filename_to_ui(value)!r} is a file.", param, ctx, ) if not self.dir_okay and stat.S_ISDIR(st.st_mode): self.fail( - f"{self.path_type} {filename_to_ui(value)!r} is a directory.", + f"{self.name.title()} {filename_to_ui(value)!r} is a directory.", param, ctx, ) if self.writable and not os.access(value, os.W_OK): self.fail( - f"{self.path_type} {filename_to_ui(value)!r} is not writable.", + f"{self.name.title()} {filename_to_ui(value)!r} is not writable.", param, ctx, ) if self.readable and not os.access(value, os.R_OK): self.fail( - f"{self.path_type} {filename_to_ui(value)!r} is not readable.", + f"{self.name.title()} {filename_to_ui(value)!r} is not readable.", param, ctx, ) diff --git a/tests/test_types.py b/tests/test_types.py index 4e995f123..e7e531e4a 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,3 +1,5 @@ +import pathlib + import pytest import click @@ -78,3 +80,23 @@ def test_cast_multi_default(runner, nargs, multiple, default, expect): result = runner.invoke(cli, standalone_mode=False) assert result.exception is None assert result.return_value == expect + + +@pytest.mark.parametrize( + ("cls", "expect"), + [ + (None, "a/b/c.txt"), + (str, "a/b/c.txt"), + (bytes, b"a/b/c.txt"), + (pathlib.Path, pathlib.Path("a", "b", "c.txt")), + ], +) +def test_path_type(runner, cls, expect): + cli = click.Command( + "cli", + params=[click.Argument(["p"], type=click.Path(path_type=cls))], + callback=lambda p: p, + ) + result = runner.invoke(cli, ["a/b/c.txt"], standalone_mode=False) + assert result.exception is None + assert result.return_value == expect From 1cb86096124299579156f2c983efe05585f1a01b Mon Sep 17 00:00:00 2001 From: Saif Kazi <50794619+Saif807380@users.noreply.github.com> Date: Fri, 5 Mar 2021 20:10:50 +0530 Subject: [PATCH 229/293] Better error message for bad parameter default (#1805) raise TypeError when multiple parameter has single default --- CHANGES.rst | 2 ++ src/click/core.py | 10 +++++++++- tests/test_options.py | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index d3795e173..ac11bf313 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -182,6 +182,8 @@ Unreleased previous item. :issue:`1353` - The ``Path`` param type can be passed ``path_type=pathlib.Path`` to return a path object instead of a string. :issue:`405` +- ``TypeError`` is raised when parameter with ``multiple=True`` or + ``nargs > 1`` has non-iterable default. :issue:`1749` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index cc3aa141c..f3192c5e3 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2011,7 +2011,15 @@ def _convert(value, level): if level == 0: return self.type(value, self, ctx) - return tuple(_convert(x, level - 1) for x in value) + try: + iter_value = iter(value) + except TypeError: + raise TypeError( + "Value for parameter with multiple = True or nargs > 1" + " should be an iterable." + ) + + return tuple(_convert(x, level - 1) for x in iter_value) return _convert(value, (self.nargs != 1) + bool(self.multiple)) diff --git a/tests/test_options.py b/tests/test_options.py index 128b86034..65109d69a 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -117,6 +117,20 @@ def cli(message): assert "Error: Missing option '-m' / '--message'." in result.output +def test_multiple_bad_default(runner): + @click.command() + @click.option("--flags", multiple=True, default=False) + def cli(flags): + pass + + result = runner.invoke(cli, []) + assert result.exception + assert ( + "Value for parameter with multiple = True or nargs > 1 should be an iterable." + in result.exception.args + ) + + def test_empty_envvar(runner): @click.command() @click.option("--mypath", type=click.Path(exists=True), envvar="MYPATH") From 21a68bd1776248ff071bbfc321d81e9f341b5250 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 5 Mar 2021 08:46:06 -0800 Subject: [PATCH 230/293] add pass_meta_key decorator --- CHANGES.rst | 3 +++ docs/api.rst | 3 +++ src/click/decorators.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_context.py | 23 +++++++++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index ac11bf313..406088e07 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -184,6 +184,9 @@ Unreleased return a path object instead of a string. :issue:`405` - ``TypeError`` is raised when parameter with ``multiple=True`` or ``nargs > 1`` has non-iterable default. :issue:`1749` +- Add a ``pass_meta_key`` decorator for passing a key from + ``Context.meta``. This is useful for extensions using ``meta`` to + store information. :issue:`1739` Version 7.1.2 diff --git a/docs/api.rst b/docs/api.rst index 5e8c765c4..23d6af34c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -31,6 +31,9 @@ Decorators .. autofunction:: make_pass_decorator +.. autofunction:: click.decorators.pass_meta_key + + Utilities --------- diff --git a/src/click/decorators.py b/src/click/decorators.py index 5742f05e5..d2fe1056f 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -1,4 +1,5 @@ import inspect +import typing as t from functools import update_wrapper from .core import Argument @@ -8,6 +9,8 @@ from .globals import get_current_context from .utils import echo +if t.TYPE_CHECKING: + F = t.TypeVar("F", bound=t.Callable[..., t.Any]) def pass_context(f): """Marks a callback as wanting to receive the current context @@ -75,6 +78,39 @@ def new_func(*args, **kwargs): return decorator +def pass_meta_key( + key: str, *, doc_description: t.Optional[str] = None +) -> "t.Callable[[F], F]": + """Create a decorator that passes a key from + :attr:`click.Context.meta` as the first argument to the decorated + function. + + :param key: Key in ``Context.meta`` to pass. + :param doc_description: Description of the object being passed, + inserted into the decorator's docstring. Defaults to "the 'key' + key from Context.meta". + + .. versionadded:: 8.0 + """ + + def decorator(f: "F") -> "F": + def new_func(*args, **kwargs): + ctx = get_current_context() + obj = ctx.meta[key] + return ctx.invoke(f, obj, *args, **kwargs) + + return update_wrapper(t.cast("F", new_func), f) + + if doc_description is None: + doc_description = f"the {key!r} key from :attr:`click.Context.meta`" + + decorator.__doc__ = ( + f"Decorator that passes {doc_description} as the first argument" + " to the decorated function." + ) + return decorator + + def _make_command(f, name, attrs, cls): if isinstance(f, Command): raise TypeError("Attempted to convert a callback into a command twice.") diff --git a/tests/test_context.py b/tests/test_context.py index c6aee3c09..98f083570 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -4,6 +4,7 @@ import click from click.core import ParameterSource +from click.decorators import pass_meta_key def test_ensure_context_objects(runner): @@ -151,6 +152,28 @@ def cli(ctx): runner.invoke(cli, [], catch_exceptions=False) +def test_make_pass_meta_decorator(runner): + @click.group() + @click.pass_context + def cli(ctx): + ctx.meta["value"] = "good" + + @cli.command() + @pass_meta_key("value") + def show(value): + return value + + result = runner.invoke(cli, ["show"], standalone_mode=False) + assert result.return_value == "good" + + +def test_make_pass_meta_decorator_doc(): + pass_value = pass_meta_key("value") + assert "the 'value' key from :attr:`click.Context.meta`" in pass_value.__doc__ + pass_value = pass_meta_key("value", doc_description="the test value") + assert "passes the test value" in pass_value.__doc__ + + def test_context_pushing(): rv = [] From d2b315ae317d96860323fbed67c3736df7928ece Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 5 Mar 2021 08:47:37 -0800 Subject: [PATCH 231/293] typing for pass decorators --- docs/conf.py | 1 + src/click/decorators.py | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 63956c50d..129f6341c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,6 +24,7 @@ "sphinx_issues", "sphinx_tabs.tabs", ] +autodoc_typehints = "description" intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)} issues_github_path = "pallets/click" diff --git a/src/click/decorators.py b/src/click/decorators.py index d2fe1056f..43c8fa297 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -12,7 +12,8 @@ if t.TYPE_CHECKING: F = t.TypeVar("F", bound=t.Callable[..., t.Any]) -def pass_context(f): + +def pass_context(f: "F") -> "F": """Marks a callback as wanting to receive the current context object as first argument. """ @@ -20,10 +21,10 @@ def pass_context(f): def new_func(*args, **kwargs): return f(get_current_context(), *args, **kwargs) - return update_wrapper(new_func, f) + return update_wrapper(t.cast("F", new_func), f) -def pass_obj(f): +def pass_obj(f: "F") -> "F": """Similar to :func:`pass_context`, but only pass the object on the context onwards (:attr:`Context.obj`). This is useful if that object represents the state of a nested system. @@ -32,10 +33,12 @@ def pass_obj(f): def new_func(*args, **kwargs): return f(get_current_context().obj, *args, **kwargs) - return update_wrapper(new_func, f) + return update_wrapper(t.cast("F", new_func), f) -def make_pass_decorator(object_type, ensure=False): +def make_pass_decorator( + object_type: t.Type, ensure: bool = False +) -> "t.Callable[[F], F]": """Given an object type this creates a decorator that will work similar to :func:`pass_obj` but instead of passing the object of the current context, it will find the innermost context of type @@ -58,22 +61,25 @@ def new_func(ctx, *args, **kwargs): remembered on the context if it's not there yet. """ - def decorator(f): + def decorator(f: "F") -> "F": def new_func(*args, **kwargs): ctx = get_current_context() + if ensure: obj = ctx.ensure_object(object_type) else: obj = ctx.find_object(object_type) + if obj is None: raise RuntimeError( "Managed to invoke callback without a context" f" object of type {object_type.__name__!r}" " existing." ) + return ctx.invoke(f, obj, *args, **kwargs) - return update_wrapper(new_func, f) + return update_wrapper(t.cast("F", new_func), f) return decorator From 72732280ced162980b27fa43e7bbc3508ca2c768 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Mar 2021 17:01:49 +0000 Subject: [PATCH 232/293] [pre-commit.ci] pre-commit autoupdate --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ba87e9a0..8695a09af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: hooks: - id: black - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 + rev: 3.9.0 hooks: - id: flake8 additional_dependencies: From 861d99d2b3d350bbf1b4fd190d0d8d9966a2cb87 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Mar 2021 17:04:49 +0000 Subject: [PATCH 233/293] [pre-commit.ci] pre-commit autoupdate --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8695a09af..1ad830745 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.10.0 + rev: v2.11.0 hooks: - id: pyupgrade args: ["--py36-plus"] From df5a6e4801110f0f1c8ed8fe15dbeefd74507987 Mon Sep 17 00:00:00 2001 From: BALaka-18 Date: Mon, 29 Mar 2021 21:54:26 +0530 Subject: [PATCH 234/293] resolve symlinks on Windows Python < 3.8 --- CHANGES.rst | 2 ++ src/click/types.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 406088e07..4f0dfb94e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -187,6 +187,8 @@ Unreleased - Add a ``pass_meta_key`` decorator for passing a key from ``Context.meta``. This is useful for extensions using ``meta`` to store information. :issue:`1739` +- ``Path`` ``resolve_path`` resolves symlinks on Windows Python < 3.8. + :issue:`1813` Version 7.1.2 diff --git a/src/click/types.py b/src/click/types.py index 0ed73c099..6cf611fa7 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -734,6 +734,10 @@ def convert(self, value, param, ctx): if not is_dash: if self.resolve_path: + # realpath on Windows Python < 3.8 doesn't resolve symlinks + if os.path.islink(rv): + rv = os.readlink(rv) + rv = os.path.realpath(rv) try: From bf3a945b3a220589c67d55dcb73cc1c9a9a175b3 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 31 Mar 2021 09:38:52 -0700 Subject: [PATCH 235/293] Delete .DS_Store --- .DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 7a4fb5d5925e485310af4069a9299d9de1bd8ef2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM-EI;=6h2d^U8qK4jP2##n0V1Nt+lO*F+qM*5?hU+UZ`o=vbGzSU4*5yB8D5^ zL45-s#V7DVd;tB<%t+V;?u=>dOfqwZ+3%a3`R1Hm4iJ$jbQ|kL%S2?Mvdt}`nNs*V z*NHNxN3OvN@QFfd)7y$49ytC4+6)*4i~>dhqkvJsC~y`Oz&o3ZHRHXnuA0^;U=%o) z3h?v6MrE7VcBrg;bfA$b0Bjn=vY?MVKyqwt^V$xTl~l~BvIn85LZ=u)(Q(~oIBZ_q zp|XljLeWX+I}4qm2zht#YzZfkSJt#f0i(dA0-U=qP#t-DM7{C+J#=t|gXs?HjbE~$ zM>J0kb;%>28Y2+F;FO~T2 zw0y4n4*VUu2SB>gXw^OLMU-G9X_2|ga{aP6Mqki3a?zz3a z+|^#k(UJ31)dkDSSed=ta=*WMdws>;*t$7bvHRQG zYb*Axjjh2TV=Y}@zgyXFp1$vY7<~MKO`{0?M5W8>^Qirb-e`lz)pp>A!66g$ct{7- zi617h3nT@Sxc^xoxxs`$7P)kSxN1@hk>VzWl3qV9)FM5ChZf=WyNHzX3_oQ*kF$1U zd{q!99AO+C9tH735$xULtSq9U3|@(LX5EQBU07qDei5 zC~|@8LWfm2%o{pPtPb%XV^}dpi%t`GXb{7vfN#@50);)tQ1e&=PGQQ5d8a31J6!-K z#MwzD$&1g+4jx0N=~yap9LMqb#&9?SQ;Y& Date: Thu, 18 Mar 2021 00:41:35 +0530 Subject: [PATCH 236/293] deprecation notice appears at front of help and short help Co-authored-by: David Lord --- CHANGES.rst | 3 +++ src/click/core.py | 50 ++++++++++++++++++++++-------------------- tests/test_commands.py | 4 ++-- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4f0dfb94e..9db156610 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -189,6 +189,9 @@ Unreleased store information. :issue:`1739` - ``Path`` ``resolve_path`` resolves symlinks on Windows Python < 3.8. :issue:`1813` +- Command deprecation notice appears at the start of the help text, as + well as in the short help. The notice is not in all caps. + :issue:`1791` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index f3192c5e3..17cfc3a7a 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -40,14 +40,6 @@ SUBCOMMAND_METAVAR = "COMMAND [ARGS]..." SUBCOMMANDS_METAVAR = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." -DEPRECATED_HELP_NOTICE = " (DEPRECATED)" -DEPRECATED_INVOKE_NOTICE = "DeprecationWarning: The command {name} is deprecated." - - -def _maybe_show_deprecated_notice(cmd): - if cmd.deprecated: - echo(style(DEPRECATED_INVOKE_NOTICE.format(name=cmd.name), fg="red"), err=True) - def _fast_exit(code): """Low-level exit that skips Python's cleanup but speeds up exit by @@ -1193,12 +1185,15 @@ def get_short_help_str(self, limit=45): """Gets short help for the command or makes it by shortening the long help string. """ - return ( - self.short_help - or self.help - and make_default_short_help(self.help, limit) - or "" - ) + text = self.short_help or "" + + if not text and self.help: + text = make_default_short_help(self.help, limit) + + if self.deprecated: + text = f"(Deprecated) {text}" + + return text.strip() def format_help(self, ctx, formatter): """Writes the help into the formatter if it exists. @@ -1219,17 +1214,16 @@ def format_help(self, ctx, formatter): def format_help_text(self, ctx, formatter): """Writes the help text to the formatter if it exists.""" - if self.help: - formatter.write_paragraph() - with formatter.indentation(): - help_text = self.help - if self.deprecated: - help_text += DEPRECATED_HELP_NOTICE - formatter.write_text(help_text) - elif self.deprecated: + text = self.help or "" + + if self.deprecated: + text = f"(Deprecated) {text}" + + if text: formatter.write_paragraph() + with formatter.indentation(): - formatter.write_text(DEPRECATED_HELP_NOTICE) + formatter.write_text(text) def format_options(self, ctx, formatter): """Writes all the options into the formatter if they exist.""" @@ -1275,7 +1269,15 @@ def invoke(self, ctx): """Given a context, this invokes the attached callback (if it exists) in the right way. """ - _maybe_show_deprecated_notice(self) + if self.deprecated: + echo( + style( + f"DeprecationWarning: The command {self.name!r} is deprecated.", + fg="red", + ), + err=True, + ) + if self.callback is not None: return ctx.invoke(self.callback, **ctx.params) diff --git a/tests/test_commands.py b/tests/test_commands.py index c8da4ceb8..205e0f888 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -297,14 +297,14 @@ def cmd_with_help(): pass result = runner.invoke(cmd_with_help, ["--help"]) - assert "(DEPRECATED)" in result.output + assert "(Deprecated)" in result.output @click.command(deprecated=True) def cmd_without_help(): pass result = runner.invoke(cmd_without_help, ["--help"]) - assert "(DEPRECATED)" in result.output + assert "(Deprecated)" in result.output def test_deprecated_in_invocation(runner): From ee4feaaa99a50831fb52963a08e16c9d3e70aacd Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 1 Apr 2021 11:04:52 -0700 Subject: [PATCH 237/293] expand patterns in Windows args --- CHANGES.rst | 2 ++ src/click/core.py | 14 +++++++++++--- src/click/utils.py | 46 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 10 ++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9db156610..6b5cb3541 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -192,6 +192,8 @@ Unreleased - Command deprecation notice appears at the start of the help text, as well as in the short help. The notice is not in all caps. :issue:`1791` +- When taking arguments from ``sys.argv`` on Windows, glob patterns, + user dir, and env vars are expanded. :issue:`1096` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index 17cfc3a7a..80b1075a5 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -30,6 +30,7 @@ from .types import convert_type from .types import IntRange from .utils import _detect_program_name +from .utils import _expand_args from .utils import echo from .utils import make_default_short_help from .utils import make_str @@ -903,9 +904,6 @@ def main( This method is also available by directly calling the instance of a :class:`Command`. - .. versionadded:: 3.0 - Added the `standalone_mode` flag to control the standalone mode. - :param args: the arguments that should be used for parsing. If not provided, ``sys.argv[1:]`` is used. :param prog_name: the program name that should be used. By default @@ -926,6 +924,13 @@ def main( of :meth:`invoke`. :param extra: extra keyword arguments are forwarded to the context constructor. See :class:`Context` for more information. + + .. versionchanged:: 8.0 + When taking arguments from ``sys.argv`` on Windows, glob + patterns, user dir, and env vars are expanded. + + .. versionchanged:: 3.0 + Added the ``standalone_mode`` parameter. """ # Verify that the environment is configured correctly, or reject # further execution to avoid a broken script. @@ -933,6 +938,9 @@ def main( if args is None: args = sys.argv[1:] + + if os.name == "nt": + args = _expand_args(args) else: args = list(args) diff --git a/src/click/utils.py b/src/click/utils.py index eccdfd9f5..4a52478ac 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -1,5 +1,6 @@ import os import sys +import typing as t from ._compat import _default_text_stderr from ._compat import _default_text_stdout @@ -482,3 +483,48 @@ def _detect_program_name(path=None, _main=sys.modules["__main__"]): py_module = f"{py_module}.{name}" return f"python -m {py_module.lstrip('.')}" + + +def _expand_args( + args: t.Iterable[str], + *, + user: bool = True, + env: bool = True, + glob_recursive: bool = True, +) -> t.List[str]: + """Simulate Unix shell expansion with Python functions. + + See :func:`glob.glob`, :func:`os.path.expanduser`, and + :func:`os.path.expandvars`. + + This intended for use on Windows, where the shell does not do any + expansion. It may not exactly match what a Unix shell would do. + + :param args: List of command line arguments to expand. + :param user: Expand user home directory. + :param env: Expand environment variables. + :param glob_recursive: ``**`` matches directories recursively. + + .. versionadded:: 8.0 + + :meta private: + """ + from glob import glob + + out = [] + + for arg in args: + if user: + arg = os.path.expanduser(arg) + + if env: + arg = os.path.expandvars(arg) + + matches = glob(arg, recursive=glob_recursive) + + if not matches: + out.append(arg) + else: + out.extend(matches) + + return out diff --git a/tests/test_utils.py b/tests/test_utils.py index cc0893dab..441794b43 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -429,3 +429,13 @@ def __init__(self, package_name): ) def test_detect_program_name(path, main, expected): assert click.utils._detect_program_name(path, _main=main) == expected + + +def test_expand_args(monkeypatch): + user = os.path.expanduser("~") + assert user in click.utils._expand_args(["~"]) + monkeypatch.setenv("CLICK_TEST", "hello") + assert "hello" in click.utils._expand_args(["$CLICK_TEST"]) + assert "setup.cfg" in click.utils._expand_args(["*.cfg"]) + assert os.path.join("docs", "conf.py") in click.utils._expand_args(["**/conf.py"]) + assert "*.not-found" in click.utils._expand_args(["*.not-found"]) From bc21607df91d6c18f8c1054b82a59127cd17f0ea Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 2 Apr 2021 08:35:44 -0700 Subject: [PATCH 238/293] revert adding space between option help --- CHANGES.rst | 2 ++ src/click/formatting.py | 4 ---- tests/test_formatting.py | 36 ------------------------------------ 3 files changed, 2 insertions(+), 40 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6b5cb3541..b88d16fb7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -149,6 +149,8 @@ Unreleased passed in as a default value. :issue:`549, 736, 764, 921, 1015, 1618` - Fix formatting when ``Command.options_metavar`` is empty. :pr:`1551` +- Revert adding space between option help text that wraps. + :issue:`1831` - The default value passed to ``prompt`` will be cast to the correct type like an input value would be. :pr:`1517` - Automatically generated short help messages will stop at the first diff --git a/src/click/formatting.py b/src/click/formatting.py index 0d3c65e83..9cb5a1948 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -225,10 +225,6 @@ def write_dl(self, rows, col_max=30, col_spacing=2): for line in lines[1:]: self.write(f"{'':>{first_col + self.current_indent}}{line}\n") - - if len(lines) > 1: - # separate long help from next option - self.write("\n") else: self.write("\n") diff --git a/tests/test_formatting.py b/tests/test_formatting.py index bb2bb8fdc..ec32bf92f 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -331,42 +331,6 @@ def cli(): ] -def test_formatting_usage_multiline_option_padding(runner): - @click.command("foo") - @click.option("--bar", help="This help message will be padded if it wraps.") - def cli(): - pass - - result = runner.invoke(cli, "--help", terminal_width=45) - assert not result.exception - assert result.output.splitlines() == [ - "Usage: foo [OPTIONS]", - "", - "Options:", - " --bar TEXT This help message will be", - " padded if it wraps.", - "", - " --help Show this message and exit.", - ] - - -def test_formatting_usage_no_option_padding(runner): - @click.command("foo") - @click.option("--bar", help="This help message will be padded if it wraps.") - def cli(): - pass - - result = runner.invoke(cli, "--help", terminal_width=80) - assert not result.exception - assert result.output.splitlines() == [ - "Usage: foo [OPTIONS]", - "", - "Options:", - " --bar TEXT This help message will be padded if it wraps.", - " --help Show this message and exit.", - ] - - def test_formatting_with_options_metavar_empty(runner): cli = click.Command("cli", options_metavar="", params=[click.Argument(["var"])]) result = runner.invoke(cli, ["--help"]) From 1e02000e172d4aa46bf4f577ac763b48f5ff01fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20B=C3=A1nffy?= Date: Tue, 23 Mar 2021 21:31:02 +0000 Subject: [PATCH 239/293] add italic and overline to style --- CHANGES.rst | 3 ++- src/click/termui.py | 11 ++++++++++- tests/test_utils.py | 9 +++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b88d16fb7..907e21ae2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -174,7 +174,8 @@ Unreleased addition to its type. :issue:`457` - ``confirmation_prompt`` can be set to a custom string. :issue:`723` - Allow styled output in Jupyter on Windows. :issue:`1271` -- ``style()`` supports the ``strikethrough`` style. :issue:`805` +- ``style()`` supports the ``strikethrough``, ``italic``, and + ``overline`` styles. :issue:`805, 1821` - Multiline marker is removed from short help text. :issue:`1597` - Restore progress bar behavior of echoing only the label if the file is not a TTY. :issue:`1138` diff --git a/src/click/termui.py b/src/click/termui.py index 4ea76d805..8342fdeaf 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -464,6 +464,8 @@ def style( bold=None, dim=None, underline=None, + overline=None, + italic=None, blink=None, reverse=None, strikethrough=None, @@ -518,6 +520,8 @@ def style( :param dim: if provided this will enable or disable dim mode. This is badly supported. :param underline: if provided this will enable or disable underline. + :param overline: if provided this will enable or disable overline. + :param italic: if provided this will enable or disable italic. :param blink: if provided this will enable or disable blinking. :param reverse: if provided this will enable or disable inverse rendering (foreground becomes background and the @@ -535,7 +539,8 @@ def style( Added support for 256 and RGB color codes. .. versionchanged:: 8.0 - Added the ``strikethrough`` parameter. + Added the ``strikethrough``, ``italic``, and ``overline`` + parameters. .. versionchanged:: 7.0 Added support for bright colors. @@ -565,6 +570,10 @@ def style( bits.append(f"\033[{2 if dim else 22}m") if underline is not None: bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if underline else 55}m") + if italic is not None: + bits.append(f"\033[{5 if underline else 23}m") if blink is not None: bits.append(f"\033[{5 if blink else 25}m") if reverse is not None: diff --git a/tests/test_utils.py b/tests/test_utils.py index 441794b43..3150b09c8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -64,10 +64,15 @@ def test_echo_custom_file(): ({"bg": "white"}, "\x1b[47mx y\x1b[0m"), ({"bg": 91}, "\x1b[48;5;91mx y\x1b[0m"), ({"bg": (135, 0, 175)}, "\x1b[48;2;135;0;175mx y\x1b[0m"), - ({"blink": True}, "\x1b[5mx y\x1b[0m"), - ({"underline": True}, "\x1b[4mx y\x1b[0m"), ({"bold": True}, "\x1b[1mx y\x1b[0m"), ({"dim": True}, "\x1b[2mx y\x1b[0m"), + ({"underline": True}, "\x1b[4mx y\x1b[0m"), + ({"overline": True}, "\x1b[55mx y\x1b[0m"), + ({"italic": True}, "\x1b[23mx y\x1b[0m"), + ({"blink": True}, "\x1b[5mx y\x1b[0m"), + ({"reverse": True}, "\x1b[7mx y\x1b[0m"), + ({"strikethrough": True}, "\x1b[9mx y\x1b[0m"), + ({"fg": "black", "reset": False}, "\x1b[30mx y"), ], ) def test_styling(styles, ref): From 03e2fee8a99f08976aef0d38cb3fa372a94c928a Mon Sep 17 00:00:00 2001 From: Saif807380 Date: Thu, 1 Apr 2021 17:11:37 +0530 Subject: [PATCH 240/293] mark cli messages for translation --- CHANGES.rst | 2 +- src/click/core.py | 9 ++++++--- src/click/decorators.py | 10 +++++++--- src/click/exceptions.py | 18 ++++++++++++++---- src/click/termui.py | 11 ++++++----- tests/test_imports.py | 1 + 6 files changed, 35 insertions(+), 16 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 907e21ae2..172770e77 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -197,7 +197,7 @@ Unreleased :issue:`1791` - When taking arguments from ``sys.argv`` on Windows, glob patterns, user dir, and env vars are expanded. :issue:`1096` - +- Wrapped public messages with ``_(gettext)`` for i18n support. :issue:`303` Version 7.1.2 ------------- diff --git a/src/click/core.py b/src/click/core.py index 80b1075a5..1b91af9cd 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -6,6 +6,7 @@ from contextlib import contextmanager from contextlib import ExitStack from functools import update_wrapper +from gettext import gettext as _ from itertools import repeat from ._unicodefun import _verify_python_env @@ -995,7 +996,7 @@ def main( except Abort: if not standalone_mode: raise - echo("Aborted!", file=sys.stderr) + echo(_("Aborted!"), file=sys.stderr) sys.exit(1) def _main_shell_completion(self, ctx_args, prog_name, complete_var=None): @@ -1170,7 +1171,7 @@ def show_help(ctx, param, value): is_eager=True, expose_value=False, callback=show_help, - help="Show this message and exit.", + help=_("Show this message and exit."), ) def make_parser(self, ctx): @@ -1280,7 +1281,9 @@ def invoke(self, ctx): if self.deprecated: echo( style( - f"DeprecationWarning: The command {self.name!r} is deprecated.", + _( + "DeprecationWarning: The command {self.name!r} is deprecated." + ).format(self=self), fg="red", ), err=True, diff --git a/src/click/decorators.py b/src/click/decorators.py index 43c8fa297..13fb15680 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -1,6 +1,7 @@ import inspect import typing as t from functools import update_wrapper +from gettext import gettext as _ from .core import Argument from .core import Command @@ -280,7 +281,7 @@ def version_option( *param_decls, package_name=None, prog_name=None, - message="%(prog)s, version %(version)s", + message=None, **kwargs, ): """Add a ``--version`` option which immediately prints the version @@ -315,6 +316,9 @@ def version_option( .. versionchanged:: 8.0 Use :mod:`importlib.metadata` instead of ``pkg_resources``. """ + if message is None: + message = _("%(prog)s, version %(version)s") + if version is None and package_name is None: frame = inspect.currentframe() f_globals = frame.f_back.f_globals if frame is not None else None @@ -381,7 +385,7 @@ def callback(ctx, param, value): kwargs.setdefault("is_flag", True) kwargs.setdefault("expose_value", False) kwargs.setdefault("is_eager", True) - kwargs.setdefault("help", "Show the version and exit.") + kwargs.setdefault("help", _("Show the version and exit.")) kwargs["callback"] = callback return option(*param_decls, **kwargs) @@ -412,6 +416,6 @@ def callback(ctx, param, value): kwargs.setdefault("is_flag", True) kwargs.setdefault("expose_value", False) kwargs.setdefault("is_eager", True) - kwargs.setdefault("help", "Show this message and exit.") + kwargs.setdefault("help", _("Show this message and exit.")) kwargs["callback"] = callback return option(*param_decls, **kwargs) diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 9623cd812..81a1469ff 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -1,3 +1,5 @@ +from gettext import gettext as _ + from ._compat import filename_to_ui from ._compat import get_text_stderr from .utils import echo @@ -28,7 +30,7 @@ def __str__(self): def show(self, file=None): if file is None: file = get_text_stderr() - echo(f"Error: {self.format_message()}", file=file) + echo(_("Error: {self.format_message()}").format(self=self), file=file) class UsageError(ClickException): @@ -59,8 +61,16 @@ def show(self, file=None): ) if self.ctx is not None: color = self.ctx.color - echo(f"{self.ctx.get_usage()}\n{hint}", file=file, color=color) - echo(f"Error: {self.format_message()}", file=file, color=color) + echo( + _("{usage}\n{hint}").format(usage=self.ctx.get_usage(), hint=hint), + file=file, + color=color, + ) + echo( + _("Error: {message}").format(message=self.format_message()), + file=file, + color=color, + ) class BadParameter(UsageError): @@ -144,7 +154,7 @@ def format_message(self): def __str__(self): if self.message is None: param_name = self.param.name if self.param else None - return f"missing parameter: {param_name}" + return _("missing parameter: {param_name}").format(param_name=param_name) else: return self.message diff --git a/src/click/termui.py b/src/click/termui.py index 8342fdeaf..72b03f818 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -4,6 +4,7 @@ import os import sys import typing as t +from gettext import gettext as _ from ._compat import is_bytes from ._compat import isatty @@ -145,7 +146,7 @@ def prompt_func(text): if confirmation_prompt: if confirmation_prompt is True: - confirmation_prompt = "Repeat for confirmation" + confirmation_prompt = _("Repeat for confirmation") confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) @@ -161,9 +162,9 @@ def prompt_func(text): result = value_proc(value) except UsageError as e: if hide_input: - echo("Error: the value you entered was invalid", err=err) + echo(_("Error: the value you entered was invalid"), err=err) else: - echo(f"Error: {e.message}", err=err) # noqa: B306 + echo(_("Error: {e.message}").format(e=e), err=err) # noqa: B306 continue if not confirmation_prompt: return result @@ -173,7 +174,7 @@ def prompt_func(text): break if value == value2: return result - echo("Error: the two entered values do not match", err=err) + echo(_("Error: the two entered values do not match"), err=err) def confirm( @@ -222,7 +223,7 @@ def confirm( elif default is not None and value == "": rv = default else: - echo("Error: invalid input", err=err) + echo(_("Error: invalid input"), err=err) continue break if abort and not rv: diff --git a/tests/test_imports.py b/tests/test_imports.py index dd26972ab..ec32fcafd 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -47,6 +47,7 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, "enum", "typing", "types", + "gettext", } if WIN: From 8d49e146ab8c2312e7917bb7c3f8abf01b8b55bf Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 3 Apr 2021 12:34:17 -0700 Subject: [PATCH 241/293] mark more messages for translation --- CHANGES.rst | 4 +- src/click/_termui_impl.py | 9 +++- src/click/_unicodefun.py | 67 ++++++++++++++++------------- src/click/core.py | 43 ++++++++++--------- src/click/decorators.py | 3 +- src/click/exceptions.py | 69 ++++++++++++++++++------------ src/click/formatting.py | 9 +++- src/click/parser.py | 18 ++++++-- src/click/shell_completion.py | 9 ++-- src/click/termui.py | 13 ++++-- src/click/types.py | 79 ++++++++++++++++++++++++++--------- tests/test_arguments.py | 4 +- tests/test_basic.py | 2 +- tests/test_options.py | 4 +- tests/test_termui.py | 2 +- 15 files changed, 218 insertions(+), 117 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 172770e77..13facd9b1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -197,7 +197,9 @@ Unreleased :issue:`1791` - When taking arguments from ``sys.argv`` on Windows, glob patterns, user dir, and env vars are expanded. :issue:`1096` -- Wrapped public messages with ``_(gettext)`` for i18n support. :issue:`303` +- Marked messages shown by the CLI with ``gettext()`` to allow + applications to translate Click's built-in strings. :issue:`303` + Version 7.1.2 ------------- diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 3c7d57f22..0e9860bd8 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -8,6 +8,7 @@ import os import sys import time +from gettext import gettext as _ from ._compat import _default_text_stdout from ._compat import CYGWIN @@ -489,9 +490,13 @@ def edit_file(self, filename): c = subprocess.Popen(f'{editor} "{filename}"', env=environ, shell=True) exit_code = c.wait() if exit_code != 0: - raise ClickException(f"{editor}: Editing failed!") + raise ClickException( + _("{editor}: Editing failed").format(editor=editor) + ) except OSError as e: - raise ClickException(f"{editor}: Editing failed: {e}") + raise ClickException( + _("{editor}: Editing failed: {e}").format(editor=editor, e=e) + ) def edit(self, text): import tempfile diff --git a/src/click/_unicodefun.py b/src/click/_unicodefun.py index 53ec9d267..aa1102427 100644 --- a/src/click/_unicodefun.py +++ b/src/click/_unicodefun.py @@ -1,5 +1,6 @@ import codecs import os +from gettext import gettext as _ def _verify_python_env(): @@ -13,7 +14,15 @@ def _verify_python_env(): if fs_enc != "ascii": return - extra = "" + extra = [ + _( + "Click will abort further execution because Python was" + " configured to use ASCII as encoding for the environment." + " Consult https://click.palletsprojects.com/unicode-support/" + " for mitigation steps." + ) + ] + if os.name == "posix": import subprocess @@ -37,27 +46,32 @@ def _verify_python_env(): if locale.lower() in ("c.utf8", "c.utf-8"): has_c_utf8 = True - extra += "\n\n" if not good_locales: - extra += ( - "Additional information: on this system no suitable" - " UTF-8 locales were discovered. This most likely" - " requires resolving by reconfiguring the locale" - " system." + extra.append( + _( + "Additional information: on this system no suitable" + " UTF-8 locales were discovered. This most likely" + " requires resolving by reconfiguring the locale" + " system." + ) ) elif has_c_utf8: - extra += ( - "This system supports the C.UTF-8 locale which is" - " recommended. You might be able to resolve your issue" - " by exporting the following environment variables:\n\n" - " export LC_ALL=C.UTF-8\n" - " export LANG=C.UTF-8" + extra.append( + _( + "This system supports the C.UTF-8 locale which is" + " recommended. You might be able to resolve your" + " issue by exporting the following environment" + " variables:" + ) ) + extra.append(" export LC_ALL=C.UTF-8\n export LANG=C.UTF-8") else: - extra += ( - "This system lists some UTF-8 supporting locales that" - " you can pick from. The following suitable locales" - f" were discovered: {', '.join(sorted(good_locales))}" + extra.append( + _( + "This system lists some UTF-8 supporting locales" + " that you can pick from. The following suitable" + " locales were discovered: {locales}" + ).format(locales=", ".join(sorted(good_locales))) ) bad_locale = None @@ -67,16 +81,13 @@ def _verify_python_env(): if locale is not None: break if bad_locale is not None: - extra += ( - "\n\nClick discovered that you exported a UTF-8 locale" - " but the locale system could not pick up from it" - " because it does not exist. The exported locale is" - f" {bad_locale!r} but it is not supported" + extra.append( + _( + "Click discovered that you exported a UTF-8 locale" + " but the locale system could not pick up from it" + " because it does not exist. The exported locale is" + " {locale!r} but it is not supported." + ).format(locale=bad_locale) ) - raise RuntimeError( - "Click will abort further execution because Python was" - " configured to use ASCII as encoding for the environment." - " Consult https://click.palletsprojects.com/unicode-support/" - f" for mitigation steps.{extra}" - ) + raise RuntimeError("\n\n".join(extra)) diff --git a/src/click/core.py b/src/click/core.py index 1b91af9cd..a5e7a79b4 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -7,6 +7,7 @@ from contextlib import ExitStack from functools import update_wrapper from gettext import gettext as _ +from gettext import ngettext from itertools import repeat from ._unicodefun import _verify_python_env @@ -1200,7 +1201,7 @@ def get_short_help_str(self, limit=45): text = make_default_short_help(self.help, limit) if self.deprecated: - text = f"(Deprecated) {text}" + text = _("(Deprecated) {text}").format(text=text) return text.strip() @@ -1226,7 +1227,7 @@ def format_help_text(self, ctx, formatter): text = self.help or "" if self.deprecated: - text = f"(Deprecated) {text}" + text = _("(Deprecated) {text}").format(text=text) if text: formatter.write_paragraph() @@ -1243,7 +1244,7 @@ def format_options(self, ctx, formatter): opts.append(rv) if opts: - with formatter.section("Options"): + with formatter.section(_("Options")): formatter.write_dl(opts) def format_epilog(self, ctx, formatter): @@ -1266,9 +1267,11 @@ def parse_args(self, ctx, args): if args and not ctx.allow_extra_args and not ctx.resilient_parsing: ctx.fail( - "Got unexpected extra" - f" argument{'s' if len(args) != 1 else ''}" - f" ({' '.join(map(make_str, args))})" + ngettext( + "Got unexpected extra argument ({args})", + "Got unexpected extra arguments ({args})", + len(args), + ).format(args=" ".join(map(str, args))) ) ctx.args = args @@ -1281,9 +1284,9 @@ def invoke(self, ctx): if self.deprecated: echo( style( - _( - "DeprecationWarning: The command {self.name!r} is deprecated." - ).format(self=self), + _("DeprecationWarning: The command {name!r} is deprecated.").format( + name=self.name + ), fg="red", ), err=True, @@ -1477,7 +1480,7 @@ def format_commands(self, ctx, formatter): rows.append((subcommand, help)) if rows: - with formatter.section("Commands"): + with formatter.section(_("Commands")): formatter.write_dl(rows) def parse_args(self, ctx, args): @@ -1509,7 +1512,7 @@ def _process_result(value): with ctx: super().invoke(ctx) return _process_result([] if self.chain else None) - ctx.fail("Missing command.") + ctx.fail(_("Missing command.")) # Fetch args back out args = ctx.protected_args + ctx.args @@ -1583,7 +1586,7 @@ def resolve_command(self, ctx, args): if cmd is None and not ctx.resilient_parsing: if split_opt(cmd_name)[0]: self.parse_args(ctx, ctx.args) - ctx.fail(f"No such command '{original_cmd_name}'.") + ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name)) return cmd.name if cmd else None, cmd, args[1:] def get_command(self, ctx, cmd_name): @@ -2061,10 +2064,12 @@ def process_value(self, ctx, value): else len(value) != self.nargs ) ): - were = "was" if len(value) == 1 else "were" ctx.fail( - f"Argument {self.name!r} takes {self.nargs} values but" - f" {len(value)} {were} given." + ngettext( + "Argument {name!r} takes {nargs} values but 1 was given.", + "Argument {name!r} takes {nargs} values but {len} were given.", + len(value), + ).format(name=self.name, nargs=self.nargs, len=len(value)) ) if self.callback is not None: @@ -2424,7 +2429,7 @@ def _write_opts(opts): if isinstance(envvar, (list, tuple)) else envvar ) - extra.append(f"env var: {var_str}") + extra.append(_("env var: {var}").format(var=var_str)) default_value = self.get_default(ctx, call=False) show_default_is_str = isinstance(self.show_default, str) @@ -2437,7 +2442,7 @@ def _write_opts(opts): elif isinstance(default_value, (list, tuple)): default_string = ", ".join(str(d) for d in default_value) elif callable(default_value): - default_string = "(dynamic)" + default_string = _("(dynamic)") elif self.is_bool_flag and self.secondary_opts: # For boolean flags that have distinct True/False opts, # use the opt without prefix instead of the value. @@ -2447,7 +2452,7 @@ def _write_opts(opts): else: default_string = default_value - extra.append(f"default: {default_string}") + extra.append(_("default: {default}").format(default=default_string)) if isinstance(self.type, _NumberRangeBase): range_str = self.type._describe_range() @@ -2456,7 +2461,7 @@ def _write_opts(opts): extra.append(range_str) if self.required: - extra.append("required") + extra.append(_("required")) if extra: extra_str = ";".join(extra) help = f"{help} [{extra_str}]" if help else f"[{extra_str}]" diff --git a/src/click/decorators.py b/src/click/decorators.py index 13fb15680..a447084c2 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -305,7 +305,8 @@ def version_option( :param prog_name: The name of the CLI to show in the message. If not provided, it will be detected from the command. :param message: The message to show. The values ``%(prog)s``, - ``%(package)s``, and ``%(version)s`` are available. + ``%(package)s``, and ``%(version)s`` are available. Defaults to + ``"%(prog)s, version %(version)s"``. :param kwargs: Extra arguments are passed to :func:`option`. :raise RuntimeError: ``version`` could not be detected. diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 81a1469ff..ab9b31299 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -1,4 +1,5 @@ from gettext import gettext as _ +from gettext import ngettext from ._compat import filename_to_ui from ._compat import get_text_stderr @@ -30,7 +31,7 @@ def __str__(self): def show(self, file=None): if file is None: file = get_text_stderr() - echo(_("Error: {self.format_message()}").format(self=self), file=file) + echo(_("Error: {message}").format(message=self.format_message()), file=file) class UsageError(ClickException): @@ -55,17 +56,13 @@ def show(self, file=None): color = None hint = "" if self.cmd is not None and self.cmd.get_help_option(self.ctx) is not None: - hint = ( - f"Try '{self.ctx.command_path}" - f" {self.ctx.help_option_names[0]}' for help.\n" + hint = _("Try '{command} {option}' for help.").format( + command=self.ctx.command_path, option=self.ctx.help_option_names[0] ) + hint = f"{hint}\n" if self.ctx is not None: color = self.ctx.color - echo( - _("{usage}\n{hint}").format(usage=self.ctx.get_usage(), hint=hint), - file=file, - color=color, - ) + echo(f"{self.ctx.get_usage()}\n{hint}", file=file, color=color) echo( _("Error: {message}").format(message=self.format_message()), file=file, @@ -102,10 +99,11 @@ def format_message(self): elif self.param is not None: param_hint = self.param.get_error_hint(self.ctx) else: - return f"Invalid value: {self.message}" - param_hint = _join_param_hints(param_hint) + return _("Invalid value: {message}").format(message=self.message) - return f"Invalid value for {param_hint}: {self.message}" + return _("Invalid value for {param_hint}: {message}").format( + param_hint=_join_param_hints(param_hint), message=self.message + ) class MissingParameter(BadParameter): @@ -133,7 +131,9 @@ def format_message(self): param_hint = self.param.get_error_hint(self.ctx) else: param_hint = None + param_hint = _join_param_hints(param_hint) + param_hint = f" {param_hint}" if param_hint else "" param_type = self.param_type if param_type is None and self.param is not None: @@ -144,17 +144,28 @@ def format_message(self): msg_extra = self.param.type.get_missing_message(self.param) if msg_extra: if msg: - msg += f". {msg_extra}" + msg += f". {msg_extra}" else: msg = msg_extra - hint_str = f" {param_hint}" if param_hint else "" - return f"Missing {param_type}{hint_str}.{' ' if msg else ''}{msg or ''}" + msg = f" {msg}" if msg else "" + + # Translate param_type for known types. + if param_type == "argument": + missing = _("Missing argument") + elif param_type == "option": + missing = _("Missing option") + elif param_type == "parameter": + missing = _("Missing parameter") + else: + missing = _("Missing {param_type}").format(param_type=param_type) + + return f"{missing}{param_hint}.{msg}" def __str__(self): if self.message is None: param_name = self.param.name if self.param else None - return _("missing parameter: {param_name}").format(param_name=param_name) + return _("Missing parameter: {param_name}").format(param_name=param_name) else: return self.message @@ -168,21 +179,23 @@ class NoSuchOption(UsageError): def __init__(self, option_name, message=None, possibilities=None, ctx=None): if message is None: - message = f"no such option: {option_name}" + message = _("No such option: {name}").format(name=option_name) super().__init__(message, ctx) self.option_name = option_name self.possibilities = possibilities def format_message(self): - bits = [self.message] - if self.possibilities: - if len(self.possibilities) == 1: - bits.append(f"Did you mean {self.possibilities[0]}?") - else: - possibilities = sorted(self.possibilities) - bits.append(f"(Possible options: {', '.join(possibilities)})") - return " ".join(bits) + if not self.possibilities: + return self.message + + possibility_str = ", ".join(sorted(self.possibilities)) + suggest = ngettext( + "Did you mean {possibility}?", + "(Possible options: {possibilities})", + len(self.possibilities), + ).format(possibility=possibility_str, possibilities=possibility_str) + return f"{self.message} {suggest}" class BadOptionUsage(UsageError): @@ -215,14 +228,16 @@ class FileError(ClickException): def __init__(self, filename, hint=None): ui_filename = filename_to_ui(filename) if hint is None: - hint = "unknown error" + hint = _("unknown error") super().__init__(hint) self.ui_filename = ui_filename self.filename = filename def format_message(self): - return f"Could not open file {self.ui_filename}: {self.message}" + return _("Could not open file {filename!r}: {message}").format( + filename=self.ui_filename, message=self.message + ) class Abort(RuntimeError): diff --git a/src/click/formatting.py b/src/click/formatting.py index 9cb5a1948..72e25c976 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -1,5 +1,6 @@ import typing as t from contextlib import contextmanager +from gettext import gettext as _ from ._compat import term_len from .parser import split_opt @@ -129,13 +130,17 @@ def dedent(self): """Decreases the indentation.""" self.current_indent -= self.indent_increment - def write_usage(self, prog, args="", prefix="Usage: "): + def write_usage(self, prog, args="", prefix=None): """Writes a usage line into the buffer. :param prog: the program name. :param args: whitespace separated list of arguments. - :param prefix: the prefix for the first line. + :param prefix: The prefix for the first line. Defaults to + ``"Usage: "``. """ + if prefix is None: + prefix = f"{_('Usage:')} " + usage_prefix = f"{prefix:>{self.current_indent}}{prog} " text_width = self.width - self.current_indent diff --git a/src/click/parser.py b/src/click/parser.py index d730e0106..4eede0c1a 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -22,6 +22,8 @@ # Copyright 2001-2006 Gregory P. Ward # Copyright 2002-2006 Python Software Foundation from collections import deque +from gettext import gettext as _ +from gettext import ngettext from .exceptions import BadArgumentUsage from .exceptions import BadOptionUsage @@ -194,7 +196,9 @@ def process(self, value, state): value = None elif holes != 0: raise BadArgumentUsage( - f"argument {self.dest} takes {self.nargs} values" + _("Argument {name!r} takes {nargs} values.").format( + name=self.dest, nargs=self.nargs + ) ) if self.nargs == -1 and self.obj.envvar is not None: @@ -359,7 +363,9 @@ def _match_long_opt(self, opt, explicit_value, state): value = self._get_value_from_state(opt, option, state) elif explicit_value is not None: - raise BadOptionUsage(opt, f"{opt} option does not take a value") + raise BadOptionUsage( + opt, _("Option {name!r} does not take a value.").format(name=opt) + ) else: value = None @@ -414,9 +420,13 @@ def _get_value_from_state(self, option_name, option, state): # Option allows omitting the value. value = _flag_needs_value else: - n_str = "an argument" if nargs == 1 else f"{nargs} arguments" raise BadOptionUsage( - option_name, f"{option_name} option requires {n_str}." + option_name, + ngettext( + "Option {name!r} requires an argument.", + "Option {name!r} requires {nargs} arguments.", + nargs, + ).format(name=option_name, nargs=nargs), ) elif nargs == 1: next_rarg = state.rargs[0] diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index d87e0c84d..ae498ac24 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -1,6 +1,7 @@ import os import re import typing as t +from gettext import gettext as _ from .core import Argument from .core import MultiCommand @@ -289,12 +290,14 @@ def _check_version(self): if major < "4" or major == "4" and minor < "4": raise RuntimeError( - "Shell completion is not supported for Bash" - " versions older than 4.4." + _( + "Shell completion is not supported for Bash" + " versions older than 4.4." + ) ) else: raise RuntimeError( - "Couldn't detect Bash version, shell completion is not supported." + _("Couldn't detect Bash version, shell completion is not supported.") ) def source(self): diff --git a/src/click/termui.py b/src/click/termui.py index 72b03f818..3a7e0850e 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -162,7 +162,7 @@ def prompt_func(text): result = value_proc(value) except UsageError as e: if hide_input: - echo(_("Error: the value you entered was invalid"), err=err) + echo(_("Error: The value you entered was invalid."), err=err) else: echo(_("Error: {e.message}").format(e=e), err=err) # noqa: B306 continue @@ -174,7 +174,7 @@ def prompt_func(text): break if value == value2: return result - echo(_("Error: the two entered values do not match"), err=err) + echo(_("Error: The two entered values do not match."), err=err) def confirm( @@ -732,7 +732,7 @@ def raw_terminal(): return f() -def pause(info="Press any key to continue ...", err=False): +def pause(info=None, err=False): """This command stops execution and waits for the user to press any key to continue. This is similar to the Windows batch "pause" command. If the program is not run through a terminal, this command @@ -743,12 +743,17 @@ def pause(info="Press any key to continue ...", err=False): .. versionadded:: 4.0 Added the `err` parameter. - :param info: the info string to print before pausing. + :param info: The message to print before pausing. Defaults to + ``"Press any key to continue..."``. :param err: if set to message goes to ``stderr`` instead of ``stdout``, the same as with echo. """ if not isatty(sys.stdin) or not isatty(sys.stdout): return + + if info is None: + info = _("Press any key to continue...") + try: if info: echo(info, nl=False, err=err) diff --git a/src/click/types.py b/src/click/types.py index 6cf611fa7..543833a24 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -2,6 +2,8 @@ import stat import typing as t from datetime import datetime +from gettext import gettext as _ +from gettext import ngettext from ._compat import _get_argv_encoding from ._compat import filename_to_ui @@ -228,8 +230,7 @@ def get_metavar(self, param): return f"[{choices_str}]" def get_missing_message(self, param): - choice_str = ",\n\t".join(self.choices) - return f"Choose from:\n\t{choice_str}" + return _("Choose from:\n\t{choices}").format(choices=",\n\t".join(self.choices)) def convert(self, value, param, ctx): # Match through normalization and case sensitivity @@ -256,9 +257,16 @@ def convert(self, value, param, ctx): if normed_value in normed_choices: return normed_choices[normed_value] - one_of = "one of " if len(self.choices) > 1 else "" - choices_str = ", ".join(repr(c) for c in self.choices) - self.fail(f"{value!r} is not {one_of}{choices_str}.", param, ctx) + choices_str = ", ".join(map(repr, self.choices)) + self.fail( + ngettext( + "{value!r} is not {choice}.", + "{value!r} is not one of {choices}.", + len(self.choices), + ).format(value=value, choice=choices_str, choices=choices_str), + param, + ctx, + ) def __repr__(self): return f"Choice({list(self.choices)})" @@ -335,10 +343,15 @@ def convert(self, value, param, ctx): if converted is not None: return converted - plural = "s" if len(self.formats) > 1 else "" - formats_str = ", ".join(repr(f) for f in self.formats) + formats_str = ", ".join(map(repr, self.formats)) self.fail( - f"{value!r} does not match the format{plural} {formats_str}.", param, ctx + ngettext( + "{value!r} does not match the format {format}.", + "{value!r} does not match the formats {formats}.", + len(self.formats), + ).format(value=value, format=formats_str, formats=formats_str), + param, + ctx, ) def __repr__(self): @@ -352,7 +365,13 @@ def convert(self, value, param, ctx): try: return self._number_class(value) except ValueError: - self.fail(f"{value!r} is not a valid {self.name}.", param, ctx) + self.fail( + _("{value!r} is not a valid {number_type}.").format( + value=value, number_type=self.name + ), + param, + ctx, + ) class _NumberRangeBase(_NumberParamTypeBase): @@ -393,7 +412,13 @@ def convert(self, value, param, ctx): return self._clamp(self.max, -1, self.max_open) if lt_min or gt_max: - self.fail(f"{rv} is not in the range {self._describe_range()}.", param, ctx) + self.fail( + _("{value} is not in the range {range}.").format( + value=rv, range=self._describe_range() + ), + param, + ctx, + ) return rv @@ -517,7 +542,9 @@ def convert(self, value, param, ctx): if norm in {"0", "false", "f", "no", "n", "off"}: return False - self.fail(f"{value!r} is not a valid boolean.", param, ctx) + self.fail( + _("{value!r} is not a valid boolean.").format(value=value), param, ctx + ) def __repr__(self): return "BOOL" @@ -537,7 +564,9 @@ def convert(self, value, param, ctx): try: return uuid.UUID(value) except ValueError: - self.fail(f"{value!r} is not a valid UUID.", param, ctx) + self.fail( + _("{value!r} is not a valid UUID.").format(value=value), param, ctx + ) def __repr__(self): return "UUID" @@ -698,11 +727,11 @@ def __init__( self.type = path_type if self.file_okay and not self.dir_okay: - self.name = "file" + self.name = _("file") elif self.dir_okay and not self.file_okay: - self.name = "directory" + self.name = _("directory") else: - self.name = "path" + self.name = _("path") def to_info_dict(self): info_dict = super().to_info_dict() @@ -746,32 +775,42 @@ def convert(self, value, param, ctx): if not self.exists: return self.coerce_path_result(rv) self.fail( - f"{self.name.title()} {filename_to_ui(value)!r} does not exist.", + _("{name} {filename!r} does not exist.").format( + name=self.name.title(), filename=filename_to_ui(value) + ), param, ctx, ) if not self.file_okay and stat.S_ISREG(st.st_mode): self.fail( - f"{self.name.title()} {filename_to_ui(value)!r} is a file.", + _("{name} {filename!r} is a file.").format( + name=self.name.title(), filename=filename_to_ui(value) + ), param, ctx, ) if not self.dir_okay and stat.S_ISDIR(st.st_mode): self.fail( - f"{self.name.title()} {filename_to_ui(value)!r} is a directory.", + _("{name} {filename!r} is a directory.").format( + name=self.name.title(), filename=filename_to_ui(value) + ), param, ctx, ) if self.writable and not os.access(value, os.W_OK): self.fail( - f"{self.name.title()} {filename_to_ui(value)!r} is not writable.", + _("{name} {filename!r} is not writable.").format( + name=self.name.title(), filename=filename_to_ui(value) + ), param, ctx, ) if self.readable and not os.access(value, os.R_OK): self.fail( - f"{self.name.title()} {filename_to_ui(value)!r} is not readable.", + _("{name} {filename!r} is not readable.").format( + name=self.name.title(), filename=filename_to_ui(value) + ), param, ctx, ) diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 607a636fe..e1a6de90f 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -230,7 +230,7 @@ def test_missing_argument_string_cast(): with pytest.raises(click.MissingParameter) as excinfo: click.Argument(["a"], required=True).process_value(ctx, None) - assert str(excinfo.value) == "missing parameter: a" + assert str(excinfo.value) == "Missing parameter: a" def test_implicit_non_required(runner): @@ -301,7 +301,7 @@ def cmd(a): result = runner.invoke(cmd, ["3"]) assert result.exception is not None - assert "argument a takes 2 values" in result.output + assert "Argument 'a' takes 2 values." in result.output def test_multiple_param_decls_not_allowed(runner): diff --git a/tests/test_basic.py b/tests/test_basic.py index 47356277b..c35e69ad1 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -120,7 +120,7 @@ def cli(foo): result = runner.invoke(cli, ["--foo"]) assert result.exception - assert "--foo option requires an argument" in result.output + assert "Option '--foo' requires an argument." in result.output result = runner.invoke(cli, ["--foo="]) assert not result.exception diff --git a/tests/test_options.py b/tests/test_options.py index 65109d69a..009f80e2a 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -83,7 +83,7 @@ def cli(): result = runner.invoke(cli, [unknown_flag]) assert result.exception - assert f"no such option: {unknown_flag}" in result.output + assert f"No such option: {unknown_flag}" in result.output @pytest.mark.parametrize( @@ -441,7 +441,7 @@ def test_missing_option_string_cast(): with pytest.raises(click.MissingParameter) as excinfo: click.Option(["-a"], required=True).process_value(ctx, None) - assert str(excinfo.value) == "missing parameter: a" + assert str(excinfo.value) == "Missing parameter: a" def test_missing_choice(runner): diff --git a/tests/test_termui.py b/tests/test_termui.py index 05e3bf8fb..ab459f052 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -376,7 +376,7 @@ def test_fast_edit(runner): ("prompt_required", "required", "args", "expect"), [ (True, False, None, "prompt"), - (True, False, ["-v"], "-v option requires an argument"), + (True, False, ["-v"], "Option '-v' requires an argument."), (False, True, None, "prompt"), (False, True, ["-v"], "prompt"), ], From ad2482bf696df25025ee78fb81a8cf91256af6a5 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 4 Apr 2021 08:03:22 -0700 Subject: [PATCH 242/293] test runner opens stderr with backslashreplace --- CHANGES.rst | 2 ++ src/click/testing.py | 16 ++++++++++++---- tests/test_testing.py | 12 ++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 13facd9b1..24eabda85 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -199,6 +199,8 @@ Unreleased user dir, and env vars are expanded. :issue:`1096` - Marked messages shown by the CLI with ``gettext()`` to allow applications to translate Click's built-in strings. :issue:`303` +- Writing invalid characters to ``stderr`` when using the test runner + does not raise a ``UnicodeEncodeError``. :issue:`848` Version 7.1.2 diff --git a/src/click/testing.py b/src/click/testing.py index 557fbefad..3bbdbffe2 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -178,13 +178,17 @@ def isolation(self, input=None, env=None, color=False): This is automatically done in the :meth:`invoke` method. - .. versionadded:: 4.0 - The ``color`` parameter was added. - :param input: the input stream to put into sys.stdin. :param env: the environment overrides as dictionary. :param color: whether the output should contain color codes. The application can still override this explicitly. + + .. versionchanged:: 8.0 + ``stderr`` is opened with ``errors="backslashreplace"`` + instead of the default ``"strict"``. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. """ input = make_input_stream(input, self.charset) @@ -214,7 +218,11 @@ def isolation(self, input=None, env=None, color=False): else: bytes_error = io.BytesIO() sys.stderr = _NamedTextIOWrapper( - bytes_error, encoding=self.charset, name="", mode="w" + bytes_error, + encoding=self.charset, + name="", + mode="w", + errors="backslashreplace", ) def visible_input(prompt=None): diff --git a/tests/test_testing.py b/tests/test_testing.py index 8b4059635..1fe4caa04 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -334,3 +334,15 @@ def test_isolated_runner_custom_tempdir(runner, tmp_path): assert os.path.exists(d) os.rmdir(d) + + +def test_isolation_stderr_errors(): + """Writing to stderr should escape invalid characters instead of + raising a UnicodeEncodeError. + """ + runner = CliRunner(mix_stderr=False) + + with runner.isolation() as (_, err): + click.echo("\udce2", err=True, nl=False) + + assert err.getvalue() == b"\\udce2" From 8402871d38c6305bc649775eb99745aa3b8b1b53 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Apr 2021 17:10:25 +0000 Subject: [PATCH 243/293] [pre-commit.ci] pre-commit autoupdate --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1ad830745..da0d935bf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: rev: 20.8b1 hooks: - id: black - - repo: https://gitlab.com/pycqa/flake8 + - repo: https://github.com/PyCQA/flake8 rev: 3.9.0 hooks: - id: flake8 From 4aff02676c0d9628e095731c124b647ed256ee5a Mon Sep 17 00:00:00 2001 From: Saif807380 Date: Tue, 6 Apr 2021 20:51:19 +0530 Subject: [PATCH 244/293] fix backspace in prompt with readline enabled --- CHANGES.rst | 2 ++ src/click/termui.py | 6 ++++-- tests/test_utils.py | 8 ++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 24eabda85..c6aa8100c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -201,6 +201,8 @@ Unreleased applications to translate Click's built-in strings. :issue:`303` - Writing invalid characters to ``stderr`` when using the test runner does not raise a ``UnicodeEncodeError``. :issue:`848` +- Fix an issue where ``readline`` would clear the entire ``prompt()`` + line instead of only the input when pressing backspace. :issue:`665` Version 7.1.2 diff --git a/src/click/termui.py b/src/click/termui.py index 3a7e0850e..9850cf57a 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -127,8 +127,10 @@ def prompt_func(text): try: # Write the prompt separately so that we get nice # coloring through colorama on Windows - echo(text, nl=False, err=err) - return f("") + echo(text.rstrip(" "), nl=False, err=err) + # Echo a space to stdout to work around an issue where + # readline causes backspace to clear the whole line. + 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). diff --git a/tests/test_utils.py b/tests/test_utils.py index 3150b09c8..b3cfe7027 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -154,10 +154,10 @@ def f(_): try: click.prompt("Password", hide_input=True) except click.Abort: - click.echo("Screw you.") + click.echo("interrupted") out, err = capsys.readouterr() - assert out == "Password: \nScrew you.\n" + assert out == "Password:\ninterrupted\n" def _test_gen_func(): @@ -255,8 +255,8 @@ def emulate_input(text): emulate_input("asdlkj\n") click.prompt("Prompt to stderr", err=True) out, err = capfd.readouterr() - assert out == "" - assert err == "Prompt to stderr: " + assert out == " " + assert err == "Prompt to stderr:" emulate_input("y\n") click.confirm("Prompt to stdin") From 543358a66b60673e996a0a13a6738b4e3b069840 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 6 Apr 2021 18:04:23 +0000 Subject: [PATCH 245/293] [Security] Bump urllib3 from 1.26.3 to 1.26.4 Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.3 to 1.26.4. **This update includes a security fix.** - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.3...1.26.4) Signed-off-by: dependabot-preview[bot] --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 98613e8ac..31f3fc3e9 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -124,7 +124,7 @@ typed-ast==1.4.2 # via mypy typing-extensions==3.7.4.3 # via mypy -urllib3==1.26.3 +urllib3==1.26.4 # via requests virtualenv==20.4.2 # via diff --git a/requirements/docs.txt b/requirements/docs.txt index 50e0c8b85..e026e195d 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -65,7 +65,7 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.4 # via sphinx -urllib3==1.26.3 +urllib3==1.26.4 # via requests # The following packages are considered to be unsafe in a requirements file: From ceeeb649577e733b5d2741d73b4b517885d24379 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 8 Apr 2021 07:17:23 -0700 Subject: [PATCH 246/293] update urllib unquote import --- src/click/_termui_impl.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 0e9860bd8..46ff2190d 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -549,11 +549,12 @@ def edit(self, text): def open_url(url, wait=False, locate=False): import subprocess - def _unquote_file(url): - import urllib + def _unquote_file(url: str) -> str: + from urllib.parse import unquote if url.startswith("file://"): - url = urllib.unquote(url[7:]) + url = unquote(url[7:]) + return url if sys.platform == "darwin": From 1167ebe1f2b678b58394332e82675300d4a0299a Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 12 Apr 2021 07:36:25 -0700 Subject: [PATCH 247/293] use os.fsdecode to display paths Works with pathlib, consistent with Python's output. --- src/click/_compat.py | 8 -------- src/click/exceptions.py | 5 ++--- src/click/types.py | 13 ++++++------- src/click/utils.py | 4 ++-- tests/test_utils.py | 2 +- 5 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/click/_compat.py b/src/click/_compat.py index a5e4a875a..afa1d9247 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -333,14 +333,6 @@ def get_text_stderr(encoding=None, errors=None): return _force_correct_text_writer(sys.stderr, encoding, errors, force_writable=True) -def filename_to_ui(value): - if isinstance(value, bytes): - value = value.decode(get_filesystem_encoding(), "replace") - else: - value = value.encode("utf-8", "surrogateescape").decode("utf-8", "replace") - return value - - def get_strerror(e, default=None): if hasattr(e, "strerror"): msg = e.strerror diff --git a/src/click/exceptions.py b/src/click/exceptions.py index ab9b31299..c7c8b108f 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -1,7 +1,7 @@ +import os from gettext import gettext as _ from gettext import ngettext -from ._compat import filename_to_ui from ._compat import get_text_stderr from .utils import echo @@ -226,12 +226,11 @@ class FileError(ClickException): """Raised if a file cannot be opened.""" def __init__(self, filename, hint=None): - ui_filename = filename_to_ui(filename) if hint is None: hint = _("unknown error") super().__init__(hint) - self.ui_filename = ui_filename + self.ui_filename = os.fsdecode(filename) self.filename = filename def format_message(self): diff --git a/src/click/types.py b/src/click/types.py index 543833a24..eebf8305c 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -6,7 +6,6 @@ from gettext import ngettext from ._compat import _get_argv_encoding -from ._compat import filename_to_ui from ._compat import get_filesystem_encoding from ._compat import get_strerror from ._compat import open_stream @@ -655,7 +654,7 @@ def convert(self, value, param, ctx): ctx.call_on_close(safecall(f.flush)) return f except OSError as e: # noqa: B014 - self.fail(f"{filename_to_ui(value)!r}: {get_strerror(e)}", param, ctx) + self.fail(f"{os.fsdecode(value)!r}: {get_strerror(e)}", param, ctx) def shell_complete(self, ctx, param, incomplete): """Return a special completion marker that tells the completion @@ -776,7 +775,7 @@ def convert(self, value, param, ctx): return self.coerce_path_result(rv) self.fail( _("{name} {filename!r} does not exist.").format( - name=self.name.title(), filename=filename_to_ui(value) + name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, @@ -785,7 +784,7 @@ def convert(self, value, param, ctx): if not self.file_okay and stat.S_ISREG(st.st_mode): self.fail( _("{name} {filename!r} is a file.").format( - name=self.name.title(), filename=filename_to_ui(value) + name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, @@ -793,7 +792,7 @@ def convert(self, value, param, ctx): if not self.dir_okay and stat.S_ISDIR(st.st_mode): self.fail( _("{name} {filename!r} is a directory.").format( - name=self.name.title(), filename=filename_to_ui(value) + name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, @@ -801,7 +800,7 @@ def convert(self, value, param, ctx): if self.writable and not os.access(value, os.W_OK): self.fail( _("{name} {filename!r} is not writable.").format( - name=self.name.title(), filename=filename_to_ui(value) + name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, @@ -809,7 +808,7 @@ def convert(self, value, param, ctx): if self.readable and not os.access(value, os.R_OK): self.fail( _("{name} {filename!r} is not readable.").format( - name=self.name.title(), filename=filename_to_ui(value) + name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, diff --git a/src/click/utils.py b/src/click/utils.py index 4a52478ac..41ce001e5 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -7,7 +7,6 @@ from ._compat import _find_binary_writer from ._compat import auto_wrap_for_ansi from ._compat import binary_streams -from ._compat import filename_to_ui from ._compat import get_filesystem_encoding from ._compat import get_strerror from ._compat import is_bytes @@ -355,7 +354,8 @@ def format_filename(filename, shorten=False): """ if shorten: filename = os.path.basename(filename) - return filename_to_ui(filename) + + return os.fsdecode(filename) def get_app_dir(app_name, roaming=True, force_posix=False): diff --git a/tests/test_utils.py b/tests/test_utils.py index b3cfe7027..b77d74b52 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -93,7 +93,7 @@ def test_filename_formatting(): # filesystem encoding on windows permits this. if not WIN: - assert click.format_filename(b"/x/foo\xff.txt", shorten=True) == "foo\ufffd.txt" + assert click.format_filename(b"/x/foo\xff.txt", shorten=True) == "foo\udcff.txt" def test_prompts(runner): From 673e1a466a80a89305bfa83a665f13aa3fb9c159 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 12 Apr 2021 17:12:12 +0000 Subject: [PATCH 248/293] [pre-commit.ci] pre-commit autoupdate --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da0d935bf..eb9c0731b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.11.0 + rev: v2.12.0 hooks: - id: pyupgrade args: ["--py36-plus"] From 043511294a9eec59d08e64c351621e790ae74b76 Mon Sep 17 00:00:00 2001 From: Saif807380 Date: Fri, 9 Apr 2021 18:01:08 +0530 Subject: [PATCH 249/293] Fixed inconsistency in forwarding params --- CHANGES.rst | 3 +++ src/click/core.py | 12 ++++++++++++ tests/test_commands.py | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index c6aa8100c..a94f5f7b9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -203,6 +203,9 @@ Unreleased does not raise a ``UnicodeEncodeError``. :issue:`848` - Fix an issue where ``readline`` would clear the entire ``prompt()`` line instead of only the input when pressing backspace. :issue:`665` +- Add all kwargs passed to ``Context.invoke()`` to ``ctx.params``. + Fixes an inconsistency when nesting ``Context.forward()`` calls. + :issue:`1568` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index a5e7a79b4..2a170c9ba 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -678,6 +678,10 @@ def invoke(*args, **kwargs): # noqa: B902 in against the intention of this code and no context was created. For more information about this change and why it was done in a bugfix release see :ref:`upgrade-to-3.2`. + + .. versionchanged:: 8.0 + All ``kwargs`` are tracked in :attr:`params` so they will be + passed if :meth:`forward` is called at multiple levels. """ self, callback = args[:2] ctx = self @@ -700,6 +704,10 @@ def invoke(*args, **kwargs): # noqa: B902 if param.name not in kwargs and param.expose_value: kwargs[param.name] = param.get_default(ctx) + # Track all kwargs as params, so that forward() will pass + # them on in subsequent calls. + ctx.params.update(kwargs) + args = args[2:] with augment_usage_errors(self): with ctx: @@ -709,6 +717,10 @@ def forward(*args, **kwargs): # noqa: B902 """Similar to :meth:`invoke` but fills in default keyword arguments from the current context if the other command expects it. This cannot invoke callbacks directly, only other commands. + + .. versionchanged:: 8.0 + All ``kwargs`` are tracked in :attr:`params` so they will be + passed if ``forward`` is called at multiple levels. """ self, cmd = args[:2] diff --git a/tests/test_commands.py b/tests/test_commands.py index 205e0f888..79f87faa9 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -39,6 +39,28 @@ def dist(ctx, count): assert result.output == "Count: 1\nCount: 42\n" +def test_forwarded_params_consistency(runner): + cli = click.Group() + + @cli.command() + @click.option("-a") + @click.pass_context + def first(ctx, **kwargs): + click.echo(f"{ctx.params}") + + @cli.command() + @click.option("-a") + @click.option("-b") + @click.pass_context + def second(ctx, **kwargs): + click.echo(f"{ctx.params}") + ctx.forward(first) + + result = runner.invoke(cli, ["second", "-a", "foo", "-b", "bar"]) + assert not result.exception + assert result.output == "{'a': 'foo', 'b': 'bar'}\n{'a': 'foo', 'b': 'bar'}\n" + + def test_auto_shorthelp(runner): @click.group() def cli(): From b1e7858cae16957d2d9eb2fc462b29e67cf9d320 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 12 Apr 2021 20:12:04 -0700 Subject: [PATCH 250/293] rename resultcallback to result_callback --- CHANGES.rst | 2 ++ docs/commands.rst | 4 ++-- docs/upgrading.rst | 2 +- examples/imagepipe/imagepipe.py | 2 +- src/click/core.py | 42 +++++++++++++++++++++++---------- tests/test_chain.py | 4 ++-- 6 files changed, 37 insertions(+), 19 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a94f5f7b9..bc5c9c1be 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -206,6 +206,8 @@ Unreleased - Add all kwargs passed to ``Context.invoke()`` to ``ctx.params``. Fixes an inconsistency when nesting ``Context.forward()`` calls. :issue:`1568` +- The ``MultiCommand.resultcallback`` decorator is renamed to + ``result_callback``. The old name is deprecated. :issue:`1160` Version 7.1.2 diff --git a/docs/commands.rst b/docs/commands.rst index 2bb91115b..5c021237c 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -351,7 +351,7 @@ 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 -a callback with :meth:`MultiCommand.resultcallback` that goes over all +a callback with :meth:`MultiCommand.result_callback` that goes over all these functions and then invoke them. To make this a bit more concrete consider this example: @@ -363,7 +363,7 @@ To make this a bit more concrete consider this example: def cli(input): pass - @cli.resultcallback() + @cli.result_callback() def process_pipeline(processors, input): iterator = (x.rstrip('\r\n') for x in input) for processor in processors: diff --git a/docs/upgrading.rst b/docs/upgrading.rst index ea082bbee..c6fa5545f 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -90,7 +90,7 @@ restored. If you do require the know which exact commands will be invoked there are different ways to cope with this. The first one is to let the subcommands all return functions and then to invoke the functions in a -:meth:`Context.resultcallback`. +:meth:`Context.result_callback`. .. _upgrade-to-2.0: diff --git a/examples/imagepipe/imagepipe.py b/examples/imagepipe/imagepipe.py index 57432faaf..e2d2f03fd 100644 --- a/examples/imagepipe/imagepipe.py +++ b/examples/imagepipe/imagepipe.py @@ -20,7 +20,7 @@ def cli(): """ -@cli.resultcallback() +@cli.result_callback() def process_commands(processors): """This result callback is invoked with an iterable of all the chained subcommands. As in this example each subcommand returns a function diff --git a/src/click/core.py b/src/click/core.py index 2a170c9ba..e7e33f59f 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -309,7 +309,7 @@ def __init__( #: If chaining is enabled this will be set to ``'*'`` in case #: any commands are executed. It is however not possible to #: figure out which ones. If you require this knowledge you - #: should use a :func:`resultcallback`. + #: should use a :func:`result_callback`. self.invoked_subcommand = None if terminal_width is None and parent is not None: @@ -1363,8 +1363,9 @@ class MultiCommand(Command): is enabled. This restricts the form of commands in that they cannot have optional arguments but it allows multiple commands to be chained together. - :param result_callback: the result callback to attach to this multi - command. + :param result_callback: The result callback to attach to this multi + command. This can be set or changed later with the + :meth:`result_callback` decorator. """ allow_extra_args = True @@ -1392,9 +1393,9 @@ def __init__( subcommand_metavar = SUBCOMMAND_METAVAR self.subcommand_metavar = subcommand_metavar self.chain = chain - #: The result callback that is stored. This can be set or - #: overridden with the :func:`resultcallback` decorator. - self.result_callback = result_callback + # The result callback that is stored. This can be set or + # overridden with the :func:`result_callback` decorator. + self._result_callback = result_callback if self.chain: for param in self.params: @@ -1427,7 +1428,7 @@ def format_options(self, ctx, formatter): super().format_options(ctx, formatter) self.format_commands(ctx, formatter) - def resultcallback(self, replace=False): + def result_callback(self, replace=False): """Adds a result callback to the command. By default if a result callback is already registered this will chain them but this can be disabled with the `replace` parameter. The result @@ -1443,30 +1444,45 @@ def resultcallback(self, replace=False): def cli(input): return 42 - @cli.resultcallback() + @cli.result_callback() def process_result(result, input): return result + input :param replace: if set to `True` an already existing result callback will be removed. + .. versionchanged:: 8.0 + Renamed from ``resultcallback``. + .. versionadded:: 3.0 """ def decorator(f): - old_callback = self.result_callback + old_callback = self._result_callback + if old_callback is None or replace: - self.result_callback = f + self._result_callback = f return f def function(__value, *args, **kwargs): return f(old_callback(__value, *args, **kwargs), *args, **kwargs) - self.result_callback = rv = update_wrapper(function, f) + self._result_callback = rv = update_wrapper(function, f) return rv return decorator + def resultcallback(self, replace=False): + import warnings + + warnings.warn( + "'resultcallback' has been renamed to 'result_callback'." + " The old name will be removed in Click 8.1.", + DeprecationWarning, + stacklevel=2, + ) + return self.result_callback(replace=replace) + def format_commands(self, ctx, formatter): """Extra format methods for multi methods that adds all the commands after the options. @@ -1512,8 +1528,8 @@ def parse_args(self, ctx, args): def invoke(self, ctx): def _process_result(value): - if self.result_callback is not None: - value = ctx.invoke(self.result_callback, value, **ctx.params) + if self._result_callback is not None: + value = ctx.invoke(self._result_callback, value, **ctx.params) return value if not ctx.protected_args: diff --git a/tests/test_chain.py b/tests/test_chain.py index 74a04f106..23520a0f2 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -99,7 +99,7 @@ def test_no_command_result_callback(runner, chain, expect): def cli(): pass - @cli.resultcallback() + @cli.result_callback() def process_result(result): click.echo(str(result), nl=False) @@ -133,7 +133,7 @@ def test_pipeline(runner): def cli(input): pass - @cli.resultcallback() + @cli.result_callback() def process_pipeline(processors, input): iterator = (x.rstrip("\r\n") for x in input) for processor in processors: From 0d01686fe6be5c335f50098a06b7e847a2753452 Mon Sep 17 00:00:00 2001 From: Seb Aebischer <8686939+saebischer@users.noreply.github.com> Date: Sat, 6 Mar 2021 16:47:26 +0000 Subject: [PATCH 251/293] fix output when using echo_stdin echo calls to read1 pause echo when using hidden input prompt and getchar don't buffer reads to avoid echoing early --- CHANGES.rst | 2 ++ src/click/testing.py | 29 ++++++++++++++--- tests/test_testing.py | 75 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index bc5c9c1be..86942e555 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -208,6 +208,8 @@ Unreleased :issue:`1568` - The ``MultiCommand.resultcallback`` decorator is renamed to ``result_callback``. The old name is deprecated. :issue:`1160` +- Fix issues with ``CliRunner`` output when using ``echo_stdin=True``. + :issue:`1101` Version 7.1.2 diff --git a/src/click/testing.py b/src/click/testing.py index 3bbdbffe2..89e58da8e 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -16,17 +16,22 @@ class EchoingStdin: def __init__(self, input, output): self._input = input self._output = output + self.paused = False def __getattr__(self, x): return getattr(self._input, x) def _echo(self, rv): - self._output.write(rv) + if not self.paused: + self._output.write(rv) return rv def read(self, n=-1): return self._echo(self._input.read(n)) + def read1(self, n=-1): + return self._echo(self._input.read1(n)) + def readline(self, n=-1): return self._echo(self._input.readline(n)) @@ -204,10 +209,16 @@ def isolation(self, input=None, env=None, color=False): if self.echo_stdin: input = EchoingStdin(input, bytes_output) + echo_input = input sys.stdin = input = _NamedTextIOWrapper( input, encoding=self.charset, name="", mode="r" ) + if self.echo_stdin: + # Force unbuffered reads, otherwise the underlying EchoingStdin + # stream will echo a big chunk of input on the first read. + input._CHUNK_SIZE = 1 + sys.stdout = _NamedTextIOWrapper( bytes_output, encoding=self.charset, name="", mode="w" ) @@ -228,20 +239,30 @@ def isolation(self, input=None, env=None, color=False): def visible_input(prompt=None): sys.stdout.write(prompt or "") val = input.readline().rstrip("\r\n") - sys.stdout.write(f"{val}\n") + if not self.echo_stdin: + sys.stdout.write(f"{val}\n") sys.stdout.flush() return val def hidden_input(prompt=None): sys.stdout.write(f"{prompt or ''}\n") sys.stdout.flush() - return input.readline().rstrip("\r\n") + if self.echo_stdin: + echo_input.paused = True + val = input.readline().rstrip("\r\n") + if self.echo_stdin: + echo_input.paused = False + return val def _getchar(echo): + if not echo and self.echo_stdin: + echo_input.paused = True char = sys.stdin.read(1) - if echo: + if echo and not self.echo_stdin: sys.stdout.write(char) sys.stdout.flush() + elif not echo and self.echo_stdin: + echo_input.paused = False return char default_color = color diff --git a/tests/test_testing.py b/tests/test_testing.py index 1fe4caa04..5d31f2eb6 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -26,12 +26,68 @@ def test(): assert not result.exception assert result.output == "Hello World!\n" + +def test_echo_stdin_stream(): + @click.command() + def test(): + i = click.get_binary_stream("stdin") + o = click.get_binary_stream("stdout") + while 1: + chunk = i.read(4096) + if not chunk: + break + o.write(chunk) + o.flush() + runner = CliRunner(echo_stdin=True) result = runner.invoke(test, input="Hello World!\n") assert not result.exception assert result.output == "Hello World!\nHello World!\n" +def test_echo_stdin_prompts(): + @click.command() + def test_python_input(): + foo = input("Foo: ") + click.echo(f"foo={foo}") + + runner = CliRunner(echo_stdin=True) + result = runner.invoke(test_python_input, input="wau wau\n") + assert not result.exception + assert result.output == "Foo: wau wau\nfoo=wau wau\n" + + @click.command() + @click.option("--foo", prompt=True) + def test_prompt(foo): + click.echo(f"foo={foo}") + + runner = CliRunner(echo_stdin=True) + result = runner.invoke(test_prompt, input="wau wau\n") + assert not result.exception + assert result.output == "Foo: wau wau\nfoo=wau wau\n" + + @click.command() + @click.option("--foo", prompt=True, hide_input=True) + def test_hidden_prompt(foo): + click.echo(f"foo={foo}") + + runner = CliRunner(echo_stdin=True) + result = runner.invoke(test_hidden_prompt, input="wau wau\n") + assert not result.exception + assert result.output == "Foo: \nfoo=wau wau\n" + + @click.command() + @click.option("--foo", prompt=True) + @click.option("--bar", prompt=True) + def test_multiple_prompts(foo, bar): + click.echo(f"foo={foo}, bar={bar}") + + runner = CliRunner(echo_stdin=True) + result = runner.invoke(test_multiple_prompts, input="one\ntwo\n") + assert not result.exception + assert result.output == "Foo: one\nBar: two\nfoo=one, bar=two\n" + + def test_runner_with_stream(): @click.command() def test(): @@ -87,6 +143,25 @@ def continue_it(): assert not result.exception assert result.output == "y\n" + runner = CliRunner(echo_stdin=True) + result = runner.invoke(continue_it, input="y") + assert not result.exception + assert result.output == "y\n" + + @click.command() + def getchar_echo(): + click.echo(click.getchar(echo=True)) + + runner = CliRunner() + result = runner.invoke(getchar_echo, input="y") + assert not result.exception + assert result.output == "yy\n" + + runner = CliRunner(echo_stdin=True) + result = runner.invoke(getchar_echo, input="y") + assert not result.exception + assert result.output == "yy\n" + def test_catch_exceptions(): class CustomError(Exception): From f6f8976900d9c9d65e82d61477a626545c58f054 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 14 Apr 2021 07:49:32 -0700 Subject: [PATCH 252/293] use decorator to pause echo pause echo_stdin unconditionally, allowing functions to echo as normal this seems to work better with the readline "echo empty string" fix --- src/click/testing.py | 47 +++++++++++++++++++++++++------------------ tests/test_testing.py | 15 ++++++-------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/click/testing.py b/src/click/testing.py index 89e58da8e..637c46c67 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -16,14 +16,15 @@ class EchoingStdin: def __init__(self, input, output): self._input = input self._output = output - self.paused = False + self._paused = False def __getattr__(self, x): return getattr(self._input, x) def _echo(self, rv): - if not self.paused: + if not self._paused: self._output.write(rv) + return rv def read(self, n=-1): @@ -45,6 +46,16 @@ def __repr__(self): return repr(self._input) +@contextlib.contextmanager +def _pause_echo(stream): + if stream is None: + yield + else: + stream._paused = True + yield + stream._paused = False + + class _NamedTextIOWrapper(io.TextIOWrapper): def __init__(self, buffer, name=None, mode=None, **kwargs): super().__init__(buffer, **kwargs) @@ -196,6 +207,7 @@ def isolation(self, input=None, env=None, color=False): Added the ``color`` parameter. """ input = make_input_stream(input, self.charset) + echo_input = None old_stdin = sys.stdin old_stdout = sys.stdout @@ -208,15 +220,15 @@ def isolation(self, input=None, env=None, color=False): bytes_output = io.BytesIO() if self.echo_stdin: - input = EchoingStdin(input, bytes_output) - echo_input = input + input = echo_input = EchoingStdin(input, bytes_output) sys.stdin = input = _NamedTextIOWrapper( input, encoding=self.charset, name="", mode="r" ) + if self.echo_stdin: - # Force unbuffered reads, otherwise the underlying EchoingStdin - # stream will echo a big chunk of input on the first read. + # Force unbuffered reads, otherwise TextIOWrapper reads a + # large chunk which is echoed early. input._CHUNK_SIZE = 1 sys.stdout = _NamedTextIOWrapper( @@ -236,33 +248,28 @@ def isolation(self, input=None, env=None, color=False): errors="backslashreplace", ) + @_pause_echo(echo_input) def visible_input(prompt=None): sys.stdout.write(prompt or "") val = input.readline().rstrip("\r\n") - if not self.echo_stdin: - sys.stdout.write(f"{val}\n") + sys.stdout.write(f"{val}\n") sys.stdout.flush() return val + @_pause_echo(echo_input) def hidden_input(prompt=None): sys.stdout.write(f"{prompt or ''}\n") sys.stdout.flush() - if self.echo_stdin: - echo_input.paused = True - val = input.readline().rstrip("\r\n") - if self.echo_stdin: - echo_input.paused = False - return val + return input.readline().rstrip("\r\n") + @_pause_echo(echo_input) def _getchar(echo): - if not echo and self.echo_stdin: - echo_input.paused = True char = sys.stdin.read(1) - if echo and not self.echo_stdin: + + if echo: sys.stdout.write(char) - sys.stdout.flush() - elif not echo and self.echo_stdin: - echo_input.paused = False + + sys.stdout.flush() return char default_color = color diff --git a/tests/test_testing.py b/tests/test_testing.py index 5d31f2eb6..d23a4f231 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -52,29 +52,27 @@ def test_python_input(): click.echo(f"foo={foo}") runner = CliRunner(echo_stdin=True) - result = runner.invoke(test_python_input, input="wau wau\n") + result = runner.invoke(test_python_input, input="bar bar\n") assert not result.exception - assert result.output == "Foo: wau wau\nfoo=wau wau\n" + assert result.output == "Foo: bar bar\nfoo=bar bar\n" @click.command() @click.option("--foo", prompt=True) def test_prompt(foo): click.echo(f"foo={foo}") - runner = CliRunner(echo_stdin=True) - result = runner.invoke(test_prompt, input="wau wau\n") + result = runner.invoke(test_prompt, input="bar bar\n") assert not result.exception - assert result.output == "Foo: wau wau\nfoo=wau wau\n" + assert result.output == "Foo: bar bar\nfoo=bar bar\n" @click.command() @click.option("--foo", prompt=True, hide_input=True) def test_hidden_prompt(foo): click.echo(f"foo={foo}") - runner = CliRunner(echo_stdin=True) - result = runner.invoke(test_hidden_prompt, input="wau wau\n") + result = runner.invoke(test_hidden_prompt, input="bar bar\n") assert not result.exception - assert result.output == "Foo: \nfoo=wau wau\n" + assert result.output == "Foo: \nfoo=bar bar\n" @click.command() @click.option("--foo", prompt=True) @@ -82,7 +80,6 @@ def test_hidden_prompt(foo): def test_multiple_prompts(foo, bar): click.echo(f"foo={foo}, bar={bar}") - runner = CliRunner(echo_stdin=True) result = runner.invoke(test_multiple_prompts, input="one\ntwo\n") assert not result.exception assert result.output == "Foo: one\nBar: two\nfoo=one, bar=two\n" From d48b59be391f9f9bfb41dd62db07e46519d08bfe Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Wed, 14 Apr 2021 17:26:41 +0200 Subject: [PATCH 253/293] fix make_default_short_help max_length, refactor --- CHANGES.rst | 2 ++ src/click/utils.py | 63 +++++++++++++++++++++++++++++---------------- tests/test_utils.py | 36 ++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 86942e555..d532792f4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -210,6 +210,8 @@ Unreleased ``result_callback``. The old name is deprecated. :issue:`1160` - Fix issues with ``CliRunner`` output when using ``echo_stdin=True``. :issue:`1101` +- Fix a bug of ``click.utils.make_default_short_help`` for which the + returned string could be as long as ``max_width + 3``. :issue:`1849` Version 7.1.2 diff --git a/src/click/utils.py b/src/click/utils.py index 41ce001e5..114afcc3c 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -47,35 +47,54 @@ def make_str(value): return str(value) -def make_default_short_help(help, max_length=45): - """Return a condensed version of help string.""" - line_ending = help.find("\n\n") - if line_ending != -1: - help = help[:line_ending] +def make_default_short_help(help: str, max_length: int = 45) -> str: + """Returns a condensed version of help string.""" + # Consider only the first paragraph. + paragraph_end = help.find("\n\n") + + if paragraph_end != -1: + help = help[:paragraph_end] + + # Collapse newlines, tabs, and spaces. words = help.split() - total_length = 0 - result = [] - done = False + if not words: + return "" + + # The first paragraph started with a "no rewrap" marker, ignore it. if words[0] == "\b": words = words[1:] - for word in words: - if word[-1:] == ".": - done = True - new_length = 1 + len(word) if result else len(word) - if total_length + new_length > max_length: - result.append("...") - done = True - else: - if result: - result.append(" ") - result.append(word) - if done: + total_length = 0 + last_index = len(words) - 1 + + for i, word in enumerate(words): + total_length += len(word) + (i > 0) + + if total_length > max_length: # too long, truncate break - total_length += new_length - return "".join(result) + if word[-1] == ".": # sentence end, truncate without "..." + return " ".join(words[: i + 1]) + + if total_length == max_length and i != last_index: + break # not at sentence end, truncate with "..." + else: + return " ".join(words) # no truncation needed + + # Account for the length of the suffix. + total_length += len("...") + + # remove words until the length is short enough + while i > 0: + total_length -= len(words[i]) + (i > 0) + + if total_length <= max_length: + break + + i -= 1 + + return " ".join(words[:i]) + "..." class LazyFile: diff --git a/tests/test_utils.py b/tests/test_utils.py index b77d74b52..23b370968 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -444,3 +444,39 @@ def test_expand_args(monkeypatch): assert "setup.cfg" in click.utils._expand_args(["*.cfg"]) assert os.path.join("docs", "conf.py") in click.utils._expand_args(["**/conf.py"]) assert "*.not-found" in click.utils._expand_args(["*.not-found"]) + + +@pytest.mark.parametrize( + ("value", "max_length", "expect"), + [ + pytest.param("", 10, "", id="empty"), + pytest.param("123 567 90", 10, "123 567 90", id="equal length, no dot"), + pytest.param("123 567 9. aaaa bbb", 10, "123 567 9.", id="sentence < max"), + pytest.param("123 567\n\n 9. aaaa bbb", 10, "123 567", id="paragraph < max"), + pytest.param("123 567 90123.", 10, "123 567...", id="truncate"), + pytest.param("123 5678 xxxxxx", 10, "123...", id="length includes suffix"), + pytest.param( + "token in ~/.netrc ciao ciao", + 20, + "token in ~/.netrc...", + id="ignore dot in word", + ), + ], +) +@pytest.mark.parametrize( + "alter", + [ + pytest.param(None, id=""), + pytest.param( + lambda text: "\n\b\n" + " ".join(text.split(" ")) + "\n", id="no-wrap mark" + ), + ], +) +def test_make_default_short_help(value, max_length, alter, expect): + assert len(expect) <= max_length + + if alter: + value = alter(value) + + out = click.utils.make_default_short_help(value, max_length) + assert out == expect From 11b28625b94cabf3a0abaa1aaddc0292e24929a4 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 15 Apr 2021 14:44:42 -0700 Subject: [PATCH 254/293] refactor type_cast_value default value is validated against multiple and nargs in __init__ --- CHANGES.rst | 3 + src/click/core.py | 182 +++++++++++++++++++++++++--------------- src/click/types.py | 17 +++- tests/test_arguments.py | 34 +++----- tests/test_options.py | 45 +++++++--- 5 files changed, 178 insertions(+), 103 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index d532792f4..51f47dc3f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -212,6 +212,9 @@ Unreleased :issue:`1101` - Fix a bug of ``click.utils.make_default_short_help`` for which the returned string could be as long as ``max_width + 3``. :issue:`1849` +- When defining a parameter, ``default`` is validated with + ``multiple`` and ``nargs``. More validation is done for values being + processed as well. :issue:`1806` Version 7.1.2 diff --git a/src/click/core.py b/src/click/core.py index e7e33f59f..14ef872a9 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -5,6 +5,7 @@ import typing as t from contextlib import contextmanager from contextlib import ExitStack +from functools import partial from functools import update_wrapper from gettext import gettext as _ from gettext import ngettext @@ -1795,6 +1796,16 @@ def list_commands(self, ctx): return sorted(rv) +def _check_iter(value): + """Check if the value is iterable but not a string. Raises a type + error, or return an iterator over the value. + """ + if isinstance(value, str): + raise TypeError + + return iter(value) + + class Parameter: r"""A parameter to a command comes in two versions: they are either :class:`Option`\s or :class:`Argument`\s. Other subclasses are currently @@ -1879,6 +1890,7 @@ def __init__( default=None, callback=None, nargs=None, + multiple=False, metavar=None, expose_value=True, is_eager=False, @@ -1903,7 +1915,7 @@ def __init__( self.required = required self.callback = callback self.nargs = nargs - self.multiple = False + self.multiple = multiple self.expose_value = expose_value self.default = default self.is_eager = is_eager @@ -1939,6 +1951,47 @@ def shell_complete(ctx, param, incomplete): self._custom_shell_complete = shell_complete + if __debug__: + if self.type.is_composite and nargs != self.type.arity: + raise ValueError( + f"'nargs' must be {self.type.arity} (or None) for" + f" type {self.type!r}, but it was {nargs}." + ) + + # Skip no default or callable default. + check_default = default if not callable(default) else None + + if check_default is not None: + if multiple: + try: + # Only check the first value against nargs. + check_default = next(_check_iter(check_default), None) + except TypeError: + raise ValueError( + "'default' must be a list when 'multiple' is true." + ) from None + + # Can be None for multiple with empty default. + if nargs != 1 and check_default is not None: + try: + _check_iter(check_default) + except TypeError: + if multiple: + message = ( + "'default' must be a list of lists when 'multiple' is" + " true and 'nargs' != 1." + ) + else: + message = "'default' must be a list when 'nargs' != 1." + + raise ValueError(message) from None + + if nargs > 1 and len(check_default) != nargs: + subject = "item length" if multiple else "length" + raise ValueError( + f"'default' {subject} must match nargs={nargs}." + ) + def to_info_dict(self): """Gather information that could be useful for a tool generating user-facing documentation. @@ -2031,47 +2084,60 @@ def consume_value(self, ctx, opts): return value, source def type_cast_value(self, ctx, value): - """Given a value this runs it properly through the type system. - This automatically handles things like `nargs` and `multiple` as - well as composite types. + """Convert and validate a value against the option's + :attr:`type`, :attr:`multiple`, and :attr:`nargs`. """ if value is None: return () if self.multiple or self.nargs == -1 else None - if self.type.is_composite: - if self.nargs <= 1: - raise TypeError( - "Attempted to invoke composite type but nargs has" - f" been set to {self.nargs}. This is not supported;" - " nargs needs to be set to a fixed value > 1." - ) - - if self.multiple: - return tuple(self.type(x, self, ctx) for x in value) - - return self.type(value, self, ctx) - - def _convert(value, level): - if level == 0: - return self.type(value, self, ctx) - + def check_iter(value): try: - iter_value = iter(value) + return _check_iter(value) except TypeError: - raise TypeError( - "Value for parameter with multiple = True or nargs > 1" - " should be an iterable." - ) + # This should only happen when passing in args manually, + # the parser should construct an iterable when parsing + # the command line. + raise BadParameter( + _("Value must be an iterable."), ctx=ctx, param=self + ) from None + + if self.nargs == 1 or self.type.is_composite: + convert = partial(self.type, param=self, ctx=ctx) + elif self.nargs == -1: + + def convert(value): + return tuple(self.type(x, self, ctx) for x in check_iter(value)) + + else: # nargs > 1 + + def convert(value): + value = tuple(check_iter(value)) + + if len(value) != self.nargs: + raise BadParameter( + ngettext( + "Takes {nargs} values but 1 was given.", + "Takes {nargs} values but {len} were given.", + len(value), + ).format(nargs=self.nargs, len=len(value)), + ctx=ctx, + param=self, + ) + + return tuple(self.type(x, self, ctx) for x in value) - return tuple(_convert(x, level - 1) for x in iter_value) + if self.multiple: + return tuple(convert(x) for x in check_iter(value)) - return _convert(value, (self.nargs != 1) + bool(self.multiple)) + return convert(value) def value_is_missing(self, value): if value is None: return True + if (self.nargs != 1 or self.multiple) and value == (): return True + return False def process_value(self, ctx, value): @@ -2081,25 +2147,6 @@ def process_value(self, ctx, value): if self.required and self.value_is_missing(value): raise MissingParameter(ctx=ctx, param=self) - # For bounded nargs (!= -1), validate the number of values. - if ( - not ctx.resilient_parsing - and self.nargs > 1 - and isinstance(value, (tuple, list)) - and ( - any(len(v) != self.nargs for v in value) - if self.multiple - else len(value) != self.nargs - ) - ): - ctx.fail( - ngettext( - "Argument {name!r} takes {nargs} values but 1 was given.", - "Argument {name!r} takes {nargs} values but {len} were given.", - len(value), - ).format(name=self.name, nargs=self.nargs, len=len(value)) - ) - if self.callback is not None: value = self.callback(ctx, self, value) @@ -2250,7 +2297,7 @@ def __init__( **attrs, ): default_is_missing = attrs.get("default", _missing) is _missing - super().__init__(param_decls, type=type, **attrs) + super().__init__(param_decls, type=type, multiple=multiple, **attrs) if prompt is True: prompt_text = self.name.replace("_", " ").capitalize() @@ -2307,32 +2354,33 @@ def __init__( if default_is_missing: self.default = 0 - self.multiple = multiple 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__: - if self.nargs < 0: - raise TypeError("Options cannot have nargs < 0") + if self.nargs == -1: + raise TypeError("nargs=-1 is not supported for options.") + if self.prompt and self.is_flag and not self.is_bool_flag: - raise TypeError("Cannot prompt for flags that are not bools.") + raise TypeError("'prompt' is not valid for non-boolean flag.") + if not self.is_bool_flag and self.secondary_opts: - raise TypeError("Got secondary option for non boolean flag.") + raise TypeError("Secondary flag is not valid for non-boolean flag.") + if self.is_bool_flag and self.hide_input and self.prompt is not None: - raise TypeError("Hidden input does not work with boolean flag prompts.") + raise TypeError( + "'prompt' with 'hide_input' is not valid for boolean flag." + ) + if self.count: if self.multiple: - raise TypeError( - "Options cannot be multiple and count at the same time." - ) - elif self.is_flag: - raise TypeError( - "Options cannot be count and flags at the same time." - ) + raise TypeError("'count' is not valid with 'multiple'.") + + if self.is_flag: + raise TypeError("'count' is not valid with 'is_flag'.") def to_info_dict(self): info_dict = super().to_info_dict() @@ -2608,12 +2656,14 @@ def __init__(self, param_decls, required=None, **attrs): else: required = attrs.get("nargs", 1) > 0 + if "multiple" in attrs: + raise TypeError("__init__() got an unexpected keyword argument 'multiple'.") + super().__init__(param_decls, required=required, **attrs) - if self.default is not None and self.nargs < 0: - raise TypeError( - "nargs=-1 in combination with a default value is not supported." - ) + if __debug__: + if self.default is not None and self.nargs == -1: + raise TypeError("'default' is not supported for nargs=-1.") @property def human_readable_name(self): diff --git a/src/click/types.py b/src/click/types.py index eebf8305c..0d210923f 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -864,11 +864,20 @@ def arity(self): return len(self.types) def convert(self, value, param, ctx): - if len(value) != len(self.types): - raise TypeError( - "It would appear that nargs is set to conflict with the" - " composite type arity." + len_type = len(self.types) + len_value = len(value) + + if len_value != len_type: + self.fail( + ngettext( + "{len_type} values are required, but {len_value} was given.", + "{len_type} values are required, but {len_value} were given.", + len_value, + ).format(len_type=len_type, len_value=len_value), + param=param, + ctx=ctx, ) + return tuple(ty(x, param, ctx) for ty, x in zip(self.types, value)) diff --git a/tests/test_arguments.py b/tests/test_arguments.py index e1a6de90f..2045f6f9c 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -22,7 +22,7 @@ def test_argument_unbounded_nargs_cant_have_default(runner): with pytest.raises(TypeError, match="nargs=-1"): @click.command() - @click.argument("src", nargs=-1, default=42) + @click.argument("src", nargs=-1, default=["42"]) def copy(src): pass @@ -167,9 +167,9 @@ def inout(output): ("nargs", "value", "expect"), [ (2, "", None), - (2, "a", "Argument 'arg' takes 2 values but 1 was given."), + (2, "a", "Takes 2 values but 1 was given."), (2, "a b", ("a", "b")), - (2, "a b c", "Argument 'arg' takes 2 values but 3 were given."), + (2, "a b c", "Takes 2 values but 3 were given."), (-1, "a b c", ("a", "b", "c")), (-1, "", ()), ], @@ -188,7 +188,8 @@ def cmd(arg): result = runner.invoke(cmd, env={"X": value}, standalone_mode=False) if isinstance(expect, str): - assert expect in str(result.exception) + assert isinstance(result.exception, click.BadParameter) + assert expect in result.exception.format_message() else: assert result.return_value == expect @@ -313,24 +314,15 @@ def copy(x): click.echo(x) -@pytest.mark.parametrize( - ("value", "code", "output"), - [ - ((), 2, "Argument 'arg' takes 2 values but 0 were given."), - (("a",), 2, "Argument 'arg' takes 2 values but 1 was given."), - (("a", "b"), 0, "len 2"), - (("a", "b", "c"), 2, "Argument 'arg' takes 2 values but 3 were given."), - ], -) -def test_nargs_default(runner, value, code, output): - @click.command() - @click.argument("arg", nargs=2, default=value) - def cmd(arg): - click.echo(f"len {len(arg)}") +def test_multiple_not_allowed(): + with pytest.raises(TypeError, match="multiple"): + click.Argument(["a"], multiple=True) + - result = runner.invoke(cmd) - assert result.exit_code == code - assert output in result.output +@pytest.mark.parametrize("value", [(), ("a",), ("a", "b", "c")]) +def test_nargs_bad_default(runner, value): + with pytest.raises(ValueError, match="nargs=2"): + click.Argument(["a"], nargs=2, default=value) def test_subcommand_help(runner): diff --git a/tests/test_options.py b/tests/test_options.py index 009f80e2a..94e5eb8d7 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -33,7 +33,7 @@ def test_invalid_option(runner): def test_invalid_nargs(runner): - with pytest.raises(TypeError, match="nargs < 0"): + with pytest.raises(TypeError, match="nargs=-1"): @click.command() @click.option("--foo", nargs=-1) @@ -117,18 +117,39 @@ def cli(message): assert "Error: Missing option '-m' / '--message'." in result.output -def test_multiple_bad_default(runner): - @click.command() - @click.option("--flags", multiple=True, default=False) - def cli(flags): - pass +@pytest.mark.parametrize( + ("multiple", "nargs", "default"), + [ + (True, 1, []), + (True, 1, [1]), + # (False, -1, []), + # (False, -1, [1]), + (False, 2, [1, 2]), + # (True, -1, [[]]), + # (True, -1, []), + # (True, -1, [[1]]), + (True, 2, []), + (True, 2, [[1, 2]]), + ], +) +def test_init_good_default_list(runner, multiple, nargs, default): + click.Option(["-a"], multiple=multiple, nargs=nargs, default=default) - result = runner.invoke(cli, []) - assert result.exception - assert ( - "Value for parameter with multiple = True or nargs > 1 should be an iterable." - in result.exception.args - ) + +@pytest.mark.parametrize( + ("multiple", "nargs", "default"), + [ + (True, 1, 1), + # (False, -1, 1), + (False, 2, [1]), + (True, 2, [[1]]), + ], +) +def test_init_bad_default_list(runner, multiple, nargs, default): + type = (str, str) if nargs == 2 else None + + with pytest.raises(ValueError, match="default"): + click.Option(["-a"], type=type, multiple=multiple, nargs=nargs, default=default) def test_empty_envvar(runner): From 21e35874e7d6e829a8093fc720ec4cf51020b79e Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 15 Apr 2021 15:21:08 -0700 Subject: [PATCH 255/293] mention click in deprecation message --- src/click/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/click/utils.py b/src/click/utils.py index 114afcc3c..6044ab795 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -345,14 +345,14 @@ def get_os_args(): value which is the name of the script. .. deprecated:: 8.0 - Will be removed in 8.1. Access ``sys.argv[1:]`` directly + Will be removed in Click 8.1. Access ``sys.argv[1:]`` directly instead. """ import warnings warnings.warn( - "'get_os_args' is deprecated and will be removed in 8.1. Access" - " 'sys.argv[1:]' directly instead.", + "'get_os_args' is deprecated and will be removed in Click 8.1." + " Access 'sys.argv[1:]' directly instead.", DeprecationWarning, stacklevel=2, ) From 8a1078c140ed5f473014eca3664b6e2c46991dbe Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 16 Apr 2021 07:00:38 -0700 Subject: [PATCH 256/293] update pallets-sphinx-themes --- requirements/dev.txt | 2 +- requirements/docs.in | 2 +- requirements/docs.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 31f3fc3e9..c159fa2c4 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -54,7 +54,7 @@ packaging==20.9 # pytest # sphinx # tox -pallets-sphinx-themes==1.2.3 +pallets-sphinx-themes==2.0.0rc1 # via -r requirements/docs.in pip-tools==5.5.0 # via -r requirements/dev.in diff --git a/requirements/docs.in b/requirements/docs.in index 3ee050af0..c1898bc7c 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1,4 +1,4 @@ -Pallets-Sphinx-Themes +Pallets-Sphinx-Themes >= 2.0.0rc1 Sphinx sphinx-issues sphinxcontrib-log-cabinet diff --git a/requirements/docs.txt b/requirements/docs.txt index e026e195d..eca7c6d6f 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -26,7 +26,7 @@ packaging==20.9 # via # pallets-sphinx-themes # sphinx -pallets-sphinx-themes==1.2.3 +pallets-sphinx-themes==2.0.0rc1 # via -r requirements/docs.in pygments==2.7.4 # via From 512427b75f73217ec6f86a9e04a8ee95039f181a Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 16 Apr 2021 07:01:53 -0700 Subject: [PATCH 257/293] update requirements --- .pre-commit-config.yaml | 2 +- requirements/dev.txt | 29 ++++++++++++++++------------- requirements/docs.txt | 6 +++--- requirements/tests.txt | 6 +++--- requirements/typing.txt | 2 +- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb9c0731b..fc4f3a42b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 3.9.0 + rev: 3.9.1 hooks: - id: flake8 additional_dependencies: diff --git a/requirements/dev.txt b/requirements/dev.txt index c159fa2c4..2c568d33b 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -28,13 +28,13 @@ filelock==3.0.12 # via # tox # virtualenv -identify==1.5.13 +identify==2.2.3 # via pre-commit idna==2.10 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==3.7.0 +importlib-metadata==3.10.1 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest @@ -46,7 +46,7 @@ mypy-extensions==0.4.3 # via mypy mypy==0.812 # via -r requirements/typing.in -nodeenv==1.5.0 +nodeenv==1.6.0 # via pre-commit packaging==20.9 # via @@ -56,25 +56,27 @@ packaging==20.9 # tox pallets-sphinx-themes==2.0.0rc1 # via -r requirements/docs.in -pip-tools==5.5.0 +pep517==0.10.0 + # via pip-tools +pip-tools==6.1.0 # via -r requirements/dev.in pluggy==0.13.1 # via # pytest # tox -pre-commit==2.10.1 +pre-commit==2.12.0 # via -r requirements/dev.in py==1.10.0 # via # pytest # tox -pygments==2.7.4 +pygments==2.8.1 # via # sphinx # sphinx-tabs pyparsing==2.4.7 # via packaging -pytest==6.2.2 +pytest==6.2.3 # via -r requirements/tests.in pytz==2021.1 # via babel @@ -90,9 +92,9 @@ snowballstemmer==2.1.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx-tabs==2.0.1 +sphinx-tabs==2.1.0 # via -r requirements/docs.in -sphinx==3.5.1 +sphinx==3.5.4 # via # -r requirements/docs.in # pallets-sphinx-themes @@ -115,22 +117,23 @@ sphinxcontrib-serializinghtml==1.1.4 # via sphinx toml==0.10.2 # via + # pep517 # pre-commit # pytest # tox -tox==3.22.0 +tox==3.23.0 # via -r requirements/dev.in -typed-ast==1.4.2 +typed-ast==1.4.3 # via mypy typing-extensions==3.7.4.3 # via mypy urllib3==1.26.4 # via requests -virtualenv==20.4.2 +virtualenv==20.4.3 # via # pre-commit # tox -zipp==3.4.0 +zipp==3.4.1 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/docs.txt b/requirements/docs.txt index eca7c6d6f..556822522 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -28,7 +28,7 @@ packaging==20.9 # sphinx pallets-sphinx-themes==2.0.0rc1 # via -r requirements/docs.in -pygments==2.7.4 +pygments==2.8.1 # via # sphinx # sphinx-tabs @@ -42,9 +42,9 @@ snowballstemmer==2.1.0 # via sphinx sphinx-issues==1.2.0 # via -r requirements/docs.in -sphinx-tabs==2.0.1 +sphinx-tabs==2.1.0 # via -r requirements/docs.in -sphinx==3.5.1 +sphinx==3.5.4 # via # -r requirements/docs.in # pallets-sphinx-themes diff --git a/requirements/tests.txt b/requirements/tests.txt index 95a42e68d..6be057bd2 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,7 +6,7 @@ # attrs==20.3.0 # via pytest -importlib_metadata==3.7.0 +importlib-metadata==3.10.1 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest @@ -18,9 +18,9 @@ py==1.10.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==6.2.2 +pytest==6.2.3 # via -r requirements/tests.in toml==0.10.2 # via pytest -zipp==3.4.0 +zipp==3.4.1 # via importlib-metadata diff --git a/requirements/typing.txt b/requirements/typing.txt index 2b3f58327..29e12e5e8 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -8,7 +8,7 @@ mypy-extensions==0.4.3 # via mypy mypy==0.812 # via -r requirements/typing.in -typed-ast==1.4.2 +typed-ast==1.4.3 # via mypy typing-extensions==3.7.4.3 # via mypy From fd5ef0e268c5454dafcaa323268cced80c2fd9ef Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 16 Apr 2021 07:02:31 -0700 Subject: [PATCH 258/293] update deprecated pre-commit hook --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc4f3a42b..201bdf01b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,6 +23,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.4.0 hooks: - - id: check-byte-order-marker + - id: fix-byte-order-marker - id: trailing-whitespace - id: end-of-file-fixer From 6708c86e2e2e93c21183f505a32bb009b607c9e4 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 16 Apr 2021 07:04:26 -0700 Subject: [PATCH 259/293] release version 8.0.0rc1 --- src/click/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/click/__init__.py b/src/click/__init__.py index b13afb4a4..a22d24eb6 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -72,4 +72,4 @@ from .utils import get_text_stream from .utils import open_file -__version__ = "8.0.0a2" +__version__ = "8.0.0rc1" From 77ce48f8d7d3b64a09741cf53dd2995d668317cf Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 16 Apr 2021 15:34:49 -0700 Subject: [PATCH 260/293] inline some unneeded constants --- src/click/core.py | 11 +++-------- src/click/utils.py | 5 +---- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index 14ef872a9..59a839a9b 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -39,11 +39,6 @@ from .utils import make_str from .utils import PacifyFlushWrapper -_missing = object() - -SUBCOMMAND_METAVAR = "COMMAND [ARGS]..." -SUBCOMMANDS_METAVAR = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." - def _fast_exit(code): """Low-level exit that skips Python's cleanup but speeds up exit by @@ -1389,9 +1384,9 @@ def __init__( self.invoke_without_command = invoke_without_command if subcommand_metavar is None: if chain: - subcommand_metavar = SUBCOMMANDS_METAVAR + subcommand_metavar = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." else: - subcommand_metavar = SUBCOMMAND_METAVAR + subcommand_metavar = "COMMAND [ARGS]..." self.subcommand_metavar = subcommand_metavar self.chain = chain # The result callback that is stored. This can be set or @@ -2296,7 +2291,7 @@ def __init__( show_envvar=False, **attrs, ): - default_is_missing = attrs.get("default", _missing) is _missing + default_is_missing = "default" not in attrs super().__init__(param_decls, type=type, multiple=multiple, **attrs) if prompt is True: diff --git a/src/click/utils.py b/src/click/utils.py index 6044ab795..f21812b19 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -18,9 +18,6 @@ from .globals import resolve_color_default -echo_native_types = (str, bytes, bytearray) - - def _posixify(name): return "-".join(name.split()).lower() @@ -239,7 +236,7 @@ def echo(message=None, file=None, nl=True, err=False, color=None): file = _default_text_stdout() # Convert non bytes/text into the native string type. - if message is not None and not isinstance(message, echo_native_types): + if message is not None and not isinstance(message, (str, bytes, bytearray)): message = str(message) if nl: From 94d371038445330805420d85a011334a4fb9e15d Mon Sep 17 00:00:00 2001 From: Guilherme Felix da Silva Maciel <12631274+guifelix@users.noreply.github.com> Date: Mon, 19 Apr 2021 09:34:34 -0400 Subject: [PATCH 261/293] Update why.rst Making lists formatting & argparse mentions more consistent --- docs/why.rst | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/why.rst b/docs/why.rst index d0912137b..29f3d6c14 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -7,15 +7,15 @@ why does Click exist? This question is easy to answer: because there is not a single command line utility for Python out there which ticks the following boxes: -* is lazily composable without restrictions -* supports implementation of Unix/POSIX command line conventions -* supports loading values from environment variables out of the box -* support for prompting of custom values -* is fully nestable and composable -* supports file handling out of the box -* comes with useful common helpers (getting terminal dimensions, +* Is lazily composable without restrictions. +* Supports implementation of Unix/POSIX command line conventions. +* Supports loading values from environment variables out of the box. +* Support for prompting of custom values. +* Is fully nestable and composable. +* Supports file handling out of the box. +* Comes with useful common helpers (getting terminal dimensions, ANSI colors, fetching direct keyboard input, screen clearing, - finding config paths, launching apps and editors, etc.) + finding config paths, launching apps and editors, etc.). There are many alternatives to Click; the obvious ones are ``optparse`` and ``argparse`` from the standard library. Have a look to see if something @@ -46,15 +46,15 @@ Why not Argparse? Click is internally based on ``optparse`` instead of ``argparse``. This is an implementation detail that a user does not have to be concerned -with. Click is not based on argparse because it has some behaviors that +with. Click is not based on ``argparse`` because it has some behaviors that make handling arbitrary command line interfaces hard: -* argparse has built-in behavior to guess if something is an +* ``argparse`` has built-in behavior to guess if something is an argument or an option. This becomes a problem when dealing with incomplete command lines; the behaviour becomes unpredictable without full knowledge of a command line. This goes against Click's ambitions of dispatching to subparsers. -* argparse does not support disabling interspersed arguments. Without +* ``argparse`` does not support disabling interspersed arguments. Without this feature, it's not possible to safely implement Click's nested parsing. @@ -134,7 +134,7 @@ Why No Auto Correction? ----------------------- The question came up why Click does not auto correct parameters given that -even optparse and argparse support automatic expansion of long arguments. +even optparse and ``argparse`` support automatic expansion of long arguments. The reason for this is that it's a liability for backwards compatibility. If people start relying on automatically modified parameters and someone adds a new parameter in the future, the script might stop working. These From 0103c9570650daa59560baf42ad0a27e57b3157f Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 23 Apr 2021 08:38:47 -0700 Subject: [PATCH 262/293] add typing annotations --- MANIFEST.in | 1 + examples/termui/termui.py | 2 +- setup.cfg | 5 +- src/click/_compat.py | 225 +++++---- src/click/_termui_impl.py | 236 +++++---- src/click/_textwrap.py | 18 +- src/click/_unicodefun.py | 33 +- src/click/_winconsole.py | 39 +- src/click/core.py | 908 +++++++++++++++++++++------------- src/click/decorators.py | 118 +++-- src/click/exceptions.py | 83 +++- src/click/formatting.py | 72 ++- src/click/globals.py | 32 +- src/click/parser.py | 134 +++-- src/click/py.typed | 0 src/click/shell_completion.py | 101 ++-- src/click/termui.py | 195 +++++--- src/click/testing.py | 171 ++++--- src/click/types.py | 237 ++++++--- src/click/utils.py | 146 +++--- tests/test_termui.py | 34 +- tests/test_testing.py | 6 +- 22 files changed, 1733 insertions(+), 1063 deletions(-) create mode 100644 src/click/py.typed diff --git a/MANIFEST.in b/MANIFEST.in index 8690e3550..e5b231d39 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,4 +6,5 @@ graft docs prune docs/_build graft examples graft tests +include src/click/py.typed global-exclude *.pyc diff --git a/examples/termui/termui.py b/examples/termui/termui.py index f4886b142..e72e65e5f 100644 --- a/examples/termui/termui.py +++ b/examples/termui/termui.py @@ -145,7 +145,7 @@ def pause(): def menu(): """Shows a simple menu.""" menu = "main" - while 1: + while True: if menu == "main": click.echo("Main menu:") click.echo(" d: debug menu") diff --git a/setup.cfg b/setup.cfg index 40061f3ad..76ac39b0f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -79,9 +79,10 @@ per-file-ignores = files = src/click python_version = 3.6 disallow_subclassing_any = True -# disallow_untyped_calls = True -# disallow_untyped_defs = True +disallow_untyped_calls = True +disallow_untyped_defs = True disallow_incomplete_defs = True +check_untyped_defs = True no_implicit_optional = True local_partial_types = True no_implicit_reexport = True diff --git a/src/click/_compat.py b/src/click/_compat.py index afa1d9247..30794b432 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -17,13 +17,17 @@ _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") -def get_filesystem_encoding(): +def get_filesystem_encoding() -> str: return sys.getfilesystemencoding() or sys.getdefaultencoding() def _make_text_stream( - stream, encoding, errors, force_readable=False, force_writable=False -): + stream: t.BinaryIO, + encoding: t.Optional[str], + errors: t.Optional[str], + force_readable: bool = False, + force_writable: bool = False, +) -> t.TextIO: if encoding is None: encoding = get_best_encoding(stream) if errors is None: @@ -38,7 +42,7 @@ def _make_text_stream( ) -def is_ascii_encoding(encoding): +def is_ascii_encoding(encoding: str) -> bool: """Checks if a given encoding is ascii.""" try: return codecs.lookup(encoding).name == "ascii" @@ -46,7 +50,7 @@ def is_ascii_encoding(encoding): return False -def get_best_encoding(stream): +def get_best_encoding(stream: t.IO) -> str: """Returns the default stream encoding if not found.""" rv = getattr(stream, "encoding", None) or sys.getdefaultencoding() if is_ascii_encoding(rv): @@ -57,23 +61,25 @@ def get_best_encoding(stream): class _NonClosingTextIOWrapper(io.TextIOWrapper): def __init__( self, - stream, - encoding, - errors, - force_readable=False, - force_writable=False, - **extra, - ): - self._stream = stream = _FixupStream(stream, force_readable, force_writable) + stream: t.BinaryIO, + encoding: t.Optional[str], + errors: t.Optional[str], + force_readable: bool = False, + force_writable: bool = False, + **extra: t.Any, + ) -> None: + self._stream = stream = t.cast( + t.BinaryIO, _FixupStream(stream, force_readable, force_writable) + ) super().__init__(stream, encoding, errors, **extra) - def __del__(self): + def __del__(self) -> None: try: self.detach() except Exception: pass - def isatty(self): + def isatty(self) -> bool: # https://bitbucket.org/pypy/pypy/issue/1803 return self._stream.isatty() @@ -88,41 +94,47 @@ class _FixupStream: of jupyter notebook). """ - def __init__(self, stream, force_readable=False, force_writable=False): + def __init__( + self, + stream: t.BinaryIO, + force_readable: bool = False, + force_writable: bool = False, + ): self._stream = stream self._force_readable = force_readable self._force_writable = force_writable - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self._stream, name) - def read1(self, size): + def read1(self, size: int) -> bytes: f = getattr(self._stream, "read1", None) + if f is not None: - return f(size) + return t.cast(bytes, f(size)) return self._stream.read(size) - def readable(self): + def readable(self) -> bool: if self._force_readable: return True x = getattr(self._stream, "readable", None) if x is not None: - return x() + return t.cast(bool, x()) try: self._stream.read(0) except Exception: return False return True - def writable(self): + def writable(self) -> bool: if self._force_writable: return True x = getattr(self._stream, "writable", None) if x is not None: - return x() + return t.cast(bool, x()) try: - self._stream.write("") + self._stream.write("") # type: ignore except Exception: try: self._stream.write(b"") @@ -130,10 +142,10 @@ def writable(self): return False return True - def seekable(self): + def seekable(self) -> bool: x = getattr(self._stream, "seekable", None) if x is not None: - return x() + return t.cast(bool, x()) try: self._stream.seek(self._stream.tell()) except Exception: @@ -141,11 +153,7 @@ def seekable(self): return True -def is_bytes(x): - return isinstance(x, (bytes, memoryview, bytearray)) - - -def _is_binary_reader(stream, default=False): +def _is_binary_reader(stream: t.IO, default: bool = False) -> bool: try: return isinstance(stream.read(0), bytes) except Exception: @@ -154,7 +162,7 @@ def _is_binary_reader(stream, default=False): # closed. In this case, we assume the default. -def _is_binary_writer(stream, default=False): +def _is_binary_writer(stream: t.IO, default: bool = False) -> bool: try: stream.write(b"") except Exception: @@ -167,39 +175,43 @@ def _is_binary_writer(stream, default=False): return True -def _find_binary_reader(stream): +def _find_binary_reader(stream: t.IO) -> t.Optional[t.BinaryIO]: # We need to figure out if the given stream is already binary. # This can happen because the official docs recommend detaching # the streams to get binary streams. Some code might do this, so # we need to deal with this case explicitly. if _is_binary_reader(stream, False): - return stream + return t.cast(t.BinaryIO, stream) buf = getattr(stream, "buffer", None) # Same situation here; this time we assume that the buffer is # actually binary in case it's closed. if buf is not None and _is_binary_reader(buf, True): - return buf + return t.cast(t.BinaryIO, buf) + return None -def _find_binary_writer(stream): + +def _find_binary_writer(stream: t.IO) -> t.Optional[t.BinaryIO]: # We need to figure out if the given stream is already binary. # This can happen because the official docs recommend detaching # the streams to get binary streams. Some code might do this, so # we need to deal with this case explicitly. if _is_binary_writer(stream, False): - return stream + return t.cast(t.BinaryIO, stream) buf = getattr(stream, "buffer", None) # Same situation here; this time we assume that the buffer is # actually binary in case it's closed. if buf is not None and _is_binary_writer(buf, True): - return buf + return t.cast(t.BinaryIO, buf) + + return None -def _stream_is_misconfigured(stream): +def _stream_is_misconfigured(stream: t.TextIO) -> bool: """A stream is misconfigured if its encoding is ASCII.""" # If the stream does not have an encoding set, we assume it's set # to ASCII. This appears to happen in certain unittest @@ -208,7 +220,7 @@ def _stream_is_misconfigured(stream): return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii") -def _is_compat_stream_attr(stream, attr, value): +def _is_compat_stream_attr(stream: t.TextIO, attr: str, value: t.Optional[str]) -> bool: """A stream attribute is compatible if it is equal to the desired value or the desired value is unset and the attribute has a value. @@ -217,7 +229,9 @@ def _is_compat_stream_attr(stream, attr, value): return stream_value == value or (value is None and stream_value is not None) -def _is_compatible_text_stream(stream, encoding, errors): +def _is_compatible_text_stream( + stream: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str] +) -> bool: """Check if a stream's encoding and errors attributes are compatible with the desired values. """ @@ -227,17 +241,18 @@ def _is_compatible_text_stream(stream, encoding, errors): def _force_correct_text_stream( - text_stream, - encoding, - errors, - is_binary, - find_binary, - force_readable=False, - force_writable=False, -): + text_stream: t.IO, + encoding: t.Optional[str], + errors: t.Optional[str], + is_binary: t.Callable[[t.IO, bool], bool], + find_binary: t.Callable[[t.IO], t.Optional[t.BinaryIO]], + force_readable: bool = False, + force_writable: bool = False, +) -> t.TextIO: if is_binary(text_stream, False): - binary_reader = text_stream + binary_reader = t.cast(t.BinaryIO, text_stream) else: + text_stream = t.cast(t.TextIO, text_stream) # If the stream looks compatible, and won't default to a # misconfigured ascii encoding, return it as-is. if _is_compatible_text_stream(text_stream, encoding, errors) and not ( @@ -246,13 +261,15 @@ def _force_correct_text_stream( return text_stream # Otherwise, get the underlying binary reader. - binary_reader = find_binary(text_stream) + possible_binary_reader = find_binary(text_stream) # If that's not possible, silently use the original reader # and get mojibake instead of exceptions. - if binary_reader is None: + if possible_binary_reader is None: return text_stream + binary_reader = possible_binary_reader + # Default errors to replace instead of strict in order to get # something that works. if errors is None: @@ -269,7 +286,12 @@ def _force_correct_text_stream( ) -def _force_correct_text_reader(text_reader, encoding, errors, force_readable=False): +def _force_correct_text_reader( + text_reader: t.IO, + encoding: t.Optional[str], + errors: t.Optional[str], + force_readable: bool = False, +) -> t.TextIO: return _force_correct_text_stream( text_reader, encoding, @@ -280,7 +302,12 @@ def _force_correct_text_reader(text_reader, encoding, errors, force_readable=Fal ) -def _force_correct_text_writer(text_writer, encoding, errors, force_writable=False): +def _force_correct_text_writer( + text_writer: t.IO, + encoding: t.Optional[str], + errors: t.Optional[str], + force_writable: bool = False, +) -> t.TextIO: return _force_correct_text_stream( text_writer, encoding, @@ -291,49 +318,55 @@ def _force_correct_text_writer(text_writer, encoding, errors, force_writable=Fal ) -def get_binary_stdin(): +def get_binary_stdin() -> t.BinaryIO: reader = _find_binary_reader(sys.stdin) if reader is None: raise RuntimeError("Was not able to determine binary stream for sys.stdin.") return reader -def get_binary_stdout(): +def get_binary_stdout() -> t.BinaryIO: writer = _find_binary_writer(sys.stdout) if writer is None: raise RuntimeError("Was not able to determine binary stream for sys.stdout.") return writer -def get_binary_stderr(): +def get_binary_stderr() -> t.BinaryIO: writer = _find_binary_writer(sys.stderr) if writer is None: raise RuntimeError("Was not able to determine binary stream for sys.stderr.") return writer -def get_text_stdin(encoding=None, errors=None): +def get_text_stdin( + encoding: t.Optional[str] = None, errors: t.Optional[str] = None +) -> t.TextIO: 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, force_readable=True) -def get_text_stdout(encoding=None, errors=None): +def get_text_stdout( + encoding: t.Optional[str] = None, errors: t.Optional[str] = None +) -> t.TextIO: 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, force_writable=True) -def get_text_stderr(encoding=None, errors=None): +def get_text_stderr( + encoding: t.Optional[str] = None, errors: t.Optional[str] = None +) -> t.TextIO: 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, force_writable=True) -def get_strerror(e, default=None): +def get_strerror(e: OSError, default: t.Optional[str] = None) -> str: if hasattr(e, "strerror"): msg = e.strerror else: @@ -341,12 +374,15 @@ def get_strerror(e, default=None): msg = default else: msg = str(e) - if isinstance(msg, bytes): - msg = msg.decode("utf-8", "replace") return msg -def _wrap_io_open(file, mode, encoding, errors): +def _wrap_io_open( + file: t.Union[str, os.PathLike, int], + mode: str, + encoding: t.Optional[str], + errors: t.Optional[str], +) -> t.IO: """Handles not passing ``encoding`` and ``errors`` in binary mode.""" if "b" in mode: return open(file, mode) @@ -354,7 +390,13 @@ def _wrap_io_open(file, mode, encoding, errors): return open(file, mode, encoding=encoding, errors=errors) -def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False): +def open_stream( + filename: str, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + atomic: bool = False, +) -> t.Tuple[t.IO, bool]: binary = "b" in mode # Standard streams first. These are simple because they don't need @@ -393,7 +435,7 @@ def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False import random try: - perm = os.stat(filename).st_mode + perm: t.Optional[int] = os.stat(filename).st_mode except OSError: perm = None @@ -424,52 +466,55 @@ def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False os.chmod(tmp_filename, perm) # in case perm includes bits in umask f = _wrap_io_open(fd, mode, encoding, errors) - return _AtomicFile(f, tmp_filename, os.path.realpath(filename)), True + af = _AtomicFile(f, tmp_filename, os.path.realpath(filename)) + return t.cast(t.IO, af), True class _AtomicFile: - def __init__(self, f, tmp_filename, real_filename): + def __init__(self, f: t.IO, tmp_filename: str, real_filename: str) -> None: self._f = f self._tmp_filename = tmp_filename self._real_filename = real_filename self.closed = False @property - def name(self): + def name(self) -> str: return self._real_filename - def close(self, delete=False): + def close(self, delete: bool = False) -> None: if self.closed: return self._f.close() os.replace(self._tmp_filename, self._real_filename) self.closed = True - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self._f, name) - def __enter__(self): + def __enter__(self) -> "_AtomicFile": return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore self.close(delete=exc_type is not None) - def __repr__(self): + def __repr__(self) -> str: return repr(self._f) -def strip_ansi(value): +def strip_ansi(value: str) -> str: return _ansi_re.sub("", value) -def _is_jupyter_kernel_output(stream): +def _is_jupyter_kernel_output(stream: t.IO) -> bool: while isinstance(stream, (_FixupStream, _NonClosingTextIOWrapper)): stream = stream._stream return stream.__class__.__module__.startswith("ipykernel.") -def should_strip_ansi(stream=None, color=None): +def should_strip_ansi( + stream: t.Optional[t.IO] = None, color: t.Optional[bool] = None +) -> bool: if color is None: if stream is None: stream = sys.stdin @@ -483,7 +528,7 @@ def should_strip_ansi(stream=None, color=None): if sys.platform.startswith("win") and WIN: from ._winconsole import _get_windows_console_stream - def _get_argv_encoding(): + def _get_argv_encoding() -> str: import locale return locale.getpreferredencoding() @@ -530,28 +575,32 @@ def _safe_write(s): else: - def _get_argv_encoding(): + def _get_argv_encoding() -> str: return getattr(sys.stdin, "encoding", None) or get_filesystem_encoding() - def _get_windows_console_stream(f, encoding, errors): + def _get_windows_console_stream( + f: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str] + ) -> t.Optional[t.TextIO]: return None -def term_len(x): +def term_len(x: str) -> int: return len(strip_ansi(x)) -def isatty(stream): +def isatty(stream: t.IO) -> bool: try: return stream.isatty() except Exception: return False -def _make_cached_stream_func(src_func, wrapper_func): - cache = WeakKeyDictionary() +def _make_cached_stream_func( + src_func: t.Callable[[], t.TextIO], wrapper_func: t.Callable[[], t.TextIO] +) -> t.Callable[[], t.TextIO]: + cache: t.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() - def func(): + def func() -> t.TextIO: stream = src_func() try: rv = cache.get(stream) @@ -575,13 +624,15 @@ def func(): _default_text_stderr = _make_cached_stream_func(lambda: sys.stderr, get_text_stderr) -binary_streams = { +binary_streams: t.Mapping[str, t.Callable[[], t.BinaryIO]] = { "stdin": get_binary_stdin, "stdout": get_binary_stdout, "stderr": get_binary_stderr, } -text_streams = { +text_streams: t.Mapping[ + str, t.Callable[[t.Optional[str], t.Optional[str]], t.TextIO] +] = { "stdin": get_text_stdin, "stdout": get_text_stdout, "stderr": get_text_stderr, diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 46ff2190d..106051ef0 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -8,6 +8,7 @@ import os import sys import time +import typing as t from gettext import gettext as _ from ._compat import _default_text_stdout @@ -21,6 +22,8 @@ from .exceptions import ClickException from .utils import echo +V = t.TypeVar("V") + if os.name == "nt": BEFORE_BAR = "\r" AFTER_BAR = "\n" @@ -29,42 +32,24 @@ AFTER_BAR = "\033[?25h\n" -def _length_hint(obj): - """Returns the length hint of an object.""" - try: - return len(obj) - except (AttributeError, TypeError): - try: - get_hint = type(obj).__length_hint__ - except AttributeError: - return None - try: - hint = get_hint(obj) - except TypeError: - return None - if hint is NotImplemented or not isinstance(hint, int) or hint < 0: - return None - return hint - - class ProgressBar: def __init__( self, - iterable, - length=None, - fill_char="#", - empty_char=" ", - bar_template="%(bar)s", - info_sep=" ", - show_eta=True, - show_percent=None, - show_pos=False, - item_show_func=None, - label=None, - file=None, - color=None, - update_min_steps=1, - width=30, + iterable: t.Optional[t.Iterable[V]], + length: t.Optional[int] = None, + fill_char: str = "#", + empty_char: str = " ", + bar_template: str = "%(bar)s", + info_sep: str = " ", + show_eta: bool = True, + show_percent: t.Optional[bool] = None, + show_pos: bool = False, + item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None, + label: t.Optional[str] = None, + file: t.Optional[t.TextIO] = None, + color: t.Optional[bool] = None, + update_min_steps: int = 1, + width: int = 30, ): self.fill_char = fill_char self.empty_char = empty_char @@ -85,72 +70,76 @@ def __init__( self.autowidth = width == 0 if length is None: - length = _length_hint(iterable) + from operator import length_hint + + length = length_hint(iterable, -1) + + if length == -1: + length = None if iterable is None: if length is None: raise TypeError("iterable or length is required") - iterable = range(length) + iterable = t.cast(t.Iterable[V], range(length)) self.iter = iter(iterable) self.length = length - self.length_known = length is not None self.pos = 0 - self.avg = [] + self.avg: t.List[float] = [] self.start = self.last_eta = time.time() self.eta_known = False self.finished = False - self.max_width = None + self.max_width: t.Optional[int] = None self.entered = False - self.current_item = None + self.current_item: t.Optional[V] = None self.is_hidden = not isatty(self.file) - self._last_line = None + self._last_line: t.Optional[str] = None - def __enter__(self): + def __enter__(self) -> "ProgressBar": self.entered = True self.render_progress() return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore self.render_finish() - def __iter__(self): + def __iter__(self) -> t.Iterable[V]: if not self.entered: raise RuntimeError("You need to use progress bars in a with block.") self.render_progress() return self.generator() - def __next__(self): + def __next__(self) -> V: # Iteration is defined in terms of a generator function, # returned by iter(self); use that to define next(). This works # because `self.iter` is an iterable consumed by that generator, # so it is re-entry safe. Calling `next(self.generator())` # twice works and does "what you want". - return next(iter(self)) + return next(iter(self)) # type: ignore - def render_finish(self): + def render_finish(self) -> None: if self.is_hidden: return self.file.write(AFTER_BAR) self.file.flush() @property - def pct(self): + def pct(self) -> float: if self.finished: return 1.0 - return min(self.pos / (float(self.length) or 1), 1.0) + return min(self.pos / (float(self.length or 1) or 1), 1.0) @property - def time_per_iteration(self): + def time_per_iteration(self) -> float: if not self.avg: return 0.0 return sum(self.avg) / float(len(self.avg)) @property - def eta(self): - if self.length_known and not self.finished: + def eta(self) -> float: + if self.length is not None and not self.finished: return self.time_per_iteration * (self.length - self.pos) return 0.0 - def format_eta(self): + def format_eta(self) -> str: if self.eta_known: t = int(self.eta) seconds = t % 60 @@ -165,39 +154,39 @@ def format_eta(self): return f"{hours:02}:{minutes:02}:{seconds:02}" return "" - def format_pos(self): + def format_pos(self) -> str: pos = str(self.pos) - if self.length_known: + if self.length is not None: pos += f"/{self.length}" return pos - def format_pct(self): + def format_pct(self) -> str: return f"{int(self.pct * 100): 4}%"[1:] - def format_bar(self): - if self.length_known: + def format_bar(self) -> str: + if self.length is not None: bar_length = int(self.pct * self.width) bar = self.fill_char * bar_length bar += self.empty_char * (self.width - bar_length) elif self.finished: bar = self.fill_char * self.width else: - bar = list(self.empty_char * (self.width or 1)) + chars = list(self.empty_char * (self.width or 1)) if self.time_per_iteration != 0: - bar[ + chars[ int( (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5) * self.width ) ] = self.fill_char - bar = "".join(bar) + bar = "".join(chars) return bar - def format_progress_line(self): + def format_progress_line(self) -> str: show_percent = self.show_percent info_bits = [] - if self.length_known and show_percent is None: + if self.length is not None and show_percent is None: show_percent = not self.show_pos if self.show_pos: @@ -220,7 +209,7 @@ def format_progress_line(self): } ).rstrip() - def render_progress(self): + def render_progress(self) -> None: import shutil if self.is_hidden: @@ -241,7 +230,7 @@ def render_progress(self): new_width = max(0, shutil.get_terminal_size().columns - clutter_length) if new_width < old_width: buf.append(BEFORE_BAR) - buf.append(" " * self.max_width) + buf.append(" " * self.max_width) # type: ignore self.max_width = new_width self.width = new_width @@ -265,9 +254,9 @@ def render_progress(self): echo(line, file=self.file, color=self.color, nl=False) self.file.flush() - def make_step(self, n_steps): + def make_step(self, n_steps: int) -> None: self.pos += n_steps - if self.length_known and self.pos >= self.length: + if self.length is not None and self.pos >= self.length: self.finished = True if (time.time() - self.last_eta) < 1.0: @@ -285,9 +274,9 @@ def make_step(self, n_steps): self.avg = self.avg[-6:] + [step] - self.eta_known = self.length_known + self.eta_known = self.length is not None - def update(self, n_steps, current_item=None): + def update(self, n_steps: int, current_item: t.Optional[V] = None) -> None: """Update the progress bar by advancing a specified number of steps, and optionally set the ``current_item`` for this new position. @@ -313,12 +302,12 @@ def update(self, n_steps, current_item=None): self.render_progress() self._completed_intervals = 0 - def finish(self): - self.eta_known = 0 + def finish(self) -> None: + self.eta_known = False self.current_item = None self.finished = True - def generator(self): + def generator(self) -> t.Iterator[V]: """Return a generator which yields the items added to the bar during construction, and updates the progress bar *after* the yielded block returns. @@ -352,7 +341,7 @@ def generator(self): self.render_progress() -def pager(generator, color=None): +def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None: """Decide what method to use for paging through text.""" stdout = _default_text_stdout() if not isatty(sys.stdin) or not isatty(stdout): @@ -381,7 +370,7 @@ def pager(generator, color=None): os.unlink(filename) -def _pipepager(generator, cmd, color): +def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> None: """Page through text by feeding it to another program. Invoking a pager through this might support colors. """ @@ -401,17 +390,18 @@ def _pipepager(generator, cmd, color): color = True c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env) - encoding = get_best_encoding(c.stdin) + stdin = t.cast(t.BinaryIO, c.stdin) + encoding = get_best_encoding(stdin) try: for text in generator: if not color: text = strip_ansi(text) - c.stdin.write(text.encode(encoding, "replace")) + stdin.write(text.encode(encoding, "replace")) except (OSError, KeyboardInterrupt): pass else: - c.stdin.close() + stdin.close() # Less doesn't respect ^C, but catches it for its own UI purposes (aborting # search or other commands inside less). @@ -430,11 +420,13 @@ def _pipepager(generator, cmd, color): break -def _tempfilepager(generator, cmd, color): +def _tempfilepager( + generator: t.Iterable[str], cmd: str, color: t.Optional[bool] +) -> None: """Page through text by invoking a program on a temporary file.""" import tempfile - filename = tempfile.mkstemp() + _, filename = tempfile.mkstemp() # TODO: This never terminates if the passed generator never terminates. text = "".join(generator) if not color: @@ -448,7 +440,9 @@ def _tempfilepager(generator, cmd, color): os.unlink(filename) -def _nullpager(stream, generator, color): +def _nullpager( + stream: t.TextIO, generator: t.Iterable[str], color: t.Optional[bool] +) -> None: """Simply print unformatted text. This is the ultimate fallback.""" for text in generator: if not color: @@ -457,13 +451,19 @@ def _nullpager(stream, generator, color): class Editor: - def __init__(self, editor=None, env=None, require_save=True, extension=".txt"): + def __init__( + self, + editor: t.Optional[str] = None, + env: t.Optional[t.Mapping[str, str]] = None, + require_save: bool = True, + extension: str = ".txt", + ) -> None: self.editor = editor self.env = env self.require_save = require_save self.extension = extension - def get_editor(self): + def get_editor(self) -> str: if self.editor is not None: return self.editor for key in "VISUAL", "EDITOR": @@ -477,15 +477,16 @@ def get_editor(self): return editor return "vi" - def edit_file(self, filename): + def edit_file(self, filename: str) -> None: import subprocess editor = self.get_editor() + environ: t.Optional[t.Dict[str, str]] = None + if self.env: environ = os.environ.copy() environ.update(self.env) - else: - environ = None + try: c = subprocess.Popen(f'{editor} "{filename}"', env=environ, shell=True) exit_code = c.wait() @@ -498,28 +499,28 @@ def edit_file(self, filename): _("{editor}: Editing failed: {e}").format(editor=editor, e=e) ) - def edit(self, text): + def edit(self, text: t.Optional[t.AnyStr]) -> t.Optional[t.AnyStr]: import tempfile if not text: - text = "" - - is_bytes = isinstance(text, (bytes, bytearray)) - - if not is_bytes: + data = b"" + elif isinstance(text, (bytes, bytearray)): + data = text + else: if text and not text.endswith("\n"): text += "\n" if WIN: - text = text.replace("\n", "\r\n").encode("utf-8-sig") + data = text.replace("\n", "\r\n").encode("utf-8-sig") else: - text = text.encode("utf-8") + data = text.encode("utf-8") fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension) + f: t.BinaryIO try: with os.fdopen(fd, "wb") as f: - f.write(text) + f.write(data) # If the filesystem resolution is 1 second, like Mac OS # 10.12 Extended, or 2 seconds, like FAT32, and the editor @@ -538,15 +539,15 @@ def edit(self, text): with open(name, "rb") as f: rv = f.read() - if is_bytes: + if isinstance(text, (bytes, bytearray)): return rv - return rv.decode("utf-8-sig").replace("\r\n", "\n") + return rv.decode("utf-8-sig").replace("\r\n", "\n") # type: ignore finally: os.unlink(name) -def open_url(url, wait=False, locate=False): +def open_url(url: str, wait: bool = False, locate: bool = False) -> int: import subprocess def _unquote_file(url: str) -> str: @@ -575,8 +576,8 @@ def _unquote_file(url: str) -> str: args = f'explorer /select,"{url}"' else: url = url.replace('"', "") - wait = "/WAIT" if wait else "" - args = f'start {wait} "" "{url}"' + wait_str = "/WAIT" if wait else "" + args = f'start {wait_str} "" "{url}"' return os.system(args) elif CYGWIN: if locate: @@ -584,8 +585,8 @@ def _unquote_file(url: str) -> str: args = f'cygstart "{url}"' else: url = url.replace('"', "") - wait = "-w" if wait else "" - args = f'cygstart {wait} "{url}"' + wait_str = "-w" if wait else "" + args = f'cygstart {wait_str} "{url}"' return os.system(args) try: @@ -606,23 +607,27 @@ def _unquote_file(url: str) -> str: return 1 -def _translate_ch_to_exc(ch): +def _translate_ch_to_exc(ch: str) -> t.Optional[BaseException]: if ch == "\x03": raise KeyboardInterrupt() + if ch == "\x04" and not WIN: # Unix-like, Ctrl+D raise EOFError() + if ch == "\x1a" and WIN: # Windows, Ctrl+Z raise EOFError() + return None + if WIN: import msvcrt @contextlib.contextmanager - def raw_terminal(): - yield + def raw_terminal() -> t.Iterator[int]: + yield -1 - def getchar(echo): + def getchar(echo: bool) -> str: # The function `getch` will return a bytes object corresponding to # the pressed character. Since Windows 10 build 1803, it will also # return \x00 when called a second time after pressing a regular key. @@ -652,16 +657,20 @@ def getchar(echo): # # Anyway, Click doesn't claim to do this Right(tm), and using `getwch` # is doing the right thing in more situations than with `getch`. + func: t.Callable[[], str] + if echo: - func = msvcrt.getwche + func = msvcrt.getwche # type: ignore else: - func = msvcrt.getwch + func = msvcrt.getwch # type: ignore rv = func() + if rv in ("\x00", "\xe0"): # \x00 and \xe0 are control characters that indicate special key, # see above. rv += func() + _translate_ch_to_exc(rv) return rv @@ -671,31 +680,38 @@ def getchar(echo): import termios @contextlib.contextmanager - def raw_terminal(): + def raw_terminal() -> t.Iterator[int]: + f: t.Optional[t.TextIO] + fd: int + if not isatty(sys.stdin): f = open("/dev/tty") fd = f.fileno() else: fd = sys.stdin.fileno() f = None + try: old_settings = termios.tcgetattr(fd) + try: tty.setraw(fd) yield fd finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) sys.stdout.flush() + if f is not None: f.close() except termios.error: pass - def getchar(echo): + def getchar(echo: bool) -> str: with raw_terminal() as fd: - ch = os.read(fd, 32) - ch = ch.decode(get_best_encoding(sys.stdin), "replace") + ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace") + if echo and isatty(sys.stdout): sys.stdout.write(ch) + _translate_ch_to_exc(ch) return ch diff --git a/src/click/_textwrap.py b/src/click/_textwrap.py index 7a052b70d..b47dcbd42 100644 --- a/src/click/_textwrap.py +++ b/src/click/_textwrap.py @@ -1,9 +1,16 @@ import textwrap +import typing as t from contextlib import contextmanager class TextWrapper(textwrap.TextWrapper): - def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): + def _handle_long_word( + self, + reversed_chunks: t.List[str], + cur_line: t.List[str], + cur_len: int, + width: int, + ) -> None: space_left = max(width - cur_len, 1) if self.break_long_words: @@ -16,22 +23,27 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): cur_line.append(reversed_chunks.pop()) @contextmanager - def extra_indent(self, indent): + def extra_indent(self, indent: str) -> t.Iterator[None]: old_initial_indent = self.initial_indent old_subsequent_indent = self.subsequent_indent self.initial_indent += indent self.subsequent_indent += indent + try: yield finally: self.initial_indent = old_initial_indent self.subsequent_indent = old_subsequent_indent - def indent_only(self, text): + def indent_only(self, text: str) -> str: rv = [] + for idx, line in enumerate(text.splitlines()): indent = self.initial_indent + if idx > 0: indent = self.subsequent_indent + rv.append(f"{indent}{line}") + return "\n".join(rv) diff --git a/src/click/_unicodefun.py b/src/click/_unicodefun.py index aa1102427..9cb30c384 100644 --- a/src/click/_unicodefun.py +++ b/src/click/_unicodefun.py @@ -3,14 +3,15 @@ from gettext import gettext as _ -def _verify_python_env(): +def _verify_python_env() -> None: """Ensures that the environment is good for Unicode.""" try: - import locale + from locale import getpreferredencoding - fs_enc = codecs.lookup(locale.getpreferredencoding()).name + fs_enc = codecs.lookup(getpreferredencoding()).name except Exception: fs_enc = "ascii" + if fs_enc != "ascii": return @@ -28,21 +29,24 @@ def _verify_python_env(): try: rv = subprocess.Popen( - ["locale", "-a"], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ["locale", "-a"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="ascii", + errors="replace", ).communicate()[0] except OSError: - rv = b"" + rv = "" + good_locales = set() has_c_utf8 = False - # Make sure we're operating on text here. - if isinstance(rv, bytes): - rv = rv.decode("ascii", "replace") - for line in rv.splitlines(): locale = line.strip() + if locale.lower().endswith((".utf-8", ".utf8")): good_locales.add(locale) + if locale.lower() in ("c.utf8", "c.utf-8"): has_c_utf8 = True @@ -75,11 +79,14 @@ def _verify_python_env(): ) bad_locale = None - for locale in os.environ.get("LC_ALL"), os.environ.get("LANG"): - if locale and locale.lower().endswith((".utf-8", ".utf8")): - bad_locale = locale - if locale is not None: + + for env_locale in os.environ.get("LC_ALL"), os.environ.get("LANG"): + if env_locale and env_locale.lower().endswith((".utf-8", ".utf8")): + bad_locale = env_locale + + if env_locale is not None: break + if bad_locale is not None: extra.append( _( diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index cd5d01234..0b46bac15 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -9,6 +9,7 @@ import io import sys import time +import typing as t from ctypes import byref from ctypes import c_char from ctypes import c_char_p @@ -178,15 +179,15 @@ def write(self, b): class ConsoleStream: - def __init__(self, text_stream, byte_stream): + def __init__(self, text_stream: t.TextIO, byte_stream: t.BinaryIO) -> None: self._text_stream = text_stream self.buffer = byte_stream @property - def name(self): + def name(self) -> str: return self.buffer.name - def write(self, x): + def write(self, x: t.AnyStr) -> int: if isinstance(x, str): return self._text_stream.write(x) try: @@ -195,14 +196,14 @@ def write(self, x): pass return self.buffer.write(x) - def writelines(self, lines): + def writelines(self, lines: t.Iterable[t.AnyStr]) -> None: for line in lines: self.write(line) - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self._text_stream, name) - def isatty(self): + def isatty(self) -> bool: return self.buffer.isatty() def __repr__(self): @@ -234,44 +235,44 @@ def write(self, text): written += to_write -def _get_text_stdin(buffer_stream): +def _get_text_stdin(buffer_stream: t.BinaryIO) -> t.TextIO: text_stream = _NonClosingTextIOWrapper( io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)), "utf-16-le", "strict", line_buffering=True, ) - return ConsoleStream(text_stream, buffer_stream) + return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) -def _get_text_stdout(buffer_stream): +def _get_text_stdout(buffer_stream: t.BinaryIO) -> t.TextIO: text_stream = _NonClosingTextIOWrapper( io.BufferedWriter(_WindowsConsoleWriter(STDOUT_HANDLE)), "utf-16-le", "strict", line_buffering=True, ) - return ConsoleStream(text_stream, buffer_stream) + return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) -def _get_text_stderr(buffer_stream): +def _get_text_stderr(buffer_stream: t.BinaryIO) -> t.TextIO: text_stream = _NonClosingTextIOWrapper( io.BufferedWriter(_WindowsConsoleWriter(STDERR_HANDLE)), "utf-16-le", "strict", line_buffering=True, ) - return ConsoleStream(text_stream, buffer_stream) + return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) -_stream_factories = { +_stream_factories: t.Mapping[int, t.Callable[[t.BinaryIO], t.TextIO]] = { 0: _get_text_stdin, 1: _get_text_stdout, 2: _get_text_stderr, } -def _is_console(f): +def _is_console(f: t.TextIO) -> bool: if not hasattr(f, "fileno"): return False @@ -284,7 +285,9 @@ def _is_console(f): return bool(GetConsoleMode(handle, byref(DWORD()))) -def _get_windows_console_stream(f, encoding, errors): +def _get_windows_console_stream( + f: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str] +) -> t.Optional[t.TextIO]: if ( get_buffer is not None and encoding in {"utf-16-le", None} @@ -293,9 +296,9 @@ def _get_windows_console_stream(f, encoding, errors): ): func = _stream_factories.get(f.fileno()) if func is not None: - f = getattr(f, "buffer", None) + b = getattr(f, "buffer", None) - if f is None: + if b is None: return None - return func(f) + return func(b) diff --git a/src/click/core.py b/src/click/core.py index 59a839a9b..1051baece 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2,7 +2,9 @@ import errno import os import sys +import typing import typing as t +from collections import abc from contextlib import contextmanager from contextlib import ExitStack from functools import partial @@ -32,6 +34,7 @@ from .types import BOOL from .types import convert_type from .types import IntRange +from .types import ParamType from .utils import _detect_program_name from .utils import _expand_args from .utils import echo @@ -39,8 +42,15 @@ from .utils import make_str from .utils import PacifyFlushWrapper +if t.TYPE_CHECKING: + import typing_extensions as te + from .shell_completion import CompletionItem -def _fast_exit(code): +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +V = t.TypeVar("V") + + +def _fast_exit(code: int) -> t.NoReturn: """Low-level exit that skips Python's cleanup but speeds up exit by about 10ms for things like shell completion. @@ -51,22 +61,28 @@ def _fast_exit(code): os._exit(code) -def _complete_visible_commands(ctx, incomplete): +def _complete_visible_commands( + ctx: "Context", incomplete: str +) -> t.Iterator[t.Tuple[str, "Command"]]: """List all the subcommands of a group that start with the incomplete value and aren't hidden. :param ctx: Invocation context for the group. :param incomplete: Value being completed. May be empty. """ - for name in ctx.command.list_commands(ctx): + multi = t.cast(MultiCommand, ctx.command) + + for name in multi.list_commands(ctx): if name.startswith(incomplete): - command = ctx.command.get_command(ctx, name) + command = multi.get_command(ctx, name) - if not command.hidden: + if command is not None and not command.hidden: yield name, command -def _check_multicommand(base_command, cmd_name, cmd, register=False): +def _check_multicommand( + base_command: "MultiCommand", cmd_name: str, cmd: "Command", register: bool = False +) -> None: if not base_command.chain or not isinstance(cmd, MultiCommand): return if register: @@ -88,12 +104,14 @@ def _check_multicommand(base_command, cmd_name, cmd, register=False): ) -def batch(iterable, batch_size): +def batch(iterable: t.Iterable[V], batch_size: int) -> t.List[t.Tuple[V, ...]]: return list(zip(*repeat(iter(iterable), batch_size))) @contextmanager -def augment_usage_errors(ctx, param=None): +def augment_usage_errors( + ctx: "Context", param: t.Optional["Parameter"] = None +) -> t.Iterator[None]: """Context manager that attaches extra information to exceptions.""" try: yield @@ -109,18 +127,22 @@ def augment_usage_errors(ctx, param=None): raise -def iter_params_for_processing(invocation_order, declaration_order): +def iter_params_for_processing( + invocation_order: t.Sequence["Parameter"], + declaration_order: t.Sequence["Parameter"], +) -> t.List["Parameter"]: """Given a sequence of parameters in the order as should be considered for processing and an iterable of parameters that exist, this returns a list in the correct order as they should be processed. """ - def sort_key(item): + def sort_key(item: "Parameter") -> t.Tuple[bool, float]: try: - idx = invocation_order.index(item) + idx: float = invocation_order.index(item) except ValueError: idx = float("inf") - return (not item.is_eager, idx) + + return not item.is_eager, idx return sorted(declaration_order, key=sort_key) @@ -244,27 +266,27 @@ class Context: #: The formatter class to create with :meth:`make_formatter`. #: #: .. versionadded:: 8.0 - formatter_class = HelpFormatter + formatter_class: t.Type["HelpFormatter"] = HelpFormatter def __init__( self, - command, - parent=None, - info_name=None, - obj=None, - auto_envvar_prefix=None, - default_map=None, - terminal_width=None, - max_content_width=None, - resilient_parsing=False, - allow_extra_args=None, - allow_interspersed_args=None, - ignore_unknown_options=None, - help_option_names=None, - token_normalize_func=None, - color=None, - show_default=None, - ): + command: "Command", + parent: t.Optional["Context"] = None, + info_name: t.Optional[str] = None, + obj: t.Optional[t.Any] = None, + auto_envvar_prefix: t.Optional[str] = None, + default_map: t.Optional[t.Dict[str, t.Any]] = None, + terminal_width: t.Optional[int] = None, + max_content_width: t.Optional[int] = None, + resilient_parsing: bool = False, + allow_extra_args: t.Optional[bool] = None, + allow_interspersed_args: t.Optional[bool] = None, + ignore_unknown_options: t.Optional[bool] = None, + help_option_names: t.Optional[t.List[str]] = None, + token_normalize_func: t.Optional[t.Callable[[str], str]] = None, + color: t.Optional[bool] = None, + show_default: t.Optional[bool] = None, + ) -> None: #: the parent context or `None` if none exists. self.parent = parent #: the :class:`Command` for this context. @@ -273,28 +295,32 @@ def __init__( self.info_name = info_name #: Map of parameter names to their parsed values. Parameters #: with ``expose_value=False`` are not stored. - self.params = {} + self.params: t.Dict[str, t.Any] = {} #: the leftover arguments. - self.args = [] + self.args: t.List[str] = [] #: protected arguments. These are arguments that are prepended #: to `args` when certain parsing scenarios are encountered but #: must be never propagated to another arguments. This is used #: to implement nested parsing. - self.protected_args = [] + self.protected_args: t.List[str] = [] + if obj is None and parent is not None: obj = parent.obj + #: the user object stored. - self.obj = obj - self._meta = getattr(parent, "meta", {}) + self.obj: t.Optional[t.Any] = obj + self._meta: t.Dict[str, t.Any] = getattr(parent, "meta", {}) #: A dictionary (-like object) with defaults for parameters. if ( default_map is None + and info_name is not None and parent is not None and parent.default_map is not None ): default_map = parent.default_map.get(info_name) - self.default_map = default_map + + self.default_map: t.Optional[t.Dict[str, t.Any]] = default_map #: This flag indicates if a subcommand is going to be executed. A #: group callback can use this information to figure out if it's @@ -306,21 +332,24 @@ def __init__( #: any commands are executed. It is however not possible to #: figure out which ones. If you require this knowledge you #: should use a :func:`result_callback`. - self.invoked_subcommand = None + self.invoked_subcommand: t.Optional[str] = None if terminal_width is None and parent is not None: terminal_width = parent.terminal_width + #: The width of the terminal (None is autodetection). - self.terminal_width = terminal_width + self.terminal_width: t.Optional[int] = terminal_width if max_content_width is None and parent is not None: max_content_width = parent.max_content_width + #: The maximum width of formatted content (None implies a sensible #: default which is 80 for most things). - self.max_content_width = max_content_width + self.max_content_width: t.Optional[int] = max_content_width if allow_extra_args is None: allow_extra_args = command.allow_extra_args + #: Indicates if the context allows extra args or if it should #: fail on parsing. #: @@ -329,14 +358,16 @@ def __init__( if allow_interspersed_args is None: allow_interspersed_args = command.allow_interspersed_args + #: Indicates if the context allows mixing of arguments and #: options or not. #: #: .. versionadded:: 3.0 - self.allow_interspersed_args = allow_interspersed_args + self.allow_interspersed_args: bool = allow_interspersed_args if ignore_unknown_options is None: ignore_unknown_options = command.ignore_unknown_options + #: Instructs click to ignore options that a command does not #: understand and will store it on the context for later #: processing. This is primarily useful for situations where you @@ -345,7 +376,7 @@ def __init__( #: forward all arguments. #: #: .. versionadded:: 4.0 - self.ignore_unknown_options = ignore_unknown_options + self.ignore_unknown_options: bool = ignore_unknown_options if help_option_names is None: if parent is not None: @@ -354,19 +385,21 @@ def __init__( help_option_names = ["--help"] #: The names for the help options. - self.help_option_names = help_option_names + self.help_option_names: t.List[str] = help_option_names if token_normalize_func is None and parent is not None: token_normalize_func = parent.token_normalize_func #: An optional normalization function for tokens. This is #: options, choices, commands etc. - self.token_normalize_func = token_normalize_func + self.token_normalize_func: t.Optional[ + t.Callable[[str], str] + ] = token_normalize_func #: Indicates if resilient parsing is enabled. In that case Click #: will do its best to not cause any failures and default values #: will be ignored. Useful for completion. - self.resilient_parsing = resilient_parsing + self.resilient_parsing: bool = resilient_parsing # If there is no envvar prefix yet, but the parent has one and # the command on this level has a name, we can expand the envvar @@ -382,28 +415,30 @@ def __init__( ) else: auto_envvar_prefix = auto_envvar_prefix.upper() + if auto_envvar_prefix is not None: auto_envvar_prefix = auto_envvar_prefix.replace("-", "_") - self.auto_envvar_prefix = auto_envvar_prefix + + self.auto_envvar_prefix: t.Optional[str] = auto_envvar_prefix if color is None and parent is not None: color = parent.color #: Controls if styling output is wanted or not. - self.color = color + self.color: t.Optional[bool] = color if show_default is None and parent is not None: show_default = parent.show_default #: Show option default values when formatting help text. - self.show_default = show_default + self.show_default: t.Optional[bool] = show_default - self._close_callbacks = [] + self._close_callbacks: t.List[t.Callable[[], t.Any]] = [] self._depth = 0 - self._parameter_source = {} + self._parameter_source: t.Dict[str, ParameterSource] = {} self._exit_stack = ExitStack() - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: """Gather information that could be useful for a tool generating user-facing documentation. This traverses the entire CLI structure. @@ -424,19 +459,19 @@ def to_info_dict(self): "auto_envvar_prefix": self.auto_envvar_prefix, } - def __enter__(self): + def __enter__(self) -> "Context": self._depth += 1 push_context(self) return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore self._depth -= 1 if self._depth == 0: self.close() pop_context() @contextmanager - def scope(self, cleanup=True): + def scope(self, cleanup: bool = True) -> t.Iterator["Context"]: """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 @@ -474,7 +509,7 @@ def scope(self, cleanup=True): self._depth -= 1 @property - def meta(self): + def meta(self) -> t.Dict[str, t.Any]: """This is a dictionary which is shared with all the contexts that are nested. It exists so that click utilities can store some state here if they need to. It is however the responsibility of @@ -501,7 +536,7 @@ def get_language(): """ return self._meta - def make_formatter(self): + def make_formatter(self) -> HelpFormatter: """Creates the :class:`~click.HelpFormatter` for the help and usage output. @@ -515,7 +550,7 @@ def make_formatter(self): width=self.terminal_width, max_width=self.max_content_width ) - def with_resource(self, context_manager): + def with_resource(self, context_manager: t.ContextManager[V]) -> V: """Register a resource as if it were used in a ``with`` statement. The resource will be cleaned up when the context is popped. @@ -544,7 +579,7 @@ def cli(ctx): """ return self._exit_stack.enter_context(context_manager) - def call_on_close(self, f): + def call_on_close(self, f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: """Register a function to be called when the context tears down. This can be used to close resources opened during the script @@ -556,7 +591,7 @@ def call_on_close(self, f): """ return self._exit_stack.callback(f) - def close(self): + def close(self) -> None: """Invoke all close callbacks registered with :meth:`call_on_close`, and exit all context managers entered with :meth:`with_resource`. @@ -566,7 +601,7 @@ def close(self): self._exit_stack = ExitStack() @property - def command_path(self): + def command_path(self) -> str: """The computed command path. This is used for the ``usage`` information on the help page. It's automatically created by combining the info names of the chain of contexts to the root. @@ -576,27 +611,34 @@ def command_path(self): rv = self.info_name if self.parent is not None: parent_command_path = [self.parent.command_path] - for param in self.parent.command.get_params(self): - parent_command_path.extend(param.get_usage_pieces(self)) + + if isinstance(self.parent.command, Command): + for param in self.parent.command.get_params(self): + parent_command_path.extend(param.get_usage_pieces(self)) + rv = f"{' '.join(parent_command_path)} {rv}" return rv.lstrip() - def find_root(self): + def find_root(self) -> "Context": """Finds the outermost context.""" node = self while node.parent is not None: node = node.parent return node - def find_object(self, object_type): + def find_object(self, object_type: t.Type[V]) -> t.Optional[V]: """Finds the closest object of a given type.""" - node = self + node: t.Optional["Context"] = self + while node is not None: if isinstance(node.obj, object_type): return node.obj + node = node.parent - def ensure_object(self, object_type): + return None + + def ensure_object(self, object_type: t.Type[V]) -> V: """Like :meth:`find_object` but sets the innermost object to a new instance of `object_type` if it does not exist. """ @@ -605,7 +647,19 @@ def ensure_object(self, object_type): self.obj = rv = object_type() return rv - def lookup_default(self, name, call=True): + @typing.overload + def lookup_default( + self, name: str, call: "te.Literal[True]" = True + ) -> t.Optional[t.Any]: + ... + + @typing.overload + def lookup_default( + self, name: str, call: "te.Literal[False]" = ... + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ... + + def lookup_default(self, name: str, call: bool = True) -> t.Optional[t.Any]: """Get the default for a parameter from :attr:`default_map`. :param name: Name of the parameter. @@ -623,7 +677,9 @@ def lookup_default(self, name, call=True): return value - def fail(self, message): + return None + + def fail(self, message: str) -> t.NoReturn: """Aborts the execution of the program with a specific error message. @@ -631,27 +687,27 @@ def fail(self, message): """ raise UsageError(message, self) - def abort(self): + def abort(self) -> t.NoReturn: """Aborts the script.""" raise Abort() - def exit(self, code=0): + def exit(self, code: int = 0) -> t.NoReturn: """Exits the application with a given exit code.""" raise Exit(code) - def get_usage(self): + def get_usage(self) -> str: """Helper method to get formatted usage string for the current context and command. """ return self.command.get_usage(self) - def get_help(self): + def get_help(self) -> str: """Helper method to get formatted help page for the current context and command. """ return self.command.get_help(self) - def _make_sub_context(self, command): + def _make_sub_context(self, command: "Command") -> "Context": """Create a new context of the same type as this context, but for a new command. @@ -659,7 +715,12 @@ def _make_sub_context(self, command): """ return type(self)(command, info_name=command.name, parent=self) - def invoke(*args, **kwargs): # noqa: B902 + def invoke( + __self, # noqa: B902 + __callback: t.Union["Command", t.Callable[..., t.Any]], + *args: t.Any, + **kwargs: t.Any, + ) -> t.Any: """Invokes a command callback in exactly the way it expects. There are two ways to invoke this method: @@ -679,37 +740,35 @@ def invoke(*args, **kwargs): # noqa: B902 All ``kwargs`` are tracked in :attr:`params` so they will be passed if :meth:`forward` is called at multiple levels. """ - self, callback = args[:2] - ctx = self - - # 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. - if isinstance(callback, Command): - other_cmd = callback - callback = other_cmd.callback + if isinstance(__callback, Command): + other_cmd = __callback - if callback is None: + if other_cmd.callback is None: raise TypeError( "The given command does not have a callback that can be invoked." ) + else: + __callback = other_cmd.callback - ctx = self._make_sub_context(other_cmd) + ctx = __self._make_sub_context(other_cmd) for param in other_cmd.params: if param.name not in kwargs and param.expose_value: - kwargs[param.name] = param.get_default(ctx) + kwargs[param.name] = param.get_default(ctx) # type: ignore # Track all kwargs as params, so that forward() will pass # them on in subsequent calls. ctx.params.update(kwargs) + else: + ctx = __self - args = args[2:] - with augment_usage_errors(self): + with augment_usage_errors(__self): with ctx: - return callback(*args, **kwargs) + return __callback(*args, **kwargs) - def forward(*args, **kwargs): # noqa: B902 + def forward( + __self, __cmd: "Command", *args: t.Any, **kwargs: t.Any # noqa: B902 + ) -> t.Any: """Similar to :meth:`invoke` but fills in default keyword arguments from the current context if the other command expects it. This cannot invoke callbacks directly, only other commands. @@ -718,19 +777,17 @@ def forward(*args, **kwargs): # noqa: B902 All ``kwargs`` are tracked in :attr:`params` so they will be passed if ``forward`` is called at multiple levels. """ - self, cmd = args[:2] - # Can only forward to other commands, not direct callbacks. - if not isinstance(cmd, Command): + if not isinstance(__cmd, Command): raise TypeError("Callback is not a command.") - for param in self.params: + for param in __self.params: if param not in kwargs: - kwargs[param] = self.params[param] + kwargs[param] = __self.params[param] - return self.invoke(cmd, **kwargs) + return __self.invoke(__cmd, *args, **kwargs) - def set_parameter_source(self, name, source): + def set_parameter_source(self, name: str, source: ParameterSource) -> None: """Set the source of a parameter. This indicates the location from which the value of the parameter was obtained. @@ -739,7 +796,7 @@ def set_parameter_source(self, name, source): """ self._parameter_source[name] = source - def get_parameter_source(self, name): + def get_parameter_source(self, name: str) -> t.Optional[ParameterSource]: """Get the source of a parameter. This indicates the location from which the value of the parameter was obtained. @@ -783,7 +840,7 @@ class BaseCommand: #: The context class to create with :meth:`make_context`. #: #: .. versionadded:: 8.0 - context_class = Context + context_class: t.Type[Context] = Context #: the default for the :attr:`Context.allow_extra_args` flag. allow_extra_args = False #: the default for the :attr:`Context.allow_interspersed_args` flag. @@ -791,18 +848,24 @@ class BaseCommand: #: the default for the :attr:`Context.ignore_unknown_options` flag. ignore_unknown_options = False - def __init__(self, name, context_settings=None): + def __init__( + self, + name: t.Optional[str], + context_settings: t.Optional[t.Dict[str, t.Any]] = None, + ) -> None: #: the name the command thinks it has. Upon registering a command #: on a :class:`Group` the group will default the command name #: with this information. You should instead use the #: :class:`Context`\'s :attr:`~Context.info_name` attribute. self.name = name + if context_settings is None: context_settings = {} + #: an optional dictionary with defaults passed to the context. - self.context_settings = context_settings + self.context_settings: t.Dict[str, t.Any] = context_settings - def to_info_dict(self, ctx): + def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: """Gather information that could be useful for a tool generating user-facing documentation. This traverses the entire structure below this command. @@ -816,16 +879,22 @@ def to_info_dict(self, ctx): """ return {"name": self.name} - def __repr__(self): + def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.name}>" - def get_usage(self, ctx): + def get_usage(self, ctx: Context) -> str: raise NotImplementedError("Base commands cannot get usage") - def get_help(self, ctx): + def get_help(self, ctx: Context) -> str: raise NotImplementedError("Base commands cannot get help") - def make_context(self, info_name, args, parent=None, **extra): + def make_context( + self, + info_name: t.Optional[str], + args: t.List[str], + parent: t.Optional[Context] = None, + **extra: t.Any, + ) -> Context: """This function when given an info name and arguments will kick off the parsing and create a new :class:`Context`. It does not invoke the actual command callback though. @@ -833,11 +902,11 @@ def make_context(self, info_name, args, parent=None, **extra): To quickly customize the context class used without overriding this method, set the :attr:`context_class` attribute. - :param info_name: the info name for this invokation. Generally this + :param info_name: the info name for this invocation. Generally this is the most descriptive name for the script or command. For the toplevel script it's usually the name of the script, for commands below it it's - the name of the script. + the name of the command. :param args: the arguments to parse as list of strings. :param parent: the parent context if available. :param extra: extra keyword arguments forwarded to the context @@ -850,26 +919,28 @@ def make_context(self, info_name, args, parent=None, **extra): if key not in extra: extra[key] = value - ctx = self.context_class(self, info_name=info_name, parent=parent, **extra) + ctx = self.context_class( + self, info_name=info_name, parent=parent, **extra # type: ignore + ) with ctx.scope(cleanup=False): self.parse_args(ctx, args) return ctx - def parse_args(self, ctx, args): + def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: """Given a context and a list of arguments this creates the parser and parses the arguments, then modifies the context as necessary. This is automatically invoked by :meth:`make_context`. """ raise NotImplementedError("Base commands do not know how to parse arguments.") - def invoke(self, ctx): + def invoke(self, ctx: Context) -> t.Any: """Given a context, this invokes the command. The default implementation is raising a not implemented error. """ raise NotImplementedError("Base commands are not invokable by default") - def shell_complete(self, ctx, incomplete): + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: """Return a list of completions for the incomplete value. Looks at the names of chained multi-commands. @@ -884,7 +955,7 @@ def shell_complete(self, ctx, incomplete): """ from click.shell_completion import CompletionItem - results = [] + results: t.List["CompletionItem"] = [] while ctx.parent is not None: ctx = ctx.parent @@ -898,14 +969,36 @@ def shell_complete(self, ctx, incomplete): return results + @typing.overload + def main( + self, + args: t.Optional[t.Sequence[str]] = None, + prog_name: t.Optional[str] = None, + complete_var: t.Optional[str] = None, + standalone_mode: "te.Literal[True]" = True, + **extra: t.Any, + ) -> t.NoReturn: + ... + + @typing.overload def main( self, - args=None, - prog_name=None, - complete_var=None, - standalone_mode=True, - **extra, - ): + args: t.Optional[t.Sequence[str]] = None, + prog_name: t.Optional[str] = None, + complete_var: t.Optional[str] = None, + standalone_mode: bool = ..., + **extra: t.Any, + ) -> t.Any: + ... + + def main( + self, + args: t.Optional[t.Sequence[str]] = None, + prog_name: t.Optional[str] = None, + complete_var: t.Optional[str] = None, + standalone_mode: bool = True, + **extra: t.Any, + ) -> t.Any: """This is the way to invoke a script with all the bells and whistles as a command line application. This will always terminate the application after a call. If this is not wanted, ``SystemExit`` @@ -984,8 +1077,8 @@ def main( sys.exit(e.exit_code) except OSError as e: if e.errno == errno.EPIPE: - sys.stdout = PacifyFlushWrapper(sys.stdout) - sys.stderr = PacifyFlushWrapper(sys.stderr) + sys.stdout = t.cast(t.TextIO, PacifyFlushWrapper(sys.stdout)) + sys.stderr = t.cast(t.TextIO, PacifyFlushWrapper(sys.stderr)) sys.exit(1) else: raise @@ -1008,7 +1101,12 @@ def main( echo(_("Aborted!"), file=sys.stderr) sys.exit(1) - def _main_shell_completion(self, ctx_args, prog_name, complete_var=None): + def _main_shell_completion( + self, + ctx_args: t.Dict[str, t.Any], + prog_name: str, + complete_var: t.Optional[str] = None, + ) -> None: """Check if the shell is asking for tab completion, process that, then exit early. Called from :meth:`main` before the program is invoked. @@ -1031,7 +1129,7 @@ def _main_shell_completion(self, ctx_args, prog_name, complete_var=None): rv = shell_complete(self, ctx_args, prog_name, complete_var, instruction) _fast_exit(rv) - def __call__(self, *args, **kwargs): + def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any: """Alias for :meth:`main`.""" return self.main(*args, **kwargs) @@ -1073,19 +1171,19 @@ class Command(BaseCommand): 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, - no_args_is_help=False, - hidden=False, - deprecated=False, - ): + name: t.Optional[str], + context_settings: t.Optional[t.Dict[str, t.Any]] = None, + callback: t.Optional[t.Callable[..., t.Any]] = None, + params: t.Optional[t.List["Parameter"]] = None, + help: t.Optional[str] = None, + epilog: t.Optional[str] = None, + short_help: t.Optional[str] = None, + options_metavar: t.Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + ) -> None: super().__init__(name, context_settings) #: the callback to execute when the command fires. This might be #: `None` in which case nothing happens. @@ -1093,11 +1191,13 @@ def __init__( #: the list of parameters for this command in the order they #: should show up in the help page and execute. Eager parameters #: will automatically be handled before non eager ones. - self.params = params or [] + self.params: t.List["Parameter"] = params or [] + # if a form feed (page break) is found in the help text, truncate help # text to the content preceding the first form feed if help and "\f" in help: help = help.split("\f", 1)[0] + self.help = help self.epilog = epilog self.options_metavar = options_metavar @@ -1107,7 +1207,7 @@ def __init__( self.hidden = hidden self.deprecated = deprecated - def to_info_dict(self, ctx): + def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict(ctx) info_dict.update( params=[param.to_info_dict() for param in self.get_params(ctx)], @@ -1119,10 +1219,7 @@ def to_info_dict(self, ctx): ) return info_dict - def __repr__(self): - return f"<{self.__class__.__name__} {self.name}>" - - def get_usage(self, ctx): + def get_usage(self, ctx: Context) -> str: """Formats the usage line into a string and returns it. Calls :meth:`format_usage` internally. @@ -1131,14 +1228,16 @@ def get_usage(self, ctx): self.format_usage(ctx, formatter) return formatter.getvalue().rstrip("\n") - def get_params(self, ctx): + def get_params(self, ctx: Context) -> t.List["Parameter"]: rv = self.params help_option = self.get_help_option(ctx) + if help_option is not None: - rv = rv + [help_option] + rv = [*rv, help_option] + return rv - def format_usage(self, ctx, formatter): + def format_usage(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the usage line into the formatter. This is a low-level method called by :meth:`get_usage`. @@ -1146,30 +1245,33 @@ def format_usage(self, ctx, formatter): pieces = self.collect_usage_pieces(ctx) formatter.write_usage(ctx.command_path, " ".join(pieces)) - def collect_usage_pieces(self, ctx): + def collect_usage_pieces(self, ctx: Context) -> t.List[str]: """Returns all the pieces that go into the usage line and returns it as a list of strings. """ rv = [self.options_metavar] if self.options_metavar else [] + for param in self.get_params(ctx): rv.extend(param.get_usage_pieces(ctx)) + return rv - def get_help_option_names(self, ctx): + def get_help_option_names(self, ctx: Context) -> t.List[str]: """Returns the names for the help option.""" all_names = set(ctx.help_option_names) for param in self.params: all_names.difference_update(param.opts) all_names.difference_update(param.secondary_opts) - return all_names + return list(all_names) - def get_help_option(self, ctx): + def get_help_option(self, ctx: Context) -> t.Optional["Option"]: """Returns the help option object.""" help_options = self.get_help_option_names(ctx) + if not help_options or not self.add_help_option: - return + return None - def show_help(ctx, param, value): + def show_help(ctx: Context, param: "Parameter", value: str) -> None: if value and not ctx.resilient_parsing: echo(ctx.get_help(), color=ctx.color) ctx.exit() @@ -1183,14 +1285,14 @@ def show_help(ctx, param, value): help=_("Show this message and exit."), ) - def make_parser(self, ctx): + def make_parser(self, ctx: Context) -> OptionParser: """Creates the underlying option parser for this command.""" parser = OptionParser(ctx) for param in self.get_params(ctx): param.add_to_parser(parser, ctx) return parser - def get_help(self, ctx): + def get_help(self, ctx: Context) -> str: """Formats the help into a string and returns it. Calls :meth:`format_help` internally. @@ -1199,7 +1301,7 @@ def get_help(self, ctx): self.format_help(ctx, formatter) return formatter.getvalue().rstrip("\n") - def get_short_help_str(self, limit=45): + def get_short_help_str(self, limit: int = 45) -> str: """Gets short help for the command or makes it by shortening the long help string. """ @@ -1213,7 +1315,7 @@ def get_short_help_str(self, limit=45): return text.strip() - def format_help(self, ctx, formatter): + def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the help into the formatter if it exists. This is a low-level method called by :meth:`get_help`. @@ -1230,7 +1332,7 @@ def format_help(self, ctx, formatter): self.format_options(ctx, formatter) self.format_epilog(ctx, formatter) - def format_help_text(self, ctx, formatter): + def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the help text to the formatter if it exists.""" text = self.help or "" @@ -1243,7 +1345,7 @@ def format_help_text(self, ctx, formatter): with formatter.indentation(): formatter.write_text(text) - def format_options(self, ctx, formatter): + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes all the options into the formatter if they exist.""" opts = [] for param in self.get_params(ctx): @@ -1255,14 +1357,14 @@ def format_options(self, ctx, formatter): with formatter.section(_("Options")): formatter.write_dl(opts) - def format_epilog(self, ctx, formatter): + def format_epilog(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the epilog into the formatter if it exists.""" if self.epilog: formatter.write_paragraph() with formatter.indentation(): formatter.write_text(self.epilog) - def parse_args(self, ctx, args): + def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: if not args and self.no_args_is_help and not ctx.resilient_parsing: echo(ctx.get_help(), color=ctx.color) ctx.exit() @@ -1285,25 +1387,20 @@ def parse_args(self, ctx, args): ctx.args = args return args - def invoke(self, ctx): + def invoke(self, ctx: Context) -> t.Any: """Given a context, this invokes the attached callback (if it exists) in the right way. """ if self.deprecated: - echo( - style( - _("DeprecationWarning: The command {name!r} is deprecated.").format( - name=self.name - ), - fg="red", - ), - err=True, - ) + message = _( + "DeprecationWarning: The command {name!r} is deprecated." + ).format(name=self.name) + echo(style(message, fg="red"), err=True) if self.callback is not None: return ctx.invoke(self.callback, **ctx.params) - def shell_complete(self, ctx, incomplete): + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: """Return a list of completions for the incomplete value. Looks at the names of options and chained multi-commands. @@ -1314,7 +1411,7 @@ def shell_complete(self, ctx, incomplete): """ from click.shell_completion import CompletionItem - results = [] + results: t.List["CompletionItem"] = [] if incomplete and not incomplete[0].isalnum(): for param in self.get_params(ctx): @@ -1323,7 +1420,7 @@ def shell_complete(self, ctx, incomplete): or param.hidden or ( not param.multiple - and ctx.get_parameter_source(param.name) + and ctx.get_parameter_source(param.name) # type: ignore is ParameterSource.COMMANDLINE ) ): @@ -1331,7 +1428,7 @@ def shell_complete(self, ctx, incomplete): results.extend( CompletionItem(name, help=param.help) - for name in param.opts + param.secondary_opts + for name in [*param.opts, *param.secondary_opts] if name.startswith(incomplete) ) @@ -1369,24 +1466,28 @@ class MultiCommand(Command): def __init__( self, - name=None, - invoke_without_command=False, - no_args_is_help=None, - subcommand_metavar=None, - chain=False, - result_callback=None, - **attrs, - ): + name: t.Optional[str] = None, + invoke_without_command: bool = False, + no_args_is_help: t.Optional[bool] = None, + subcommand_metavar: t.Optional[str] = None, + chain: bool = False, + result_callback: t.Optional[t.Callable[..., t.Any]] = None, + **attrs: t.Any, + ) -> None: super().__init__(name, **attrs) + if no_args_is_help is None: no_args_is_help = not invoke_without_command + self.no_args_is_help = no_args_is_help self.invoke_without_command = invoke_without_command + if subcommand_metavar is None: if chain: subcommand_metavar = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." else: subcommand_metavar = "COMMAND [ARGS]..." + self.subcommand_metavar = subcommand_metavar self.chain = chain # The result callback that is stored. This can be set or @@ -1401,12 +1502,16 @@ def __init__( " optional arguments." ) - def to_info_dict(self, ctx): + def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict(ctx) commands = {} for name in self.list_commands(ctx): command = self.get_command(ctx, name) + + if command is None: + continue + sub_ctx = ctx._make_sub_context(command) with sub_ctx.scope(cleanup=False): @@ -1415,16 +1520,16 @@ def to_info_dict(self, ctx): info_dict.update(commands=commands, chain=self.chain) return info_dict - def collect_usage_pieces(self, ctx): + def collect_usage_pieces(self, ctx: Context) -> t.List[str]: rv = super().collect_usage_pieces(ctx) rv.append(self.subcommand_metavar) return rv - def format_options(self, ctx, formatter): + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: super().format_options(ctx, formatter) self.format_commands(ctx, formatter) - def result_callback(self, replace=False): + def result_callback(self, replace: bool = False) -> t.Callable[[F], F]: """Adds a result callback to the command. By default if a result callback is already registered this will chain them but this can be disabled with the `replace` parameter. The result @@ -1453,22 +1558,23 @@ def process_result(result, input): .. versionadded:: 3.0 """ - def decorator(f): + def decorator(f: F) -> F: old_callback = self._result_callback if old_callback is None or replace: self._result_callback = f return f - def function(__value, *args, **kwargs): - return f(old_callback(__value, *args, **kwargs), *args, **kwargs) + def function(__value, *args, **kwargs): # type: ignore + inner = old_callback(__value, *args, **kwargs) # type: ignore + return f(inner, *args, **kwargs) - self._result_callback = rv = update_wrapper(function, f) + self._result_callback = rv = update_wrapper(t.cast(F, function), f) return rv return decorator - def resultcallback(self, replace=False): + def resultcallback(self, replace: bool = False) -> t.Callable[[F], F]: import warnings warnings.warn( @@ -1479,7 +1585,7 @@ def resultcallback(self, replace=False): ) return self.result_callback(replace=replace) - def format_commands(self, ctx, formatter): + def format_commands(self, ctx: Context, formatter: HelpFormatter) -> None: """Extra format methods for multi methods that adds all the commands after the options. """ @@ -1507,7 +1613,7 @@ def format_commands(self, ctx, formatter): with formatter.section(_("Commands")): formatter.write_dl(rows) - def parse_args(self, ctx, args): + def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: if not args and self.no_args_is_help and not ctx.resilient_parsing: echo(ctx.get_help(), color=ctx.color) ctx.exit() @@ -1522,8 +1628,8 @@ def parse_args(self, ctx, args): return ctx.args - def invoke(self, ctx): - def _process_result(value): + def invoke(self, ctx: Context) -> t.Any: + def _process_result(value: t.Any) -> t.Any: if self._result_callback is not None: value = ctx.invoke(self._result_callback, value, **ctx.params) return value @@ -1539,7 +1645,7 @@ def _process_result(value): ctx.fail(_("Missing command.")) # Fetch args back out - args = ctx.protected_args + ctx.args + args = [*ctx.protected_args, *ctx.args] ctx.args = [] ctx.protected_args = [] @@ -1551,6 +1657,7 @@ def _process_result(value): # resources until the result processor has worked. with ctx: cmd_name, cmd, args = self.resolve_command(ctx, args) + assert cmd is not None ctx.invoked_subcommand = cmd_name super().invoke(ctx) sub_ctx = cmd.make_context(cmd_name, args, parent=ctx) @@ -1572,6 +1679,7 @@ def _process_result(value): contexts = [] while args: cmd_name, cmd, args = self.resolve_command(ctx, args) + assert cmd is not None sub_ctx = cmd.make_context( cmd_name, args, @@ -1588,7 +1696,9 @@ def _process_result(value): rv.append(sub_ctx.command.invoke(sub_ctx)) return _process_result(rv) - def resolve_command(self, ctx, args): + def resolve_command( + self, ctx: Context, args: t.List[str] + ) -> t.Tuple[t.Optional[str], t.Optional[Command], t.List[str]]: cmd_name = make_str(args[0]) original_cmd_name = cmd_name @@ -1613,19 +1723,19 @@ def resolve_command(self, ctx, args): ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name)) return cmd.name if cmd else None, cmd, args[1:] - def get_command(self, ctx, cmd_name): + def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: """Given a context and a command name, this returns a :class:`Command` object if it exists or returns `None`. """ - raise NotImplementedError() + raise NotImplementedError - def list_commands(self, ctx): + def list_commands(self, ctx: Context) -> t.List[str]: """Returns a list of subcommand names in the order they should appear. """ return [] - def shell_complete(self, ctx, incomplete): + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: """Return a list of completions for the incomplete value. Looks at the names of options, subcommands, and chained multi-commands. @@ -1666,7 +1776,7 @@ class Group(MultiCommand): #: subcommands use a custom command class. #: #: .. versionadded:: 8.0 - command_class: t.Optional[t.Type[BaseCommand]] = None + command_class: t.Optional[t.Type[Command]] = None #: If set, this is used by the group's :meth:`group` decorator #: as the default :class:`Group` class. This is useful to make all @@ -1681,18 +1791,23 @@ class Group(MultiCommand): group_class: t.Optional[t.Union[t.Type["Group"], t.Type[type]]] = None # Literal[type] isn't valid, so use Type[type] - def __init__(self, name=None, commands=None, **attrs): + def __init__( + self, + name: t.Optional[str] = None, + commands: t.Optional[t.Union[t.Dict[str, Command], t.Sequence[Command]]] = None, + **attrs: t.Any, + ) -> None: super().__init__(name, **attrs) if commands is None: commands = {} - elif isinstance(commands, (list, tuple)): - commands = {c.name: c for c in commands} + elif isinstance(commands, abc.Sequence): + commands = {c.name: c for c in commands if c.name is not None} #: The registered subcommands by their exported names. - self.commands = commands + self.commands: t.Dict[str, Command] = commands - def add_command(self, cmd, name=None): + def add_command(self, cmd: Command, name: t.Optional[str] = None) -> None: """Registers another :class:`Command` with this group. If the name is not provided, the name of the command is used. """ @@ -1702,7 +1817,9 @@ def add_command(self, cmd, name=None): _check_multicommand(self, name, cmd, register=True) self.commands[name] = cmd - def command(self, *args, **kwargs): + def command( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], Command]: """A shortcut decorator for declaring and attaching a command to the group. This takes the same arguments as :func:`command` and immediately registers the created command with this group by @@ -1719,14 +1836,16 @@ def command(self, *args, **kwargs): if self.command_class is not None and "cls" not in kwargs: kwargs["cls"] = self.command_class - def decorator(f): + def decorator(f: t.Callable[..., t.Any]) -> Command: cmd = command(*args, **kwargs)(f) self.add_command(cmd) return cmd return decorator - def group(self, *args, **kwargs): + def group( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], "Group"]: """A shortcut decorator for declaring and attaching a group to the group. This takes the same arguments as :func:`group` and immediately registers the created group with this group by @@ -1746,17 +1865,17 @@ def group(self, *args, **kwargs): else: kwargs["cls"] = self.group_class - def decorator(f): + def decorator(f: t.Callable[..., t.Any]) -> "Group": cmd = group(*args, **kwargs)(f) self.add_command(cmd) return cmd return decorator - def get_command(self, ctx, cmd_name): + def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: return self.commands.get(cmd_name) - def list_commands(self, ctx): + def list_commands(self, ctx: Context) -> t.List[str]: return sorted(self.commands) @@ -1767,31 +1886,42 @@ class CommandCollection(MultiCommand): provides all the commands for each of them. """ - def __init__(self, name=None, sources=None, **attrs): + def __init__( + self, + name: t.Optional[str] = None, + sources: t.Optional[t.List[MultiCommand]] = None, + **attrs: t.Any, + ) -> None: super().__init__(name, **attrs) #: The list of registered multi commands. - self.sources = sources or [] + self.sources: t.List[MultiCommand] = sources or [] - def add_source(self, multi_cmd): + def add_source(self, multi_cmd: MultiCommand) -> None: """Adds a new multi command to the chain dispatcher.""" self.sources.append(multi_cmd) - def get_command(self, ctx, cmd_name): + def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: for source in self.sources: rv = source.get_command(ctx, cmd_name) + if rv is not None: if self.chain: _check_multicommand(self, cmd_name, rv) + return rv - def list_commands(self, ctx): - rv = set() + return None + + def list_commands(self, ctx: Context) -> t.List[str]: + rv: t.Set[str] = set() + for source in self.sources: rv.update(source.list_commands(ctx)) + return sorted(rv) -def _check_iter(value): +def _check_iter(value: t.Any) -> t.Iterator[t.Any]: """Check if the value is iterable but not a string. Raises a type error, or return an iterator over the value. """ @@ -1879,20 +2009,28 @@ class Parameter: def __init__( self, - param_decls=None, - type=None, - required=False, - default=None, - callback=None, - nargs=None, - multiple=False, - metavar=None, - expose_value=True, - is_eager=False, - envvar=None, - shell_complete=None, - autocompletion=None, - ): + param_decls: t.Optional[t.Sequence[str]] = None, + type: t.Optional[t.Union["ParamType", t.Any]] = None, + required: bool = False, + default: t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]] = None, + callback: t.Optional[t.Callable[[Context, "Parameter", t.Any], t.Any]] = None, + nargs: t.Optional[int] = None, + multiple: bool = False, + metavar: t.Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: t.Optional[t.Union[str, t.Sequence[str]]] = None, + shell_complete: t.Optional[ + t.Callable[ + [Context, "Parameter", str], t.List[t.Union["CompletionItem", str]] + ] + ] = None, + autocompletion: t.Optional[ + t.Callable[ + [Context, t.List[str], str], t.List[t.Union[t.Tuple[str, str], str]] + ] + ] = None, + ) -> None: self.name, self.opts, self.secondary_opts = self._parse_decls( param_decls or (), expose_value ) @@ -1928,12 +2066,14 @@ def __init__( stacklevel=2, ) - def shell_complete(ctx, param, incomplete): + def shell_complete( + ctx: Context, param: "Parameter", incomplete: str + ) -> t.List[t.Union["CompletionItem", str]]: from click.shell_completion import CompletionItem out = [] - for c in autocompletion(ctx, [], incomplete): + for c in autocompletion(ctx, [], incomplete): # type: ignore if isinstance(c, tuple): c = CompletionItem(c[0], help=c[1]) elif isinstance(c, str): @@ -1987,7 +2127,7 @@ def shell_complete(ctx, param, incomplete): f"'default' {subject} must match nargs={nargs}." ) - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: """Gather information that could be useful for a tool generating user-facing documentation. @@ -2009,27 +2149,50 @@ def to_info_dict(self): "envvar": self.envvar, } - def __repr__(self): + def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.name}>" + def _parse_decls( + self, decls: t.Sequence[str], expose_value: bool + ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: + raise NotImplementedError() + @property - def human_readable_name(self): + def human_readable_name(self) -> str: """Returns the human readable name of this parameter. This is the same as the name for options, but the metavar for arguments. """ - return self.name + return self.name # type: ignore - def make_metavar(self): + def make_metavar(self) -> str: if self.metavar is not None: return self.metavar + metavar = self.type.get_metavar(self) + if metavar is None: metavar = self.type.name.upper() + if self.nargs != 1: metavar += "..." + return metavar - def get_default(self, ctx, call=True): + @typing.overload + def get_default( + self, ctx: Context, call: "te.Literal[True]" = True + ) -> t.Optional[t.Any]: + ... + + @typing.overload + def get_default( + self, ctx: Context, call: bool = ... + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ... + + def get_default( + self, ctx: Context, call: bool = True + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: """Get the default for the parameter. Tries :meth:`Context.lookup_value` first, then the local default. @@ -2043,7 +2206,7 @@ def get_default(self, ctx, call=True): .. versionchanged:: 8.0 Added the ``call`` parameter. """ - value = ctx.lookup_default(self.name, call=False) + value = ctx.lookup_default(self.name, call=False) # type: ignore if value is None: value = self.default @@ -2057,11 +2220,13 @@ def get_default(self, ctx, call=True): return self.type_cast_value(ctx, value) - def add_to_parser(self, parser, ctx): - pass + def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: + raise NotImplementedError() - def consume_value(self, ctx, opts): - value = opts.get(self.name) + def consume_value( + self, ctx: Context, opts: t.Mapping[str, t.Any] + ) -> t.Tuple[t.Any, ParameterSource]: + value = opts.get(self.name) # type: ignore source = ParameterSource.COMMANDLINE if value is None: @@ -2069,7 +2234,7 @@ def consume_value(self, ctx, opts): source = ParameterSource.ENVIRONMENT if value is None: - value = ctx.lookup_default(self.name) + value = ctx.lookup_default(self.name) # type: ignore source = ParameterSource.DEFAULT_MAP if value is None: @@ -2078,14 +2243,14 @@ def consume_value(self, ctx, opts): return value, source - def type_cast_value(self, ctx, value): + def type_cast_value(self, ctx: Context, value: t.Any) -> t.Any: """Convert and validate a value against the option's :attr:`type`, :attr:`multiple`, and :attr:`nargs`. """ if value is None: return () if self.multiple or self.nargs == -1 else None - def check_iter(value): + def check_iter(value: t.Any) -> t.Iterator: try: return _check_iter(value) except TypeError: @@ -2097,15 +2262,17 @@ def check_iter(value): ) from None if self.nargs == 1 or self.type.is_composite: - convert = partial(self.type, param=self, ctx=ctx) + convert: t.Callable[[t.Any], t.Any] = partial( + self.type, param=self, ctx=ctx + ) elif self.nargs == -1: - def convert(value): + def convert(value: t.Any) -> t.Tuple: return tuple(self.type(x, self, ctx) for x in check_iter(value)) else: # nargs > 1 - def convert(value): + def convert(value: t.Any) -> t.Tuple: value = tuple(check_iter(value)) if len(value) != self.nargs: @@ -2126,7 +2293,7 @@ def convert(value): return convert(value) - def value_is_missing(self, value): + def value_is_missing(self, value: t.Any) -> bool: if value is None: return True @@ -2135,7 +2302,7 @@ def value_is_missing(self, value): return False - def process_value(self, ctx, value): + def process_value(self, ctx: Context, value: t.Any) -> t.Any: if value is not None: value = self.type_cast_value(ctx, value) @@ -2147,34 +2314,38 @@ def process_value(self, ctx, value): return value - def resolve_envvar_value(self, ctx): + def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: if self.envvar is None: - return + return None + + if isinstance(self.envvar, str): + rv = os.environ.get(self.envvar) - if isinstance(self.envvar, (tuple, list)): + if rv: + return rv + else: for envvar in self.envvar: rv = os.environ.get(envvar) if rv: return rv - else: - rv = os.environ.get(self.envvar) - if rv: - return rv + return None - def value_from_envvar(self, ctx): - rv = self.resolve_envvar_value(ctx) + def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: + rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx) if rv is not None and self.nargs != 1: rv = self.type.split_envvar_value(rv) return rv - def handle_parse_result(self, ctx, opts, args): + def handle_parse_result( + self, ctx: Context, opts: t.Mapping[str, t.Any], args: t.List[str] + ) -> t.Tuple[t.Any, t.List[str]]: with augment_usage_errors(ctx, param=self): value, source = self.consume_value(ctx, opts) - ctx.set_parameter_source(self.name, source) + ctx.set_parameter_source(self.name, source) # type: ignore try: value = self.process_value(ctx, value) @@ -2185,24 +2356,24 @@ def handle_parse_result(self, ctx, opts, args): value = None if self.expose_value: - ctx.params[self.name] = value + ctx.params[self.name] = value # type: ignore return value, args - def get_help_record(self, ctx): + def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]: pass - def get_usage_pieces(self, ctx): + def get_usage_pieces(self, ctx: Context) -> t.List[str]: return [] - def get_error_hint(self, ctx): + def get_error_hint(self, ctx: Context) -> str: """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(repr(x) for x in hint_list) - def shell_complete(self, ctx, incomplete): + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: """Return a list of completions for the incomplete value. If a ``shell_complete`` function was given during init, it is used. Otherwise, the :attr:`type` @@ -2221,7 +2392,7 @@ def shell_complete(self, ctx, incomplete): results = [CompletionItem(c) for c in results] - return results + return t.cast(t.List["CompletionItem"], results) return self.type.shell_complete(ctx, self, incomplete) @@ -2273,33 +2444,36 @@ class Option(Parameter): def __init__( self, - param_decls=None, - show_default=False, - prompt=False, - confirmation_prompt=False, - prompt_required=True, - hide_input=False, - is_flag=None, - flag_value=None, - multiple=False, - count=False, - allow_from_autoenv=True, - type=None, - help=None, - hidden=False, - show_choices=True, - show_envvar=False, - **attrs, - ): + param_decls: t.Optional[t.Sequence[str]] = None, + show_default: bool = False, + prompt: t.Union[bool, str] = False, + confirmation_prompt: t.Union[bool, str] = False, + prompt_required: bool = True, + hide_input: bool = False, + is_flag: t.Optional[bool] = None, + flag_value: t.Optional[t.Any] = None, + multiple: bool = False, + count: bool = False, + allow_from_autoenv: bool = True, + type: t.Optional[t.Union["ParamType", t.Any]] = None, + help: t.Optional[str] = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = False, + **attrs: t.Any, + ) -> None: default_is_missing = "default" not in attrs super().__init__(param_decls, type=type, multiple=multiple, **attrs) if prompt is True: - prompt_text = self.name.replace("_", " ").capitalize() + if self.name is None: + raise TypeError("'name' is required with 'prompt=True'.") + + prompt_text: t.Optional[str] = self.name.replace("_", " ").capitalize() elif prompt is False: prompt_text = None else: - prompt_text = prompt + prompt_text = t.cast(str, prompt) self.prompt = prompt_text self.confirmation_prompt = confirmation_prompt @@ -2327,16 +2501,16 @@ def __init__( self._flag_needs_value = flag_value is not None if is_flag and default_is_missing: - self.default = False + self.default: t.Union[t.Any, t.Callable[[], t.Any]] = False if flag_value is None: flag_value = not self.default - self.is_flag = is_flag - self.flag_value = flag_value + self.is_flag: bool = is_flag + self.flag_value: t.Any = flag_value if self.is_flag and isinstance(self.flag_value, bool) and type in [None, bool]: - self.type = BOOL + self.type: "ParamType" = BOOL self.is_bool_flag = True else: self.is_bool_flag = False @@ -2377,7 +2551,7 @@ def __init__( if self.is_flag: raise TypeError("'count' is not valid with 'is_flag'.") - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict() info_dict.update( help=self.help, @@ -2389,7 +2563,9 @@ def to_info_dict(self): ) return info_dict - def _parse_decls(self, decls, expose_value): + def _parse_decls( + self, decls: t.Sequence[str], expose_value: bool + ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: opts = [] secondary_opts = [] name = None @@ -2440,13 +2616,7 @@ def _parse_decls(self, decls, expose_value): return name, opts, secondary_opts - def add_to_parser(self, parser, ctx): - kwargs = { - "dest": self.name, - "nargs": self.nargs, - "obj": self, - } - + def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: if self.multiple: action = "append" elif self.count: @@ -2455,50 +2625,79 @@ def add_to_parser(self, parser, ctx): action = "store" if self.is_flag: - kwargs.pop("nargs", None) - action_const = f"{action}_const" + action = f"{action}_const" + if self.is_bool_flag and self.secondary_opts: - parser.add_option(self.opts, action=action_const, const=True, **kwargs) parser.add_option( - self.secondary_opts, action=action_const, const=False, **kwargs + obj=self, opts=self.opts, dest=self.name, action=action, const=True + ) + parser.add_option( + obj=self, + opts=self.secondary_opts, + dest=self.name, + action=action, + const=False, ) else: parser.add_option( - self.opts, action=action_const, const=self.flag_value, **kwargs + obj=self, + opts=self.opts, + dest=self.name, + action=action, + const=self.flag_value, ) else: - kwargs["action"] = action - parser.add_option(self.opts, **kwargs) + parser.add_option( + obj=self, + opts=self.opts, + dest=self.name, + action=action, + nargs=self.nargs, + ) - def get_help_record(self, ctx): + def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]: if self.hidden: - return - any_prefix_is_slash = [] + return None + + any_prefix_is_slash = False + + def _write_opts(opts: t.Sequence[str]) -> str: + nonlocal any_prefix_is_slash - def _write_opts(opts): rv, any_slashes = join_options(opts) + if any_slashes: - any_prefix_is_slash[:] = [True] + any_prefix_is_slash = True + if not self.is_flag and not self.count: rv += f" {self.make_metavar()}" + return rv rv = [_write_opts(self.opts)] + if self.secondary_opts: rv.append(_write_opts(self.secondary_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: + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name is not None + ): envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + if envvar is not None: var_str = ( - ", ".join(str(d) for d in envvar) - if isinstance(envvar, (list, tuple)) - else envvar + envvar + if isinstance(envvar, str) + else ", ".join(str(d) for d in envvar) ) extra.append(_("env var: {var}").format(var=var_str)) @@ -2509,7 +2708,7 @@ def _write_opts(opts): default_value is not None and (self.show_default or ctx.show_default) ): if show_default_is_str: - default_string = f"({self.show_default})" + default_string: t.Union[str, t.Any] = f"({self.show_default})" elif isinstance(default_value, (list, tuple)): default_string = ", ".join(str(d) for d in default_value) elif callable(default_value): @@ -2533,13 +2732,28 @@ def _write_opts(opts): if self.required: extra.append(_("required")) + if extra: extra_str = ";".join(extra) help = f"{help} [{extra_str}]" if help else f"[{extra_str}]" return ("; " if any_prefix_is_slash else " / ").join(rv), help - def get_default(self, ctx, call=True): + @typing.overload + def get_default( + self, ctx: Context, call: "te.Literal[True]" = True + ) -> t.Optional[t.Any]: + ... + + @typing.overload + def get_default( + self, ctx: Context, call: bool = ... + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ... + + def get_default( + self, ctx: Context, call: bool = True + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: # If we're a non boolean flag our default is more complex because # we need to look at all flags in the same group to figure out # if we're the the default one in which case we return the flag @@ -2547,18 +2761,20 @@ def get_default(self, ctx, call=True): if self.is_flag and not self.is_bool_flag: for param in ctx.command.params: if param.name == self.name and param.default: - return param.flag_value + return param.flag_value # type: ignore return None return super().get_default(ctx, call=call) - def prompt_for_value(self, ctx): + def prompt_for_value(self, ctx: Context) -> t.Any: """This is an alternative flow that can be activated in the full value processing if a value does not exist. It will prompt the user until a valid value exists and then returns the processed value as result. """ + assert self.prompt is not None + # Calculate the default before prompting anything to be stable. default = self.get_default(ctx) @@ -2577,28 +2793,31 @@ def prompt_for_value(self, ctx): value_proc=lambda x: self.process_value(ctx, x), ) - def resolve_envvar_value(self, ctx): + def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: rv = super().resolve_envvar_value(ctx) if rv is not None: return rv - if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name is not None + ): envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" rv = os.environ.get(envvar) - if rv: - return rv + return rv - def value_from_envvar(self, ctx): - rv = self.resolve_envvar_value(ctx) + def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: + rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx) if rv is None: return None value_depth = (self.nargs != 1) + bool(self.multiple) - if value_depth > 0 and rv is not None: + if value_depth > 0: rv = self.type.split_envvar_value(rv) if self.multiple and self.nargs != 1: @@ -2606,7 +2825,9 @@ def value_from_envvar(self, ctx): return rv - def consume_value(self, ctx, opts): + def consume_value( + self, ctx: Context, opts: t.Mapping[str, "Parameter"] + ) -> t.Tuple[t.Any, ParameterSource]: value, source = super().consume_value(ctx, opts) # The parser will emit a sentinel value if the option can be @@ -2644,7 +2865,12 @@ class Argument(Parameter): param_type_name = "argument" - def __init__(self, param_decls, required=None, **attrs): + def __init__( + self, + param_decls: t.Sequence[str], + required: t.Optional[bool] = None, + **attrs: t.Any, + ) -> None: if required is None: if attrs.get("default") is not None: required = False @@ -2661,24 +2887,26 @@ def __init__(self, param_decls, required=None, **attrs): raise TypeError("'default' is not supported for nargs=-1.") @property - def human_readable_name(self): + def human_readable_name(self) -> str: if self.metavar is not None: return self.metavar - return self.name.upper() + return self.name.upper() # type: ignore - def make_metavar(self): + def make_metavar(self) -> str: if self.metavar is not None: return self.metavar var = self.type.get_metavar(self) if not var: - var = self.name.upper() + var = self.name.upper() # type: ignore if not self.required: var = f"[{var}]" if self.nargs != 1: var += "..." return var - def _parse_decls(self, decls, expose_value): + def _parse_decls( + self, decls: t.Sequence[str], expose_value: bool + ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: if not decls: if not expose_value: return None, [], [] @@ -2693,11 +2921,11 @@ def _parse_decls(self, decls, expose_value): ) return name, [arg], [] - def get_usage_pieces(self, ctx): + def get_usage_pieces(self, ctx: Context) -> t.List[str]: return [self.make_metavar()] - def get_error_hint(self, ctx): + def get_error_hint(self, ctx: Context) -> str: return repr(self.make_metavar()) - def add_to_parser(self, parser, ctx): + def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) diff --git a/src/click/decorators.py b/src/click/decorators.py index a447084c2..8849b00e6 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -1,40 +1,43 @@ import inspect +import types import typing as t from functools import update_wrapper from gettext import gettext as _ from .core import Argument from .core import Command +from .core import Context from .core import Group from .core import Option +from .core import Parameter from .globals import get_current_context from .utils import echo -if t.TYPE_CHECKING: - F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +FC = t.TypeVar("FC", t.Callable[..., t.Any], Command) -def pass_context(f: "F") -> "F": +def pass_context(f: F) -> F: """Marks a callback as wanting to receive the current context object as first argument. """ - def new_func(*args, **kwargs): + def new_func(*args, **kwargs): # type: ignore return f(get_current_context(), *args, **kwargs) - return update_wrapper(t.cast("F", new_func), f) + return update_wrapper(t.cast(F, new_func), f) -def pass_obj(f: "F") -> "F": +def pass_obj(f: F) -> F: """Similar to :func:`pass_context`, but only pass the object on the context onwards (:attr:`Context.obj`). This is useful if that object represents the state of a nested system. """ - def new_func(*args, **kwargs): + def new_func(*args, **kwargs): # type: ignore return f(get_current_context().obj, *args, **kwargs) - return update_wrapper(t.cast("F", new_func), f) + return update_wrapper(t.cast(F, new_func), f) def make_pass_decorator( @@ -62,8 +65,8 @@ def new_func(ctx, *args, **kwargs): remembered on the context if it's not there yet. """ - def decorator(f: "F") -> "F": - def new_func(*args, **kwargs): + def decorator(f: F) -> F: + def new_func(*args, **kwargs): # type: ignore ctx = get_current_context() if ensure: @@ -80,7 +83,7 @@ def new_func(*args, **kwargs): return ctx.invoke(f, obj, *args, **kwargs) - return update_wrapper(t.cast("F", new_func), f) + return update_wrapper(t.cast(F, new_func), f) return decorator @@ -100,13 +103,13 @@ def pass_meta_key( .. versionadded:: 8.0 """ - def decorator(f: "F") -> "F": - def new_func(*args, **kwargs): + def decorator(f: F) -> F: + def new_func(*args, **kwargs): # type: ignore ctx = get_current_context() obj = ctx.meta[key] return ctx.invoke(f, obj, *args, **kwargs) - return update_wrapper(t.cast("F", new_func), f) + return update_wrapper(t.cast(F, new_func), f) if doc_description is None: doc_description = f"the {key!r} key from :attr:`click.Context.meta`" @@ -118,22 +121,29 @@ def new_func(*args, **kwargs): return decorator -def _make_command(f, name, attrs, cls): +def _make_command( + f: F, + name: t.Optional[str], + attrs: t.MutableMapping[str, t.Any], + cls: t.Type[Command], +) -> Command: if isinstance(f, Command): raise TypeError("Attempted to convert a callback into a command twice.") + try: - params = f.__click_params__ + params = f.__click_params__ # type: ignore params.reverse() - del f.__click_params__ + del f.__click_params__ # type: ignore except AttributeError: params = [] + help = attrs.get("help") + if help is None: help = inspect.getdoc(f) - if isinstance(help, bytes): - help = help.decode("utf-8") else: help = inspect.cleandoc(help) + attrs["help"] = help return cls( name=name or f.__name__.lower().replace("_", "-"), @@ -143,7 +153,11 @@ def _make_command(f, name, attrs, cls): ) -def command(name=None, cls=None, **attrs): +def command( + name: t.Optional[str] = None, + cls: t.Optional[t.Type[Command]] = None, + **attrs: t.Any, +) -> t.Callable[[F], Command]: 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. @@ -166,33 +180,34 @@ def command(name=None, cls=None, **attrs): if cls is None: cls = Command - def decorator(f): - cmd = _make_command(f, name, attrs, cls) + def decorator(f: t.Callable[..., t.Any]) -> Command: + cmd = _make_command(f, name, attrs, cls) # type: ignore cmd.__doc__ = f.__doc__ return cmd return decorator -def group(name=None, **attrs): +def group(name: t.Optional[str] = None, **attrs: t.Any) -> t.Callable[[F], Group]: """Creates a new :class:`Group` with a function as callback. This works otherwise the same as :func:`command` just that the `cls` parameter is set to :class:`Group`. """ attrs.setdefault("cls", Group) - return command(name, **attrs) + return t.cast(Group, command(name, **attrs)) -def _param_memo(f, param): +def _param_memo(f: FC, param: Parameter) -> None: if isinstance(f, Command): f.params.append(param) else: if not hasattr(f, "__click_params__"): - f.__click_params__ = [] - f.__click_params__.append(param) + f.__click_params__ = [] # type: ignore + + f.__click_params__.append(param) # type: ignore -def argument(*param_decls, **attrs): +def argument(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: """Attaches an argument to the command. All positional arguments are passed as parameter declarations to :class:`Argument`; all keyword arguments are forwarded unchanged (except ``cls``). @@ -203,7 +218,7 @@ def argument(*param_decls, **attrs): :class:`Argument`. """ - def decorator(f): + def decorator(f: FC) -> FC: ArgumentClass = attrs.pop("cls", Argument) _param_memo(f, ArgumentClass(param_decls, **attrs)) return f @@ -211,7 +226,7 @@ def decorator(f): return decorator -def option(*param_decls, **attrs): +def option(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: """Attaches an option to the command. All positional arguments are passed as parameter declarations to :class:`Option`; all keyword arguments are forwarded unchanged (except ``cls``). @@ -222,7 +237,7 @@ def option(*param_decls, **attrs): :class:`Option`. """ - def decorator(f): + def decorator(f: FC) -> FC: # Issue 926, copy attrs, so pre-defined options can re-use the same cls= option_attrs = attrs.copy() @@ -235,7 +250,7 @@ def decorator(f): return decorator -def confirmation_option(*param_decls, **kwargs): +def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: """Add a ``--yes`` option which shows a prompt before continuing if not passed. If the prompt is declined, the program will exit. @@ -244,7 +259,7 @@ def confirmation_option(*param_decls, **kwargs): :param kwargs: Extra arguments are passed to :func:`option`. """ - def callback(ctx, param, value): + def callback(ctx: Context, param: Parameter, value: bool) -> None: if not value: ctx.abort() @@ -259,7 +274,7 @@ def callback(ctx, param, value): return option(*param_decls, **kwargs) -def password_option(*param_decls, **kwargs): +def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: """Add a ``--password`` option which prompts for a password, hiding input and asking to enter the value again for confirmation. @@ -277,13 +292,13 @@ def password_option(*param_decls, **kwargs): def version_option( - version=None, - *param_decls, - package_name=None, - prog_name=None, - message=None, - **kwargs, -): + version: t.Optional[str] = None, + *param_decls: str, + package_name: t.Optional[str] = None, + prog_name: t.Optional[str] = None, + message: t.Optional[str] = None, + **kwargs: t.Any, +) -> t.Callable[[FC], FC]: """Add a ``--version`` option which immediately prints the version number and exits the program. @@ -322,6 +337,8 @@ def version_option( if version is None and package_name is None: frame = inspect.currentframe() + assert frame is not None + assert frame.f_back is not None f_globals = frame.f_back.f_globals if frame is not None else None # break reference cycle # https://docs.python.org/3/library/inspect.html#the-interpreter-stack @@ -336,7 +353,7 @@ def version_option( if package_name: package_name = package_name.partition(".")[0] - def callback(ctx, param, value): + def callback(ctx: Context, param: Parameter, value: bool) -> None: if not value or ctx.resilient_parsing: return @@ -347,12 +364,14 @@ def callback(ctx, param, value): prog_name = ctx.find_root().info_name if version is None and package_name is not None: + metadata: t.Optional[types.ModuleType] + try: - from importlib import metadata + from importlib import metadata # type: ignore except ImportError: # Python < 3.8 try: - import importlib_metadata as metadata + import importlib_metadata as metadata # type: ignore except ImportError: metadata = None @@ -362,8 +381,8 @@ def callback(ctx, param, value): ) try: - version = metadata.version(package_name) - except metadata.PackageNotFoundError: + version = metadata.version(package_name) # type: ignore + except metadata.PackageNotFoundError: # type: ignore raise RuntimeError( f"{package_name!r} is not installed. Try passing" " 'package_name' instead." @@ -375,7 +394,8 @@ def callback(ctx, param, value): ) echo( - message % {"prog": prog_name, "package": package_name, "version": version}, + t.cast(str, message) + % {"prog": prog_name, "package": package_name, "version": version}, color=ctx.color, ) ctx.exit() @@ -391,7 +411,7 @@ def callback(ctx, param, value): return option(*param_decls, **kwargs) -def help_option(*param_decls, **kwargs): +def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: """Add a ``--help`` option which immediately prints the help page and exits the program. @@ -404,7 +424,7 @@ def help_option(*param_decls, **kwargs): :param kwargs: Extra arguments are passed to :func:`option`. """ - def callback(ctx, param, value): + def callback(ctx: Context, param: Parameter, value: bool) -> None: if not value or ctx.resilient_parsing: return diff --git a/src/click/exceptions.py b/src/click/exceptions.py index c7c8b108f..9e20b3eb5 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -1,14 +1,22 @@ import os +import typing as t from gettext import gettext as _ from gettext import ngettext from ._compat import get_text_stderr from .utils import echo +if t.TYPE_CHECKING: + from .core import Context + from .core import Parameter -def _join_param_hints(param_hint): - if isinstance(param_hint, (tuple, list)): + +def _join_param_hints( + param_hint: t.Optional[t.Union[t.Sequence[str], str]] +) -> t.Optional[str]: + if param_hint is not None and not isinstance(param_hint, str): return " / ".join(repr(x) for x in param_hint) + return param_hint @@ -18,19 +26,20 @@ class ClickException(Exception): #: The exit code for this exception. exit_code = 1 - def __init__(self, message): + def __init__(self, message: str) -> None: super().__init__(message) self.message = message - def format_message(self): + def format_message(self) -> str: return self.message - def __str__(self): + def __str__(self) -> str: return self.message - def show(self, file=None): + def show(self, file: t.Optional[t.IO] = None) -> None: if file is None: file = get_text_stderr() + echo(_("Error: {message}").format(message=self.format_message()), file=file) @@ -45,17 +54,20 @@ class UsageError(ClickException): exit_code = 2 - def __init__(self, message, ctx=None): + def __init__(self, message: str, ctx: t.Optional["Context"] = None) -> None: super().__init__(message) self.ctx = ctx self.cmd = self.ctx.command if self.ctx else None - def show(self, file=None): + def show(self, file: t.Optional[t.IO] = None) -> 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: + if ( + self.ctx is not None + and self.ctx.command.get_help_option(self.ctx) is not None + ): hint = _("Try '{command} {option}' for help.").format( command=self.ctx.command_path, option=self.ctx.help_option_names[0] ) @@ -88,16 +100,22 @@ class BadParameter(UsageError): each item is quoted and separated. """ - def __init__(self, message, ctx=None, param=None, param_hint=None): + def __init__( + self, + message: str, + ctx: t.Optional["Context"] = None, + param: t.Optional["Parameter"] = None, + param_hint: t.Optional[str] = None, + ) -> None: super().__init__(message, ctx) self.param = param self.param_hint = param_hint - def format_message(self): + def format_message(self) -> str: if self.param_hint is not None: param_hint = self.param_hint elif self.param is not None: - param_hint = self.param.get_error_hint(self.ctx) + param_hint = self.param.get_error_hint(self.ctx) # type: ignore else: return _("Invalid value: {message}").format(message=self.message) @@ -119,16 +137,21 @@ class MissingParameter(BadParameter): """ def __init__( - self, message=None, ctx=None, param=None, param_hint=None, param_type=None - ): - super().__init__(message, ctx, param, param_hint) + self, + message: t.Optional[str] = None, + ctx: t.Optional["Context"] = None, + param: t.Optional["Parameter"] = None, + param_hint: t.Optional[str] = None, + param_type: t.Optional[str] = None, + ) -> None: + super().__init__(message or "", ctx, param, param_hint) self.param_type = param_type - def format_message(self): + def format_message(self) -> str: if self.param_hint is not None: - param_hint = self.param_hint + param_hint: t.Optional[str] = self.param_hint elif self.param is not None: - param_hint = self.param.get_error_hint(self.ctx) + param_hint = self.param.get_error_hint(self.ctx) # type: ignore else: param_hint = None @@ -162,8 +185,8 @@ def format_message(self): return f"{missing}{param_hint}.{msg}" - def __str__(self): - if self.message is None: + def __str__(self) -> str: + if not self.message: param_name = self.param.name if self.param else None return _("Missing parameter: {param_name}").format(param_name=param_name) else: @@ -177,7 +200,13 @@ class NoSuchOption(UsageError): .. versionadded:: 4.0 """ - def __init__(self, option_name, message=None, possibilities=None, ctx=None): + def __init__( + self, + option_name: str, + message: t.Optional[str] = None, + possibilities: t.Optional[t.Sequence[str]] = None, + ctx: t.Optional["Context"] = None, + ) -> None: if message is None: message = _("No such option: {name}").format(name=option_name) @@ -185,7 +214,7 @@ def __init__(self, option_name, message=None, possibilities=None, ctx=None): self.option_name = option_name self.possibilities = possibilities - def format_message(self): + def format_message(self) -> str: if not self.possibilities: return self.message @@ -208,7 +237,9 @@ class BadOptionUsage(UsageError): :param option_name: the name of the option being used incorrectly. """ - def __init__(self, option_name, message, ctx=None): + def __init__( + self, option_name: str, message: str, ctx: t.Optional["Context"] = None + ) -> None: super().__init__(message, ctx) self.option_name = option_name @@ -225,7 +256,7 @@ class BadArgumentUsage(UsageError): class FileError(ClickException): """Raised if a file cannot be opened.""" - def __init__(self, filename, hint=None): + def __init__(self, filename: str, hint: t.Optional[str] = None) -> None: if hint is None: hint = _("unknown error") @@ -233,7 +264,7 @@ def __init__(self, filename, hint=None): self.ui_filename = os.fsdecode(filename) self.filename = filename - def format_message(self): + def format_message(self) -> str: return _("Could not open file {filename!r}: {message}").format( filename=self.ui_filename, message=self.message ) @@ -252,5 +283,5 @@ class Exit(RuntimeError): __slots__ = ("exit_code",) - def __init__(self, code=0): + def __init__(self, code: int = 0) -> None: self.exit_code = code diff --git a/src/click/formatting.py b/src/click/formatting.py index 72e25c976..1e59fc220 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -9,23 +9,30 @@ FORCED_WIDTH: t.Optional[int] = None -def measure_table(rows): - widths = {} +def measure_table(rows: t.Iterable[t.Tuple[str, str]]) -> t.Tuple[int, ...]: + widths: t.Dict[int, int] = {} + for row in rows: for idx, col in enumerate(row): widths[idx] = max(widths.get(idx, 0), term_len(col)) + return tuple(y for x, y in sorted(widths.items())) -def iter_rows(rows, col_count): +def iter_rows( + rows: t.Iterable[t.Tuple[str, str]], col_count: int +) -> t.Iterator[t.Tuple[str, ...]]: for row in rows: - row = tuple(row) yield row + ("",) * (col_count - len(row)) def wrap_text( - text, width=78, initial_indent="", subsequent_indent="", preserve_paragraphs=False -): + text: str, + width: int = 78, + initial_indent: str = "", + subsequent_indent: str = "", + preserve_paragraphs: bool = False, +) -> str: """A helper function that intelligently wraps text. By default, it assumes that it operates on a single paragraph of text but if the `preserve_paragraphs` parameter is provided it will intelligently @@ -56,11 +63,11 @@ def wrap_text( if not preserve_paragraphs: return wrapper.fill(text) - p = [] - buf = [] + p: t.List[t.Tuple[int, bool, str]] = [] + buf: t.List[str] = [] indent = None - def _flush_par(): + def _flush_par() -> None: if not buf: return if buf[0].strip() == "\b": @@ -104,7 +111,12 @@ class HelpFormatter: width clamped to a maximum of 78. """ - def __init__(self, indent_increment=2, width=None, max_width=None): + def __init__( + self, + indent_increment: int = 2, + width: t.Optional[int] = None, + max_width: t.Optional[int] = None, + ) -> None: import shutil self.indent_increment = indent_increment @@ -116,21 +128,23 @@ def __init__(self, indent_increment=2, width=None, max_width=None): width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50) self.width = width self.current_indent = 0 - self.buffer = [] + self.buffer: t.List[str] = [] - def write(self, string): + def write(self, string: str) -> None: """Writes a unicode string into the internal buffer.""" self.buffer.append(string) - def indent(self): + def indent(self) -> None: """Increases the indentation.""" self.current_indent += self.indent_increment - def dedent(self): + def dedent(self) -> None: """Decreases the indentation.""" self.current_indent -= self.indent_increment - def write_usage(self, prog, args="", prefix=None): + def write_usage( + self, prog: str, args: str = "", prefix: t.Optional[str] = None + ) -> None: """Writes a usage line into the buffer. :param prog: the program name. @@ -168,16 +182,16 @@ def write_usage(self, prog, args="", prefix=None): self.write("\n") - def write_heading(self, heading): + def write_heading(self, heading: str) -> None: """Writes a heading into the buffer.""" self.write(f"{'':>{self.current_indent}}{heading}:\n") - def write_paragraph(self): + def write_paragraph(self) -> None: """Writes a paragraph into the buffer.""" if self.buffer: self.write("\n") - def write_text(self, text): + def write_text(self, text: str) -> None: """Writes re-indented text into the buffer. This rewraps and preserves paragraphs. """ @@ -194,7 +208,12 @@ def write_text(self, text): ) self.write("\n") - def write_dl(self, rows, col_max=30, col_spacing=2): + def write_dl( + self, + rows: t.Sequence[t.Tuple[str, str]], + col_max: int = 30, + col_spacing: int = 2, + ) -> None: """Writes a definition list into the buffer. This is how options and commands are usually formatted. @@ -234,7 +253,7 @@ def write_dl(self, rows, col_max=30, col_spacing=2): self.write("\n") @contextmanager - def section(self, name): + def section(self, name: str) -> t.Iterator[None]: """Helpful context manager that writes a paragraph, a heading, and the indents. @@ -249,7 +268,7 @@ def section(self, name): self.dedent() @contextmanager - def indentation(self): + def indentation(self) -> t.Iterator[None]: """A context manager that increases the indentation.""" self.indent() try: @@ -257,12 +276,12 @@ def indentation(self): finally: self.dedent() - def getvalue(self): + def getvalue(self) -> str: """Returns the buffer contents.""" return "".join(self.buffer) -def join_options(options): +def join_options(options: t.Sequence[str]) -> t.Tuple[str, bool]: """Given a list of option strings this joins them in the most appropriate way and returns them in the form ``(formatted_string, any_prefix_is_slash)`` where the second item in the tuple is a flag that @@ -270,13 +289,14 @@ def join_options(options): """ rv = [] any_prefix_is_slash = False + for opt in options: prefix = split_opt(opt)[0] + if prefix == "/": any_prefix_is_slash = True + rv.append((len(prefix), opt)) rv.sort(key=lambda x: x[0]) - - rv = ", ".join(x[1] for x in rv) - return rv, any_prefix_is_slash + return ", ".join(x[1] for x in rv), any_prefix_is_slash diff --git a/src/click/globals.py b/src/click/globals.py index 2bdd178ba..cfcade148 100644 --- a/src/click/globals.py +++ b/src/click/globals.py @@ -1,9 +1,25 @@ +import typing +import typing as t from threading import local +if t.TYPE_CHECKING: + import typing_extensions as te + from .core import Context + _local = local() -def get_current_context(silent=False): +@typing.overload +def get_current_context(silent: "te.Literal[False]" = False) -> "Context": + ... + + +@typing.overload +def get_current_context(silent: bool = ...) -> t.Optional["Context"]: + ... + + +def get_current_context(silent: bool = False) -> t.Optional["Context"]: """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 @@ -19,29 +35,35 @@ def get_current_context(silent=False): :exc:`RuntimeError`. """ try: - return _local.stack[-1] + return t.cast("Context", _local.stack[-1]) except (AttributeError, IndexError): if not silent: raise RuntimeError("There is no active click context.") + return None -def push_context(ctx): + +def push_context(ctx: "Context") -> None: """Pushes a new context to the current stack.""" _local.__dict__.setdefault("stack", []).append(ctx) -def pop_context(): +def pop_context() -> None: """Removes the top level from the stack.""" _local.stack.pop() -def resolve_color_default(color=None): +def resolve_color_default(color: t.Optional[bool] = None) -> t.Optional[bool]: """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 + + return None diff --git a/src/click/parser.py b/src/click/parser.py index 4eede0c1a..ba1f4d3b0 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -21,6 +21,7 @@ # maintained by the Python Software Foundation. # Copyright 2001-2006 Gregory P. Ward # Copyright 2002-2006 Python Software Foundation +import typing as t from collections import deque from gettext import gettext as _ from gettext import ngettext @@ -30,13 +31,23 @@ from .exceptions import NoSuchOption from .exceptions import UsageError +if t.TYPE_CHECKING: + from .core import Argument as CoreArgument + from .core import Context + from .core import Option as CoreOption + from .core import Parameter as CoreParameter + +V = t.TypeVar("V") + # Sentinel value that indicates an option was passed as a flag without a # value but is not a flag option. Option.consume_value uses this to # prompt or use the flag_value. _flag_needs_value = object() -def _unpack_args(args, nargs_spec): +def _unpack_args( + args: t.Sequence[str], nargs_spec: t.Sequence[int] +) -> t.Tuple[t.Sequence[t.Union[str, t.Sequence[t.Optional[str]], None]], t.List[str]]: """Given an iterable of arguments and an iterable of nargs specifications, it returns a tuple with all the unpacked arguments at the first index and all remaining arguments as the second. @@ -48,10 +59,10 @@ def _unpack_args(args, nargs_spec): """ args = deque(args) nargs_spec = deque(nargs_spec) - rv = [] - spos = None + rv: t.List[t.Union[str, t.Tuple[t.Optional[str], ...], None]] = [] + spos: t.Optional[int] = None - def _fetch(c): + def _fetch(c: t.Deque[V]) -> t.Optional[V]: try: if spos is None: return c.popleft() @@ -62,18 +73,25 @@ def _fetch(c): while nargs_spec: nargs = _fetch(nargs_spec) + + if nargs is None: + continue + if nargs == 1: rv.append(_fetch(args)) elif nargs > 1: x = [_fetch(args) for _ in range(nargs)] + # If we're reversed, we're pulling in the arguments in reverse, # so we need to turn them around. if spos is not None: x.reverse() + rv.append(tuple(x)) elif nargs < 0: if spos is not None: raise TypeError("Cannot have two nargs < 0") + spos = len(rv) rv.append(None) @@ -87,7 +105,7 @@ def _fetch(c): return tuple(rv), list(args) -def split_opt(opt): +def split_opt(opt: str) -> t.Tuple[str, str]: first = opt[:1] if first.isalnum(): return "", opt @@ -96,14 +114,14 @@ def split_opt(opt): return first, opt[1:] -def normalize_opt(opt, ctx): +def normalize_opt(opt: str, ctx: t.Optional["Context"]) -> str: if ctx is None or ctx.token_normalize_func is None: return opt prefix, opt = split_opt(opt) return f"{prefix}{ctx.token_normalize_func(opt)}" -def split_arg_string(string): +def split_arg_string(string: str) -> t.List[str]: """Split an argument string as with :func:`shlex.split`, but don't fail if the string is incomplete. Ignores a missing closing quote or incomplete escape sequence and uses the partial token as-is. @@ -138,7 +156,15 @@ def split_arg_string(string): class Option: - def __init__(self, opts, dest, action=None, nargs=1, const=None, obj=None): + def __init__( + self, + obj: "CoreOption", + opts: t.Sequence[str], + dest: t.Optional[str], + action: t.Optional[str] = None, + nargs: int = 1, + const: t.Optional[t.Any] = None, + ): self._short_opts = [] self._long_opts = [] self.prefixes = set() @@ -164,33 +190,38 @@ def __init__(self, opts, dest, action=None, nargs=1, const=None, obj=None): self.obj = obj @property - def takes_value(self): + def takes_value(self) -> bool: return self.action in ("store", "append") - def process(self, value, state): + def process(self, value: str, state: "ParsingState") -> None: if self.action == "store": - state.opts[self.dest] = value + state.opts[self.dest] = value # type: ignore elif self.action == "store_const": - state.opts[self.dest] = self.const + state.opts[self.dest] = self.const # type: ignore elif self.action == "append": - state.opts.setdefault(self.dest, []).append(value) + state.opts.setdefault(self.dest, []).append(value) # type: ignore elif self.action == "append_const": - state.opts.setdefault(self.dest, []).append(self.const) + state.opts.setdefault(self.dest, []).append(self.const) # type: ignore elif self.action == "count": - state.opts[self.dest] = state.opts.get(self.dest, 0) + 1 + state.opts[self.dest] = state.opts.get(self.dest, 0) + 1 # type: ignore else: raise ValueError(f"unknown action '{self.action}'") state.order.append(self.obj) class Argument: - def __init__(self, dest, nargs=1, obj=None): + def __init__(self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1): self.dest = dest self.nargs = nargs self.obj = obj - def process(self, value, state): + def process( + self, + value: t.Union[t.Optional[str], t.Sequence[t.Optional[str]]], + state: "ParsingState", + ) -> None: if self.nargs > 1: + assert value is not None holes = sum(1 for x in value if x is None) if holes == len(value): value = None @@ -204,16 +235,16 @@ def process(self, value, state): if self.nargs == -1 and self.obj.envvar is not None: value = None - state.opts[self.dest] = value + state.opts[self.dest] = value # type: ignore state.order.append(self.obj) class ParsingState: - def __init__(self, rargs): - self.opts = {} - self.largs = [] + def __init__(self, rargs: t.List[str]) -> None: + self.opts: t.Dict[str, t.Any] = {} + self.largs: t.List[str] = [] self.rargs = rargs - self.order = [] + self.order: t.List["CoreParameter"] = [] class OptionParser: @@ -230,7 +261,7 @@ class OptionParser: should go with. """ - def __init__(self, ctx=None): + def __init__(self, ctx: t.Optional["Context"] = None) -> None: #: The :class:`~click.Context` for this parser. This might be #: `None` for some advanced use cases. self.ctx = ctx @@ -244,15 +275,25 @@ def __init__(self, ctx=None): #: second mode where it will ignore it and continue processing #: after shifting all the unknown options into the resulting args. self.ignore_unknown_options = False + if ctx is not None: self.allow_interspersed_args = ctx.allow_interspersed_args self.ignore_unknown_options = ctx.ignore_unknown_options - self._short_opt = {} - self._long_opt = {} - self._opt_prefixes = {"-", "--"} - self._args = [] - def add_option(self, opts, dest, action=None, nargs=1, const=None, obj=None): + self._short_opt: t.Dict[str, Option] = {} + self._long_opt: t.Dict[str, Option] = {} + self._opt_prefixes = {"-", "--"} + self._args: t.List[Argument] = [] + + def add_option( + self, + obj: "CoreOption", + opts: t.Sequence[str], + dest: t.Optional[str], + action: t.Optional[str] = None, + nargs: int = 1, + const: t.Optional[t.Any] = None, + ) -> None: """Adds a new option named `dest` to the parser. The destination is not inferred (unlike with optparse) and needs to be explicitly provided. Action can be any of ``store``, ``store_const``, @@ -261,27 +302,27 @@ def add_option(self, opts, dest, action=None, nargs=1, const=None, obj=None): The `obj` can be used to identify the option in the order list that is returned from the parser. """ - if obj is None: - obj = dest opts = [normalize_opt(opt, self.ctx) for opt in opts] - option = Option(opts, dest, action=action, nargs=nargs, const=const, obj=obj) + option = Option(obj, opts, dest, action=action, nargs=nargs, const=const) self._opt_prefixes.update(option.prefixes) for opt in option._short_opts: self._short_opt[opt] = option for opt in option._long_opts: self._long_opt[opt] = option - def add_argument(self, dest, nargs=1, obj=None): + def add_argument( + self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1 + ) -> None: """Adds a positional argument named `dest` to the parser. The `obj` can be used to identify the option in the order list that is returned from the parser. """ - if obj is None: - obj = dest - self._args.append(Argument(dest=dest, nargs=nargs, obj=obj)) + self._args.append(Argument(obj, dest=dest, nargs=nargs)) - def parse_args(self, args): + def parse_args( + self, args: t.List[str] + ) -> t.Tuple[t.Dict[str, t.Any], t.List[str], t.List["CoreParameter"]]: """Parses positional arguments and returns ``(values, args, order)`` for the parsed options and arguments as well as the leftover arguments if there are any. The order is a list of objects as they @@ -297,7 +338,7 @@ def parse_args(self, args): raise return state.opts, state.largs, state.order - def _process_args_for_args(self, state): + def _process_args_for_args(self, state: ParsingState) -> None: pargs, args = _unpack_args( state.largs + state.rargs, [x.nargs for x in self._args] ) @@ -308,7 +349,7 @@ def _process_args_for_args(self, state): state.largs = args state.rargs = [] - def _process_args_for_options(self, state): + def _process_args_for_options(self, state: ParsingState) -> None: while state.rargs: arg = state.rargs.pop(0) arglen = len(arg) @@ -344,7 +385,9 @@ def _process_args_for_options(self, state): # *empty* -- still a subset of [arg0, ..., arg(i-1)], but # not a very interesting subset! - def _match_long_opt(self, opt, explicit_value, state): + def _match_long_opt( + self, opt: str, explicit_value: t.Optional[str], state: ParsingState + ) -> None: if opt not in self._long_opt: from difflib import get_close_matches @@ -372,7 +415,7 @@ def _match_long_opt(self, opt, explicit_value, state): option.process(value, state) - def _match_short_opt(self, arg, state): + def _match_short_opt(self, arg: str, state: ParsingState) -> None: stop = False i = 1 prefix = arg[0] @@ -412,7 +455,9 @@ def _match_short_opt(self, arg, state): if self.ignore_unknown_options and unknown_options: state.largs.append(f"{prefix}{''.join(unknown_options)}") - def _get_value_from_state(self, option_name, option, state): + def _get_value_from_state( + self, option_name: str, option: Option, state: ParsingState + ) -> t.Any: nargs = option.nargs if len(state.rargs) < nargs: @@ -448,7 +493,7 @@ def _get_value_from_state(self, option_name, option, state): return value - def _process_opts(self, arg, state): + def _process_opts(self, arg: str, state: ParsingState) -> None: explicit_value = None # Long option handling happens in two parts. The first part is # supporting explicitly attached values. In any case, we will try @@ -472,7 +517,10 @@ def _process_opts(self, arg, state): # short option code and will instead raise the no option # error. if arg[:2] not in self._opt_prefixes: - return self._match_short_opt(arg, state) + self._match_short_opt(arg, state) + return + if not self.ignore_unknown_options: raise + state.largs.append(arg) diff --git a/src/click/py.typed b/src/click/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index ae498ac24..706fb6939 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -4,14 +4,23 @@ from gettext import gettext as _ from .core import Argument +from .core import BaseCommand +from .core import Context from .core import MultiCommand from .core import Option +from .core import Parameter from .core import ParameterSource from .parser import split_arg_string from .utils import echo -def shell_complete(cli, ctx_args, prog_name, complete_var, instruction): +def shell_complete( + cli: BaseCommand, + ctx_args: t.Dict[str, t.Any], + prog_name: str, + complete_var: str, + instruction: str, +) -> int: """Perform shell completion for the given CLI program. :param cli: Command being called. @@ -64,13 +73,19 @@ class CompletionItem: __slots__ = ("value", "type", "help", "_info") - def __init__(self, value, type="plain", help=None, **kwargs): + def __init__( + self, + value: t.Any, + type: str = "plain", + help: t.Optional[str] = None, + **kwargs: t.Any, + ) -> None: self.value = value self.type = type self.help = help self._info = kwargs - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return self._info.get(name) @@ -185,31 +200,38 @@ class ShellComplete: .. versionadded:: 8.0 """ - name: t.ClassVar[t.Optional[str]] = None + name: t.ClassVar[str] """Name to register the shell as with :func:`add_completion_class`. This is used in completion instructions (``{name}_source`` and ``{name}_complete``). """ - source_template: t.ClassVar[t.Optional[str]] = None + + source_template: t.ClassVar[str] """Completion script template formatted by :meth:`source`. This must be provided by subclasses. """ - def __init__(self, cli, ctx_args, prog_name, complete_var): + def __init__( + self, + cli: BaseCommand, + ctx_args: t.Dict[str, t.Any], + prog_name: str, + complete_var: str, + ) -> None: self.cli = cli self.ctx_args = ctx_args self.prog_name = prog_name self.complete_var = complete_var @property - def func_name(self): + def func_name(self) -> str: """The name of the shell function defined by the completion script. """ safe_name = re.sub(r"\W*", "", self.prog_name.replace("-", "_"), re.ASCII) return f"_{safe_name}_completion" - def source_vars(self): + def source_vars(self) -> t.Dict[str, t.Any]: """Vars for formatting :attr:`source_template`. By default this provides ``complete_func``, ``complete_var``, @@ -221,7 +243,7 @@ def source_vars(self): "prog_name": self.prog_name, } - def source(self): + def source(self) -> str: """Produce the shell script that defines the completion function. By default this ``%``-style formats :attr:`source_template` with the dict returned by @@ -229,14 +251,16 @@ def source(self): """ return self.source_template % self.source_vars() - def get_completion_args(self): + def get_completion_args(self) -> t.Tuple[t.List[str], str]: """Use the env vars defined by the shell script to return a tuple of ``args, incomplete``. This must be implemented by subclasses. """ raise NotImplementedError - def get_completions(self, args, incomplete): + def get_completions( + self, args: t.List[str], incomplete: str + ) -> t.List[CompletionItem]: """Determine the context and last complete command or parameter from the complete args. Call that object's ``shell_complete`` method to get the completions for the incomplete value. @@ -245,14 +269,10 @@ def get_completions(self, args, incomplete): :param incomplete: Value being completed. May be empty. """ ctx = _resolve_context(self.cli, self.ctx_args, self.prog_name, args) - - if ctx is None: - return [] - obj, incomplete = _resolve_incomplete(ctx, args, incomplete) return obj.shell_complete(ctx, incomplete) - def format_completion(self, item): + def format_completion(self, item: CompletionItem) -> str: """Format a completion item into the form recognized by the shell script. This must be implemented by subclasses. @@ -260,7 +280,7 @@ def format_completion(self, item): """ raise NotImplementedError - def complete(self): + def complete(self) -> str: """Produce the completion data to send back to the shell. By default this calls :meth:`get_completion_args`, gets the @@ -279,7 +299,7 @@ class BashComplete(ShellComplete): name = "bash" source_template = _SOURCE_BASH - def _check_version(self): + def _check_version(self) -> None: import subprocess output = subprocess.run(["bash", "--version"], stdout=subprocess.PIPE) @@ -300,11 +320,11 @@ def _check_version(self): _("Couldn't detect Bash version, shell completion is not supported.") ) - def source(self): + def source(self) -> str: self._check_version() return super().source() - def get_completion_args(self): + def get_completion_args(self) -> t.Tuple[t.List[str], str]: cwords = split_arg_string(os.environ["COMP_WORDS"]) cword = int(os.environ["COMP_CWORD"]) args = cwords[1:cword] @@ -326,7 +346,7 @@ class ZshComplete(ShellComplete): name = "zsh" source_template = _SOURCE_ZSH - def get_completion_args(self): + def get_completion_args(self) -> t.Tuple[t.List[str], str]: cwords = split_arg_string(os.environ["COMP_WORDS"]) cword = int(os.environ["COMP_CWORD"]) args = cwords[1:cword] @@ -348,7 +368,7 @@ class FishComplete(ShellComplete): name = "fish" source_template = _SOURCE_FISH - def get_completion_args(self): + def get_completion_args(self) -> t.Tuple[t.List[str], str]: cwords = split_arg_string(os.environ["COMP_WORDS"]) incomplete = os.environ["COMP_CWORD"] args = cwords[1:] @@ -367,14 +387,16 @@ def format_completion(self, item: CompletionItem) -> str: return f"{item.type},{item.value}" -_available_shells = { +_available_shells: t.Dict[str, t.Type[ShellComplete]] = { "bash": BashComplete, "fish": FishComplete, "zsh": ZshComplete, } -def add_completion_class(cls, name=None): +def add_completion_class( + cls: t.Type[ShellComplete], name: t.Optional[str] = None +) -> None: """Register a :class:`ShellComplete` subclass under the given name. The name will be provided by the completion instruction environment variable during completion. @@ -390,7 +412,7 @@ def add_completion_class(cls, name=None): _available_shells[name] = cls -def get_completion_class(shell): +def get_completion_class(shell: str) -> t.Optional[t.Type[ShellComplete]]: """Look up a registered :class:`ShellComplete` subclass by the name provided by the completion instruction environment variable. If the name isn't registered, returns ``None``. @@ -400,7 +422,7 @@ def get_completion_class(shell): return _available_shells.get(shell) -def _is_incomplete_argument(ctx, param): +def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool: """Determine if the given parameter is an argument that can still accept values. @@ -411,6 +433,7 @@ def _is_incomplete_argument(ctx, param): if not isinstance(param, Argument): return False + assert param.name is not None value = ctx.params[param.name] return ( param.nargs == -1 @@ -423,12 +446,12 @@ def _is_incomplete_argument(ctx, param): ) -def _start_of_option(value): +def _start_of_option(value: str) -> bool: """Check if the value looks like the start of an option.""" - return value and not value[0].isalnum() + return not value[0].isalnum() if value else False -def _is_incomplete_option(args, param): +def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool: """Determine if the given parameter is an option that needs a value. :param args: List of complete args before the incomplete value. @@ -452,7 +475,9 @@ def _is_incomplete_option(args, param): return last_option is not None and last_option in param.opts -def _resolve_context(cli, ctx_args, prog_name, args): +def _resolve_context( + cli: BaseCommand, ctx_args: t.Dict[str, t.Any], prog_name: str, args: t.List[str] +) -> Context: """Produce the context hierarchy starting with the command and traversing the complete arguments. This only follows the commands, it doesn't trigger input prompts or callbacks. @@ -466,9 +491,11 @@ def _resolve_context(cli, ctx_args, prog_name, args): args = ctx.protected_args + ctx.args while args: - if isinstance(ctx.command, MultiCommand): - if not ctx.command.chain: - name, cmd, args = ctx.command.resolve_command(ctx, args) + command = ctx.command + + if isinstance(command, MultiCommand): + if not command.chain: + name, cmd, args = command.resolve_command(ctx, args) if cmd is None: return ctx @@ -477,7 +504,7 @@ def _resolve_context(cli, ctx_args, prog_name, args): args = ctx.protected_args + ctx.args else: while args: - name, cmd, args = ctx.command.resolve_command(ctx, args) + name, cmd, args = command.resolve_command(ctx, args) if cmd is None: return ctx @@ -493,14 +520,16 @@ def _resolve_context(cli, ctx_args, prog_name, args): args = sub_ctx.args ctx = sub_ctx - args = sub_ctx.protected_args + sub_ctx.args + args = [*sub_ctx.protected_args, *sub_ctx.args] else: break return ctx -def _resolve_incomplete(ctx, args, incomplete): +def _resolve_incomplete( + ctx: Context, args: t.List[str], incomplete: str +) -> t.Tuple[t.Union[BaseCommand, Parameter], str]: """Find the Click object that will handle the completion of the incomplete value. Return the object and the incomplete value. diff --git a/src/click/termui.py b/src/click/termui.py index 9850cf57a..1c1f22926 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -3,10 +3,10 @@ import itertools import os import sys +import typing import typing as t from gettext import gettext as _ -from ._compat import is_bytes from ._compat import isatty from ._compat import strip_ansi from ._compat import WIN @@ -15,12 +15,18 @@ from .globals import resolve_color_default from .types import Choice from .types import convert_type +from .types import ParamType from .utils import echo from .utils import LazyFile +if t.TYPE_CHECKING: + from ._termui_impl import ProgressBar + +V = t.TypeVar("V") + # The prompt functions to use. The doc tools currently override these # functions to customize how they work. -visible_prompt_func = input +visible_prompt_func: t.Callable[[str], str] = input _ansi_colors = { "black": 30, @@ -44,15 +50,20 @@ _ansi_reset_all = "\033[0m" -def hidden_prompt_func(prompt): +def hidden_prompt_func(prompt: str) -> str: import getpass return getpass.getpass(prompt) def _build_prompt( - text, suffix, show_default=False, default=None, show_choices=True, type=None -): + text: str, + suffix: str, + show_default: bool = False, + default: t.Optional[t.Any] = None, + show_choices: bool = True, + type: t.Optional[ParamType] = None, +) -> str: prompt = text if type is not None and show_choices and isinstance(type, Choice): prompt += f" ({', '.join(map(str, type.choices))})" @@ -61,25 +72,25 @@ def _build_prompt( return f"{prompt}{suffix}" -def _format_default(default): +def _format_default(default: t.Any) -> t.Any: if isinstance(default, (io.IOBase, LazyFile)) and hasattr(default, "name"): - return default.name + return default.name # type: ignore return default 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, -): + text: str, + default: t.Optional[t.Any] = None, + hide_input: bool = False, + confirmation_prompt: t.Union[bool, str] = False, + type: t.Optional[ParamType] = None, + value_proc: t.Optional[t.Callable[[str], t.Any]] = None, + prompt_suffix: str = ": ", + show_default: bool = True, + err: bool = False, + show_choices: bool = True, +) -> t.Any: """Prompts a user for input. This is a convenience function that can be used to prompt a user for input later. @@ -120,9 +131,8 @@ def prompt( Added the `err` parameter. """ - result = None - def prompt_func(text): + def prompt_func(text: str) -> str: f = hidden_prompt_func if hide_input else visible_prompt_func try: # Write the prompt separately so that we get nice @@ -150,10 +160,11 @@ def prompt_func(text): if confirmation_prompt is True: confirmation_prompt = _("Repeat for confirmation") + confirmation_prompt = t.cast(str, confirmation_prompt) confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) - while 1: - while 1: + while True: + while True: value = prompt_func(prompt) if value: break @@ -170,7 +181,8 @@ def prompt_func(text): continue if not confirmation_prompt: return result - while 1: + while True: + confirmation_prompt = t.cast(str, confirmation_prompt) value2 = prompt_func(confirmation_prompt) if value2: break @@ -180,8 +192,13 @@ def prompt_func(text): def confirm( - text, default=False, abort=False, prompt_suffix=": ", show_default=True, err=False -): + text: str, + default: t.Optional[bool] = False, + abort: bool = False, + prompt_suffix: str = ": ", + show_default: bool = True, + err: bool = False, +) -> bool: """Prompts for confirmation (yes/no question). If the user aborts the input by sending a interrupt signal this @@ -210,7 +227,7 @@ def confirm( "y/n" if default is None else ("Y/n" if default else "y/N"), ) - while 1: + while True: try: # Write the prompt separately so that we get nice # coloring through colorama on Windows @@ -233,7 +250,7 @@ def confirm( return rv -def get_terminal_size(): +def get_terminal_size() -> os.terminal_size: """Returns the current size of the terminal as tuple in the form ``(width, height)`` in columns and rows. @@ -253,7 +270,10 @@ def get_terminal_size(): return shutil.get_terminal_size() -def echo_via_pager(text_or_generator, color=None): +def echo_via_pager( + text_or_generator: t.Union[t.Iterable[str], t.Callable[[], t.Iterable[str]], str], + color: t.Optional[bool] = None, +) -> None: """This function takes a text and shows it via an environment specific pager on stdout. @@ -268,11 +288,11 @@ def echo_via_pager(text_or_generator, color=None): color = resolve_color_default(color) if inspect.isgeneratorfunction(text_or_generator): - i = text_or_generator() + i = t.cast(t.Callable[[], t.Iterable[str]], text_or_generator)() elif isinstance(text_or_generator, str): i = [text_or_generator] else: - i = iter(text_or_generator) + i = iter(t.cast(t.Iterable[str], text_or_generator)) # convert every element of i to a text type if necessary text_generator = (el if isinstance(el, str) else str(el) for el in i) @@ -283,22 +303,22 @@ def echo_via_pager(text_or_generator, color=None): def progressbar( - iterable=None, - length=None, - label=None, - show_eta=True, - show_percent=None, - show_pos=False, - item_show_func=None, - fill_char="#", - empty_char="-", - bar_template="%(label)s [%(bar)s] %(info)s", - info_sep=" ", - width=36, - file=None, - color=None, - update_min_steps=1, -): + iterable: t.Optional[t.Iterable[V]] = None, + length: t.Optional[int] = None, + label: t.Optional[str] = None, + show_eta: bool = True, + show_percent: t.Optional[bool] = None, + show_pos: bool = False, + item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: t.Optional[t.TextIO] = None, + color: t.Optional[bool] = None, + update_min_steps: int = 1, +) -> "ProgressBar": """This function creates an iterable context manager that can be used to iterate over something while showing a progress bar. It will either iterate over the `iterable` or `length` items (that are counted @@ -434,7 +454,7 @@ def progressbar( ) -def clear(): +def clear() -> None: """Clears the terminal screen. This will have the effect of clearing the whole visible space of the terminal and moving the cursor to the top left. This does not do anything if not connected to a terminal. @@ -449,7 +469,9 @@ def clear(): sys.stdout.write("\033[2J\033[1;1H") -def _interpret_color(color, offset=0): +def _interpret_color( + color: t.Union[int, t.Tuple[int, int, int], str], offset: int = 0 +) -> str: if isinstance(color, int): return f"{38 + offset};5;{color:d}" @@ -461,19 +483,19 @@ def _interpret_color(color, offset=0): def style( - text, - fg=None, - bg=None, - bold=None, - dim=None, - underline=None, - overline=None, - italic=None, - blink=None, - reverse=None, - strikethrough=None, - reset=True, -): + text: t.Any, + fg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, + bg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, + bold: t.Optional[bool] = None, + dim: t.Optional[bool] = None, + underline: t.Optional[bool] = None, + overline: t.Optional[bool] = None, + italic: t.Optional[bool] = None, + blink: t.Optional[bool] = None, + reverse: t.Optional[bool] = None, + strikethrough: t.Optional[bool] = None, + reset: bool = True, +) -> str: """Styles a text with ANSI styles and returns the new string. By default the styling is self contained which means that at the end of the string a reset code is issued. This can be prevented by @@ -589,7 +611,7 @@ def style( return "".join(bits) -def unstyle(text): +def unstyle(text: str) -> str: """Removes ANSI styling information from a string. Usually it's not necessary to use this function as Click's echo function will automatically remove styling if necessary. @@ -601,7 +623,14 @@ def unstyle(text): return strip_ansi(text) -def secho(message=None, file=None, nl=True, err=False, color=None, **styles): +def secho( + message: t.Optional[t.Any] = None, + file: t.Optional[t.IO] = None, + nl: bool = True, + err: bool = False, + color: t.Optional[bool] = None, + **styles: t.Any, +) -> None: """This function combines :func:`echo` and :func:`style` into one call. As such the following two calls are the same:: @@ -622,15 +651,20 @@ def secho(message=None, file=None, nl=True, err=False, color=None, **styles): .. versionadded:: 2.0 """ - if message is not None and not is_bytes(message): + if message is not None and not isinstance(message, (bytes, bytearray)): 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, extension=".txt", filename=None -): + text: t.Optional[t.AnyStr] = None, + editor: t.Optional[str] = None, + env: t.Optional[t.Mapping[str, str]] = None, + require_save: bool = True, + extension: str = ".txt", + filename: t.Optional[str] = None, +) -> t.Optional[t.AnyStr]: r"""Edits the given text in the defined editor. If an editor is given (should be the full path to the executable but the regular operating system search path is used for finding the executable) it overrides @@ -660,15 +694,16 @@ def edit( """ from ._termui_impl import Editor - editor = Editor( - editor=editor, env=env, require_save=require_save, extension=extension - ) + ed = Editor(editor=editor, env=env, require_save=require_save, extension=extension) + if filename is None: - return editor.edit(text) - editor.edit_file(filename) + return ed.edit(text) + ed.edit_file(filename) + return None -def launch(url, wait=False, locate=False): + +def launch(url: str, wait: bool = False, locate: bool = False) -> int: """This function launches the given URL (or filename) in the default viewer application for this file type. If this is an executable, it might launch the executable in a new session. The return value is @@ -702,7 +737,7 @@ def launch(url, wait=False, locate=False): _getchar: t.Optional[t.Callable[[bool], str]] = None -def getchar(echo=False): +def getchar(echo: bool = False) -> str: """Fetches a single character from the terminal and returns it. This will always return a unicode character and under certain rare circumstances this might return more than one character. The @@ -722,19 +757,23 @@ def getchar(echo=False): :param echo: if set to `True`, the character read will also show up on the terminal. The default is to not show it. """ - f = _getchar - if f is None: + global _getchar + + if _getchar is None: from ._termui_impl import getchar as f - return f(echo) + + _getchar = f + + return _getchar(echo) -def raw_terminal(): +def raw_terminal() -> t.ContextManager[int]: from ._termui_impl import raw_terminal as f return f() -def pause(info=None, err=False): +def pause(info: t.Optional[str] = None, err: bool = False) -> None: """This command stops execution and waits for the user to press any key to continue. This is similar to the Windows batch "pause" command. If the program is not run through a terminal, this command diff --git a/src/click/testing.py b/src/click/testing.py index 637c46c67..d19b850fc 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -5,49 +5,54 @@ import shutil import sys import tempfile +import typing as t +from types import TracebackType from . import formatting from . import termui from . import utils from ._compat import _find_binary_reader +if t.TYPE_CHECKING: + from .core import BaseCommand + class EchoingStdin: - def __init__(self, input, output): + def __init__(self, input: t.BinaryIO, output: t.BinaryIO) -> None: self._input = input self._output = output self._paused = False - def __getattr__(self, x): + def __getattr__(self, x: str) -> t.Any: return getattr(self._input, x) - def _echo(self, rv): + def _echo(self, rv: bytes) -> bytes: if not self._paused: self._output.write(rv) return rv - def read(self, n=-1): + def read(self, n: int = -1) -> bytes: return self._echo(self._input.read(n)) - def read1(self, n=-1): - return self._echo(self._input.read1(n)) + def read1(self, n: int = -1) -> bytes: + return self._echo(self._input.read1(n)) # type: ignore - def readline(self, n=-1): + def readline(self, n: int = -1) -> bytes: return self._echo(self._input.readline(n)) - def readlines(self): + def readlines(self) -> t.List[bytes]: return [self._echo(x) for x in self._input.readlines()] - def __iter__(self): + def __iter__(self) -> t.Iterator[bytes]: return iter(self._echo(x) for x in self._input) - def __repr__(self): + def __repr__(self) -> str: return repr(self._input) @contextlib.contextmanager -def _pause_echo(stream): +def _pause_echo(stream: t.Optional[EchoingStdin]) -> t.Iterator[None]: if stream is None: yield else: @@ -57,24 +62,28 @@ def _pause_echo(stream): class _NamedTextIOWrapper(io.TextIOWrapper): - def __init__(self, buffer, name=None, mode=None, **kwargs): + def __init__( + self, buffer: t.BinaryIO, name: str, mode: str, **kwargs: t.Any + ) -> None: super().__init__(buffer, **kwargs) self._name = name self._mode = mode @property - def name(self): + def name(self) -> str: return self._name @property - def mode(self): + def mode(self) -> str: return self._mode -def make_input_stream(input, charset): +def make_input_stream( + input: t.Optional[t.Union[str, bytes, t.IO]], charset: str +) -> t.BinaryIO: # Is already an input stream. if hasattr(input, "read"): - rv = _find_binary_reader(input) + rv = _find_binary_reader(t.cast(t.IO, input)) if rv is not None: return rv @@ -83,10 +92,10 @@ def make_input_stream(input, charset): if input is None: input = b"" - elif not isinstance(input, bytes): + elif isinstance(input, str): input = input.encode(charset) - return io.BytesIO(input) + return io.BytesIO(t.cast(bytes, input)) class Result: @@ -94,13 +103,15 @@ class Result: def __init__( self, - runner, - stdout_bytes, - stderr_bytes, - return_value, - exit_code, - exception, - exc_info=None, + runner: "CliRunner", + stdout_bytes: bytes, + stderr_bytes: t.Optional[bytes], + return_value: t.Any, + exit_code: int, + exception: t.Optional[BaseException], + exc_info: t.Optional[ + t.Tuple[t.Type[BaseException], BaseException, TracebackType] + ] = None, ): #: The runner that created the result self.runner = runner @@ -120,19 +131,19 @@ def __init__( self.exc_info = exc_info @property - def output(self): + def output(self) -> str: """The (standard) output as unicode string.""" return self.stdout @property - def stdout(self): + def stdout(self) -> str: """The standard output as unicode string.""" return self.stdout_bytes.decode(self.runner.charset, "replace").replace( "\r\n", "\n" ) @property - def stderr(self): + def stderr(self) -> str: """The standard error as unicode string.""" if self.stderr_bytes is None: raise ValueError("stderr not separately captured") @@ -140,7 +151,7 @@ def stderr(self): "\r\n", "\n" ) - def __repr__(self): + def __repr__(self) -> str: exc_str = repr(self.exception) if self.exception else "okay" return f"<{type(self).__name__} {exc_str}>" @@ -164,20 +175,28 @@ class CliRunner: independently """ - def __init__(self, charset="utf-8", env=None, echo_stdin=False, mix_stderr=True): + def __init__( + self, + charset: str = "utf-8", + env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, + echo_stdin: bool = False, + mix_stderr: bool = True, + ) -> None: self.charset = charset self.env = env or {} self.echo_stdin = echo_stdin self.mix_stderr = mix_stderr - def get_default_prog_name(self, cli): + def get_default_prog_name(self, cli: "BaseCommand") -> str: """Given a command object it will return the default program name for it. The default is the `name` attribute or ``"root"`` if not set. """ return cli.name or "root" - def make_env(self, overrides=None): + def make_env( + self, overrides: t.Optional[t.Mapping[str, t.Optional[str]]] = None + ) -> t.Mapping[str, t.Optional[str]]: """Returns the environment overrides for invoking a script.""" rv = dict(self.env) if overrides: @@ -185,7 +204,12 @@ def make_env(self, overrides=None): return rv @contextlib.contextmanager - def isolation(self, input=None, env=None, color=False): + def isolation( + self, + input: t.Optional[t.Union[str, bytes, t.IO]] = None, + env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, + color: bool = False, + ) -> t.Iterator[t.Tuple[io.BytesIO, t.Optional[io.BytesIO]]]: """A context manager that sets up the isolation for invoking of a command line tool. This sets up stdin with the given input data and `os.environ` with the overrides from the given dictionary. @@ -206,7 +230,7 @@ def isolation(self, input=None, env=None, color=False): .. versionchanged:: 4.0 Added the ``color`` parameter. """ - input = make_input_stream(input, self.charset) + bytes_input = make_input_stream(input, self.charset) echo_input = None old_stdin = sys.stdin @@ -220,16 +244,18 @@ def isolation(self, input=None, env=None, color=False): bytes_output = io.BytesIO() if self.echo_stdin: - input = echo_input = EchoingStdin(input, bytes_output) + bytes_input = echo_input = t.cast( + t.BinaryIO, EchoingStdin(bytes_input, bytes_output) + ) - sys.stdin = input = _NamedTextIOWrapper( - input, encoding=self.charset, name="", mode="r" + sys.stdin = text_input = _NamedTextIOWrapper( + bytes_input, encoding=self.charset, name="", mode="r" ) if self.echo_stdin: # Force unbuffered reads, otherwise TextIOWrapper reads a # large chunk which is echoed early. - input._CHUNK_SIZE = 1 + text_input._CHUNK_SIZE = 1 # type: ignore sys.stdout = _NamedTextIOWrapper( bytes_output, encoding=self.charset, name="", mode="w" @@ -248,22 +274,22 @@ def isolation(self, input=None, env=None, color=False): errors="backslashreplace", ) - @_pause_echo(echo_input) - def visible_input(prompt=None): + @_pause_echo(echo_input) # type: ignore + def visible_input(prompt: t.Optional[str] = None) -> str: sys.stdout.write(prompt or "") - val = input.readline().rstrip("\r\n") + val = text_input.readline().rstrip("\r\n") sys.stdout.write(f"{val}\n") sys.stdout.flush() return val - @_pause_echo(echo_input) - def hidden_input(prompt=None): + @_pause_echo(echo_input) # type: ignore + def hidden_input(prompt: t.Optional[str] = None) -> str: sys.stdout.write(f"{prompt or ''}\n") sys.stdout.flush() - return input.readline().rstrip("\r\n") + return text_input.readline().rstrip("\r\n") - @_pause_echo(echo_input) - def _getchar(echo): + @_pause_echo(echo_input) # type: ignore + def _getchar(echo: bool) -> str: char = sys.stdin.read(1) if echo: @@ -274,7 +300,9 @@ def _getchar(echo): default_color = color - def should_strip_ansi(stream=None, color=None): + def should_strip_ansi( + stream: t.Optional[t.IO] = None, color: t.Optional[bool] = None + ) -> bool: if color is None: return not default_color return not color @@ -282,11 +310,11 @@ def should_strip_ansi(stream=None, color=None): old_visible_prompt_func = termui.visible_prompt_func old_hidden_prompt_func = termui.hidden_prompt_func old__getchar_func = termui._getchar - old_should_strip_ansi = utils.should_strip_ansi + old_should_strip_ansi = utils.should_strip_ansi # type: ignore termui.visible_prompt_func = visible_input termui.hidden_prompt_func = hidden_input termui._getchar = _getchar - utils.should_strip_ansi = should_strip_ansi + utils.should_strip_ansi = should_strip_ansi # type: ignore old_env = {} try: @@ -315,19 +343,19 @@ def should_strip_ansi(stream=None, color=None): termui.visible_prompt_func = old_visible_prompt_func termui.hidden_prompt_func = old_hidden_prompt_func termui._getchar = old__getchar_func - utils.should_strip_ansi = old_should_strip_ansi + utils.should_strip_ansi = old_should_strip_ansi # type: ignore formatting.FORCED_WIDTH = old_forced_width def invoke( self, - cli, - args=None, - input=None, - env=None, - catch_exceptions=True, - color=False, - **extra, - ): + cli: "BaseCommand", + args: t.Optional[t.Union[str, t.Sequence[str]]] = None, + input: t.Optional[t.Union[str, bytes, t.IO]] = None, + env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, + catch_exceptions: bool = True, + color: bool = False, + **extra: t.Any, + ) -> Result: """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 @@ -365,7 +393,7 @@ def invoke( exc_info = None with self.isolation(input=input, env=env, color=color) as outstreams: return_value = None - exception = None + exception: t.Optional[BaseException] = None exit_code = 0 if isinstance(args, str): @@ -380,17 +408,20 @@ def invoke( return_value = 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 + e_code = t.cast(t.Optional[t.Union[int, t.Any]], e.code) - if exit_code != 0: + if e_code is None: + e_code = 0 + + if e_code != 0: exception = e - if not isinstance(exit_code, int): - sys.stdout.write(str(exit_code)) + if not isinstance(e_code, int): + sys.stdout.write(str(e_code)) sys.stdout.write("\n") - exit_code = 1 + e_code = 1 + + exit_code = e_code except Exception as e: if not catch_exceptions: @@ -404,7 +435,7 @@ def invoke( if self.mix_stderr: stderr = None else: - stderr = outstreams[1].getvalue() + stderr = outstreams[1].getvalue() # type: ignore return Result( runner=self, @@ -413,11 +444,13 @@ def invoke( return_value=return_value, exit_code=exit_code, exception=exception, - exc_info=exc_info, + exc_info=exc_info, # type: ignore ) @contextlib.contextmanager - def isolated_filesystem(self, temp_dir=None): + def isolated_filesystem( + self, temp_dir: t.Optional[t.Union[str, os.PathLike]] = None + ) -> t.Iterator[str]: """A context manager that creates a temporary directory and changes the current working directory to it. This isolates tests that affect the contents of the CWD to prevent them from diff --git a/src/click/types.py b/src/click/types.py index 0d210923f..34c78e932 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -13,6 +13,12 @@ from .utils import LazyFile from .utils import safecall +if t.TYPE_CHECKING: + import typing_extensions as te + from .core import Context + from .core import Parameter + from .shell_completion import CompletionItem + class ParamType: """Represents the type of a parameter. Validates and converts values @@ -32,10 +38,11 @@ class ParamType: input. """ - is_composite = False + is_composite: t.ClassVar[bool] = False + arity: t.ClassVar[int] = 1 #: the descriptive name of this type - name: t.Optional[str] = None + name: str #: if a list of this type is expected and the value is pulled from a #: string environment variable, this is what splits it up. `None` @@ -45,7 +52,7 @@ class ParamType: #: Windows). envvar_list_splitter: t.ClassVar[t.Optional[str]] = None - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: """Gather information that could be useful for a tool generating user-facing documentation. @@ -59,21 +66,28 @@ def to_info_dict(self): param_type = param_type.partition("ParameterType")[0] return {"param_type": param_type, "name": self.name} - def __call__(self, value, param=None, ctx=None): + def __call__( + self, + value: t.Any, + param: t.Optional["Parameter"] = None, + ctx: t.Optional["Context"] = None, + ) -> t.Any: if value is not None: return self.convert(value, param, ctx) - def get_metavar(self, param): + def get_metavar(self, param: "Parameter") -> t.Optional[str]: """Returns the metavar default for this param if it provides one.""" - def get_missing_message(self, param): + def get_missing_message(self, param: "Parameter") -> t.Optional[str]: """Optionally might return extra information about a missing parameter. .. versionadded:: 2.0 """ - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: """Convert the value to the correct type. This is not called if the value is ``None`` (the missing value). @@ -95,7 +109,7 @@ def convert(self, value, param, ctx): """ return value - def split_envvar_value(self, rv): + def split_envvar_value(self, rv: str) -> t.Sequence[str]: """Given a value from an environment variable this splits it up into small chunks depending on the defined envvar list splitter. @@ -105,11 +119,18 @@ def split_envvar_value(self, rv): """ return (rv or "").split(self.envvar_list_splitter) - def fail(self, message, param=None, ctx=None): + def fail( + self, + message: str, + param: t.Optional["Parameter"] = None, + ctx: t.Optional["Context"] = None, + ) -> t.NoReturn: """Helper method to fail with an invalid value message.""" raise BadParameter(message, ctx=ctx, param=param) - def shell_complete(self, ctx, param, incomplete): + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: """Return a list of :class:`~click.shell_completion.CompletionItem` objects for the incomplete value. Most types do not provide completions, but @@ -129,21 +150,23 @@ class CompositeParamType(ParamType): is_composite = True @property - def arity(self): + def arity(self) -> int: # type: ignore raise NotImplementedError() class FuncParamType(ParamType): - def __init__(self, func): + def __init__(self, func: t.Callable[[t.Any], t.Any]) -> None: self.name = func.__name__ self.func = func - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict() info_dict["func"] = self.func return info_dict - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: try: return self.func(value) except ValueError: @@ -158,17 +181,21 @@ def convert(self, value, param, ctx): class UnprocessedParamType(ParamType): name = "text" - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: return value - def __repr__(self): + def __repr__(self) -> str: return "UNPROCESSED" class StringParamType(ParamType): name = "text" - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: if isinstance(value, bytes): enc = _get_argv_encoding() try: @@ -185,7 +212,7 @@ def convert(self, value, param, ctx): return value return str(value) - def __repr__(self): + def __repr__(self) -> str: return "STRING" @@ -208,17 +235,17 @@ class Choice(ParamType): name = "choice" - def __init__(self, choices, case_sensitive=True): + def __init__(self, choices: t.Sequence[str], case_sensitive: bool = True) -> None: self.choices = choices self.case_sensitive = case_sensitive - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict() info_dict["choices"] = self.choices info_dict["case_sensitive"] = self.case_sensitive return info_dict - def get_metavar(self, param): + def get_metavar(self, param: "Parameter") -> str: choices_str = "|".join(self.choices) # Use curly braces to indicate a required argument. @@ -228,10 +255,12 @@ def get_metavar(self, param): # Use square braces to indicate an option or optional argument. return f"[{choices_str}]" - def get_missing_message(self, param): + def get_missing_message(self, param: "Parameter") -> str: return _("Choose from:\n\t{choices}").format(choices=",\n\t".join(self.choices)) - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: # Match through normalization and case sensitivity # first do token_normalize_func, then lowercase # preserve original `value` to produce an accurate message in @@ -267,10 +296,12 @@ def convert(self, value, param, ctx): ctx, ) - def __repr__(self): + def __repr__(self) -> str: return f"Choice({list(self.choices)})" - def shell_complete(self, ctx, param, incomplete): + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: """Complete choices that start with the incomplete value. :param ctx: Invocation context for this command. @@ -315,24 +346,26 @@ class DateTime(ParamType): name = "datetime" - def __init__(self, formats=None): + def __init__(self, formats: t.Optional[t.Sequence[str]] = None): self.formats = formats or ["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"] - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict() info_dict["formats"] = self.formats return info_dict - def get_metavar(self, param): + def get_metavar(self, param: "Parameter") -> str: return f"[{'|'.join(self.formats)}]" - def _try_to_convert_date(self, value, format): + def _try_to_convert_date(self, value: t.Any, format: str) -> t.Optional[datetime]: try: return datetime.strptime(value, format) except ValueError: return None - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: if isinstance(value, datetime): return value @@ -353,14 +386,16 @@ def convert(self, value, param, ctx): ctx, ) - def __repr__(self): + def __repr__(self) -> str: return "DateTime" class _NumberParamTypeBase(ParamType): - _number_class: t.ClassVar[t.Optional[t.Type]] = None + _number_class: t.ClassVar[t.Type] - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: try: return self._number_class(value) except ValueError: @@ -374,14 +409,21 @@ def convert(self, value, param, ctx): class _NumberRangeBase(_NumberParamTypeBase): - def __init__(self, min=None, max=None, min_open=False, max_open=False, clamp=False): + def __init__( + self, + min: t.Optional[float] = None, + max: t.Optional[float] = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: self.min = min self.max = max self.min_open = min_open self.max_open = max_open self.clamp = clamp - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict() info_dict.update( min=self.min, @@ -392,23 +434,25 @@ def to_info_dict(self): ) return info_dict - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: import operator rv = super().convert(value, param, ctx) - lt_min = self.min is not None and ( + lt_min: bool = self.min is not None and ( operator.le if self.min_open else operator.lt )(rv, self.min) - gt_max = self.max is not None and ( + gt_max: bool = self.max is not None and ( operator.ge if self.max_open else operator.gt )(rv, self.max) if self.clamp: if lt_min: - return self._clamp(self.min, 1, self.min_open) + return self._clamp(self.min, 1, self.min_open) # type: ignore if gt_max: - return self._clamp(self.max, -1, self.max_open) + return self._clamp(self.max, -1, self.max_open) # type: ignore if lt_min or gt_max: self.fail( @@ -421,7 +465,7 @@ def convert(self, value, param, ctx): return rv - def _clamp(self, bound, dir, open): + def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float: """Find the valid value to clamp to bound in the given direction. @@ -431,7 +475,7 @@ def _clamp(self, bound, dir, open): """ raise NotImplementedError - def _describe_range(self): + def _describe_range(self) -> str: """Describe the range for use in help text.""" if self.min is None: op = "<" if self.max_open else "<=" @@ -445,7 +489,7 @@ def _describe_range(self): rop = "<" if self.max_open else "<=" return f"{self.min}{lop}x{rop}{self.max}" - def __repr__(self): + def __repr__(self) -> str: clamp = " clamped" if self.clamp else "" return f"<{type(self).__name__} {self._describe_range()}{clamp}>" @@ -454,7 +498,7 @@ class IntParamType(_NumberParamTypeBase): name = "integer" _number_class = int - def __repr__(self): + def __repr__(self) -> str: return "INT" @@ -475,7 +519,9 @@ class IntRange(_NumberRangeBase, IntParamType): name = "integer range" - def _clamp(self, bound, dir, open): + def _clamp( # type: ignore + self, bound: int, dir: "te.Literal[1, -1]", open: bool + ) -> int: if not open: return bound @@ -486,7 +532,7 @@ class FloatParamType(_NumberParamTypeBase): name = "float" _number_class = float - def __repr__(self): + def __repr__(self) -> str: return "FLOAT" @@ -508,7 +554,14 @@ class FloatRange(_NumberRangeBase, FloatParamType): name = "float range" - def __init__(self, min=None, max=None, min_open=False, max_open=False, clamp=False): + def __init__( + self, + min: t.Optional[float] = None, + max: t.Optional[float] = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: super().__init__( min=min, max=max, min_open=min_open, max_open=max_open, clamp=clamp ) @@ -516,7 +569,7 @@ def __init__(self, min=None, max=None, min_open=False, max_open=False, clamp=Fal if (min_open or max_open) and clamp: raise TypeError("Clamping is not supported for open bounds.") - def _clamp(self, bound, dir, open): + def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float: if not open: return bound @@ -529,7 +582,9 @@ def _clamp(self, bound, dir, open): class BoolParamType(ParamType): name = "boolean" - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: if value in {False, True}: return bool(value) @@ -545,14 +600,16 @@ def convert(self, value, param, ctx): _("{value!r} is not a valid boolean.").format(value=value), param, ctx ) - def __repr__(self): + def __repr__(self) -> str: return "BOOL" class UUIDParameterType(ParamType): name = "uuid" - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: import uuid if isinstance(value, uuid.UUID): @@ -567,7 +624,7 @@ def convert(self, value, param, ctx): _("{value!r} is not a valid UUID.").format(value=value), param, ctx ) - def __repr__(self): + def __repr__(self) -> str: return "UUID" @@ -602,20 +659,25 @@ class File(ParamType): envvar_list_splitter = os.path.pathsep def __init__( - self, mode="r", encoding=None, errors="strict", lazy=None, atomic=False - ): + self, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + lazy: t.Optional[bool] = None, + atomic: bool = False, + ) -> None: self.mode = mode self.encoding = encoding self.errors = errors self.lazy = lazy self.atomic = atomic - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict() info_dict.update(mode=self.mode, encoding=self.encoding) return info_dict - def resolve_lazy_flag(self, value): + def resolve_lazy_flag(self, value: t.Any) -> bool: if self.lazy is not None: return self.lazy if value == "-": @@ -624,7 +686,9 @@ def resolve_lazy_flag(self, value): return True return False - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: try: if hasattr(value, "read") or hasattr(value, "write"): return value @@ -632,16 +696,22 @@ def convert(self, value, param, ctx): lazy = self.resolve_lazy_flag(value) if lazy: - f = LazyFile( - value, self.mode, self.encoding, self.errors, atomic=self.atomic + f: t.IO = t.cast( + t.IO, + LazyFile( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ), ) + if ctx is not None: - ctx.call_on_close(f.close_intelligently) + ctx.call_on_close(f.close_intelligently) # type: ignore + return f f, should_close = open_stream( value, self.mode, self.encoding, self.errors, atomic=self.atomic ) + # If a context is provided, we automatically close the file # at the end of the context execution (or flush out). If a # context does not exist, it's the caller's responsibility to @@ -652,11 +722,14 @@ def convert(self, value, param, ctx): ctx.call_on_close(safecall(f.close)) else: ctx.call_on_close(safecall(f.flush)) + return f except OSError as e: # noqa: B014 self.fail(f"{os.fsdecode(value)!r}: {get_strerror(e)}", param, ctx) - def shell_complete(self, ctx, param, incomplete): + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: """Return a special completion marker that tells the completion system to use the shell to provide file path completions. @@ -707,14 +780,14 @@ class Path(ParamType): def __init__( self, - exists=False, - file_okay=True, - dir_okay=True, - writable=False, - readable=True, - resolve_path=False, - allow_dash=False, - path_type=None, + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: t.Optional[t.Type] = None, ): self.exists = exists self.file_okay = file_okay @@ -732,7 +805,7 @@ def __init__( else: self.name = _("path") - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict() info_dict.update( exists=self.exists, @@ -744,7 +817,7 @@ def to_info_dict(self): ) return info_dict - def coerce_path_result(self, rv): + def coerce_path_result(self, rv: t.Any) -> t.Any: if self.type is not None and not isinstance(rv, self.type): if self.type is str: rv = os.fsdecode(rv) @@ -755,7 +828,9 @@ def coerce_path_result(self, rv): return rv - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: rv = value is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") @@ -816,7 +891,9 @@ def convert(self, value, param, ctx): return self.coerce_path_result(rv) - def shell_complete(self, ctx, param, incomplete): + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: """Return a special completion marker that tells the completion system to use the shell to provide path completions for only directories or any paths. @@ -847,23 +924,25 @@ class Tuple(CompositeParamType): :param types: a list of types that should be used for the tuple items. """ - def __init__(self, types): + def __init__(self, types: t.Sequence[t.Union[t.Type, ParamType]]) -> None: self.types = [convert_type(ty) for ty in types] - def to_info_dict(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict() info_dict["types"] = [t.to_info_dict() for t in self.types] return info_dict @property - def name(self): + def name(self) -> str: # type: ignore return f"<{' '.join(ty.name for ty in self.types)}>" @property - def arity(self): + def arity(self) -> int: # type: ignore return len(self.types) - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: len_type = len(self.types) len_value = len(value) @@ -881,7 +960,7 @@ def convert(self, value, param, ctx): return tuple(ty(x, param, ctx) for ty, x in zip(self.types, value)) -def convert_type(ty, default=None): +def convert_type(ty: t.Optional[t.Any], default: t.Optional[t.Any] = None) -> ParamType: """Find the most appropriate :class:`ParamType` for the given Python type. If the type isn't provided, it can be inferred from a default value. diff --git a/src/click/utils.py b/src/click/utils.py index f21812b19..a2fd76762 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -1,6 +1,8 @@ import os import sys import typing as t +from functools import update_wrapper +from types import ModuleType from ._compat import _default_text_stderr from ._compat import _default_text_stdout @@ -9,7 +11,6 @@ from ._compat import binary_streams from ._compat import get_filesystem_encoding from ._compat import get_strerror -from ._compat import is_bytes from ._compat import open_stream from ._compat import should_strip_ansi from ._compat import strip_ansi @@ -17,24 +18,29 @@ from ._compat import WIN from .globals import resolve_color_default +if t.TYPE_CHECKING: + import typing_extensions as te -def _posixify(name): +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + + +def _posixify(name: str) -> str: return "-".join(name.split()).lower() -def safecall(func): +def safecall(func: F) -> F: """Wraps a function so that it swallows exceptions.""" - def wrapper(*args, **kwargs): + def wrapper(*args, **kwargs): # type: ignore try: return func(*args, **kwargs) except Exception: pass - return wrapper + return update_wrapper(t.cast(F, wrapper), func) -def make_str(value): +def make_str(value: t.Any) -> str: """Converts a value into a valid string.""" if isinstance(value, bytes): try: @@ -102,13 +108,19 @@ class LazyFile: """ def __init__( - self, filename, mode="r", encoding=None, errors="strict", atomic=False + self, + filename: str, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + atomic: bool = False, ): self.name = filename self.mode = mode self.encoding = encoding self.errors = errors self.atomic = atomic + self._f: t.Optional[t.IO] if filename == "-": self._f, self.should_close = open_stream(filename, mode, encoding, errors) @@ -121,15 +133,15 @@ def __init__( self._f = None self.should_close = True - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self.open(), name) - def __repr__(self): + def __repr__(self) -> str: if self._f is not None: return repr(self._f) return f"" - def open(self): + def open(self) -> t.IO: """Opens the file if it's not yet open. This call might fail with a :exc:`FileError`. Not handling this error will produce an error that Click shows. @@ -147,50 +159,56 @@ def open(self): self._f = rv return rv - def close(self): + def close(self) -> None: """Closes the underlying file, no matter what.""" if self._f is not None: self._f.close() - def close_intelligently(self): + def close_intelligently(self) -> None: """This function only closes the file if it was opened by the lazy file wrapper. For instance this will never close stdin. """ if self.should_close: self.close() - def __enter__(self): + def __enter__(self) -> "LazyFile": return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore self.close_intelligently() - def __iter__(self): + def __iter__(self) -> t.Iterator[t.AnyStr]: self.open() - return iter(self._f) + return iter(self._f) # type: ignore class KeepOpenFile: - def __init__(self, file): + def __init__(self, file: t.IO) -> None: self._file = file - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self._file, name) - def __enter__(self): + def __enter__(self) -> "KeepOpenFile": return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore pass - def __repr__(self): + def __repr__(self) -> str: return repr(self._file) - def __iter__(self): + def __iter__(self) -> t.Iterator[t.AnyStr]: return iter(self._file) -def echo(message=None, file=None, nl=True, err=False, color=None): +def echo( + message: t.Optional[t.Any] = None, + file: t.Optional[t.IO] = None, + nl: bool = True, + err: bool = False, + color: t.Optional[bool] = None, +) -> None: """Print a message and newline to stdout or a file. This should be used instead of :func:`print` because it provides better support for different data, files, and environments. @@ -237,45 +255,52 @@ def echo(message=None, file=None, nl=True, err=False, color=None): # Convert non bytes/text into the native string type. if message is not None and not isinstance(message, (str, bytes, bytearray)): - message = str(message) + out: t.Optional[t.Union[str, bytes]] = str(message) + else: + out = message if nl: - message = message or "" - if isinstance(message, str): - message += "\n" + out = out or "" + if isinstance(out, str): + out += "\n" else: - message += b"\n" + out += b"\n" + + if not out: + file.flush() + return # If there is a message 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 types will work as you # would expect. Eg: you can write to StringIO for other cases. - if message and is_bytes(message): + if isinstance(out, (bytes, bytearray)): binary_file = _find_binary_writer(file) + if binary_file is not None: file.flush() - binary_file.write(message) + binary_file.write(out) binary_file.flush() return # ANSI style code support. For no message or bytes, nothing happens. # When outputting to a file instead of a terminal, strip codes. - if message and not is_bytes(message): + else: color = resolve_color_default(color) + if should_strip_ansi(file, color): - message = strip_ansi(message) + out = strip_ansi(out) elif WIN: if auto_wrap_for_ansi is not None: - file = auto_wrap_for_ansi(file) + file = auto_wrap_for_ansi(file) # type: ignore elif not color: - message = strip_ansi(message) + out = strip_ansi(out) - if message: - file.write(message) + file.write(out) # type: ignore file.flush() -def get_binary_stream(name): +def get_binary_stream(name: "te.Literal['stdin', 'stdout', 'stderr']") -> t.BinaryIO: """Returns a system stream for byte processing. :param name: the name of the stream to open. Valid names are ``'stdin'``, @@ -287,7 +312,11 @@ def get_binary_stream(name): return opener() -def get_text_stream(name, encoding=None, errors="strict"): +def get_text_stream( + name: "te.Literal['stdin', 'stdout', 'stderr']", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", +) -> t.TextIO: """Returns a system stream for text processing. This usually returns a wrapped stream around a binary stream returned from :func:`get_binary_stream` but it also can take shortcuts for already @@ -305,8 +334,13 @@ def get_text_stream(name, encoding=None, errors="strict"): def open_file( - filename, mode="r", encoding=None, errors="strict", lazy=False, atomic=False -): + filename: str, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + lazy: bool = False, + atomic: bool = False, +) -> t.IO: """This is similar to how the :class:`File` works but for manual usage. Files are opened non lazy by default. This can open regular files as well as stdin/stdout if ``'-'`` is passed. @@ -330,14 +364,14 @@ def open_file( moved on close. """ if lazy: - return LazyFile(filename, mode, encoding, errors, atomic=atomic) + return t.cast(t.IO, LazyFile(filename, mode, encoding, errors, atomic=atomic)) f, should_close = open_stream(filename, mode, encoding, errors, atomic=atomic) if not should_close: - f = KeepOpenFile(f) + f = t.cast(t.IO, KeepOpenFile(f)) return f -def get_os_args(): +def get_os_args() -> t.Sequence[str]: """Returns the argument part of ``sys.argv``, removing the first value which is the name of the script. @@ -356,7 +390,9 @@ def get_os_args(): return sys.argv[1:] -def format_filename(filename, shorten=False): +def format_filename( + filename: t.Union[str, bytes, os.PathLike], shorten: bool = False +) -> str: """Formats a filename for user display. The main purpose of this function is to ensure that the filename can be displayed at all. This will decode the filename to unicode if necessary in a way that it will @@ -374,7 +410,7 @@ def format_filename(filename, shorten=False): return os.fsdecode(filename) -def get_app_dir(app_name, roaming=True, force_posix=False): +def get_app_dir(app_name: str, roaming: bool = True, force_posix: bool = False) -> str: r"""Returns the config folder for the application. The default behavior is to return whatever is most appropriate for the operating system. @@ -389,13 +425,9 @@ def get_app_dir(app_name, roaming=True, force_posix=False): ``~/.config/foo-bar`` Unix (POSIX): ``~/.foo-bar`` - Win XP (roaming): - ``C:\Documents and Settings\\Local Settings\Application Data\Foo Bar`` - Win XP (not roaming): - ``C:\Documents and Settings\\Application Data\Foo Bar`` - Win 7 (roaming): + Windows (roaming): ``C:\Users\\AppData\Roaming\Foo Bar`` - Win 7 (not roaming): + Windows (not roaming): ``C:\Users\\AppData\Local\Foo Bar`` .. versionadded:: 2.0 @@ -436,10 +468,10 @@ class PacifyFlushWrapper: pipe, all calls and attributes are proxied. """ - def __init__(self, wrapped): + def __init__(self, wrapped: t.IO) -> None: self.wrapped = wrapped - def flush(self): + def flush(self) -> None: try: self.wrapped.flush() except OSError as e: @@ -448,11 +480,13 @@ def flush(self): if e.errno != errno.EPIPE: raise - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> t.Any: return getattr(self.wrapped, attr) -def _detect_program_name(path=None, _main=sys.modules["__main__"]): +def _detect_program_name( + path: t.Optional[str] = None, _main: ModuleType = sys.modules["__main__"] +) -> str: """Determine the command used to run the program, for use in help text. If a file or entry point was executed, the file name is returned. If ``python -m`` was used to execute a module or package, @@ -491,7 +525,7 @@ def _detect_program_name(path=None, _main=sys.modules["__main__"]): # Executed a module, like "python -m example". # Rewritten by Python from "-m script" to "/path/to/script.py". # Need to look at main module to determine how it was executed. - py_module = _main.__package__ + py_module = t.cast(str, _main.__package__) name = os.path.splitext(os.path.basename(path))[0] # A submodule like "example.cli". diff --git a/tests/test_termui.py b/tests/test_termui.py index ab459f052..5e819df4f 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -18,11 +18,10 @@ def time(self): return self.now -def _create_progress(length=10, length_known=True, **kwargs): +def _create_progress(length=10, **kwargs): progress = click.progressbar(tuple(range(length))) for key, value in kwargs.items(): setattr(progress, key, value) - progress.length_known = length_known return progress @@ -36,7 +35,10 @@ def cli(): pass monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) - assert label in runner.invoke(cli, []).output + assert ( + label + in runner.invoke(cli, [], standalone_mode=False, catch_exceptions=False).output + ) def test_progressbar_length_hint(runner, monkeypatch): @@ -111,12 +113,9 @@ def test_progressbar_format_eta(runner, eta, expected): @pytest.mark.parametrize("pos, length", [(0, 5), (-1, 1), (5, 5), (6, 5), (4, 0)]) def test_progressbar_format_pos(runner, pos, length): - with _create_progress(length, length_known=length != 0, pos=pos) as progress: + with _create_progress(length, pos=pos) as progress: result = progress.format_pos() - if progress.length_known: - assert result == f"{pos}/{length}" - else: - assert result == str(pos) + assert result == f"{pos}/{length}" @pytest.mark.parametrize( @@ -124,33 +123,30 @@ def test_progressbar_format_pos(runner, pos, length): [ (8, False, 7, 0, "#######-"), (0, True, 8, 0, "########"), - (0, False, 8, 0, "--------"), - (0, False, 5, 3, "#-------"), ], ) def test_progressbar_format_bar(runner, length, finished, pos, avg, expected): with _create_progress( - length, length_known=length != 0, width=8, pos=pos, finished=finished, avg=[avg] + length, width=8, pos=pos, finished=finished, avg=[avg] ) as progress: assert progress.format_bar() == expected @pytest.mark.parametrize( - "length, length_known, show_percent, show_pos, pos, expected", + "length, show_percent, show_pos, pos, expected", [ - (0, True, True, True, 0, " [--------] 0/0 0%"), - (0, True, False, True, 0, " [--------] 0/0"), - (0, True, False, False, 0, " [--------]"), - (0, False, False, False, 0, " [--------]"), - (8, True, True, True, 8, " [########] 8/8 100%"), + (0, True, True, 0, " [--------] 0/0 0%"), + (0, False, True, 0, " [--------] 0/0"), + (0, False, False, 0, " [--------]"), + (0, False, False, 0, " [--------]"), + (8, True, True, 8, " [########] 8/8 100%"), ], ) def test_progressbar_format_progress_line( - runner, length, length_known, show_percent, show_pos, pos, expected + runner, length, show_percent, show_pos, pos, expected ): with _create_progress( length, - length_known, width=8, show_percent=show_percent, pos=pos, diff --git a/tests/test_testing.py b/tests/test_testing.py index d23a4f231..9f294b3a1 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -14,7 +14,7 @@ def test_runner(): def test(): i = click.get_binary_stream("stdin") o = click.get_binary_stream("stdout") - while 1: + while True: chunk = i.read(4096) if not chunk: break @@ -32,7 +32,7 @@ def test_echo_stdin_stream(): def test(): i = click.get_binary_stream("stdin") o = click.get_binary_stream("stdout") - while 1: + while True: chunk = i.read(4096) if not chunk: break @@ -90,7 +90,7 @@ def test_runner_with_stream(): def test(): i = click.get_binary_stream("stdin") o = click.get_binary_stream("stdout") - while 1: + while True: chunk = i.read(4096) if not chunk: break From 6143675b5b022f09943579c683337f9ef3e82968 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 23 Apr 2021 09:13:33 -0700 Subject: [PATCH 263/293] remove _compat.get_strerror --- src/click/_compat.py | 11 ----------- src/click/types.py | 3 +-- src/click/utils.py | 3 +-- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/click/_compat.py b/src/click/_compat.py index 30794b432..f22efecd0 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -366,17 +366,6 @@ def get_text_stderr( return _force_correct_text_writer(sys.stderr, encoding, errors, force_writable=True) -def get_strerror(e: OSError, default: t.Optional[str] = None) -> str: - if hasattr(e, "strerror"): - msg = e.strerror - else: - if default is not None: - msg = default - else: - msg = str(e) - return msg - - def _wrap_io_open( file: t.Union[str, os.PathLike, int], mode: str, diff --git a/src/click/types.py b/src/click/types.py index 34c78e932..52baa055c 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -7,7 +7,6 @@ from ._compat import _get_argv_encoding from ._compat import get_filesystem_encoding -from ._compat import get_strerror from ._compat import open_stream from .exceptions import BadParameter from .utils import LazyFile @@ -725,7 +724,7 @@ def convert( return f except OSError as e: # noqa: B014 - self.fail(f"{os.fsdecode(value)!r}: {get_strerror(e)}", param, ctx) + self.fail(f"{os.fsdecode(value)!r}: {e.strerror}", param, ctx) def shell_complete( self, ctx: "Context", param: "Parameter", incomplete: str diff --git a/src/click/utils.py b/src/click/utils.py index a2fd76762..91a372d36 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -10,7 +10,6 @@ from ._compat import auto_wrap_for_ansi from ._compat import binary_streams from ._compat import get_filesystem_encoding -from ._compat import get_strerror from ._compat import open_stream from ._compat import should_strip_ansi from ._compat import strip_ansi @@ -155,7 +154,7 @@ def open(self) -> t.IO: except OSError as e: # noqa: E402 from .exceptions import FileError - raise FileError(self.name, hint=get_strerror(e)) + raise FileError(self.name, hint=e.strerror) self._f = rv return rv From b3ee5307705549e1d85f7a509b22d8368e68a4b2 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 23 Apr 2021 09:16:29 -0700 Subject: [PATCH 264/293] remove _winconsole.WindowsChunkedWriter --- src/click/_compat.py | 1 - src/click/_winconsole.py | 25 ------------------------- 2 files changed, 26 deletions(-) diff --git a/src/click/_compat.py b/src/click/_compat.py index f22efecd0..b9e1f0d39 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -599,7 +599,6 @@ def func() -> t.TextIO: return rv rv = wrapper_func() try: - stream = src_func() # In case wrapper_func() modified the stream cache[stream] = rv except Exception: pass diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index 0b46bac15..6b20df315 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -210,31 +210,6 @@ def __repr__(self): return f"" -class WindowsChunkedWriter: - """ - Wraps a stream (such as stdout), acting as a transparent proxy for all - attribute access apart from method 'write()' which we wrap to write in - limited chunks due to a Windows limitation on binary console streams. - """ - - def __init__(self, wrapped): - # double-underscore everything to prevent clashes with names of - # attributes on the wrapped stream object. - self.__wrapped = wrapped - - def __getattr__(self, name): - return getattr(self.__wrapped, name) - - def write(self, text): - total_to_write = len(text) - written = 0 - - while written < total_to_write: - to_write = min(total_to_write - written, MAX_BYTES_WRITTEN) - self.__wrapped.write(text[written : written + to_write]) - written += to_write - - def _get_text_stdin(buffer_stream: t.BinaryIO) -> t.TextIO: text_stream = _NonClosingTextIOWrapper( io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)), From f5a74783ff3849c7db7ea45be0c75bedb69bbbb2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Apr 2021 17:21:52 +0000 Subject: [PATCH 265/293] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.12.0 → v2.13.0](https://github.com/asottile/pyupgrade/compare/v2.12.0...v2.13.0) - [github.com/asottile/reorder_python_imports: v2.4.0 → v2.5.0](https://github.com/asottile/reorder_python_imports/compare/v2.4.0...v2.5.0) - [github.com/psf/black: 20.8b1 → 21.4b0](https://github.com/psf/black/compare/20.8b1...21.4b0) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 201bdf01b..7f7630874 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,16 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.12.0 + rev: v2.13.0 hooks: - id: pyupgrade args: ["--py36-plus"] - repo: https://github.com/asottile/reorder_python_imports - rev: v2.4.0 + rev: v2.5.0 hooks: - id: reorder-python-imports args: ["--application-directories", "src"] - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 21.4b0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 From 5089b4851ff727a80106ea6d33808e0af8b3c215 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 16:46:49 +0000 Subject: [PATCH 266/293] Upgrade to GitHub-native Dependabot --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..86e010dff --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: monthly + time: "08:00" + open-pull-requests-limit: 99 From 8d8f81a8a99ad5d21c58e9758b5ccb12f8113c30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 20:54:28 +0000 Subject: [PATCH 267/293] Bump pre-commit from 2.12.0 to 2.12.1 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.12.0 to 2.12.1. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.12.0...v2.12.1) Signed-off-by: dependabot[bot] --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 2c568d33b..64182717d 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -64,7 +64,7 @@ pluggy==0.13.1 # via # pytest # tox -pre-commit==2.12.0 +pre-commit==2.12.1 # via -r requirements/dev.in py==1.10.0 # via From 428851b12cda9fa3f61a86fb4c09779fbfcdf3a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 20:55:01 +0000 Subject: [PATCH 268/293] Bump importlib-metadata from 3.10.1 to 4.0.1 Bumps [importlib-metadata](https://github.com/python/importlib_metadata) from 3.10.1 to 4.0.1. - [Release notes](https://github.com/python/importlib_metadata/releases) - [Changelog](https://github.com/python/importlib_metadata/blob/main/CHANGES.rst) - [Commits](https://github.com/python/importlib_metadata/compare/v3.10.1...v4.0.1) Signed-off-by: dependabot[bot] --- requirements/dev.txt | 2 +- requirements/tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 2c568d33b..e1f915905 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -34,7 +34,7 @@ idna==2.10 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==3.10.1 +importlib_metadata==4.0.1 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest diff --git a/requirements/tests.txt b/requirements/tests.txt index 6be057bd2..1048cf159 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,7 +6,7 @@ # attrs==20.3.0 # via pytest -importlib-metadata==3.10.1 +importlib_metadata==4.0.1 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest From 3a39cb21b25df39d5d0f161de5debaa9ba7cc7d9 Mon Sep 17 00:00:00 2001 From: David Lord Date: Mon, 3 May 2021 05:50:59 -0700 Subject: [PATCH 269/293] more accurate custom shell_complete annotation --- src/click/core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index 1051baece..010a2067c 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2022,7 +2022,8 @@ def __init__( envvar: t.Optional[t.Union[str, t.Sequence[str]]] = None, shell_complete: t.Optional[ t.Callable[ - [Context, "Parameter", str], t.List[t.Union["CompletionItem", str]] + [Context, "Parameter", str], + t.Union[t.List["CompletionItem"], t.List[str]], ] ] = None, autocompletion: t.Optional[ @@ -2068,7 +2069,7 @@ def __init__( def shell_complete( ctx: Context, param: "Parameter", incomplete: str - ) -> t.List[t.Union["CompletionItem", str]]: + ) -> t.List["CompletionItem"]: from click.shell_completion import CompletionItem out = [] From 3f714216b2270ee6a7f0629f1ea464ff38dc3e52 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 May 2021 17:20:57 +0000 Subject: [PATCH 270/293] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.13.0 → v2.14.0](https://github.com/asottile/pyupgrade/compare/v2.13.0...v2.14.0) - [github.com/psf/black: 21.4b0 → 21.4b2](https://github.com/psf/black/compare/21.4b0...21.4b2) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7f7630874..e87488df5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.13.0 + rev: v2.14.0 hooks: - id: pyupgrade args: ["--py36-plus"] @@ -10,7 +10,7 @@ repos: - id: reorder-python-imports args: ["--application-directories", "src"] - repo: https://github.com/psf/black - rev: 21.4b0 + rev: 21.4b2 hooks: - id: black - repo: https://github.com/PyCQA/flake8 From a1466409c45618acd8e8149f16777dc16d98ecdc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 May 2021 20:25:38 +0000 Subject: [PATCH 271/293] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.14.0 → v2.15.0](https://github.com/asottile/pyupgrade/compare/v2.14.0...v2.15.0) - [github.com/psf/black: 21.4b2 → 21.5b1](https://github.com/psf/black/compare/21.4b2...21.5b1) - [github.com/PyCQA/flake8: 3.9.1 → 3.9.2](https://github.com/PyCQA/flake8/compare/3.9.1...3.9.2) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e87488df5..26a87e0c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.14.0 + rev: v2.15.0 hooks: - id: pyupgrade args: ["--py36-plus"] @@ -10,11 +10,11 @@ repos: - id: reorder-python-imports args: ["--application-directories", "src"] - repo: https://github.com/psf/black - rev: 21.4b2 + rev: 21.5b1 hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 3.9.1 + rev: 3.9.2 hooks: - id: flake8 additional_dependencies: From e3e1691620c4791e662e4d8846b18295c1a018a2 Mon Sep 17 00:00:00 2001 From: Adrien Pensart Date: Tue, 4 May 2021 13:28:39 -0400 Subject: [PATCH 272/293] repr is erasing ANSI escapes codes --- src/click/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index 010a2067c..c57b3b866 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2372,7 +2372,7 @@ def get_error_hint(self, ctx: Context) -> str: indicate which param caused the error. """ hint_list = self.opts or [self.human_readable_name] - return " / ".join(repr(x) for x in hint_list) + return " / ".join(f"'{x}'" for x in hint_list) def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: """Return a list of completions for the incomplete value. If a @@ -2926,7 +2926,7 @@ def get_usage_pieces(self, ctx: Context) -> t.List[str]: return [self.make_metavar()] def get_error_hint(self, ctx: Context) -> str: - return repr(self.make_metavar()) + return f"'{self.make_metavar()}'" def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) From dcd991d986d43c4f12cc838c4008dfafc015c21d Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Fri, 7 May 2021 17:18:59 +0200 Subject: [PATCH 273/293] HelpFormatter.write_text uses full width --- CHANGES.rst | 2 ++ src/click/formatting.py | 3 +-- tests/test_formatting.py | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 51f47dc3f..d5705f711 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -215,6 +215,8 @@ Unreleased - When defining a parameter, ``default`` is validated with ``multiple`` and ``nargs``. More validation is done for values being processed as well. :issue:`1806` +- ``HelpFormatter.write_text`` uses the full line width when wrapping + text. :issue:`1871` Version 7.1.2 diff --git a/src/click/formatting.py b/src/click/formatting.py index 1e59fc220..ddd2a2f82 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -195,12 +195,11 @@ def write_text(self, text: str) -> None: """Writes re-indented text into the buffer. This rewraps and preserves paragraphs. """ - text_width = max(self.width - self.current_indent, 11) indent = " " * self.current_indent self.write( wrap_text( text, - text_width, + self.width, initial_indent=indent, subsequent_indent=indent, preserve_paragraphs=True, diff --git a/tests/test_formatting.py b/tests/test_formatting.py index ec32bf92f..f957e0128 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -335,3 +335,13 @@ def test_formatting_with_options_metavar_empty(runner): cli = click.Command("cli", options_metavar="", params=[click.Argument(["var"])]) result = runner.invoke(cli, ["--help"]) assert "Usage: cli VAR\n" in result.output + + +def test_help_formatter_write_text(): + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit" + formatter = click.HelpFormatter(width=len(" Lorem ipsum dolor sit amet,")) + formatter.current_indent = 2 + formatter.write_text(text) + actual = formatter.getvalue() + expected = " Lorem ipsum dolor sit amet,\n consectetur adipiscing elit\n" + assert actual == expected From 804c71c4dbf22cb5fa5bda6c2a5fb902b790f229 Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 11 May 2021 07:01:24 -0700 Subject: [PATCH 274/293] update pre-commit monthly --- .pre-commit-config.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26a87e0c8..ddc2ffb36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +ci: + autoupdate_schedule: monthly repos: - repo: https://github.com/asottile/pyupgrade rev: v2.15.0 From b862cb17cb99e748bf0160583d00874aaabe2f04 Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 11 May 2021 13:37:26 -0700 Subject: [PATCH 275/293] update requirements --- requirements/dev.txt | 24 ++++++++++++------------ requirements/docs.in | 2 +- requirements/docs.txt | 8 ++++---- requirements/tests.txt | 6 +++--- requirements/typing.txt | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 650a9111b..6cb3b3383 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,9 +8,9 @@ alabaster==0.7.12 # via sphinx appdirs==1.4.4 # via virtualenv -attrs==20.3.0 +attrs==21.2.0 # via pytest -babel==2.9.0 +babel==2.9.1 # via sphinx certifi==2020.12.5 # via requests @@ -28,19 +28,19 @@ filelock==3.0.12 # via # tox # virtualenv -identify==2.2.3 +identify==2.2.4 # via pre-commit idna==2.10 # via requests imagesize==1.2.0 # via sphinx -importlib_metadata==4.0.1 +importlib-metadata==4.0.1 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest jinja2==2.11.3 # via sphinx -markupsafe==1.1.1 +markupsafe==2.0.0 # via jinja2 mypy-extensions==0.4.3 # via mypy @@ -54,7 +54,7 @@ packaging==20.9 # pytest # sphinx # tox -pallets-sphinx-themes==2.0.0rc1 +pallets-sphinx-themes==2.0.0 # via -r requirements/docs.in pep517==0.10.0 # via pip-tools @@ -70,13 +70,13 @@ py==1.10.0 # via # pytest # tox -pygments==2.8.1 +pygments==2.9.0 # via # sphinx # sphinx-tabs pyparsing==2.4.7 # via packaging -pytest==6.2.3 +pytest==6.2.4 # via -r requirements/tests.in pytz==2021.1 # via babel @@ -84,7 +84,7 @@ pyyaml==5.4.1 # via pre-commit requests==2.25.1 # via sphinx -six==1.15.0 +six==1.16.0 # via # tox # virtualenv @@ -121,15 +121,15 @@ toml==0.10.2 # pre-commit # pytest # tox -tox==3.23.0 +tox==3.23.1 # via -r requirements/dev.in typed-ast==1.4.3 # via mypy -typing-extensions==3.7.4.3 +typing-extensions==3.10.0.0 # via mypy urllib3==1.26.4 # via requests -virtualenv==20.4.3 +virtualenv==20.4.6 # via # pre-commit # tox diff --git a/requirements/docs.in b/requirements/docs.in index c1898bc7c..3ee050af0 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1,4 +1,4 @@ -Pallets-Sphinx-Themes >= 2.0.0rc1 +Pallets-Sphinx-Themes Sphinx sphinx-issues sphinxcontrib-log-cabinet diff --git a/requirements/docs.txt b/requirements/docs.txt index 556822522..06a47fc3b 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -6,7 +6,7 @@ # alabaster==0.7.12 # via sphinx -babel==2.9.0 +babel==2.9.1 # via sphinx certifi==2020.12.5 # via requests @@ -20,15 +20,15 @@ imagesize==1.2.0 # via sphinx jinja2==2.11.3 # via sphinx -markupsafe==1.1.1 +markupsafe==2.0.0 # via jinja2 packaging==20.9 # via # pallets-sphinx-themes # sphinx -pallets-sphinx-themes==2.0.0rc1 +pallets-sphinx-themes==2.0.0 # via -r requirements/docs.in -pygments==2.8.1 +pygments==2.9.0 # via # sphinx # sphinx-tabs diff --git a/requirements/tests.txt b/requirements/tests.txt index 1048cf159..d74bbe0ee 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -4,9 +4,9 @@ # # pip-compile requirements/tests.in # -attrs==20.3.0 +attrs==21.2.0 # via pytest -importlib_metadata==4.0.1 +importlib-metadata==4.0.1 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest @@ -18,7 +18,7 @@ py==1.10.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==6.2.3 +pytest==6.2.4 # via -r requirements/tests.in toml==0.10.2 # via pytest diff --git a/requirements/typing.txt b/requirements/typing.txt index 29e12e5e8..0e342aaad 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -10,5 +10,5 @@ mypy==0.812 # via -r requirements/typing.in typed-ast==1.4.3 # via mypy -typing-extensions==3.7.4.3 +typing-extensions==3.10.0.0 # via mypy From dfa63691631e733712d8a7d706e154f3d7b7cd5d Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 11 May 2021 13:38:04 -0700 Subject: [PATCH 276/293] release version 8.0.0 --- CHANGES.rst | 6 +++--- src/click/__init__.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index d5705f711..48f27bb74 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,9 +1,9 @@ .. currentmodule:: click -Version 8.0 ------------ +Version 8.0.0 +------------- -Unreleased +Released 2021-05-11 - Drop support for Python 2 and 3.5. - Colorama is always installed on Windows in order to provide style diff --git a/src/click/__init__.py b/src/click/__init__.py index a22d24eb6..ca45e1619 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -72,4 +72,4 @@ from .utils import get_text_stream from .utils import open_file -__version__ = "8.0.0rc1" +__version__ = "8.0.0" From b700cf61962d669d9610226e3f08a0dfd423e51f Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 11 May 2021 13:48:42 -0700 Subject: [PATCH 277/293] start version 8.0.1.dev0 --- CHANGES.rst | 6 ++++++ docs/conf.py | 2 +- src/click/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 48f27bb74..2c2545f54 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. currentmodule:: click +Version 8.0.1 +------------- + +Unreleased + + Version 8.0.0 ------------- diff --git a/docs/conf.py b/docs/conf.py index 129f6341c..d76b28a5e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,7 +11,7 @@ project = "Click" copyright = "2014 Pallets" author = "Pallets" -release, version = get_version("Click", version_length=1) +release, version = get_version("Click") # General -------------------------------------------------------------- diff --git a/src/click/__init__.py b/src/click/__init__.py index ca45e1619..33c53a6a5 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -72,4 +72,4 @@ from .utils import get_text_stream from .utils import open_file -__version__ = "8.0.0" +__version__ = "8.0.1.dev0" From 8ce81fab480d1b9fce84e360df1049110cd0c5cb Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 11 May 2021 16:27:57 -0700 Subject: [PATCH 278/293] rename default branch in files --- .github/workflows/tests.yaml | 4 ++-- CONTRIBUTING.rst | 4 ++-- docs/advanced.rst | 2 +- docs/commands.rst | 2 +- docs/quickstart.rst | 18 +++++++++--------- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d52088f92..b00a86634 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -2,7 +2,7 @@ name: Tests on: push: branches: - - master + - main - '*.x' paths-ignore: - 'docs/**' @@ -10,7 +10,7 @@ on: - '*.rst' pull_request: branches: - - master + - main - '*.x' paths-ignore: - 'docs/**' diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 30d8dcb45..5e9ee6368 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -141,12 +141,12 @@ Start coding $ git checkout -b your-branch-name origin/7.x If you're submitting a feature addition or change, branch off of the - "master" branch. + "main" branch. .. code-block:: text $ git fetch origin - $ git checkout -b your-branch-name origin/master + $ git checkout -b your-branch-name origin/main - Using your favorite editor, make your changes, `committing as you go`_. diff --git a/docs/advanced.rst b/docs/advanced.rst index 3ea5a155c..7f17e0ca8 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -13,7 +13,7 @@ Command Aliases --------------- Many tools support aliases for commands (see `Command alias example -`_). +`_). For instance, you can configure ``git`` to accept ``git ci`` as alias for ``git commit``. Other tools also support auto-discovery for aliases by automatically shortening them. diff --git a/docs/commands.rst b/docs/commands.rst index 5c021237c..b70992e51 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -422,7 +422,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. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index d971f132e..90cf46719 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -97,23 +97,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``: `Color support demo - `_ + `_ * ``termui``: `Terminal UI functions demo - `_ + `_ * ``imagepipe``: `Multi command chaining demo - `_ + `_ Basic Concepts - Creating a Command ----------------------------------- From 447b6bbb5440d1e59f685bcddca5abcb784e3923 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 12 May 2021 08:28:45 -0700 Subject: [PATCH 279/293] explicit reexports --- CHANGES.rst | 3 + setup.cfg | 3 - src/click/__init__.py | 134 +++++++++++++++++++++--------------------- 3 files changed, 70 insertions(+), 70 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2c2545f54..a8a616cb6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Version 8.0.1 Unreleased +- Mark top-level names as exported so type checking understand imports + in user projects. :issue:`1879` + Version 8.0.0 ------------- diff --git a/setup.cfg b/setup.cfg index 76ac39b0f..45c6b894b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -93,9 +93,6 @@ warn_unused_ignores = True warn_return_any = True warn_unreachable = True -[mypy-click] -no_implicit_reexport = False - [mypy-colorama.*] ignore_missing_imports = True diff --git a/src/click/__init__.py b/src/click/__init__.py index 33c53a6a5..5b8e0dfc7 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -4,72 +4,72 @@ around a simple API that does not come with too much magic and is composable. """ -from .core import Argument -from .core import BaseCommand -from .core import Command -from .core import CommandCollection -from .core import Context -from .core import Group -from .core import MultiCommand -from .core import Option -from .core import Parameter -from .decorators import argument -from .decorators import command -from .decorators import confirmation_option -from .decorators import group -from .decorators import help_option -from .decorators import make_pass_decorator -from .decorators import option -from .decorators import pass_context -from .decorators import pass_obj -from .decorators import password_option -from .decorators import version_option -from .exceptions import Abort -from .exceptions import BadArgumentUsage -from .exceptions import BadOptionUsage -from .exceptions import BadParameter -from .exceptions import ClickException -from .exceptions import FileError -from .exceptions import MissingParameter -from .exceptions import NoSuchOption -from .exceptions import UsageError -from .formatting import HelpFormatter -from .formatting import wrap_text -from .globals import get_current_context -from .parser import OptionParser -from .termui import clear -from .termui import confirm -from .termui import echo_via_pager -from .termui import edit -from .termui import get_terminal_size -from .termui import getchar -from .termui import launch -from .termui import pause -from .termui import progressbar -from .termui import prompt -from .termui import secho -from .termui import style -from .termui import unstyle -from .types import BOOL -from .types import Choice -from .types import DateTime -from .types import File -from .types import FLOAT -from .types import FloatRange -from .types import INT -from .types import IntRange -from .types import ParamType -from .types import Path -from .types import STRING -from .types import Tuple -from .types import UNPROCESSED -from .types import UUID -from .utils import echo -from .utils import format_filename -from .utils import get_app_dir -from .utils import get_binary_stream -from .utils import get_os_args -from .utils import get_text_stream -from .utils import open_file +from .core import Argument as Argument +from .core import BaseCommand as BaseCommand +from .core import Command as Command +from .core import CommandCollection as CommandCollection +from .core import Context as Context +from .core import Group as Group +from .core import MultiCommand as MultiCommand +from .core import Option as Option +from .core import Parameter as Parameter +from .decorators import argument as argument +from .decorators import command as command +from .decorators import confirmation_option as confirmation_option +from .decorators import group as group +from .decorators import help_option as help_option +from .decorators import make_pass_decorator as make_pass_decorator +from .decorators import option as option +from .decorators import pass_context as pass_context +from .decorators import pass_obj as pass_obj +from .decorators import password_option as password_option +from .decorators import version_option as version_option +from .exceptions import Abort as Abort +from .exceptions import BadArgumentUsage as BadArgumentUsage +from .exceptions import BadOptionUsage as BadOptionUsage +from .exceptions import BadParameter as BadParameter +from .exceptions import ClickException as ClickException +from .exceptions import FileError as FileError +from .exceptions import MissingParameter as MissingParameter +from .exceptions import NoSuchOption as NoSuchOption +from .exceptions import UsageError as UsageError +from .formatting import HelpFormatter as HelpFormatter +from .formatting import wrap_text as wrap_text +from .globals import get_current_context as get_current_context +from .parser import OptionParser as OptionParser +from .termui import clear as clear +from .termui import confirm as confirm +from .termui import echo_via_pager as echo_via_pager +from .termui import edit as edit +from .termui import get_terminal_size as get_terminal_size +from .termui import getchar as getchar +from .termui import launch as launch +from .termui import pause as pause +from .termui import progressbar as progressbar +from .termui import prompt as prompt +from .termui import secho as secho +from .termui import style as style +from .termui import unstyle as unstyle +from .types import BOOL as BOOL +from .types import Choice as Choice +from .types import DateTime as DateTime +from .types import File as File +from .types import FLOAT as FLOAT +from .types import FloatRange as FloatRange +from .types import INT as INT +from .types import IntRange as IntRange +from .types import ParamType as ParamType +from .types import Path as Path +from .types import STRING as STRING +from .types import Tuple as Tuple +from .types import UNPROCESSED as UNPROCESSED +from .types import UUID as UUID +from .utils import echo as echo +from .utils import format_filename as format_filename +from .utils import get_app_dir as get_app_dir +from .utils import get_binary_stream as get_binary_stream +from .utils import get_os_args as get_os_args +from .utils import get_text_stream as get_text_stream +from .utils import open_file as open_file __version__ = "8.0.1.dev0" From 5a74def4c506c044301a79a0dfc00593b047b662 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 12 May 2021 08:33:15 -0700 Subject: [PATCH 280/293] ctx.obj is marked Any instead of Optional[Any] This makes typing more convenient since it's a completely generic object and won't be None if users are using it. --- CHANGES.rst | 2 ++ src/click/core.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a8a616cb6..c3b342da9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,8 @@ Unreleased - Mark top-level names as exported so type checking understand imports in user projects. :issue:`1879` +- Annotate ``Context.obj`` as ``Any`` so type checking allows all + operations on the arbitrary object. :issue:` Version 8.0.0 diff --git a/src/click/core.py b/src/click/core.py index c57b3b866..411dd2324 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -308,7 +308,7 @@ def __init__( obj = parent.obj #: the user object stored. - self.obj: t.Optional[t.Any] = obj + self.obj: t.Any = obj self._meta: t.Dict[str, t.Any] = getattr(parent, "meta", {}) #: A dictionary (-like object) with defaults for parameters. From 5295f0402644a1e3748e8c5891638743c5eaa5cc Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 12 May 2021 09:52:07 -0700 Subject: [PATCH 281/293] fix typing that wasn't available in Python 3.6.0 --- CHANGES.rst | 3 ++- src/click/core.py | 10 +++++----- src/click/parser.py | 3 ++- src/click/types.py | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index c3b342da9..eccdd7dd9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,7 +8,8 @@ Unreleased - Mark top-level names as exported so type checking understand imports in user projects. :issue:`1879` - Annotate ``Context.obj`` as ``Any`` so type checking allows all - operations on the arbitrary object. :issue:` + operations on the arbitrary object. :issue:`1885` +- Fix some types that weren't available in Python 3.6.0. :issue:`1882` Version 8.0.0 diff --git a/src/click/core.py b/src/click/core.py index 411dd2324..ab325b2eb 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -50,7 +50,7 @@ V = t.TypeVar("V") -def _fast_exit(code: int) -> t.NoReturn: +def _fast_exit(code: int) -> "te.NoReturn": """Low-level exit that skips Python's cleanup but speeds up exit by about 10ms for things like shell completion. @@ -679,7 +679,7 @@ def lookup_default(self, name: str, call: bool = True) -> t.Optional[t.Any]: return None - def fail(self, message: str) -> t.NoReturn: + def fail(self, message: str) -> "te.NoReturn": """Aborts the execution of the program with a specific error message. @@ -687,11 +687,11 @@ def fail(self, message: str) -> t.NoReturn: """ raise UsageError(message, self) - def abort(self) -> t.NoReturn: + def abort(self) -> "te.NoReturn": """Aborts the script.""" raise Abort() - def exit(self, code: int = 0) -> t.NoReturn: + def exit(self, code: int = 0) -> "te.NoReturn": """Exits the application with a given exit code.""" raise Exit(code) @@ -977,7 +977,7 @@ def main( complete_var: t.Optional[str] = None, standalone_mode: "te.Literal[True]" = True, **extra: t.Any, - ) -> t.NoReturn: + ) -> "te.NoReturn": ... @typing.overload diff --git a/src/click/parser.py b/src/click/parser.py index ba1f4d3b0..c713a24a6 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -32,6 +32,7 @@ from .exceptions import UsageError if t.TYPE_CHECKING: + import typing_extensions as te from .core import Argument as CoreArgument from .core import Context from .core import Option as CoreOption @@ -62,7 +63,7 @@ def _unpack_args( rv: t.List[t.Union[str, t.Tuple[t.Optional[str], ...], None]] = [] spos: t.Optional[int] = None - def _fetch(c: t.Deque[V]) -> t.Optional[V]: + def _fetch(c: "te.Deque[V]") -> t.Optional[V]: try: if spos is None: return c.popleft() diff --git a/src/click/types.py b/src/click/types.py index 52baa055c..86aaae090 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -123,7 +123,7 @@ def fail( message: str, param: t.Optional["Parameter"] = None, ctx: t.Optional["Context"] = None, - ) -> t.NoReturn: + ) -> "t.NoReturn": """Helper method to fail with an invalid value message.""" raise BadParameter(message, ctx=ctx, param=param) From 0dcfc32faa8020fa57ae7b2c2a409ac979cdd1e6 Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 18 May 2021 18:11:43 -0700 Subject: [PATCH 282/293] ProgressBar inherits Generic --- CHANGES.rst | 2 ++ src/click/_termui_impl.py | 8 ++++---- src/click/termui.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index eccdd7dd9..62ac200c9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,6 +10,8 @@ Unreleased - Annotate ``Context.obj`` as ``Any`` so type checking allows all operations on the arbitrary object. :issue:`1885` - Fix some types that weren't available in Python 3.6.0. :issue:`1882` +- Fix type checking for iterating over ``ProgressBar`` object. + :issue:`1892` Version 8.0.0 diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 106051ef0..06cf2b775 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -32,7 +32,7 @@ AFTER_BAR = "\033[?25h\n" -class ProgressBar: +class ProgressBar(t.Generic[V]): def __init__( self, iterable: t.Optional[t.Iterable[V]], @@ -50,7 +50,7 @@ def __init__( color: t.Optional[bool] = None, update_min_steps: int = 1, width: int = 30, - ): + ) -> None: self.fill_char = fill_char self.empty_char = empty_char self.bar_template = bar_template @@ -101,7 +101,7 @@ def __enter__(self) -> "ProgressBar": def __exit__(self, exc_type, exc_value, tb): # type: ignore self.render_finish() - def __iter__(self) -> t.Iterable[V]: + def __iter__(self) -> t.Iterator[V]: if not self.entered: raise RuntimeError("You need to use progress bars in a with block.") self.render_progress() @@ -113,7 +113,7 @@ def __next__(self) -> V: # because `self.iter` is an iterable consumed by that generator, # so it is re-entry safe. Calling `next(self.generator())` # twice works and does "what you want". - return next(iter(self)) # type: ignore + return next(iter(self)) def render_finish(self) -> None: if self.is_hidden: diff --git a/src/click/termui.py b/src/click/termui.py index 1c1f22926..034fe6ed9 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -318,7 +318,7 @@ def progressbar( file: t.Optional[t.TextIO] = None, color: t.Optional[bool] = None, update_min_steps: int = 1, -) -> "ProgressBar": +) -> "ProgressBar[V]": """This function creates an iterable context manager that can be used to iterate over something while showing a progress bar. It will either iterate over the `iterable` or `length` items (that are counted From 3df0a979d500ae3072f60ed0679bbb67d8cb339c Mon Sep 17 00:00:00 2001 From: itsxaos <33079230+itsxaos@users.noreply.github.com> Date: Fri, 14 May 2021 08:53:35 +0200 Subject: [PATCH 283/293] Fix minor mistake in 8.0 changelog --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 62ac200c9..6b48c60a9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -117,7 +117,7 @@ Released 2021-05-11 renamed to ``shell_complete``. The function must take ``ctx, param, incomplete``, must do matching rather than return all values, and must return a list of strings or a list of - ``ShellComplete``. The old name and behavior is deprecated and + ``CompletionItem``. The old name and behavior is deprecated and will be removed in 8.1. - The env var values used to start completion have changed order. The shell now comes first, such as ``{shell}_source`` rather From d77dfb582aaebbd3576af9475779e7c2e5ef0799 Mon Sep 17 00:00:00 2001 From: Vincent Philippon Date: Wed, 12 May 2021 15:31:39 -0400 Subject: [PATCH 284/293] install importlib_metadata on Python < 3.8 Use environment markers to have importlib_metadata backport package installed automatically on Python < 3.8, easing user experience. --- CHANGES.rst | 2 ++ setup.py | 8 +++++++- src/click/decorators.py | 10 +--------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6b48c60a9..1058fab67 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,6 +12,8 @@ Unreleased - Fix some types that weren't available in Python 3.6.0. :issue:`1882` - Fix type checking for iterating over ``ProgressBar`` object. :issue:`1892` +- The ``importlib_metadata`` backport package is installed on Python < + 3.8. :issue:`1889` Version 8.0.0 diff --git a/setup.py b/setup.py index c5cde9863..0a74d4144 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,9 @@ from setuptools import setup -setup(name="click", install_requires=["colorama; platform_system == 'Windows'"]) +setup( + name="click", + install_requires=[ + "colorama; platform_system == 'Windows'", + "importlib-metadata; python_version < '3.8'", + ], +) diff --git a/src/click/decorators.py b/src/click/decorators.py index 8849b00e6..0c095ca13 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -370,15 +370,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: from importlib import metadata # type: ignore except ImportError: # Python < 3.8 - try: - import importlib_metadata as metadata # type: ignore - except ImportError: - metadata = None - - if metadata is None: - raise RuntimeError( - "Install 'importlib_metadata' to get the version on Python < 3.8." - ) + import importlib_metadata as metadata # type: ignore try: version = metadata.version(package_name) # type: ignore From 61563ceace91c5725ec1113cbb245224574f657f Mon Sep 17 00:00:00 2001 From: Vincent Philippon Date: Wed, 12 May 2021 15:45:24 -0400 Subject: [PATCH 285/293] Remove importlib_metadata from tests.in and run pip-compile importlib_metadata is now installed by the installation of Click, if needed, and doesn't need to be installed seperatly. --- requirements/dev.txt | 4 ---- requirements/tests.in | 1 - requirements/tests.txt | 4 ---- 3 files changed, 9 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 6cb3b3383..0e43049f6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -34,8 +34,6 @@ idna==2.10 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==4.0.1 - # via -r requirements/tests.in iniconfig==1.1.1 # via pytest jinja2==2.11.3 @@ -133,8 +131,6 @@ virtualenv==20.4.6 # via # pre-commit # tox -zipp==3.4.1 - # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/tests.in b/requirements/tests.in index dfeb6f4a3..e079f8a60 100644 --- a/requirements/tests.in +++ b/requirements/tests.in @@ -1,2 +1 @@ pytest -importlib_metadata diff --git a/requirements/tests.txt b/requirements/tests.txt index d74bbe0ee..4ff31e318 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -6,8 +6,6 @@ # attrs==21.2.0 # via pytest -importlib-metadata==4.0.1 - # via -r requirements/tests.in iniconfig==1.1.1 # via pytest packaging==20.9 @@ -22,5 +20,3 @@ pytest==6.2.4 # via -r requirements/tests.in toml==0.10.2 # via pytest -zipp==3.4.1 - # via importlib-metadata From 83af9bb52ef2d6865fb2d4543e45b59fd7108f0a Mon Sep 17 00:00:00 2001 From: Marc Schmitzer Date: Tue, 18 May 2021 10:42:59 +0200 Subject: [PATCH 286/293] argument with nargs=-1 and envvar prefers command line value --- CHANGES.rst | 2 ++ src/click/parser.py | 4 +++- tests/test_arguments.py | 13 +++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1058fab67..0b24de0ef 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,6 +14,8 @@ Unreleased :issue:`1892` - The ``importlib_metadata`` backport package is installed on Python < 3.8. :issue:`1889` +- Arguments with ``nargs=-1`` only use env var value if no command + line values are given. :issue:`1903` Version 8.0.0 diff --git a/src/click/parser.py b/src/click/parser.py index c713a24a6..7d995f774 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -233,7 +233,9 @@ def process( ) ) - if self.nargs == -1 and self.obj.envvar is not None: + if self.nargs == -1 and self.obj.envvar is not None and value == (): + # Replace empty tuple with None so that a value from the + # environment may be tried. value = None state.opts[self.dest] = value # type: ignore diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 2045f6f9c..f4d7afd56 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -194,6 +194,19 @@ def cmd(arg): assert result.return_value == expect +def test_nargs_envvar_only_if_values_empty(runner): + @click.command() + @click.argument("arg", envvar="X", nargs=-1) + def cli(arg): + return arg + + result = runner.invoke(cli, ["a", "b"], standalone_mode=False) + assert result.return_value == ("a", "b") + + result = runner.invoke(cli, env={"X": "a"}, standalone_mode=False) + assert result.return_value == ("a",) + + def test_empty_nargs(runner): @click.command() @click.argument("arg", nargs=-1) From 26c9b926dfd9ad9baa0b7ca764fc798a0149c455 Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 18 May 2021 19:33:21 -0700 Subject: [PATCH 287/293] update changelog about version_option package name must match installed name, or be passed with package_name= --- CHANGES.rst | 6 ++++-- src/click/decorators.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0b24de0ef..4438c1525 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -33,8 +33,10 @@ Released 2021-05-11 - Add an optional parameter to ``ProgressBar.update`` to set the ``current_item``. :issue:`1226`, :pr:`1332` - ``version_option`` uses ``importlib.metadata`` (or the - ``importlib_metadata`` backport) instead of ``pkg_resources``. - :issue:`1582` + ``importlib_metadata`` backport) instead of ``pkg_resources``. The + version is detected based on the package name, not the entry point + name. The Python package name must match the installed package + name, or be passed with ``package_name=``. :issue:`1582` - If validation fails for a prompt with ``hide_input=True``, the value is not shown in the error message. :issue:`1460` - An ``IntRange`` or ``FloatRange`` option shows the accepted range in diff --git a/src/click/decorators.py b/src/click/decorators.py index 0c095ca13..5940e69f2 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -330,7 +330,10 @@ def version_option( value for messages. .. versionchanged:: 8.0 - Use :mod:`importlib.metadata` instead of ``pkg_resources``. + Use :mod:`importlib.metadata` instead of ``pkg_resources``. The + version is detected based on the package name, not the entry + point name. The Python package name must match the installed + package name, or be passed with ``package_name=``. """ if message is None: message = _("%(prog)s, version %(version)s") From ad52b19c84ab47cf6ec4df25b4e89ac939c3b253 Mon Sep 17 00:00:00 2001 From: David Lord Date: Tue, 18 May 2021 21:18:28 -0700 Subject: [PATCH 288/293] detect type from flag_value --- CHANGES.rst | 2 ++ src/click/core.py | 32 +++++++++++++++----------------- src/click/types.py | 5 +---- tests/test_options.py | 7 +++++++ 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4438c1525..7f103d054 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -16,6 +16,8 @@ Unreleased 3.8. :issue:`1889` - Arguments with ``nargs=-1`` only use env var value if no command line values are given. :issue:`1903` +- Flag options guess their type from ``flag_value`` if given, like + regular options do from ``default``. :issue:`1886` Version 8.0.0 diff --git a/src/click/core.py b/src/click/core.py index ab325b2eb..63506bab9 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -13,6 +13,7 @@ from gettext import ngettext from itertools import repeat +from . import types from ._unicodefun import _verify_python_env from .exceptions import Abort from .exceptions import BadParameter @@ -30,11 +31,6 @@ from .termui import confirm from .termui import prompt from .termui import style -from .types import _NumberRangeBase -from .types import BOOL -from .types import convert_type -from .types import IntRange -from .types import ParamType from .utils import _detect_program_name from .utils import _expand_args from .utils import echo @@ -2010,7 +2006,7 @@ class Parameter: def __init__( self, param_decls: t.Optional[t.Sequence[str]] = None, - type: t.Optional[t.Union["ParamType", t.Any]] = None, + type: t.Optional[t.Union[types.ParamType, t.Any]] = None, required: bool = False, default: t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]] = None, callback: t.Optional[t.Callable[[Context, "Parameter", t.Any], t.Any]] = None, @@ -2035,8 +2031,7 @@ def __init__( self.name, self.opts, self.secondary_opts = self._parse_decls( param_decls or (), expose_value ) - - self.type = convert_type(type, default) + self.type = types.convert_type(type, default) # Default nargs to what the type tells us if we have that # information available. @@ -2439,6 +2434,9 @@ class Option(Parameter): context. :param help: the help string. :param hidden: hide this option from help outputs. + + .. versionchanged:: 8.0.1 + ``type`` is detected from ``flag_value`` if given. """ param_type_name = "option" @@ -2456,7 +2454,7 @@ def __init__( multiple: bool = False, count: bool = False, allow_from_autoenv: bool = True, - type: t.Optional[t.Union["ParamType", t.Any]] = None, + type: t.Optional[t.Union[types.ParamType, t.Any]] = None, help: t.Optional[str] = None, hidden: bool = False, show_choices: bool = True, @@ -2507,20 +2505,20 @@ def __init__( if flag_value is None: flag_value = not self.default + if is_flag and type is None: + # Re-guess the type from the flag value instead of the + # default. + self.type = types.convert_type(None, flag_value) + self.is_flag: bool = is_flag + self.is_bool_flag = isinstance(self.type, types.BoolParamType) self.flag_value: t.Any = flag_value - if self.is_flag and isinstance(self.flag_value, bool) and type in [None, bool]: - self.type: "ParamType" = BOOL - self.is_bool_flag = True - else: - self.is_bool_flag = False - # Counting self.count = count if count: if type is None: - self.type = IntRange(min=0) + self.type = types.IntRange(min=0) if default_is_missing: self.default = 0 @@ -2725,7 +2723,7 @@ def _write_opts(opts: t.Sequence[str]) -> str: extra.append(_("default: {default}").format(default=default_string)) - if isinstance(self.type, _NumberRangeBase): + if isinstance(self.type, types._NumberRangeBase): range_str = self.type._describe_range() if range_str: diff --git a/src/click/types.py b/src/click/types.py index 86aaae090..21f0e4f77 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -1000,10 +1000,7 @@ def convert_type(ty: t.Optional[t.Any], default: t.Optional[t.Any] = None) -> Pa if ty is float: return FLOAT - # Booleans are only okay if not guessed. For is_flag options with - # flag_value, default=True indicates which flag_value is the - # default. - if ty is bool and not guessed_type: + if ty is bool: return BOOL if guessed_type: diff --git a/tests/test_options.py b/tests/test_options.py index 94e5eb8d7..50e6b5ab0 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -756,3 +756,10 @@ def cli(opt, a, b): result = runner.invoke(cli, args, standalone_mode=False, catch_exceptions=False) assert result.return_value == expect + + +def test_type_from_flag_value(): + param = click.Option(["-a", "x"], default=True, flag_value=4) + assert param.type is click.INT + param = click.Option(["-b", "x"], flag_value=8) + assert param.type is click.INT From 0c108f22c7f87f6ea4657ffa2a54516dcd3548f6 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 19 May 2021 07:47:46 -0700 Subject: [PATCH 289/293] document values passed to types --- CHANGES.rst | 2 ++ docs/parameters.rst | 15 ++++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7f103d054..b59fc443f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -18,6 +18,8 @@ Unreleased line values are given. :issue:`1903` - Flag options guess their type from ``flag_value`` if given, like regular options do from ``default``. :issue:`1886` +- Added documentation that custom parameter types may be passed + already valid values in addition to strings. :issue:`1898` Version 8.0.0 diff --git a/docs/parameters.rst b/docs/parameters.rst index 27c84ea6f..b3604e750 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -118,19 +118,15 @@ integers. name = "integer" def convert(self, value, param, ctx): + if isinstance(value, int): + return value + try: if value[:2].lower() == "0x": return int(value[2:], 16) elif value[:1] == "0": return int(value, 8) return int(value, 10) - except TypeError: - self.fail( - "expected string for int() conversion, got " - f"{value!r} of type {type(value).__name__}", - param, - ctx, - ) except ValueError: self.fail(f"{value!r} is not a valid integer", param, ctx) @@ -140,3 +136,8 @@ The :attr:`~ParamType.name` attribute is optional and is used for documentation. Call :meth:`~ParamType.fail` if conversion fails. The ``param`` and ``ctx`` arguments may be ``None`` in some cases such as prompts. + +Values from user input or the command line will be strings, but default +values and Python arguments may already be the correct type. The custom +type should check at the top if the value is already valid and pass it +through to support those cases. From 0db91e220517c767e1e81fd37d1dd7ce83fa77b7 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 19 May 2021 10:39:53 -0700 Subject: [PATCH 290/293] return resolved name, not original name --- CHANGES.rst | 5 +++++ docs/advanced.rst | 6 +++++- examples/aliases/aliases.py | 5 +++++ src/click/core.py | 2 +- tests/test_commands.py | 14 ++++++++++++++ 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b59fc443f..6d807d18d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,6 +20,11 @@ Unreleased regular options do from ``default``. :issue:`1886` - Added documentation that custom parameter types may be passed already valid values in addition to strings. :issue:`1898` +- Resolving commands returns the name that was given, not + ``command.name``, fixing an unintended change to help text and + ``default_map`` lookups. When using patterns like ``AliasedGroup``, + override ``resolve_command`` to change the name that is returned if + needed. :issue:`1895` Version 8.0.0 diff --git a/docs/advanced.rst b/docs/advanced.rst index 7f17e0ca8..3df492a77 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -35,7 +35,6 @@ it would accept ``pus`` as an alias (so long as it was unique): .. click:example:: class AliasedGroup(click.Group): - def get_command(self, ctx, cmd_name): rv = click.Group.get_command(self, ctx, cmd_name) if rv is not None: @@ -48,6 +47,11 @@ it would accept ``pus`` as an alias (so long as it was unique): return click.Group.get_command(self, ctx, matches[0]) ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") + def resolve_command(self, ctx, args): + # always return the full command name + _, cmd, args = super().resolve_command(ctx, args) + return cmd.name, cmd, args + And it can then be used like this: .. click:example:: diff --git a/examples/aliases/aliases.py b/examples/aliases/aliases.py index c3da657fd..af3caa60b 100644 --- a/examples/aliases/aliases.py +++ b/examples/aliases/aliases.py @@ -67,6 +67,11 @@ def get_command(self, ctx, cmd_name): return click.Group.get_command(self, ctx, matches[0]) ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") + def resolve_command(self, ctx, args): + # always return the command's name, not the alias + _, cmd, args = super().resolve_command(ctx, args) + return cmd.name, cmd, args + def read_config(ctx, param, value): """Callback that is used whenever --config is passed. We use this to diff --git a/src/click/core.py b/src/click/core.py index 63506bab9..3a7533bea 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1717,7 +1717,7 @@ def resolve_command( if split_opt(cmd_name)[0]: self.parse_args(ctx, ctx.args) ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name)) - return cmd.name if cmd else None, cmd, args[1:] + return cmd_name if cmd else None, cmd, args[1:] def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: """Given a context and a command name, this returns a diff --git a/tests/test_commands.py b/tests/test_commands.py index 79f87faa9..9ebf6121c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -285,6 +285,10 @@ class AliasedGroup(click.Group): def get_command(self, ctx, cmd_name): return push + def resolve_command(self, ctx, args): + _, command, args = super().resolve_command(ctx, args) + return command.name, command, args + cli = AliasedGroup() @cli.command() @@ -296,6 +300,16 @@ def push(): assert result.output.startswith("Usage: root push [OPTIONS]") +def test_group_add_command_name(runner): + cli = click.Group("cli") + cmd = click.Command("a", params=[click.Option(["-x"], required=True)]) + cli.add_command(cmd, "b") + # Check that the command is accessed through the registered name, + # not the original name. + result = runner.invoke(cli, ["b"], default_map={"b": {"x": 3}}) + assert result.exit_code == 0 + + def test_unprocessed_options(runner): @click.command(context_settings=dict(ignore_unknown_options=True)) @click.argument("args", nargs=-1, type=click.UNPROCESSED) From 985ca1651be08747ce713eb52568f8c8a932131f Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 19 May 2021 11:35:07 -0700 Subject: [PATCH 291/293] show help text with invalid default --- CHANGES.rst | 2 ++ src/click/core.py | 28 ++++++++++++++++++++++++---- tests/test_basic.py | 17 +++++++++++++++++ tests/test_options.py | 2 +- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6d807d18d..9bc6e54af 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -25,6 +25,8 @@ Unreleased ``default_map`` lookups. When using patterns like ``AliasedGroup``, override ``resolve_command`` to change the name that is returned if needed. :issue:`1895` +- If a default value is invalid, it does not prevent showing help + text. :issue:`1889` Version 8.0.0 diff --git a/src/click/core.py b/src/click/core.py index 3a7533bea..7000cffc1 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2196,6 +2196,10 @@ def get_default( :param call: If the default is a callable, call it. Disable to return the callable instead. + .. versionchanged:: 8.0.1 + Type casting can fail in resilient parsing mode. Invalid + defaults will not prevent showing help text. + .. versionchanged:: 8.0 Looks at ``ctx.default_map`` first. @@ -2214,7 +2218,13 @@ def get_default( value = value() - return self.type_cast_value(ctx, value) + try: + return self.type_cast_value(ctx, value) + except BadParameter: + if ctx.resilient_parsing: + return value + + raise def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: raise NotImplementedError() @@ -2700,14 +2710,24 @@ def _write_opts(opts: t.Sequence[str]) -> str: ) extra.append(_("env var: {var}").format(var=var_str)) - default_value = self.get_default(ctx, call=False) + # Temporarily enable resilient parsing to avoid type casting + # failing for the default. Might be possible to extend this to + # help formatting in general. + resilient = ctx.resilient_parsing + ctx.resilient_parsing = True + + try: + default_value = self.get_default(ctx, call=False) + finally: + ctx.resilient_parsing = resilient + show_default_is_str = isinstance(self.show_default, str) if show_default_is_str or ( default_value is not None and (self.show_default or ctx.show_default) ): if show_default_is_str: - default_string: t.Union[str, t.Any] = f"({self.show_default})" + default_string = f"({self.show_default})" elif isinstance(default_value, (list, tuple)): default_string = ", ".join(str(d) for d in default_value) elif callable(default_value): @@ -2719,7 +2739,7 @@ def _write_opts(opts: t.Sequence[str]) -> str: (self.opts if self.default else self.secondary_opts)[0] )[1] else: - default_string = default_value + default_string = str(default_value) extra.append(_("default: {default}").format(default=default_string)) diff --git a/tests/test_basic.py b/tests/test_basic.py index c35e69ad1..c38c1afd4 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -547,3 +547,20 @@ def cmd(): result = runner.invoke(cli, ["--help"]) assert "Summary line without period" in result.output assert "Here is a sentence." not in result.output + + +def test_help_invalid_default(runner): + cli = click.Command( + "cli", + params=[ + click.Option( + ["-a"], + type=click.Path(exists=True), + default="not found", + show_default=True, + ), + ], + ) + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "default: not found" in result.output diff --git a/tests/test_options.py b/tests/test_options.py index 50e6b5ab0..323d1c422 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -553,7 +553,7 @@ def cmd(config): def test_argument_custom_class(runner): class CustomArgument(click.Argument): - def get_default(self, ctx): + def get_default(self, ctx, call=True): """a dumb override of a default value for testing""" return "I am a default" From 6439aaedbd886d2db6095e7677c7920b86839a88 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 19 May 2021 13:50:30 -0700 Subject: [PATCH 292/293] flag to control Windows pattern expansion --- CHANGES.rst | 4 ++++ src/click/core.py | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9bc6e54af..a0a6e2bac 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -27,6 +27,10 @@ Unreleased needed. :issue:`1895` - If a default value is invalid, it does not prevent showing help text. :issue:`1889` +- Pass ``windows_expand_args=False`` when calling the main command to + disable pattern expansion on Windows. There is no way to escape + patterns in CMD, so if the program needs to pass them on as-is then + expansion must be disabled. :issue:`1901` Version 8.0.0 diff --git a/src/click/core.py b/src/click/core.py index 7000cffc1..e2ccf59a3 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -993,6 +993,7 @@ def main( prog_name: t.Optional[str] = None, complete_var: t.Optional[str] = None, standalone_mode: bool = True, + windows_expand_args: bool = True, **extra: t.Any, ) -> t.Any: """This is the way to invoke a script with all the bells and @@ -1021,9 +1022,15 @@ def main( propagated to the caller and the return value of this function is the return value of :meth:`invoke`. + :param windows_expand_args: Expand glob patterns, user dir, and + env vars in command line args on Windows. :param extra: extra keyword arguments are forwarded to the context constructor. See :class:`Context` for more information. + .. versionchanged:: 8.0.1 + Added the ``windows_expand_args`` parameter to allow + disabling command line arg expansion on Windows. + .. versionchanged:: 8.0 When taking arguments from ``sys.argv`` on Windows, glob patterns, user dir, and env vars are expanded. @@ -1038,7 +1045,7 @@ def main( if args is None: args = sys.argv[1:] - if os.name == "nt": + if os.name == "nt" and windows_expand_args: args = _expand_args(args) else: args = list(args) From 3149679ff1ba63e7a88287737f08fefb9193e8a8 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 19 May 2021 13:58:08 -0700 Subject: [PATCH 293/293] release version 8.0.1 --- CHANGES.rst | 2 +- src/click/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a0a6e2bac..9f0ae9636 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,7 +3,7 @@ Version 8.0.1 ------------- -Unreleased +Released 2021-05-19 - Mark top-level names as exported so type checking understand imports in user projects. :issue:`1879` diff --git a/src/click/__init__.py b/src/click/__init__.py index 5b8e0dfc7..9e0afb230 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -72,4 +72,4 @@ from .utils import get_text_stream as get_text_stream from .utils import open_file as open_file -__version__ = "8.0.1.dev0" +__version__ = "8.0.1"