diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 86e010d..90f94bc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,9 @@ version: 2 updates: -- package-ecosystem: pip +- package-ecosystem: "github-actions" directory: "/" schedule: - interval: monthly - time: "08:00" - open-pull-requests-limit: 99 + interval: "monthly" + day: "monday" + time: "16:00" + timezone: "UTC" diff --git a/.github/workflows/lock.yaml b/.github/workflows/lock.yaml index 7128f38..b4f7633 100644 --- a/.github/workflows/lock.yaml +++ b/.github/workflows/lock.yaml @@ -8,8 +8,8 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v2 + - uses: dessant/lock-threads@v3 with: github-token: ${{ github.token }} - issue-lock-inactive-days: 14 - pr-lock-inactive-days: 14 + issue-inactive-days: 14 + pr-inactive-days: 14 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b00a866..64268e1 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -24,32 +24,27 @@ 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: Linux, python: '3.10', os: ubuntu-latest, tox: py310} + - {name: Windows, python: '3.10', os: windows-latest, tox: py310} + - {name: Mac, python: '3.10', os: macos-latest, tox: py310} + - {name: '3.11-dev', python: '3.11-dev', os: ubuntu-latest, tox: py311} + - {name: '3.9', python: '3.9', os: ubuntu-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: Typing, python: '3.9', os: ubuntu-latest, tox: typing} + - {name: 'PyPy', python: 'pypy-3.7', os: ubuntu-latest, tox: pypy37} + - {name: Typing, python: '3.10', os: ubuntu-latest, tox: typing} steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v3 with: python-version: ${{ matrix.python }} + cache: 'pip' + cache-dependency-path: 'requirements/*.txt' - 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@v2 - with: - path: ${{ steps.pip-cache.outputs.dir }} - key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('setup.py') }}|${{ hashFiles('requirements/*.txt') }} - name: cache mypy uses: actions/cache@v2 with: diff --git a/.gitignore b/.gitignore index fc5a65f..c1a1723 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.idea/ /.vscode/ +.DS_Store/ /env/ /venv/ __pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a1f9e7..472866d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,29 +1,31 @@ ci: + autoupdate_branch: "8.1.x" autoupdate_schedule: monthly repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.29.0 + rev: v2.32.0 hooks: - id: pyupgrade - args: ["--py36-plus"] + args: ["--py37-plus"] - repo: https://github.com/asottile/reorder_python_imports - rev: v2.6.0 + rev: v3.1.0 hooks: - id: reorder-python-imports args: ["--application-directories", "src"] + additional_dependencies: ["setuptools>60.9"] - repo: https://github.com/psf/black - rev: 21.9b0 + rev: 22.3.0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + rev: 4.0.1 hooks: - id: flake8 additional_dependencies: - flake8-bugbear - flake8-implicit-str-concat - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.2.0 hooks: - id: fix-byte-order-marker - id: trailing-whitespace diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 0c36363..346900b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,4 +1,8 @@ version: 2 +build: + os: ubuntu-20.04 + tools: + python: "3.10" python: install: - requirements: requirements/docs.txt diff --git a/CHANGES.rst b/CHANGES.rst index b65e91d..1766567 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,115 @@ .. currentmodule:: click +Version 8.1.3 +------------- + +Released 2022-04-28 + +- Use verbose form of ``typing.Callable`` for ``@command`` and + ``@group``. :issue:`2255` +- Show error when attempting to create an option with + ``multiple=True, is_flag=True``. Use ``count`` instead. + :issue:`2246` + + +Version 8.1.2 +------------- + +Released 2022-03-31 + +- Fix error message for readable path check that was mixed up with the + executable check. :pr:`2236` +- Restore parameter order for ``Path``, placing the ``executable`` + parameter at the end. It is recommended to use keyword arguments + instead of positional arguments. :issue:`2235` + + +Version 8.1.1 +------------- + +Released 2022-03-30 + +- Fix an issue with decorator typing that caused type checking to + report that a command was not callable. :issue:`2227` + + +Version 8.1.0 +------------- + +Released 2022-03-28 + +- Drop support for Python 3.6. :pr:`2129` +- Remove previously deprecated code. :pr:`2130` + + - ``Group.resultcallback`` is renamed to ``result_callback``. + - ``autocompletion`` parameter to ``Command`` is renamed to + ``shell_complete``. + - ``get_terminal_size`` is removed, use + ``shutil.get_terminal_size`` instead. + - ``get_os_args`` is removed, use ``sys.argv[1:]`` instead. + +- Rely on :pep:`538` and :pep:`540` to handle selecting UTF-8 encoding + instead of ASCII. Click's locale encoding detection is removed. + :issue:`2198` +- Single options boolean flags with ``show_default=True`` only show + the default if it is ``True``. :issue:`1971` +- The ``command`` and ``group`` decorators can be applied with or + without parentheses. :issue:`1359` +- The ``Path`` type can check whether the target is executable. + :issue:`1961` +- ``Command.show_default`` overrides ``Context.show_default``, instead + of the other way around. :issue:`1963` +- Parameter decorators and ``@group`` handles ``cls=None`` the same as + not passing ``cls``. ``@option`` handles ``help=None`` the same as + not passing ``help``. :issue:`#1959` +- A flag option with ``required=True`` requires that the flag is + passed instead of choosing the implicit default value. :issue:`1978` +- Indentation in help text passed to ``Option`` and ``Command`` is + cleaned the same as using the ``@option`` and ``@command`` + decorators does. A command's ``epilog`` and ``short_help`` are also + processed. :issue:`1985` +- Store unprocessed ``Command.help``, ``epilog`` and ``short_help`` + strings. Processing is only done when formatting help text for + output. :issue:`2149` +- Allow empty str input for ``prompt()`` when + ``confirmation_prompt=True`` and ``default=""``. :issue:`2157` +- Windows glob pattern expansion doesn't fail if a value is an invalid + pattern. :issue:`2195` +- It's possible to pass a list of ``params`` to ``@command``. Any + params defined with decorators are appended to the passed params. + :issue:`2131`. +- ``@command`` decorator is annotated as returning the correct type if + a ``cls`` argument is used. :issue:`2211` +- A ``Group`` with ``invoke_without_command=True`` and ``chain=False`` + will invoke its result callback with the group function's return + value. :issue:`2124` +- ``to_info_dict`` will not fail if a ``ParamType`` doesn't define a + ``name``. :issue:`2168` +- Shell completion prioritizes option values with option prefixes over + new options. :issue:`2040` +- Options that get an environment variable value using + ``autoenvvar_prefix`` treat an empty value as ``None``, consistent + with a direct ``envvar``. :issue:`2146` + + +Version 8.0.4 +------------- + +Released 2022-02-18 + +- ``open_file`` recognizes ``Path("-")`` as a standard stream, the + same as the string ``"-"``. :issue:`2106` +- The ``option`` and ``argument`` decorators preserve the type + annotation of the decorated function. :pr:`2155` +- A callable default value can customize its help text by overriding + ``__str__`` instead of always showing ``(dynamic)``. :issue:`2099` +- Fix a typo in the Bash completion script that affected file and + directory completion. If this script was generated by a previous + version, it should be regenerated. :issue:`2163` +- Fix typing for ``echo`` and ``secho`` file argument. + :issue:`2174, 2185` + + Version 8.0.3 ------------- diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index 5a91862..0000000 Binary files a/docs/.DS_Store and /dev/null differ diff --git a/docs/api.rst b/docs/api.rst index 5133085..09efd03 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -63,8 +63,6 @@ Utilities .. autofunction:: pause -.. autofunction:: get_terminal_size - .. autofunction:: get_binary_stream .. autofunction:: get_text_stream diff --git a/docs/options.rst b/docs/options.rst index 277a98b..5c23bad 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -109,6 +109,27 @@ To show the default values when showing command help, use ``show_default=True`` invoke(dots, args=['--help']) +For single option boolean flags, the default remains hidden if the default +value is False. + +.. click:example:: + + @click.command() + @click.option('--n', default=1, show_default=True) + @click.option("--gr", is_flag=True, show_default=True, default=False, help="Greet the world.") + @click.option("--br", is_flag=True, show_default=True, default=True, help="Add a thematic break") + def dots(n, gr, br): + if gr: + click.echo('Hello world!') + click.echo('.' * n) + if br: + click.echo('-' * n) + +.. click:run:: + + invoke(dots, args=['--help']) + + Multi Value Options ------------------- @@ -538,10 +559,10 @@ parameter ``--foo`` was required and defined before, you would need to specify it for ``--version`` to work. For more information, see :ref:`callback-evaluation-order`. -A callback is a function that is invoked with two parameters: the current -:class:`Context` and the value. The context provides some useful features -such as quitting the application and gives access to other already -processed parameters. +A callback is a function that is invoked with three parameters: the +current :class:`Context`, the current :class:`Parameter`, and the value. +The context provides some useful features such as quitting the +application and gives access to other already processed parameters. Here an example for a ``--version`` flag: diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 90cf467..17606bc 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -163,7 +163,7 @@ functional at least on a basic level even if everything is completely broken. What this means is that the :func:`echo` function applies some error -correction in case the terminal is misconfigured instead of dying with an +correction in case the terminal is misconfigured instead of dying with a :exc:`UnicodeError`. The echo function also supports color and other styles in output. It diff --git a/docs/shell-completion.rst b/docs/shell-completion.rst index 699d25d..ebf73c3 100644 --- a/docs/shell-completion.rst +++ b/docs/shell-completion.rst @@ -132,6 +132,8 @@ with the incomplete value. .. code-block:: python class EnvVarType(ParamType): + name = "envvar" + def shell_complete(self, ctx, param, incomplete): return [ CompletionItem(name) diff --git a/examples/README b/examples/README index 6be3296..566153f 100644 --- a/examples/README +++ b/examples/README @@ -9,4 +9,4 @@ Click Examples through the wrong interpreter. For more information about this see the documentation: - https://click.palletsprojects.com/en/7.x/setuptools/ + https://click.palletsprojects.com/setuptools/ diff --git a/requirements/dev.in b/requirements/dev.in index 2588467..99f5942 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -1,6 +1,6 @@ -r docs.in -r tests.in -r typing.in -pip-tools +pip-compile-multi pre-commit tox diff --git a/requirements/dev.txt b/requirements/dev.txt index 48f65e4..c9bcd60 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,141 +1,58 @@ +# SHA1:54b5b77ec8c7a0064ffa93b2fd16cb0130ba177c # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile-multi # To update, run: # -# pip-compile requirements/dev.in +# pip-compile-multi # -alabaster==0.7.12 - # via sphinx -attrs==21.2.0 - # via pytest -babel==2.9.1 - # via sphinx -backports.entry-points-selectable==1.1.0 - # via virtualenv -certifi==2021.5.30 - # via requests +-r docs.txt +-r tests.txt +-r typing.txt cfgv==3.3.1 # via pre-commit -charset-normalizer==2.0.6 - # via requests -click==8.0.1 - # via pip-tools -distlib==0.3.3 - # via virtualenv -docutils==0.16 +click==8.1.2 # via - # sphinx - # sphinx-tabs -filelock==3.3.0 + # pip-compile-multi + # pip-tools +distlib==0.3.4 + # via virtualenv +filelock==3.6.0 # via # tox # virtualenv -identify==2.3.0 +identify==2.5.0 # via pre-commit -idna==3.2 - # via requests -imagesize==1.2.0 - # via sphinx -iniconfig==1.1.1 - # via pytest -jinja2==3.0.2 - # via sphinx -markupsafe==2.0.1 - # via jinja2 -mypy-extensions==0.4.3 - # via mypy -mypy==0.910 - # via -r requirements/typing.in nodeenv==1.6.0 # via pre-commit -packaging==21.0 - # via - # pallets-sphinx-themes - # pytest - # sphinx - # tox -pallets-sphinx-themes==2.0.1 - # via -r requirements/docs.in -pep517==0.11.0 +pep517==0.12.0 # via pip-tools -pip-tools==6.3.0 +pip-compile-multi==2.4.5 # via -r requirements/dev.in -platformdirs==2.4.0 +pip-tools==6.6.0 + # via pip-compile-multi +platformdirs==2.5.2 # via virtualenv -pluggy==1.0.0 - # via - # pytest - # tox -pre-commit==2.15.0 +pre-commit==2.18.1 # via -r requirements/dev.in -py==1.10.0 - # via - # pytest - # tox -pygments==2.10.0 - # via - # sphinx - # sphinx-tabs -pyparsing==2.4.7 - # via packaging -pytest==6.2.5 - # via -r requirements/tests.in -pytz==2021.3 - # via babel -pyyaml==5.4.1 +pyyaml==6.0 # via pre-commit -requests==2.26.0 - # via sphinx six==1.16.0 # via # tox # virtualenv -snowballstemmer==2.1.0 - # via sphinx -sphinx-issues==1.2.0 - # via -r requirements/docs.in -sphinx-tabs==3.2.0 - # via -r requirements/docs.in -sphinx==4.2.0 - # 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==2.0.0 - # 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.5 - # via sphinx toml==0.10.2 # via - # mypy # pre-commit - # pytest # tox -tomli==1.2.1 - # via pep517 -tox==3.24.4 +toposort==1.7 + # via pip-compile-multi +tox==3.25.0 # via -r requirements/dev.in -typing-extensions==3.10.0.2 - # via mypy -urllib3==1.26.7 - # via requests -virtualenv==20.8.1 +virtualenv==20.14.1 # via # pre-commit # tox -wheel==0.37.0 +wheel==0.37.1 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/docs.txt b/requirements/docs.txt index 00eefc0..2c10a6c 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,58 +1,59 @@ +# SHA1:34fd4ca6516e97c7348e6facdd9c4ebb68209d1c # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile-multi # To update, run: # -# pip-compile requirements/docs.in +# pip-compile-multi # alabaster==0.7.12 # via sphinx -babel==2.9.1 +babel==2.10.1 # via sphinx -certifi==2021.5.30 +certifi==2021.10.8 # via requests -charset-normalizer==2.0.6 +charset-normalizer==2.0.12 # via requests -docutils==0.16 +docutils==0.17.1 # via # sphinx # sphinx-tabs -idna==3.2 +idna==3.3 # via requests -imagesize==1.2.0 +imagesize==1.3.0 # via sphinx -jinja2==3.0.2 +jinja2==3.1.2 # via sphinx -markupsafe==2.0.1 +markupsafe==2.1.1 # via jinja2 -packaging==21.0 +packaging==21.3 # via # pallets-sphinx-themes # sphinx -pallets-sphinx-themes==2.0.1 +pallets-sphinx-themes==2.0.2 # via -r requirements/docs.in -pygments==2.10.0 +pygments==2.12.0 # via # sphinx # sphinx-tabs -pyparsing==2.4.7 +pyparsing==3.0.8 # via packaging -pytz==2021.3 +pytz==2022.1 # via babel -requests==2.26.0 +requests==2.27.1 # via sphinx -snowballstemmer==2.1.0 +snowballstemmer==2.2.0 # via sphinx -sphinx-issues==1.2.0 - # via -r requirements/docs.in -sphinx-tabs==3.2.0 - # via -r requirements/docs.in -sphinx==4.2.0 +sphinx==4.5.0 # via # -r requirements/docs.in # pallets-sphinx-themes # sphinx-issues # sphinx-tabs # sphinxcontrib-log-cabinet +sphinx-issues==3.0.1 + # via -r requirements/docs.in +sphinx-tabs==3.3.1 + # via -r requirements/docs.in sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 @@ -67,8 +68,5 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -urllib3==1.26.7 +urllib3==1.26.9 # via requests - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements/tests.txt b/requirements/tests.txt index b899729..4cd3fe9 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,22 +1,23 @@ +# SHA1:0eaa389e1fdb3a1917c0f987514bd561be5718ee # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile-multi # To update, run: # -# pip-compile requirements/tests.in +# pip-compile-multi # -attrs==21.2.0 +attrs==21.4.0 # via pytest iniconfig==1.1.1 # via pytest -packaging==21.0 +packaging==21.3 # via pytest pluggy==1.0.0 # via pytest -py==1.10.0 +py==1.11.0 # via pytest -pyparsing==2.4.7 +pyparsing==3.0.8 # via packaging -pytest==6.2.5 +pytest==7.1.2 # via -r requirements/tests.in -toml==0.10.2 +tomli==2.0.1 # via pytest diff --git a/requirements/typing.txt b/requirements/typing.txt index f5af819..2d97fef 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -1,14 +1,15 @@ +# SHA1:7983aaa01d64547827c20395d77e248c41b2572f # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile-multi # To update, run: # -# pip-compile requirements/typing.in +# pip-compile-multi # +mypy==0.950 + # via -r requirements/typing.in mypy-extensions==0.4.3 # via mypy -mypy==0.910 - # via -r requirements/typing.in -toml==0.10.2 +tomli==2.0.1 # via mypy -typing-extensions==3.10.0.2 +typing-extensions==4.2.0 # via mypy diff --git a/setup.cfg b/setup.cfg index 45c6b89..592e1a1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,8 +29,8 @@ classifiers = [options] packages = find: package_dir = = src -include_package_data = true -python_requires = >= 3.6 +include_package_data = True +python_requires = >= 3.7 # Dependencies are in setup.py for GitHub's dependency graph. [options.packages.find] @@ -42,14 +42,14 @@ filterwarnings = error [coverage:run] -branch = true +branch = True source = click tests [coverage:paths] source = - click + src */site-packages [flake8] @@ -57,7 +57,7 @@ source = # E = pycodestyle errors # F = flake8 pyflakes # W = pycodestyle warnings -# B9 = bugbear opinions, +# B9 = bugbear opinions # ISC = implicit str concat select = B, E, F, W, B9, ISC ignore = @@ -72,12 +72,13 @@ ignore = # up to 88 allowed by bugbear B950 max-line-length = 80 per-file-ignores = - # __init__ module exports names + # __init__ exports names src/click/__init__.py: F401 [mypy] files = src/click -python_version = 3.6 +python_version = 3.7 +show_error_codes = True disallow_subclassing_any = True disallow_untyped_calls = True disallow_untyped_defs = True diff --git a/src/click/__init__.py b/src/click/__init__.py index a2ed5d1..e3ef423 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -41,7 +41,6 @@ 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 @@ -68,8 +67,7 @@ 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.3" +__version__ = "8.1.3" diff --git a/src/click/_compat.py b/src/click/_compat.py index b9e1f0d..766d286 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -388,9 +388,9 @@ def open_stream( ) -> t.Tuple[t.IO, bool]: binary = "b" in mode - # Standard streams first. These are simple because they don't need - # special handling for the atomic flag. It's entirely ignored. - if filename == "-": + # Standard streams first. These are simple because they ignore the + # atomic flag. Use fsdecode to handle Path("-"). + if os.fsdecode(filename) == "-": if any(m in mode for m in ["w", "a", "x"]): if binary: return get_binary_stdout(), False @@ -561,7 +561,6 @@ if sys.platform.startswith("win") and WIN: return rv - else: def _get_argv_encoding() -> str: diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 39c1d08..4b979bc 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -675,7 +675,6 @@ if WIN: _translate_ch_to_exc(rv) return rv - else: import tty import termios diff --git a/src/click/_unicodefun.py b/src/click/_unicodefun.py deleted file mode 100644 index 9cb30c3..0000000 --- a/src/click/_unicodefun.py +++ /dev/null @@ -1,100 +0,0 @@ -import codecs -import os -from gettext import gettext as _ - - -def _verify_python_env() -> None: - """Ensures that the environment is good for Unicode.""" - try: - from locale import getpreferredencoding - - fs_enc = codecs.lookup(getpreferredencoding()).name - except Exception: - fs_enc = "ascii" - - if fs_enc != "ascii": - return - - 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 - - try: - rv = subprocess.Popen( - ["locale", "-a"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - encoding="ascii", - errors="replace", - ).communicate()[0] - except OSError: - rv = "" - - good_locales = set() - has_c_utf8 = False - - 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 - - if not good_locales: - 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.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.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 - - 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( - _( - "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("\n\n".join(extra)) diff --git a/src/click/core.py b/src/click/core.py index f226354..5abfb0f 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1,8 +1,8 @@ import enum import errno +import inspect import os import sys -import typing import typing as t from collections import abc from contextlib import contextmanager @@ -14,7 +14,6 @@ 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 from .exceptions import ClickException @@ -224,9 +223,14 @@ 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: Show defaults for all options. If not set, - defaults to the value from a parent context. Overrides an - option's ``show_default`` argument. + :param show_default: Show the default value for commands. If this + value is not set, it defaults to the value from the parent + context. ``Command.show_default`` overrides this default for the + specific command. + + .. versionchanged:: 8.1 + The ``show_default`` parameter is overridden by + ``Command.show_default``, instead of the other way around. .. versionchanged:: 8.0 The ``show_default`` parameter defaults to the value from the @@ -288,6 +292,8 @@ class Context: #: must be never propagated to another arguments. This is used #: to implement nested parsing. self.protected_args: t.List[str] = [] + #: the collected prefixes of the command's options. + self._opt_prefixes: t.Set[str] = set(parent._opt_prefixes) if parent else set() if obj is None and parent is not None: obj = parent.obj @@ -632,13 +638,13 @@ class Context: self.obj = rv = object_type() return rv - @typing.overload + @t.overload def lookup_default( self, name: str, call: "te.Literal[True]" = True ) -> t.Optional[t.Any]: ... - @typing.overload + @t.overload def lookup_default( self, name: str, call: "te.Literal[False]" = ... ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: @@ -956,7 +962,7 @@ class BaseCommand: return results - @typing.overload + @t.overload def main( self, args: t.Optional[t.Sequence[str]] = None, @@ -967,7 +973,7 @@ class BaseCommand: ) -> "te.NoReturn": ... - @typing.overload + @t.overload def main( self, args: t.Optional[t.Sequence[str]] = None, @@ -1029,10 +1035,6 @@ class BaseCommand: .. versionchanged:: 3.0 Added the ``standalone_mode`` parameter. """ - # Verify that the environment is configured correctly, or reject - # further execution to avoid a broken script. - _verify_python_env() - if args is None: args = sys.argv[1:] @@ -1133,13 +1135,6 @@ class Command(BaseCommand): Click. A basic command handles command line parsing and might dispatch more parsing to commands nested below it. - .. versionchanged:: 2.0 - Added the `context_settings` parameter. - .. versionchanged:: 8.0 - Added repr showing the command name - .. versionchanged:: 7.1 - Added the `no_args_is_help` parameter. - :param name: the name of the command to use unless a group overrides it. :param context_settings: an optional dictionary with defaults that are passed to the context object. @@ -1161,6 +1156,20 @@ class Command(BaseCommand): :param deprecated: issues a message indicating that the command is deprecated. + + .. versionchanged:: 8.1 + ``help``, ``epilog``, and ``short_help`` are stored unprocessed, + all formatting is done when outputting help text, not at init, + and is done even if not using the ``@command`` decorator. + + .. versionchanged:: 8.0 + Added a ``repr`` showing the command name. + + .. versionchanged:: 7.1 + Added the ``no_args_is_help`` parameter. + + .. versionchanged:: 2.0 + Added the ``context_settings`` parameter. """ def __init__( @@ -1186,12 +1195,6 @@ class Command(BaseCommand): #: should show up in the help page and execute. Eager parameters #: will automatically be handled before non eager ones. 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 @@ -1299,10 +1302,12 @@ class Command(BaseCommand): """Gets short help for the command or makes it by shortening the long help string. """ - text = self.short_help or "" - - if not text and self.help: + if self.short_help: + text = inspect.cleandoc(self.short_help) + elif self.help: text = make_default_short_help(self.help, limit) + else: + text = "" if self.deprecated: text = _("(Deprecated) {text}").format(text=text) @@ -1328,12 +1333,13 @@ class Command(BaseCommand): def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the help text to the formatter if it exists.""" - text = self.help or "" + text = self.help if self.help is not None else "" if self.deprecated: text = _("(Deprecated) {text}").format(text=text) if text: + text = inspect.cleandoc(text).partition("\f")[0] formatter.write_paragraph() with formatter.indentation(): @@ -1354,9 +1360,11 @@ class Command(BaseCommand): def format_epilog(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the epilog into the formatter if it exists.""" if self.epilog: + epilog = inspect.cleandoc(self.epilog) formatter.write_paragraph() + with formatter.indentation(): - formatter.write_text(self.epilog) + formatter.write_text(epilog) 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: @@ -1379,6 +1387,7 @@ class Command(BaseCommand): ) ctx.args = args + ctx._opt_prefixes.update(parser._opt_prefixes) return args def invoke(self, ctx: Context) -> t.Any: @@ -1568,17 +1577,6 @@ class MultiCommand(Command): return decorator - def resultcallback(self, replace: bool = False) -> t.Callable[[F], F]: - 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: Context, formatter: HelpFormatter) -> None: """Extra format methods for multi methods that adds all the commands after the options. @@ -1631,11 +1629,11 @@ class MultiCommand(Command): if not ctx.protected_args: if self.invoke_without_command: # No subcommand was invoked, so the result callback is - # invoked with None for regular groups, or an empty list - # for chained groups. + # invoked with the group return value for regular + # groups, or an empty list for chained groups. with ctx: - super().invoke(ctx) - return _process_result([] if self.chain else None) + rv = super().invoke(ctx) + return _process_result([] if self.chain else rv) ctx.fail(_("Missing command.")) # Fetch args back out @@ -1811,9 +1809,19 @@ class Group(MultiCommand): _check_multicommand(self, name, cmd, register=True) self.commands[name] = cmd + @t.overload + def command(self, __func: t.Callable[..., t.Any]) -> Command: + ... + + @t.overload def command( self, *args: t.Any, **kwargs: t.Any ) -> t.Callable[[t.Callable[..., t.Any]], Command]: + ... + + def command( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Union[t.Callable[[t.Callable[..., t.Any]], Command], 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 @@ -1822,24 +1830,49 @@ class Group(MultiCommand): To customize the command class used, set the :attr:`command_class` attribute. + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + .. 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: + if self.command_class and kwargs.get("cls") is None: kwargs["cls"] = self.command_class + func: t.Optional[t.Callable] = None + + if args and callable(args[0]): + assert ( + len(args) == 1 and not kwargs + ), "Use 'command(**kwargs)(callable)' to provide arguments." + (func,) = args + args = () + def decorator(f: t.Callable[..., t.Any]) -> Command: - cmd = command(*args, **kwargs)(f) + cmd: Command = command(*args, **kwargs)(f) self.add_command(cmd) return cmd + if func is not None: + return decorator(func) + return decorator + @t.overload + def group(self, __func: t.Callable[..., t.Any]) -> "Group": + ... + + @t.overload def group( self, *args: t.Any, **kwargs: t.Any ) -> t.Callable[[t.Callable[..., t.Any]], "Group"]: + ... + + def group( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Union[t.Callable[[t.Callable[..., t.Any]], "Group"], "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 @@ -1848,22 +1881,37 @@ class Group(MultiCommand): To customize the group class used, set the :attr:`group_class` attribute. + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + .. 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: + func: t.Optional[t.Callable] = None + + if args and callable(args[0]): + assert ( + len(args) == 1 and not kwargs + ), "Use 'group(**kwargs)(callable)' to provide arguments." + (func,) = args + args = () + + if self.group_class is not None and kwargs.get("cls") is None: if self.group_class is type: kwargs["cls"] = type(self) else: kwargs["cls"] = self.group_class def decorator(f: t.Callable[..., t.Any]) -> "Group": - cmd = group(*args, **kwargs)(f) + cmd: Group = group(*args, **kwargs)(f) self.add_command(cmd) return cmd + if func is not None: + return decorator(func) + return decorator def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: @@ -2020,11 +2068,6 @@ class Parameter: t.Union[t.List["CompletionItem"], t.List[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 @@ -2048,36 +2091,6 @@ class Parameter: self.is_eager = is_eager self.metavar = metavar self.envvar = envvar - - 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: Context, param: "Parameter", incomplete: str - ) -> t.List["CompletionItem"]: - from click.shell_completion import CompletionItem - - out = [] - - for c in autocompletion(ctx, [], incomplete): # type: ignore - 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 if __debug__: @@ -2172,13 +2185,13 @@ class Parameter: return metavar - @typing.overload + @t.overload def get_default( self, ctx: Context, call: "te.Literal[True]" = True ) -> t.Optional[t.Any]: ... - @typing.overload + @t.overload def get_default( self, ctx: Context, call: bool = ... ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: @@ -2399,25 +2412,27 @@ class Option(Parameter): All other parameters are passed onwards to the parameter constructor. - :param show_default: controls if the default value should be shown on the - help page. Normally, defaults are not shown. If this - value is a string, it shows the string instead of the - value. This is particularly useful for dynamic options. - :param show_envvar: controls if an environment variable should be shown on - the help page. Normally, environment variables - are not shown. - :param prompt: if set to `True` or a non empty string then the user will be - prompted for input. If set to `True` the prompt will be the - option name capitalized. + :param show_default: Show the default value for this option in its + help text. Values are not shown by default, unless + :attr:`Context.show_default` is ``True``. If this value is a + string, it shows that string in parentheses instead of the + actual value. This is particularly useful for dynamic options. + For single option boolean flags, the default remains hidden if + its value is ``False``. + :param show_envvar: Controls if an environment variable should be + shown on the help page. Normally, environment variables are not + shown. + :param prompt: If set to ``True`` or a non empty string then the + user will be prompted for input. If set to ``True`` the prompt + will be the option name capitalized. :param confirmation_prompt: 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. - :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. + :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. :param is_flag: forces this option to act as a flag. The default is auto detection. :param flag_value: which value should be used for this flag if it's @@ -2435,6 +2450,18 @@ class Option(Parameter): :param help: the help string. :param hidden: hide this option from help outputs. + .. versionchanged:: 8.1.0 + Help text indentation is cleaned here instead of only in the + ``@option`` decorator. + + .. versionchanged:: 8.1.0 + The ``show_default`` parameter overrides + ``Context.show_default``. + + .. versionchanged:: 8.1.0 + The default of a single option boolean flag is not shown if the + default value is ``False``. + .. versionchanged:: 8.0.1 ``type`` is detected from ``flag_value`` if given. """ @@ -2444,7 +2471,7 @@ class Option(Parameter): def __init__( self, param_decls: t.Optional[t.Sequence[str]] = None, - show_default: t.Union[bool, str] = False, + show_default: t.Union[bool, str, None] = None, prompt: t.Union[bool, str] = False, confirmation_prompt: t.Union[bool, str] = False, prompt_required: bool = True, @@ -2461,6 +2488,9 @@ class Option(Parameter): show_envvar: bool = False, **attrs: t.Any, ) -> None: + if help: + help = inspect.cleandoc(help) + default_is_missing = "default" not in attrs super().__init__(param_decls, type=type, multiple=multiple, **attrs) @@ -2472,7 +2502,7 @@ class Option(Parameter): elif prompt is False: prompt_text = None else: - prompt_text = t.cast(str, prompt) + prompt_text = prompt self.prompt = prompt_text self.confirmation_prompt = confirmation_prompt @@ -2499,7 +2529,7 @@ class Option(Parameter): # flag if flag_value is set. self._flag_needs_value = flag_value is not None - if is_flag and default_is_missing: + if is_flag and default_is_missing and not self.required: self.default: t.Union[t.Any, t.Callable[[], t.Any]] = False if flag_value is None: @@ -2550,6 +2580,9 @@ class Option(Parameter): if self.is_flag: raise TypeError("'count' is not valid with 'is_flag'.") + if self.multiple and self.is_flag: + raise TypeError("'multiple' is not valid with 'is_flag', use 'count'.") + def to_info_dict(self) -> t.Dict[str, t.Any]: info_dict = super().to_info_dict() info_dict.update( @@ -2711,16 +2744,23 @@ class Option(Parameter): finally: ctx.resilient_parsing = resilient - show_default_is_str = isinstance(self.show_default, str) + show_default = False + show_default_is_str = False - if show_default_is_str or ( - default_value is not None and (self.show_default or ctx.show_default) - ): + if self.show_default is not None: + if isinstance(self.show_default, str): + show_default_is_str = show_default = True + else: + show_default = self.show_default + elif ctx.show_default is not None: + show_default = ctx.show_default + + if show_default_is_str or (show_default and (default_value is not None)): 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) - elif callable(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, @@ -2728,6 +2768,8 @@ class Option(Parameter): default_string = split_opt( (self.opts if self.default else self.secondary_opts)[0] )[1] + elif self.is_bool_flag and not self.secondary_opts and not default_value: + default_string = "" else: default_string = str(default_value) @@ -2753,13 +2795,13 @@ class Option(Parameter): return ("; " if any_prefix_is_slash else " / ").join(rv), help - @typing.overload + @t.overload def get_default( self, ctx: Context, call: "te.Literal[True]" = True ) -> t.Optional[t.Any]: ... - @typing.overload + @t.overload def get_default( self, ctx: Context, call: bool = ... ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: @@ -2770,7 +2812,7 @@ class Option(Parameter): ) -> 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 + # if we're the default one in which case we return the flag # value as default. if self.is_flag and not self.is_bool_flag: for param in ctx.command.params: @@ -2821,7 +2863,10 @@ class Option(Parameter): envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" rv = os.environ.get(envvar) - return rv + if rv: + return rv + + return None def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx) diff --git a/src/click/decorators.py b/src/click/decorators.py index f1cc005..28618dc 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -14,7 +14,7 @@ from .globals import get_current_context from .utils import echo F = t.TypeVar("F", bound=t.Callable[..., t.Any]) -FC = t.TypeVar("FC", t.Callable[..., t.Any], Command) +FC = t.TypeVar("FC", bound=t.Union[t.Callable[..., t.Any], Command]) def pass_context(f: F) -> F: @@ -121,43 +121,38 @@ def pass_meta_key( return decorator -def _make_command( - f: F, - name: t.Optional[str], - attrs: t.MutableMapping[str, t.Any], - cls: t.Type[Command], +CmdType = t.TypeVar("CmdType", bound=Command) + + +@t.overload +def command( + __func: t.Callable[..., t.Any], ) -> Command: - if isinstance(f, Command): - raise TypeError("Attempted to convert a callback into a command twice.") + ... - try: - params = f.__click_params__ # type: ignore - params.reverse() - del f.__click_params__ # type: ignore - except AttributeError: - params = [] - help = attrs.get("help") +@t.overload +def command( + name: t.Optional[str] = None, + **attrs: t.Any, +) -> t.Callable[..., Command]: + ... - if help is None: - help = inspect.getdoc(f) - else: - help = inspect.cleandoc(help) - attrs["help"] = help - return cls( - name=name or f.__name__.lower().replace("_", "-"), - callback=f, - params=params, - **attrs, - ) +@t.overload +def command( + name: t.Optional[str] = None, + cls: t.Type[CmdType] = ..., + **attrs: t.Any, +) -> t.Callable[..., CmdType]: + ... def command( - name: t.Optional[str] = None, + name: t.Union[str, t.Callable[..., t.Any], None] = None, cls: t.Optional[t.Type[Command]] = None, **attrs: t.Any, -) -> t.Callable[[F], Command]: +) -> t.Union[Command, t.Callable[..., 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. @@ -167,6 +162,8 @@ def command( pass the intended name as the first argument. All keyword arguments are forwarded to the underlying command class. + For the ``params`` argument, any decorated params are appended to + the end of the list. Once decorated the function turns into a :class:`Command` instance that can be invoked as a command line utility or be attached to a @@ -176,24 +173,91 @@ def command( name with underscores replaced by dashes. :param cls: the command class to instantiate. This defaults to :class:`Command`. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + + .. versionchanged:: 8.1 + The ``params`` argument can be used. Decorated params are + appended to the end of the list. """ + + func: t.Optional[t.Callable[..., t.Any]] = None + + if callable(name): + func = name + name = None + assert cls is None, "Use 'command(cls=cls)(callable)' to specify a class." + assert not attrs, "Use 'command(**kwargs)(callable)' to provide arguments." + if cls is None: cls = Command def decorator(f: t.Callable[..., t.Any]) -> Command: - cmd = _make_command(f, name, attrs, cls) # type: ignore + if isinstance(f, Command): + raise TypeError("Attempted to convert a callback into a command twice.") + + attr_params = attrs.pop("params", None) + params = attr_params if attr_params is not None else [] + + try: + decorator_params = f.__click_params__ # type: ignore + except AttributeError: + pass + else: + del f.__click_params__ # type: ignore + params.extend(reversed(decorator_params)) + + if attrs.get("help") is None: + attrs["help"] = f.__doc__ + + cmd = cls( # type: ignore[misc] + name=name or f.__name__.lower().replace("_", "-"), # type: ignore[arg-type] + callback=f, + params=params, + **attrs, + ) cmd.__doc__ = f.__doc__ return cmd + if func is not None: + return decorator(func) + return decorator -def group(name: t.Optional[str] = None, **attrs: t.Any) -> t.Callable[[F], Group]: +@t.overload +def group( + __func: t.Callable[..., t.Any], +) -> Group: + ... + + +@t.overload +def group( + name: t.Optional[str] = None, + **attrs: t.Any, +) -> t.Callable[[F], Group]: + ... + + +def group( + name: t.Union[str, t.Callable[..., t.Any], None] = None, **attrs: t.Any +) -> t.Union[Group, 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`. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. """ - attrs.setdefault("cls", Group) + if attrs.get("cls") is None: + attrs["cls"] = Group + + if callable(name): + grp: t.Callable[[F], Group] = t.cast(Group, command(**attrs)) + return grp(name) + return t.cast(Group, command(name, **attrs)) @@ -219,7 +283,7 @@ def argument(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: """ def decorator(f: FC) -> FC: - ArgumentClass = attrs.pop("cls", Argument) + ArgumentClass = attrs.pop("cls", None) or Argument _param_memo(f, ArgumentClass(param_decls, **attrs)) return f @@ -240,10 +304,7 @@ def option(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: def decorator(f: FC) -> FC: # Issue 926, copy attrs, so pre-defined options can re-use the same cls= option_attrs = attrs.copy() - - if "help" in option_attrs: - option_attrs["help"] = inspect.cleandoc(option_attrs["help"]) - OptionClass = option_attrs.pop("cls", Option) + OptionClass = option_attrs.pop("cls", None) or Option _param_memo(f, OptionClass(param_decls, **option_attrs)) return f diff --git a/src/click/globals.py b/src/click/globals.py index a7b0c93..480058f 100644 --- a/src/click/globals.py +++ b/src/click/globals.py @@ -1,4 +1,3 @@ -import typing import typing as t from threading import local @@ -9,12 +8,12 @@ if t.TYPE_CHECKING: _local = local() -@typing.overload +@t.overload def get_current_context(silent: "te.Literal[False]" = False) -> "Context": ... -@typing.overload +@t.overload def get_current_context(silent: bool = ...) -> t.Optional["Context"]: ... diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index cad080d..c17a8e6 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -102,10 +102,10 @@ _SOURCE_BASH = """\ IFS=',' read type value <<< "$completion" if [[ $type == 'dir' ]]; then - COMREPLY=() + COMPREPLY=() compopt -o dirnames elif [[ $type == 'file' ]]; then - COMREPLY=() + COMPREPLY=() compopt -o default elif [[ $type == 'plain' ]]; then COMPREPLY+=($value) @@ -448,17 +448,16 @@ def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool: ) -def _start_of_option(value: str) -> bool: +def _start_of_option(ctx: Context, value: str) -> bool: """Check if the value looks like the start of an option.""" if not value: return False c = value[0] - # Allow "/" since that starts a path. - return not c.isalnum() and c != "/" + return c in ctx._opt_prefixes -def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool: +def _is_incomplete_option(ctx: Context, 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. @@ -467,7 +466,7 @@ def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool: if not isinstance(param, Option): return False - if param.is_flag: + if param.is_flag or param.count: return False last_option = None @@ -476,7 +475,7 @@ def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool: if index + 1 > param.nargs: break - if _start_of_option(arg): + if _start_of_option(ctx, arg): last_option = arg return last_option is not None and last_option in param.opts @@ -551,7 +550,7 @@ def _resolve_incomplete( # split and discard the "=" to make completion easier. if incomplete == "=": incomplete = "" - elif "=" in incomplete and _start_of_option(incomplete): + elif "=" in incomplete and _start_of_option(ctx, incomplete): name, _, incomplete = incomplete.partition("=") args.append(name) @@ -559,7 +558,7 @@ def _resolve_incomplete( # 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): + if "--" not in args and _start_of_option(ctx, incomplete): return ctx.command, incomplete params = ctx.command.get_params(ctx) @@ -567,7 +566,7 @@ def _resolve_incomplete( # If the last complete arg is an option name with an incomplete # value, the option will provide value completions. for param in params: - if _is_incomplete_option(args, param): + if _is_incomplete_option(ctx, args, param): return param, incomplete # It's not an option name or value. The first argument without a diff --git a/src/click/termui.py b/src/click/termui.py index cf8d5f1..bfb2f5a 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -3,7 +3,6 @@ import io import itertools import os import sys -import typing import typing as t from gettext import gettext as _ @@ -94,7 +93,7 @@ def prompt( """Prompts a user for input. This is a convenience function that can be used to prompt a user for input later. - If the user aborts the input by sending a interrupt signal, this + If the user aborts the input by sending an interrupt signal, this function will catch it and raise a :exc:`Abort` exception. :param text: the text to show for the prompt. @@ -160,7 +159,6 @@ def prompt( 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 True: @@ -182,9 +180,9 @@ def prompt( if not confirmation_prompt: return result while True: - confirmation_prompt = t.cast(str, confirmation_prompt) value2 = prompt_func(confirmation_prompt) - if value2: + is_empty = not value and not value2 + if value2 or is_empty: break if value == value2: return result @@ -252,26 +250,6 @@ def confirm( return rv -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. - - .. deprecated:: 8.0 - Will be removed in Click 8.1. Use - :func:`shutil.get_terminal_size` instead. - """ - import shutil - import warnings - - 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: t.Union[t.Iterable[str], t.Callable[[], t.Iterable[str]], str], color: t.Optional[bool] = None, @@ -627,7 +605,7 @@ def unstyle(text: str) -> str: def secho( message: t.Optional[t.Any] = None, - file: t.Optional[t.IO] = None, + file: t.Optional[t.IO[t.AnyStr]] = None, nl: bool = True, err: bool = False, color: t.Optional[bool] = None, diff --git a/src/click/testing.py b/src/click/testing.py index d19b850..e395c2e 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -464,16 +464,16 @@ class CliRunner: Added the ``temp_dir`` parameter. """ cwd = os.getcwd() - t = tempfile.mkdtemp(dir=temp_dir) - os.chdir(t) + dt = tempfile.mkdtemp(dir=temp_dir) # type: ignore[type-var] + os.chdir(dt) try: - yield t + yield t.cast(str, dt) finally: os.chdir(cwd) if temp_dir is None: try: - shutil.rmtree(t) + shutil.rmtree(dt) except OSError: # noqa: B014 pass diff --git a/src/click/types.py b/src/click/types.py index 103d218..b45ee53 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -63,7 +63,14 @@ class ParamType: # 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} + + # Custom subclasses might not remember to set a name. + if hasattr(self, "name"): + name = self.name + else: + name = param_type + + return {"param_type": param_type, "name": name} def __call__( self, @@ -724,7 +731,7 @@ class File(ParamType): return f except OSError as e: # noqa: B014 - self.fail(f"{os.fsdecode(value)!r}: {e.strerror}", param, ctx) + self.fail(f"'{os.fsdecode(value)}': {e.strerror}", param, ctx) def shell_complete( self, ctx: "Context", param: "Parameter", incomplete: str @@ -744,30 +751,31 @@ class File(ParamType): class Path(ParamType): - """The path type is similar to the :class:`File` type but it performs - different checks. First of all, instead of returning an open file - handle it returns just the filename. Secondly, it can perform various - basic checks about what the file or directory should be. + """The ``Path`` type is similar to the :class:`File` type, but + returns the filename instead of an open file. Various checks can be + enabled to validate the type of file and permissions. - :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 - silently skipped. - :param file_okay: controls if a file is a possible value. - :param dir_okay: controls if a directory is a possible value. - :param writable: if true, a writable check is performed. + :param exists: The file or directory needs to exist for the value to + be valid. If this is not set to ``True``, and the file does not + exist, then all further checks are silently skipped. + :param file_okay: Allow a file as a value. + :param dir_okay: Allow a directory as a value. :param readable: if true, a readable check is performed. - :param resolve_path: if this is true, then the path is fully resolved - before the value is passed onwards. This means - that it's absolute and symlinks are resolved. It - will not expand a tilde-prefix, as this is - supposed to be done by the shell only. - :param allow_dash: If this is set to `True`, a single dash to indicate - standard streams is permitted. + :param writable: if true, a writable check is performed. + :param executable: if true, an executable check is performed. + :param resolve_path: Make the value absolute and resolve any + symlinks. A ``~`` is not expanded, as this is supposed to be + done by the shell only. + :param allow_dash: Allow a single dash as a value, which indicates + a standard stream (but does not open it). Use + :func:`~click.open_file` to handle opening this value. :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.1 + Added the ``executable`` parameter. + .. versionchanged:: 8.0 Allow passing ``type=pathlib.Path``. @@ -787,12 +795,14 @@ class Path(ParamType): resolve_path: bool = False, allow_dash: bool = False, path_type: t.Optional[t.Type] = None, + executable: bool = False, ): self.exists = exists self.file_okay = file_okay self.dir_okay = dir_okay - self.writable = writable self.readable = readable + self.writable = writable + self.executable = executable self.resolve_path = resolve_path self.allow_dash = allow_dash self.type = path_type @@ -865,12 +875,22 @@ class Path(ParamType): ) if not self.dir_okay and stat.S_ISDIR(st.st_mode): self.fail( - _("{name} {filename!r} is a directory.").format( + _("{name} '{filename}' is a directory.").format( name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, ) + + if self.readable and not os.access(rv, os.R_OK): + self.fail( + _("{name} {filename!r} is not readable.").format( + name=self.name.title(), filename=os.fsdecode(value) + ), + param, + ctx, + ) + if self.writable and not os.access(rv, os.W_OK): self.fail( _("{name} {filename!r} is not writable.").format( @@ -879,9 +899,10 @@ class Path(ParamType): param, ctx, ) - if self.readable and not os.access(rv, os.R_OK): + + if self.executable and not os.access(value, os.X_OK): self.fail( - _("{name} {filename!r} is not readable.").format( + _("{name} {filename!r} is not executable.").format( name=self.name.title(), filename=os.fsdecode(value) ), param, diff --git a/src/click/utils.py b/src/click/utils.py index 16033d6..8283788 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -1,4 +1,5 @@ import os +import re import sys import typing as t from functools import update_wrapper @@ -203,7 +204,7 @@ class KeepOpenFile: def echo( message: t.Optional[t.Any] = None, - file: t.Optional[t.IO] = None, + file: t.Optional[t.IO[t.Any]] = None, nl: bool = True, err: bool = False, color: t.Optional[bool] = None, @@ -340,55 +341,45 @@ def open_file( 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. + """Open a file, with extra behavior to handle ``'-'`` to indicate + a standard stream, lazy open on write, and atomic write. Similar to + the behavior of the :class:`~click.File` param type. - If stdin/stdout is returned the stream is wrapped so that the context - manager will not close the stream accidentally. This makes it possible - to always use the function like this without having to worry to - accidentally close a standard stream:: + If ``'-'`` is given to open ``stdout`` or ``stdin``, the stream is + wrapped so that using it in a context manager will not close it. + This makes it possible to use the function without accidentally + closing a standard stream: + + .. code-block:: python with open_file(filename) as f: ... - .. versionadded:: 3.0 + :param filename: The name of the file to open, or ``'-'`` for + ``stdin``/``stdout``. + :param mode: The mode in which to open the file. + :param encoding: The encoding to decode or encode a file opened in + text mode. + :param errors: The error handling mode. + :param lazy: Wait to open the file until it is accessed. For read + mode, the file is temporarily opened to raise access errors + early, then closed until it is read again. + :param atomic: Write to a temporary file and replace the given file + on close. - :param filename: the name of the file to open (or ``'-'`` for stdin/stdout). - :param mode: the mode in which to open the file. - :param encoding: the encoding to use. - :param errors: the error handling for this file. - :param lazy: can be flipped to true to open the file lazily. - :param atomic: in atomic mode writes go into a temporary file and it's - moved on close. + .. versionadded:: 3.0 """ if lazy: 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 = t.cast(t.IO, KeepOpenFile(f)) + return f -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. - - .. deprecated:: 8.0 - 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 Click 8.1." - " Access 'sys.argv[1:]' directly instead.", - DeprecationWarning, - stacklevel=2, - ) - return sys.argv[1:] - - def format_filename( filename: t.Union[str, bytes, os.PathLike], shorten: bool = False ) -> str: @@ -484,7 +475,7 @@ class PacifyFlushWrapper: def _detect_program_name( - path: t.Optional[str] = None, _main: ModuleType = sys.modules["__main__"] + path: t.Optional[str] = None, _main: t.Optional[ModuleType] = None ) -> 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 @@ -506,6 +497,9 @@ def _detect_program_name( :meta private: """ + if _main is None: + _main = sys.modules["__main__"] + if not path: path = sys.argv[0] @@ -546,7 +540,7 @@ def _expand_args( 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 + This is 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. @@ -554,6 +548,10 @@ def _expand_args( :param env: Expand environment variables. :param glob_recursive: ``**`` matches directories recursively. + .. versionchanged:: 8.1 + Invalid glob patterns are treated as empty expansions rather + than raising an error. + .. versionadded:: 8.0 :meta private: @@ -569,7 +567,10 @@ def _expand_args( if env: arg = os.path.expandvars(arg) - matches = glob(arg, recursive=glob_recursive) + try: + matches = glob(arg, recursive=glob_recursive) + except re.error: + matches = [] if not matches: out.append(arg) diff --git a/tests/test_arguments.py b/tests/test_arguments.py index f4d7afd..735df4b 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -41,25 +41,25 @@ def test_nargs_tup(runner): assert result.output.splitlines() == ["name=peter", "point=1/2"] -def test_nargs_tup_composite(runner): - variations = [ +@pytest.mark.parametrize( + "opts", + [ dict(type=(str, int)), dict(type=click.Tuple([str, int])), dict(nargs=2, type=click.Tuple([str, int])), dict(nargs=2, type=(str, int)), - ] + ], +) +def test_nargs_tup_composite(runner, opts): + @click.command() + @click.argument("item", **opts) + def copy(item): + name, id = item + click.echo(f"name={name} id={id:d}") - for opts in variations: - - @click.command() - @click.argument("item", **opts) - def copy(item): - name, id = item - click.echo(f"name={name} id={id:d}") - - result = runner.invoke(copy, ["peter", "1"]) - assert not result.exception - assert result.output.splitlines() == ["name=peter id=1"] + result = runner.invoke(copy, ["peter", "1"]) + assert result.exception is None + assert result.output.splitlines() == ["name=peter id=1"] def test_nargs_err(runner): @@ -120,9 +120,9 @@ def test_file_args(runner): assert result.exit_code == 0 -def test_path_args(runner): +def test_path_allow_dash(runner): @click.command() - @click.argument("input", type=click.Path(dir_okay=False, allow_dash=True)) + @click.argument("input", type=click.Path(allow_dash=True)) def foo(input): click.echo(input) diff --git a/tests/test_basic.py b/tests/test_basic.py index c38c1af..d68b962 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,5 +1,4 @@ import os -import uuid from itertools import chain import pytest @@ -104,125 +103,135 @@ def test_group_from_list(runner): assert result.output == "sub" -def test_basic_option(runner): +@pytest.mark.parametrize( + ("args", "expect"), + [ + ([], "S:[no value]"), + (["--s=42"], "S:[42]"), + (["--s"], "Error: Option '--s' requires an argument."), + (["--s="], "S:[]"), + (["--s=\N{SNOWMAN}"], "S:[\N{SNOWMAN}]"), + ], +) +def test_string_option(runner, args, expect): @click.command() - @click.option("--foo", default="no value") - def cli(foo): - click.echo(f"FOO:[{foo}]") + @click.option("--s", default="no value") + def cli(s): + click.echo(f"S:[{s}]") - result = runner.invoke(cli, []) - assert not result.exception - assert "FOO:[no value]" in result.output + result = runner.invoke(cli, args) + assert expect in result.output - result = runner.invoke(cli, ["--foo=42"]) - assert not result.exception - assert "FOO:[42]" in result.output - - result = runner.invoke(cli, ["--foo"]) - assert result.exception - assert "Option '--foo' requires an argument." in result.output - - result = runner.invoke(cli, ["--foo="]) - assert not result.exception - assert "FOO:[]" in result.output - - result = runner.invoke(cli, ["--foo=\N{SNOWMAN}"]) - assert not result.exception - assert "FOO:[\N{SNOWMAN}]" in result.output + if expect.startswith("Error:"): + assert result.exception is not None + else: + assert result.exception is None -def test_int_option(runner): +@pytest.mark.parametrize( + ("args", "expect"), + [ + ([], "I:[84]"), + (["--i=23"], "I:[46]"), + (["--i=x"], "Error: Invalid value for '--i': 'x' is not a valid integer."), + ], +) +def test_int_option(runner, args, expect): @click.command() - @click.option("--foo", default=42) - def cli(foo): - click.echo(f"FOO:[{foo * 2}]") + @click.option("--i", default=42) + def cli(i): + click.echo(f"I:[{i * 2}]") - result = runner.invoke(cli, []) - assert not result.exception - assert "FOO:[84]" in result.output + result = runner.invoke(cli, args) + assert expect in result.output - result = runner.invoke(cli, ["--foo=23"]) - assert not result.exception - assert "FOO:[46]" in result.output - - result = runner.invoke(cli, ["--foo=bar"]) - assert result.exception - assert "Invalid value for '--foo': 'bar' is not a valid integer." in result.output + if expect.startswith("Error:"): + assert result.exception is not None + else: + assert result.exception is None -def test_uuid_option(runner): +@pytest.mark.parametrize( + ("args", "expect"), + [ + ([], "U:[ba122011-349f-423b-873b-9d6a79c688ab]"), + ( + ["--u=821592c1-c50e-4971-9cd6-e89dc6832f86"], + "U:[821592c1-c50e-4971-9cd6-e89dc6832f86]", + ), + (["--u=x"], "Error: Invalid value for '--u': 'x' is not a valid UUID."), + ], +) +def test_uuid_option(runner, args, expect): @click.command() @click.option( "--u", default="ba122011-349f-423b-873b-9d6a79c688ab", type=click.UUID ) def cli(u): - assert type(u) is uuid.UUID click.echo(f"U:[{u}]") - result = runner.invoke(cli, []) - assert not result.exception - assert "U:[ba122011-349f-423b-873b-9d6a79c688ab]" in result.output + result = runner.invoke(cli, args) + assert expect in result.output - result = runner.invoke(cli, ["--u=821592c1-c50e-4971-9cd6-e89dc6832f86"]) - assert not result.exception - assert "U:[821592c1-c50e-4971-9cd6-e89dc6832f86]" in result.output - - result = runner.invoke(cli, ["--u=bar"]) - assert result.exception - assert "Invalid value for '--u': 'bar' is not a valid UUID." in result.output + if expect.startswith("Error:"): + assert result.exception is not None + else: + assert result.exception is None -def test_float_option(runner): +@pytest.mark.parametrize( + ("args", "expect"), + [ + ([], "F:[42.0]"), + ("--f=23.5", "F:[23.5]"), + ("--f=x", "Error: Invalid value for '--f': 'x' is not a valid float."), + ], +) +def test_float_option(runner, args, expect): @click.command() - @click.option("--foo", default=42, type=click.FLOAT) - def cli(foo): - assert type(foo) is float - click.echo(f"FOO:[{foo}]") + @click.option("--f", default=42.0) + def cli(f): + click.echo(f"F:[{f}]") - result = runner.invoke(cli, []) - assert not result.exception - assert "FOO:[42.0]" in result.output + result = runner.invoke(cli, args) + assert expect in result.output - result = runner.invoke(cli, ["--foo=23.5"]) - assert not result.exception - assert "FOO:[23.5]" in result.output - - result = runner.invoke(cli, ["--foo=bar"]) - assert result.exception - assert "Invalid value for '--foo': 'bar' is not a valid float." in result.output + if expect.startswith("Error:"): + assert result.exception is not None + else: + assert result.exception is None -def test_boolean_option(runner): - for default in True, False: +@pytest.mark.parametrize("default", [True, False]) +@pytest.mark.parametrize( + ("args", "expect"), [(["--on"], True), (["--off"], False), ([], None)] +) +def test_boolean_switch(runner, default, args, expect): + @click.command() + @click.option("--on/--off", default=default) + def cli(on): + return on - @click.command() - @click.option("--with-foo/--without-foo", default=default) - def cli(with_foo): - click.echo(with_foo) + if expect is None: + expect = default - result = runner.invoke(cli, ["--with-foo"]) - assert not result.exception - assert result.output == "True\n" - result = runner.invoke(cli, ["--without-foo"]) - assert not result.exception - assert result.output == "False\n" - result = runner.invoke(cli, []) - assert not result.exception - assert result.output == f"{default}\n" + result = runner.invoke(cli, args, standalone_mode=False) + assert result.return_value is expect - for default in True, False: - @click.command() - @click.option("--flag", is_flag=True, default=default) - def cli(flag): - click.echo(flag) +@pytest.mark.parametrize("default", [True, False]) +@pytest.mark.parametrize(("args", "expect"), [(["--f"], True), ([], False)]) +def test_boolean_flag(runner, default, args, expect): + @click.command() + @click.option("--f", is_flag=True, default=default) + def cli(f): + return f - result = runner.invoke(cli, ["--flag"]) - assert not result.exception - assert result.output == f"{not default}\n" - result = runner.invoke(cli, []) - assert not result.exception - assert result.output == f"{default}\n" + if default: + expect = not expect + + result = runner.invoke(cli, args, standalone_mode=False) + assert result.return_value is expect @pytest.mark.parametrize( diff --git a/tests/test_chain.py b/tests/test_chain.py index 23520a0..6b2eae3 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -34,7 +34,17 @@ def test_basic_chaining(runner): ] -def test_chaining_help(runner): +@pytest.mark.parametrize( + ("args", "expect"), + [ + (["--help"], "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..."), + (["--help"], "ROOT HELP"), + (["sdist", "--help"], "SDIST HELP"), + (["bdist", "--help"], "BDIST HELP"), + (["bdist", "sdist", "--help"], "SDIST HELP"), + ], +) +def test_chaining_help(runner, args, expect): @click.group(chain=True) def cli(): """ROOT HELP""" @@ -50,22 +60,9 @@ def test_chaining_help(runner): """BDIST HELP""" click.echo("bdist called") - result = runner.invoke(cli, ["--help"]) + result = runner.invoke(cli, args) assert not result.exception - assert "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." in result.output - assert "ROOT HELP" in result.output - - result = runner.invoke(cli, ["sdist", "--help"]) - assert not result.exception - assert "SDIST HELP" in result.output - - result = runner.invoke(cli, ["bdist", "--help"]) - assert not result.exception - assert "BDIST HELP" in result.output - - result = runner.invoke(cli, ["bdist", "sdist", "--help"]) - assert not result.exception - assert "SDIST HELP" in result.output + assert expect in result.output def test_chaining_with_options(runner): @@ -88,20 +85,20 @@ def test_chaining_with_options(runner): assert result.output.splitlines() == ["bdist called 1", "sdist called 2"] -@pytest.mark.parametrize(("chain", "expect"), [(False, "None"), (True, "[]")]) +@pytest.mark.parametrize(("chain", "expect"), [(False, "1"), (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 ``[]``. + its return value, a chained group with ``[]``. """ @click.group(invoke_without_command=True, chain=chain) def cli(): - pass + return 1 @cli.result_callback() def process_result(result): - click.echo(str(result), nl=False) + click.echo(result, nl=False) result = runner.invoke(cli, []) assert result.output == expect @@ -127,15 +124,23 @@ def test_chaining_with_arguments(runner): assert result.output.splitlines() == ["bdist called 1", "sdist called 2"] -def test_pipeline(runner): +@pytest.mark.parametrize( + ("args", "input", "expect"), + [ + (["-f", "-"], "foo\nbar", ["foo", "bar"]), + (["-f", "-", "strip"], "foo \n bar", ["foo", "bar"]), + (["-f", "-", "strip", "uppercase"], "foo \n bar", ["FOO", "BAR"]), + ], +) +def test_pipeline(runner, args, input, expect): @click.group(chain=True, invoke_without_command=True) - @click.option("-i", "--input", type=click.File("r")) - def cli(input): + @click.option("-f", type=click.File("r")) + def cli(f): pass @cli.result_callback() - def process_pipeline(processors, input): - iterator = (x.rstrip("\r\n") for x in input) + def process_pipeline(processors, f): + iterator = (x.rstrip("\r\n") for x in f) for processor in processors: iterator = processor(iterator) for item in iterator: @@ -157,17 +162,9 @@ def test_pipeline(runner): return processor - result = runner.invoke(cli, ["-i", "-"], input="foo\nbar") + result = runner.invoke(cli, args, input=input) assert not result.exception - assert result.output.splitlines() == ["foo", "bar"] - - result = runner.invoke(cli, ["-i", "-", "strip"], input="foo \n bar") - assert not result.exception - assert result.output.splitlines() == ["foo", "bar"] - - result = runner.invoke(cli, ["-i", "-", "strip", "uppercase"], input="foo \n bar") - assert not result.exception - assert result.output.splitlines() == ["FOO", "BAR"] + assert result.output.splitlines() == expect def test_args_and_chain(runner): diff --git a/tests/test_command_decorators.py b/tests/test_command_decorators.py new file mode 100644 index 0000000..9d54d7b --- /dev/null +++ b/tests/test_command_decorators.py @@ -0,0 +1,51 @@ +import click + + +def test_command_no_parens(runner): + @click.command + def cli(): + click.echo("hello") + + result = runner.invoke(cli) + assert result.exception is None + assert result.output == "hello\n" + + +def test_group_no_parens(runner): + @click.group + def grp(): + click.echo("grp1") + + @grp.command + def cmd1(): + click.echo("cmd1") + + @grp.group + def grp2(): + click.echo("grp2") + + @grp2.command + def cmd2(): + click.echo("cmd2") + + result = runner.invoke(grp, ["cmd1"]) + assert result.exception is None + assert result.output == "grp1\ncmd1\n" + + result = runner.invoke(grp, ["grp2", "cmd2"]) + assert result.exception is None + assert result.output == "grp1\ngrp2\ncmd2\n" + + +def test_params_argument(runner): + opt = click.Argument(["a"]) + + @click.command(params=[opt]) + @click.argument("b") + def cli(a, b): + click.echo(f"{a} {b}") + + assert cli.params[0].name == "a" + assert cli.params[1].name == "b" + result = runner.invoke(cli, ["1", "2"]) + assert result.output == "1 2\n" diff --git a/tests/test_commands.py b/tests/test_commands.py index 788398c..3a0d4b9 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,5 +1,7 @@ import re +import pytest + import click @@ -93,6 +95,20 @@ def test_auto_shorthelp(runner): ) +def test_help_truncation(runner): + @click.command() + def cli(): + """This is a command with truncated help. + \f + + This text should be truncated. + """ + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "This is a command with truncated help." in result.output + + def test_no_args_is_help(runner): @click.command(no_args_is_help=True) def cli(): @@ -119,7 +135,16 @@ def test_default_maps(runner): assert result.output == "changed\n" -def test_group_with_args(runner): +@pytest.mark.parametrize( + ("args", "exit_code", "expect"), + [ + (["obj1"], 2, "Error: Missing command."), + (["obj1", "--help"], 0, "Show this message and exit."), + (["obj1", "move"], 0, "obj=obj1\nmove\n"), + ([], 0, "Show this message and exit."), + ], +) +def test_group_with_args(runner, args, exit_code, expect): @click.group() @click.argument("obj") def cli(obj): @@ -129,21 +154,9 @@ def test_group_with_args(runner): def move(): click.echo("move") - result = runner.invoke(cli, []) - assert result.exit_code == 0 - assert "Show this message and exit." in result.output - - result = runner.invoke(cli, ["obj1"]) - assert result.exit_code == 2 - assert "Error: Missing command." in result.output - - result = runner.invoke(cli, ["obj1", "--help"]) - assert result.exit_code == 0 - assert "Show this message and exit." in result.output - - result = runner.invoke(cli, ["obj1", "move"]) - assert result.exit_code == 0 - assert result.output == "obj=obj1\nmove\n" + result = runner.invoke(cli, args) + assert result.exit_code == exit_code + assert expect in result.output def test_base_command(runner): @@ -196,18 +209,12 @@ def test_base_command(runner): cli.add_command(OptParseCommand("test", parser, test_callback)) - result = runner.invoke( - cli, ["test", "-f", "test.txt", "-q", "whatever.txt", "whateverelse.txt"] - ) - assert not result.exception - assert result.output.splitlines() == [ - "whatever.txt whateverelse.txt", - "test.txt", - "False", - ] + result = runner.invoke(cli, ["test", "-f", "f.txt", "-q", "q1.txt", "q2.txt"]) + assert result.exception is None + assert result.output.splitlines() == ["q1.txt q2.txt", "f.txt", "False"] result = runner.invoke(cli, ["test", "--help"]) - assert not result.exception + assert result.exception is None assert result.output.splitlines() == [ "Usage: foo test [OPTIONS]", "", @@ -328,20 +335,13 @@ def test_unprocessed_options(runner): ] -def test_deprecated_in_help_messages(runner): - @click.command(deprecated=True) - def cmd_with_help(): - """CLI HELP""" +@pytest.mark.parametrize("doc", ["CLI HELP", None]) +def test_deprecated_in_help_messages(runner, doc): + @click.command(deprecated=True, help=doc) + def cli(): pass - result = runner.invoke(cmd_with_help, ["--help"]) - assert "(Deprecated)" in result.output - - @click.command(deprecated=True) - def cmd_without_help(): - pass - - result = runner.invoke(cmd_without_help, ["--help"]) + result = runner.invoke(cli, ["--help"]) assert "(Deprecated)" in result.output @@ -352,3 +352,62 @@ def test_deprecated_in_invocation(runner): result = runner.invoke(deprecated_cmd) assert "DeprecationWarning:" in result.output + + +def test_command_parse_args_collects_option_prefixes(): + @click.command() + @click.option("+p", is_flag=True) + @click.option("!e", is_flag=True) + def test(p, e): + pass + + ctx = click.Context(test) + test.parse_args(ctx, []) + + assert ctx._opt_prefixes == {"-", "--", "+", "!"} + + +def test_group_parse_args_collects_base_option_prefixes(): + @click.group() + @click.option("~t", is_flag=True) + def group(t): + pass + + @group.command() + @click.option("+p", is_flag=True) + def command1(p): + pass + + @group.command() + @click.option("!e", is_flag=True) + def command2(e): + pass + + ctx = click.Context(group) + group.parse_args(ctx, ["command1", "+p"]) + + assert ctx._opt_prefixes == {"-", "--", "~"} + + +def test_group_invoke_collects_used_option_prefixes(runner): + opt_prefixes = set() + + @click.group() + @click.option("~t", is_flag=True) + def group(t): + pass + + @group.command() + @click.option("+p", is_flag=True) + @click.pass_context + def command1(ctx, p): + nonlocal opt_prefixes + opt_prefixes = ctx._opt_prefixes + + @group.command() + @click.option("!e", is_flag=True) + def command2(e): + pass + + runner.invoke(group, ["command1"]) + assert opt_prefixes == {"-", "--", "~", "+"} diff --git a/tests/test_context.py b/tests/test_context.py index 98f0835..df8b497 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -365,3 +365,11 @@ def test_parameter_source(runner, option_args, invoke_args, expect): rv = runner.invoke(cli, standalone_mode=False, **invoke_args) assert rv.return_value == expect + + +def test_propagate_opt_prefixes(): + parent = click.Context(click.Command("test")) + parent._opt_prefixes = {"-", "--", "!"} + ctx = click.Context(click.Command("test2"), parent=parent) + + assert ctx._opt_prefixes == {"-", "--", "!"} diff --git a/tests/test_formatting.py b/tests/test_formatting.py index f957e01..1cbf32b 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -322,12 +322,13 @@ def test_global_show_default(runner): pass result = runner.invoke(cli, ["--help"]) + # the default to "--help" is not shown because it is False assert result.output.splitlines() == [ "Usage: cli [OPTIONS]", "", "Options:", " -f TEXT Output file name [default: out.txt]", - " --help Show this message and exit. [default: False]", + " --help Show this message and exit.", ] diff --git a/tests/test_info_dict.py b/tests/test_info_dict.py index b58ad6e..79d39ee 100644 --- a/tests/test_info_dict.py +++ b/tests/test_info_dict.py @@ -266,3 +266,10 @@ def test_context(): "ignore_unknown_options": False, "auto_envvar_prefix": None, } + + +def test_paramtype_no_name(): + class TestType(click.ParamType): + pass + + assert TestType().to_info_dict()["name"] == "TestType" diff --git a/tests/test_options.py b/tests/test_options.py index 2e34337..d4090c7 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -153,14 +153,15 @@ def test_init_bad_default_list(runner, multiple, nargs, default): click.Option(["-a"], type=type, multiple=multiple, nargs=nargs, default=default) -def test_empty_envvar(runner): +@pytest.mark.parametrize("env_key", ["MYPATH", "AUTO_MYPATH"]) +def test_empty_envvar(runner, env_key): @click.command() @click.option("--mypath", type=click.Path(exists=True), envvar="MYPATH") def cli(mypath): click.echo(f"mypath: {mypath}") - result = runner.invoke(cli, [], env={"MYPATH": ""}) - assert result.exit_code == 0 + result = runner.invoke(cli, env={env_key: ""}, auto_envvar_prefix="AUTO") + assert result.exception is None assert result.output == "mypath: None\n" @@ -305,6 +306,19 @@ def test_dynamic_default_help_text(runner): assert "(current user)" in result.output +def test_dynamic_default_help_special_method(runner): + class Value: + def __call__(self): + return 42 + + def __str__(self): + return "special value" + + opt = click.Option(["-a"], default=Value(), show_default=True) + ctx = click.Context(click.Command("cli")) + assert "special value" in opt.get_help_record(ctx)[1] + + @pytest.mark.parametrize( ("type", "expect"), [ @@ -489,6 +503,15 @@ def test_missing_option_string_cast(): assert str(excinfo.value) == "Missing parameter: a" +def test_missing_required_flag(runner): + cli = click.Command( + "cli", params=[click.Option(["--on/--off"], is_flag=True, required=True)] + ) + result = runner.invoke(cli) + assert result.exit_code == 2 + assert "Error: Missing option '--on'." in result.output + + def test_missing_choice(runner): @click.command() @click.option("--foo", type=click.Choice(["foo", "bar"]), required=True) @@ -630,7 +653,6 @@ def test_option_custom_class_reusable(runner): # Both of the commands should have the --help option now. for cmd in (cmd1, cmd2): - result = runner.invoke(cmd, ["--help"]) assert "I am a help text" in result.output assert "you wont see me" not in result.output @@ -723,16 +745,37 @@ def test_show_default_boolean_flag_name(runner, default, expect): 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. +def test_show_true_default_boolean_flag_value(runner): + """When a boolean flag only has one opt and its default is True, + it will show the default 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, + default=True, + help="Enable the cache.", ) ctx = click.Context(click.Command("test")) message = opt.get_help_record(ctx)[1] - assert "[default: False]" in message + assert "[default: True]" in message + + +@pytest.mark.parametrize("default", [False, None]) +def test_hide_false_default_boolean_flag_value(runner, default): + """When a boolean flag only has one opt and its default is False or + None, it will not show the default + """ + opt = click.Option( + ("--cache",), + is_flag=True, + show_default=True, + default=default, + help="Enable the cache.", + ) + ctx = click.Context(click.Command("test")) + message = opt.get_help_record(ctx)[1] + assert "[default: " not in message def test_show_default_string(runner): @@ -761,6 +804,28 @@ def test_do_not_show_default_empty_multiple(): assert message == "values" +@pytest.mark.parametrize( + ("ctx_value", "opt_value", "expect"), + [ + (None, None, False), + (None, False, False), + (None, True, True), + (False, None, False), + (False, False, False), + (False, True, True), + (True, None, True), + (True, False, False), + (True, True, True), + (False, "one", True), + ], +) +def test_show_default_precedence(ctx_value, opt_value, expect): + ctx = click.Context(click.Command("test"), show_default=ctx_value) + opt = click.Option("-a", default=1, help="value", show_default=opt_value) + help = opt.get_help_record(ctx)[1] + assert ("default:" in help) is expect + + @pytest.mark.parametrize( ("args", "expect"), [ @@ -839,3 +904,21 @@ def test_type_from_flag_value(): ) def test_is_bool_flag_is_correctly_set(option, expected): assert option.is_bool_flag is expected + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"count": True, "multiple": True}, "'count' is not valid with 'multiple'."), + ({"count": True, "is_flag": True}, "'count' is not valid with 'is_flag'."), + ( + {"multiple": True, "is_flag": True}, + "'multiple' is not valid with 'is_flag', use 'count'.", + ), + ], +) +def test_invalid_flag_combinations(runner, kwargs, message): + with pytest.raises(TypeError) as e: + click.Option(["-a"], **kwargs) + + assert message in str(e.value) diff --git a/tests/test_parser.py b/tests/test_parser.py index 964f9c8..f694916 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,5 +1,7 @@ import pytest +import click +from click.parser import OptionParser from click.parser import split_arg_string @@ -15,3 +17,16 @@ from click.parser import split_arg_string ) def test_split_arg_string(value, expect): assert split_arg_string(value) == expect + + +def test_parser_default_prefixes(): + parser = OptionParser() + assert parser._opt_prefixes == {"-", "--"} + + +def test_parser_collects_prefixes(): + ctx = click.Context(click.Command("test")) + parser = OptionParser(ctx) + click.Option("+p", is_flag=True).add_to_parser(parser, ctx) + click.Option("!e", is_flag=True).add_to_parser(parser, ctx) + assert parser._opt_prefixes == {"-", "--", "+", "!"} diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 8338d0e..57729f5 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -110,6 +110,45 @@ def test_type_choice(): assert _get_words(cli, ["-c"], "a2") == ["a2"] +def test_choice_special_characters(): + cli = Command("cli", params=[Option(["-c"], type=Choice(["!1", "!2", "+3"]))]) + assert _get_words(cli, ["-c"], "") == ["!1", "!2", "+3"] + assert _get_words(cli, ["-c"], "!") == ["!1", "!2"] + assert _get_words(cli, ["-c"], "!2") == ["!2"] + + +def test_choice_conflicting_prefix(): + cli = Command( + "cli", + params=[ + Option(["-c"], type=Choice(["!1", "!2", "+3"])), + Option(["+p"], is_flag=True), + ], + ) + assert _get_words(cli, ["-c"], "") == ["!1", "!2", "+3"] + assert _get_words(cli, ["-c"], "+") == ["+p"] + + +def test_option_count(): + cli = Command("cli", params=[Option(["-c"], count=True)]) + assert _get_words(cli, ["-c"], "") == [] + assert _get_words(cli, ["-c"], "-") == ["--help"] + + +def test_option_optional(): + cli = Command( + "cli", + add_help_option=False, + params=[ + Option(["--name"], is_flag=False, flag_value="value"), + Option(["--flag"], is_flag=True), + ], + ) + assert _get_words(cli, ["--name"], "") == [] + assert _get_words(cli, ["--name"], "-") == ["--flag"] + assert _get_words(cli, ["--name", "--flag"], "-") == [] + + @pytest.mark.parametrize( ("type", "expect"), [(File(), "file"), (Path(), "file"), (Path(file_okay=False), "dir")], @@ -161,20 +200,6 @@ def test_option_custom(): assert _get_words(cli, ["a", "b"], "c") == ["C"] -def test_autocompletion_deprecated(): - # 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(): - 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", diff --git a/tests/test_termui.py b/tests/test_termui.py index 5e819df..3123252 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -420,17 +420,22 @@ def test_prompt_required_false(runner, args, expect): @pytest.mark.parametrize( - ("prompt", "input", "expect"), + ("prompt", "input", "default", "expect"), [ - (True, "password\npassword", "password"), - ("Confirm Password", "password\npassword\n", "password"), - (False, None, None), + (True, "password\npassword", None, "password"), + ("Confirm Password", "password\npassword\n", None, "password"), + (True, "", "", ""), + (False, None, None, None), ], ) -def test_confirmation_prompt(runner, prompt, input, expect): +def test_confirmation_prompt(runner, prompt, input, default, expect): @click.command() @click.option( - "--password", prompt=prompt, hide_input=True, confirmation_prompt=prompt + "--password", + prompt=prompt, + hide_input=True, + default=default, + confirmation_prompt=prompt, ) def cli(password): return password diff --git a/tests/test_utils.py b/tests/test_utils.py index 271177d..5dfeb02 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -320,6 +320,22 @@ def test_open_file(runner): assert result.output == "foobar\nmeep\n" +def test_open_file_pathlib_dash(runner): + @click.command() + @click.argument( + "filename", type=click.Path(allow_dash=True, path_type=pathlib.Path) + ) + def cli(filename): + click.echo(str(type(filename))) + + with click.open_file(filename) as f: + click.echo(f.read()) + + result = runner.invoke(cli, ["-"], input="value") + assert result.exception is None + assert result.output == "pathlib.Path\nvalue\n" + + def test_open_file_ignore_errors_stdin(runner): @click.command() @click.argument("filename") @@ -428,20 +444,12 @@ class MockMain: ("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", - ), + (str(pathlib.Path("example/__main__.py")), "example", "python -m example"), + (str(pathlib.Path("example/cli.py")), "example", "python -m example.cli"), ], ) def test_detect_program_name(path, main, expected): - assert click.utils._detect_program_name(path, _main=main) == expected + assert click.utils._detect_program_name(path, _main=MockMain(main)) == expected def test_expand_args(monkeypatch): @@ -452,6 +460,8 @@ 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"]) + # a bad glob pattern, such as a pytest identifier, should return itself + assert click.utils._expand_args(["test.py::test_bad"])[0] == "test.py::test_bad" @pytest.mark.parametrize( diff --git a/tox.ini b/tox.ini index de68730..056ca0d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{39,38,37,36,py3} + py3{11,10,9,8,7},pypy3{8,7} style typing docs