diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 000000000..adddea75d --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,16 @@ +name: pre-commit +on: + pull_request: + push: + branches: [main, stable] +jobs: + main: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + with: + python-version: 3.x + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 + - uses: pre-commit-ci/lite-action@9d882e7a565f7008d4faf128f27d1cb6503d4ebf # v1.0.2 + if: ${{ !cancelled() }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 515a7a5e4..068f09c5e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,7 +21,6 @@ jobs: - {python: '3.10'} - {python: '3.9'} - {python: '3.8'} - - {python: '3.7'} - {name: PyPy, python: 'pypy-3.10', tox: pypy310} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.gitignore b/.gitignore index 62c1b887d..787c22259 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .vscode/ .venv*/ venv*/ +.env*/ +env*/ __pycache__/ dist/ .coverage* diff --git a/CHANGES.rst b/CHANGES.rst index 46b62d185..5b8be0af3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,104 @@ .. currentmodule:: click +Version 8.2.0 +------------- + +Unreleased + +- Drop support for Python 3.7. :pr:`2588` +- Use modern packaging metadata with ``pyproject.toml`` instead of ``setup.cfg``. + :pr:`2438` +- Use ``flit_core`` instead of ``setuptools`` as build backend. :pr:`2543` +- Deprecate the ``__version__`` attribute. Use feature detection, or + ``importlib.metadata.version("click")``, instead. :issue:`2598` +- ``BaseCommand`` is deprecated. ``Command`` is the base class for all + commands. :issue:`2589` +- ``MultiCommand`` is deprecated. ``Group`` is the base class for all group + commands. :issue:`2590` +- The current parser and related classes and methods, are deprecated. + :issue:`2205` + + - ``OptionParser`` and the ``parser`` module, which is a modified copy of + ``optparse`` in the standard library. + - ``Context.protected_args`` is unneeded. ``Context.args`` contains any + remaining arguments while parsing. + - ``Parameter.add_to_parser`` (on both ``Argument`` and ``Option``) is + unneeded. Parsing works directly without building a separate parser. + - ``split_arg_string`` is moved from ``parser`` to ``shell_completion``. + +- Enable deferred evaluation of annotations with + ``from __future__ import annotations``. :pr:`2270` +- When generating a command's name from a decorated function's name, the + suffixes ``_command``, ``_cmd``, ``_group``, and ``_grp`` are removed. + :issue:`2322` +- Show the ``types.ParamType.name`` for ``types.Choice`` options within + ``--help`` message if ``show_choices=False`` is specified. + :issue:`2356` +- Do not display default values in prompts when ``Option.show_default`` is + ``False``. :pr:`2509` +- Add ``get_help_extra`` method on ``Option`` to fetch the generated extra + items used in ``get_help_record`` to render help text. :issue:`2516` + :pr:`2517` +- Keep stdout and stderr streams independent in ``CliRunner``. Always + collect stderr output and never raise an exception. Add a new + output stream to simulate what the user sees in its terminal. Removes + the ``mix_stderr`` parameter in ``CliRunner``. :issue:`2522` :pr:`2523` +- ``Option.show_envvar`` now also shows environment variable in error messages. + :issue:`2695` :pr:`2696` +- ``Context.close`` will be called on exit. This results in all + ``Context.call_on_close`` callbacks and context managers added via + ``Context.with_resource`` to be closed on exit as well. :pr:`2680` +- Add ``ProgressBar(hidden: bool)`` to allow hiding the progressbar. :issue:`2609` +- A ``UserWarning`` will be shown when multiple parameters attempt to use the + same name. :issue:`2396` +- When using ``Option.envvar`` with ``Option.flag_value``, the ``flag_value`` + will always be used instead of the value of the environment variable. + :issue:`2746` :pr:`2788` +- Add ``Choice.get_invalid_choice_message`` method for customizing the + invalid choice message. :issue:`2621` :pr:`2622` +- If help is shown because ``no_args_is_help`` is enabled (defaults to ``True`` + for groups, ``False`` for commands), the exit code is 2 instead of 0. + :issue:`1489` :pr:`1489` +- Contexts created during shell completion are closed properly, fixing + a ``ResourceWarning`` when using ``click.File``. :issue:`2644` :pr:`2800` + :pr:`2767` +- ``click.edit(filename)`` now supports passing an iterable of filenames in + case the editor supports editing multiple files at once. Its return type + is now also typed: ``AnyStr`` if ``text`` is passed, otherwise ``None``. + :issue:`2067` :pr:`2068` +- Specialized typing of ``progressbar(length=...)`` as ``ProgressBar[int]``. + :pr:`2630` +- Improve ``echo_via_pager`` behaviour in face of errors. + :issue:`2674` + + - Terminate the pager in case a generator passed to ``echo_via_pager`` + raises an exception. + - Ensure to always close the pipe to the pager process and wait for it + to terminate. + - ``echo_via_pager`` will not ignore ``KeyboardInterrupt`` anymore. This + allows the user to search for future output of the generator when + using less and then aborting the program using ctrl-c. + +- ``deprecated: bool | str`` can now be used on options and arguments. This + previously was only available for ``Command``. The message can now also be + customised by using a ``str`` instead of a ``bool``. :issue:`2263` :pr:`2271` + + - ``Command.deprecated`` formatting in ``--help`` changed from + ``(Deprecated) help`` to ``help (DEPRECATED)``. + - Parameters cannot be required nor prompted or an error is raised. + - A warning will be printed when something deprecated is used. + +- Add a ``catch_exceptions`` parameter to ``CliRunner``. If + ``catch_exceptions`` is not passed to ``CliRunner.invoke``, + the value from ``CliRunner``. :issue:`2817` :pr:`2818` +- ``Option.flag_value`` will no longer have a default value set based on + ``Option.default`` if ``Option.is_flag`` is ``False``. This results in + ``Option.default`` not needing to implement `__bool__`. :pr:`2829` +- Incorrect ``click.edit`` typing has been corrected. :pr:`2804` +- ``Choice`` is now generic and supports any iterable value. + This allows you to use enums and other non-``str`` values. :pr:`2796` + :issue:`605` + Version 8.1.8 ------------- @@ -957,12 +1056,10 @@ Released 2014-08-22 function. - Fixed default parameters not being handled properly by the context invoke method. This is a backwards incompatible change if the - function was used improperly. See :ref:`upgrade-to-3.2` for more - information. + function was used improperly. - Removed the ``invoked_subcommands`` attribute largely. It is not possible to provide it to work error free due to how the parsing - works so this API has been deprecated. See :ref:`upgrade-to-3.2` for - more information. + works so this API has been deprecated. - Restored the functionality of ``invoked_subcommand`` which was broken as a regression in 3.1. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index d67a15387..5a9106a86 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -107,12 +107,6 @@ First time setup > env\Scripts\activate -- Upgrade pip and setuptools. - - .. code-block:: text - - $ python -m pip install --upgrade pip setuptools - - Install the development dependencies, then install Click in editable mode. diff --git a/docs/advanced.rst b/docs/advanced.rst index 9ef0b1401..2911eac55 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -7,44 +7,46 @@ In addition to common functionality that is implemented in the library itself, there are countless patterns that can be implemented by extending Click. This page should give some insight into what can be accomplished. + .. _aliases: Command Aliases --------------- -Many tools support aliases for commands (see `Command alias example -`_). -For instance, you can configure ``git`` to accept ``git ci`` as alias for -``git commit``. Other tools also support auto-discovery for aliases by -automatically shortening them. - -Click does not support this out of the box, but it's very easy to customize -the :class:`Group` or any other :class:`MultiCommand` to provide this -functionality. +Many tools support aliases for commands. For example, you can configure +``git`` to accept ``git ci`` as alias for ``git commit``. Other tools also +support auto-discovery for aliases by automatically shortening them. -As explained in :ref:`custom-multi-commands`, a multi command can provide -two methods: :meth:`~MultiCommand.list_commands` and -:meth:`~MultiCommand.get_command`. In this particular case, you only need -to override the latter as you generally don't want to enumerate the -aliases on the help page in order to avoid confusion. +It's possible to customize :class:`Group` to provide this functionality. As +explained in :ref:`custom-groups`, a group provides two methods: +:meth:`~Group.list_commands` and :meth:`~Group.get_command`. In this particular +case, you only need to override the latter as you generally don't want to +enumerate the aliases on the help page in order to avoid confusion. -This following example implements a subclass of :class:`Group` that -accepts a prefix for a command. If there were a command called ``push``, -it would accept ``pus`` as an alias (so long as it was unique): +The following example implements a subclass of :class:`Group` that accepts a +prefix for a command. If there was a command called ``push``, it would accept +``pus`` as an alias (so long as it was unique): .. click:example:: class AliasedGroup(click.Group): def get_command(self, ctx, cmd_name): - rv = click.Group.get_command(self, ctx, cmd_name) + rv = super().get_command(ctx, cmd_name) + if rv is not None: return rv - matches = [x for x in self.list_commands(ctx) - if x.startswith(cmd_name)] + + matches = [ + x for x in self.list_commands(ctx) + if x.startswith(cmd_name) + ] + if not matches: return None - elif len(matches) == 1: + + if len(matches) == 1: return click.Group.get_command(self, ctx, matches[0]) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") def resolve_command(self, ctx, args): @@ -52,22 +54,27 @@ it would accept ``pus`` as an alias (so long as it was unique): _, cmd, args = super().resolve_command(ctx, args) return cmd.name, cmd, args -And it can then be used like this: +It can be used like this: .. click:example:: - @click.command(cls=AliasedGroup) + @click.group(cls=AliasedGroup) def cli(): pass - @cli.command() + @cli.command def push(): pass - @cli.command() + @cli.command def pop(): pass +See the `alias example`_ in Click's repository for another example. + +.. _alias example: https://github.com/pallets/click/tree/main/examples/aliases + + Parameter Modifications ----------------------- @@ -266,7 +273,7 @@ triggering a parsing error. This can generally be activated in two different ways: 1. It can be enabled on custom :class:`Command` subclasses by changing - the :attr:`~BaseCommand.ignore_unknown_options` attribute. + the :attr:`~Command.ignore_unknown_options` attribute. 2. It can be enabled by changing the attribute of the same name on the context class (:attr:`Context.ignore_unknown_options`). This is best changed through the ``context_settings`` dictionary on the command. @@ -487,3 +494,8 @@ cleanup function. db.record_use() db.save() db.close() + + +.. versionchanged:: 8.2 ``Context.call_on_close`` and context managers registered + via ``Context.with_resource`` will be closed when the CLI exits. These were + previously not called on exit. diff --git a/docs/api.rst b/docs/api.rst index 09efd033c..0d5e04229 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -113,6 +113,7 @@ Context :members: :member-order: bysource +.. _click-api-types: Types ----- @@ -134,6 +135,7 @@ Types .. autoclass:: Path .. autoclass:: Choice + :members: .. autoclass:: IntRange diff --git a/docs/commands.rst b/docs/commands.rst index b70992e51..5fe24067f 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -3,9 +3,10 @@ Commands and Groups .. currentmodule:: click -The most important feature of Click is the concept of arbitrarily nesting -command line utilities. This is implemented through the :class:`Command` -and :class:`Group` (actually :class:`MultiCommand`). +The structure of a Click application is defined with :class:`Command`, which +defines an individual named command, and :class:`Group`, which defines a nested +collection of commands (or more groups) under a name. + Callback Invocation ------------------- @@ -15,10 +16,9 @@ If the script is the only command, it will always fire (unless a parameter callback prevents it. This for instance happens if someone passes ``--help`` to the script). -For groups and multi commands, the situation looks different. In this case, -the callback fires whenever a subcommand fires (unless this behavior is -changed). What this means in practice is that an outer command runs -when an inner command runs: +For groups, the situation looks different. In this case, the callback fires +whenever a subcommand fires. What this means in practice is that an outer +command runs when an inner command runs: .. click:example:: @@ -148,15 +148,12 @@ nested applications; see :ref:`complex-guide` for more information. Group Invocation Without Command -------------------------------- -By default, a group or multi command is not invoked unless a subcommand is -passed. In fact, not providing a command automatically passes ``--help`` -by default. This behavior can be changed by passing -``invoke_without_command=True`` to a group. In that case, the callback is -always invoked instead of showing the help page. The context object also -includes information about whether or not the invocation would go to a -subcommand. - -Example: +By default, a group is not invoked unless a subcommand is passed. In fact, not +providing a command automatically passes ``--help`` by default. This behavior +can be changed by passing ``invoke_without_command=True`` to a group. In that +case, the callback is always invoked instead of showing the help page. The +context object also includes information about whether or not the invocation +would go to a subcommand. .. click:example:: @@ -172,220 +169,191 @@ Example: def sync(): click.echo('The subcommand') -And how it works in practice: - .. click:run:: invoke(cli, prog_name='tool', args=[]) invoke(cli, prog_name='tool', args=['sync']) -.. _custom-multi-commands: -Custom Multi Commands ---------------------- +.. _custom-groups: -In addition to using :func:`click.group`, you can also build your own -custom multi commands. This is useful when you want to support commands -being loaded lazily from plugins. +Custom Groups +------------- -A custom multi command just needs to implement a list and load method: +You can customize the behavior of a group beyond the arguments it accepts by +subclassing :class:`click.Group`. -.. click:example:: +The most common methods to override are :meth:`~click.Group.get_command` and +:meth:`~click.Group.list_commands`. - import click - import os +The following example implements a basic plugin system that loads commands from +Python files in a folder. The command is lazily loaded to avoid slow startup. - plugin_folder = os.path.join(os.path.dirname(__file__), 'commands') +.. code-block:: python + + import importlib.util + import os + import click - class MyCLI(click.MultiCommand): + class PluginGroup(click.Group): + def __init__(self, name=None, plugin_folder="commands", **kwargs): + super().__init__(name=name, **kwargs) + self.plugin_folder = plugin_folder def list_commands(self, ctx): rv = [] - for filename in os.listdir(plugin_folder): - if filename.endswith('.py') and filename != '__init__.py': + + for filename in os.listdir(self.plugin_folder): + if filename.endswith(".py"): rv.append(filename[:-3]) + rv.sort() return rv def get_command(self, ctx, name): - ns = {} - fn = os.path.join(plugin_folder, name + '.py') - with open(fn) as f: - code = compile(f.read(), fn, 'exec') - eval(code, ns, ns) - return ns['cli'] - - cli = MyCLI(help='This tool\'s subcommands are loaded from a ' - 'plugin folder dynamically.') + path = os.path.join(self.plugin_folder, f"{name}.py") + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.cli + + cli = PluginGroup( + plugin_folder=os.path.join(os.path.dirname(__file__), "commands") + ) - if __name__ == '__main__': + if __name__ == "__main__": cli() -These custom classes can also be used with decorators: +Custom classes can also be used with decorators: -.. click:example:: +.. code-block:: python - @click.command(cls=MyCLI) + @click.group( + cls=PluginGroup, + plugin_folder=os.path.join(os.path.dirname(__file__), "commands") + ) def cli(): pass -Merging Multi Commands ----------------------- -In addition to implementing custom multi commands, it can also be -interesting to merge multiple together into one script. While this is -generally not as recommended as it nests one below the other, the merging -approach can be useful in some circumstances for a nicer shell experience. +.. _command-chaining: -The default implementation for such a merging system is the -:class:`CommandCollection` class. It accepts a list of other multi -commands and makes the commands available on the same level. +Command Chaining +---------------- -Example usage: +It is useful to invoke more than one subcommand in one call. For example, +``my-app validate build upload`` would invoke ``validate``, then ``build``, then +``upload``. To implement this, pass ``chain=True`` when creating a group. .. click:example:: - import click - - @click.group() - def cli1(): - pass - - @cli1.command() - def cmd1(): - """Command on cli1""" - - @click.group() - def cli2(): + @click.group(chain=True) + def cli(): pass - @cli2.command() - def cmd2(): - """Command on cli2""" + @cli.command('validate') + def validate(): + click.echo('validate') - cli = click.CommandCollection(sources=[cli1, cli2]) + @cli.command('build') + def build(): + click.echo('build') - if __name__ == '__main__': - cli() - -And what it looks like: +You can invoke it like this: .. click:run:: - invoke(cli, prog_name='cli', args=['--help']) + invoke(cli, prog_name='my-app', args=['validate', 'build']) -In case a command exists in more than one source, the first source wins. +When using chaining, there are a few restrictions: +- Only the last command may use ``nargs=-1`` on an argument, otherwise the + parser will not be able to find further commands. +- It is not possible to nest groups below a chain group. +- On the command line, options must be specified before arguments for each + command in the chain. +- The :attr:`Context.invoked_subcommand` attribute will be ``'*'`` because the + parser doesn't know the full list of commands that will run yet. -.. _multi-command-chaining: +.. _command-pipelines: -Multi Command Chaining ----------------------- +Command Pipelines +------------------ -.. versionadded:: 3.0 +When using chaining, a common pattern is to have each command process the +result of the previous command. -Sometimes it is useful to be allowed to invoke more than one subcommand in -one go. For instance if you have installed a setuptools package before -you might be familiar with the ``setup.py sdist bdist_wheel upload`` -command chain which invokes ``sdist`` before ``bdist_wheel`` before -``upload``. Starting with Click 3.0 this is very simple to implement. -All you have to do is to pass ``chain=True`` to your multicommand: +A straightforward way to do this is to use :func:`make_pass_decorator` to pass +a context object to each command, and store and read the data on that object. .. click:example:: - @click.group(chain=True) - def cli(): - pass - - - @cli.command('sdist') - def sdist(): - click.echo('sdist called') + pass_ns = click.make_pass_decorator(dict, ensure=True) + @click.group(chain=True) + @click.argument("name") + @pass_ns + def cli(ns, name): + ns["name"] = name - @cli.command('bdist_wheel') - def bdist_wheel(): - click.echo('bdist_wheel called') + @cli.command + @pass_ns + def lower(ns): + ns["name"] = ns["name"].lower() -Now you can invoke it like this: + @cli.command + @pass_ns + def show(ns): + click.echo(ns["name"]) .. click:run:: - invoke(cli, prog_name='setup.py', args=['sdist', 'bdist_wheel']) - -When using multi command chaining you can only have one command (the last) -use ``nargs=-1`` on an argument. It is also not possible to nest multi -commands below chained multicommands. Other than that there are no -restrictions on how they work. They can accept options and arguments as -normal. The order between options and arguments is limited for chained -commands. Currently only ``--options argument`` order is allowed. + invoke(cli, prog_name="process", args=["Click", "show", "lower", "show"]) -Another note: the :attr:`Context.invoked_subcommand` attribute is a bit -useless for multi commands as it will give ``'*'`` as value if more than -one command is invoked. This is necessary because the handling of -subcommands happens one after another so the exact subcommands that will -be handled are not yet available when the callback fires. +Another way to do this is to collect data returned by each command, then process +it at the end of the chain. Use the group's :meth:`~Group.result_callback` +decorator to register a function that is called after the chain is finished. It +is passed the list of return values as well as any parameters registered on the +group. -.. note:: +A command can return anything, including a function. Here's an example of that, +where each subcommand creates a function that processes the input, then the +result callback calls each function. The command takes a file, processes each +line, then outputs it. If no subcommands are given, it outputs the contents +of the file unchanged. - It is currently not possible for chain commands to be nested. This - will be fixed in future versions of Click. - - -Multi Command Pipelines ------------------------ - -.. versionadded:: 3.0 - -A very common usecase of multi command chaining is to have one command -process the result of the previous command. There are various ways in -which this can be facilitated. The most obvious way is to store a value -on the context object and process it from function to function. This -works by decorating a function with :func:`pass_context` after which the -context object is provided and a subcommand can store its data there. - -Another way to accomplish this is to setup pipelines by returning -processing functions. Think of it like this: when a subcommand gets -invoked it processes all of its parameters and comes up with a plan of -how to do its processing. At that point it then returns a processing -function and returns. - -Where do the returned functions go? The chained multicommand can register -a callback with :meth:`MultiCommand.result_callback` that goes over all -these functions and then invoke them. - -To make this a bit more concrete consider this example: - -.. click:example:: +.. code-block:: python @click.group(chain=True, invoke_without_command=True) - @click.option('-i', '--input', type=click.File('r')) - def cli(input): + @click.argument("fin", type=click.File("r")) + def cli(fin): pass @cli.result_callback() - def process_pipeline(processors, input): - iterator = (x.rstrip('\r\n') for x in input) + def process_pipeline(processors, fin): + iterator = (x.rstrip("\r\n") for x in input) + for processor in processors: iterator = processor(iterator) + for item in iterator: click.echo(item) - @cli.command('uppercase') + @cli.command("upper") def make_uppercase(): def processor(iterator): for line in iterator: yield line.upper() return processor - @cli.command('lowercase') + @cli.command("lower") def make_lowercase(): def processor(iterator): for line in iterator: yield line.lower() return processor - @cli.command('strip') + @cli.command("strip") def make_strip(): def processor(iterator): for line in iterator: @@ -420,11 +388,11 @@ make resource handling much more complicated. For such it's recommended to not use the file type and manually open the file through :func:`open_file`. -For a more complex example that also improves upon handling of the -pipelines have a look at the `imagepipe multi command chaining demo -`__ in -the Click repository. It implements a pipeline based image editing tool -that has a nice internal structure for the pipelines. +For a more complex example that also improves upon handling of the pipelines, +see the `imagepipe example`_ in the Click repository. It implements a +pipeline based image editing tool that has a nice internal structure. + +.. _imagepipe example: https://github.com/pallets/click/tree/main/examples/imagepipe Overriding Defaults @@ -535,15 +503,15 @@ that were previously hard to implement. In essence any command callback can now return a value. This return value is bubbled to certain receivers. One usecase for this has already been -show in the example of :ref:`multi-command-chaining` where it has been -demonstrated that chained multi commands can have callbacks that process +show in the example of :ref:`command-chaining` where it has been +demonstrated that chained groups can have callbacks that process all return values. When working with command return values in Click, this is what you need to know: - The return value of a command callback is generally returned from the - :meth:`BaseCommand.invoke` method. The exception to this rule has to + :meth:`Command.invoke` method. The exception to this rule has to do with :class:`Group`\s: * In a group the return value is generally the return value of the @@ -553,7 +521,7 @@ know: * If a group is set up for chaining then the return value is a list of all subcommands' results. * Return values of groups can be processed through a - :attr:`MultiCommand.result_callback`. This is invoked with the + :attr:`Group.result_callback`. This is invoked with the list of all return values in chain mode, or the single return value in case of non chained commands. @@ -563,9 +531,9 @@ know: - Click does not have any hard requirements for the return values and does not use them itself. This allows return values to be used for - custom decorators or workflows (like in the multi command chaining + custom decorators or workflows (like in the command chaining example). - When a Click script is invoked as command line application (through - :meth:`BaseCommand.main`) the return value is ignored unless the + :meth:`Command.main`) the return value is ignored unless the `standalone_mode` is disabled in which case it's bubbled through. diff --git a/docs/complex.rst b/docs/complex.rst index f24b0fe84..3db0159b8 100644 --- a/docs/complex.rst +++ b/docs/complex.rst @@ -224,9 +224,9 @@ Lazily Loading Subcommands Large CLIs and CLIs with slow imports may benefit from deferring the loading of subcommands. The interfaces which support this mode of use are -:meth:`MultiCommand.list_commands` and :meth:`MultiCommand.get_command`. A custom -:class:`MultiCommand` subclass can implement a lazy loader by storing extra data such -that :meth:`MultiCommand.get_command` is responsible for running imports. +:meth:`Group.list_commands` and :meth:`Group.get_command`. A custom +:class:`Group` subclass can implement a lazy loader by storing extra data such +that :meth:`Group.get_command` is responsible for running imports. Since the primary case for this is a :class:`Group` which loads its subcommands lazily, the following example shows a lazy-group implementation. @@ -279,7 +279,7 @@ stores a mapping from subcommand names to the information for importing them. # get the Command object from that module cmd_object = getattr(mod, cmd_object_name) # check the result to make debugging easier - if not isinstance(cmd_object, click.BaseCommand): + if not isinstance(cmd_object, click.Command): raise ValueError( f"Lazy loading of {import_path} failed by returning " "a non-command object" @@ -306,6 +306,8 @@ subcommands like so: def cli(): pass +.. code-block:: python + # in foo.py import click @@ -313,6 +315,8 @@ subcommands like so: def cli(): pass +.. code-block:: python + # in bar.py import click from lazy_group import LazyGroup @@ -325,6 +329,8 @@ subcommands like so: def cli(): pass +.. code-block:: python + # in baz.py import click @@ -337,7 +343,7 @@ What triggers Lazy Loading? ``````````````````````````` There are several events which may trigger lazy loading by running the -:meth:`MultiCommand.get_command` function. +:meth:`Group.get_command` function. Some are intuititve, and some are less so. All cases are described with respect to the above example, assuming the main program @@ -358,9 +364,9 @@ Further Deferring Imports It is possible to make the process even lazier, but it is generally more difficult the more you want to defer work. -For example, subcommands could be represented as a custom :class:`BaseCommand` subclass +For example, subcommands could be represented as a custom :class:`Command` subclass which defers importing the command until it is invoked, but which provides -:meth:`BaseCommand.get_short_help_str` in order to support completions and helptext. +:meth:`Command.get_short_help_str` in order to support completions and helptext. More simply, commands can be constructed whose callback functions defer any actual work until after an import. @@ -377,7 +383,7 @@ the "real" callback function is deferred until invocation time: foo_concrete(n, w) -Because ``click`` builds helptext and usage info from options, arguments, and command +Because Click builds helptext and usage info from options, arguments, and command attributes, it has no awareness that the underlying function is in any way handling a -deferred import. Therefore, all ``click``-provided utilities and functionality will work +deferred import. Therefore, all Click-provided utilities and functionality will work as normal on such a command. diff --git a/docs/documentation.rst b/docs/documentation.rst index da0aaa148..1b7210eab 100644 --- a/docs/documentation.rst +++ b/docs/documentation.rst @@ -1,32 +1,29 @@ -Documenting Scripts +Help Pages =================== .. currentmodule:: click -Click makes it very easy to document your command line tools. First of -all, it automatically generates help pages for you. While these are -currently not customizable in terms of their layout, all of the text -can be changed. +Click makes it very easy to document your command line tools. For most things Click automatically generates help pages for you. By design the text is customizable, but the layout is not. Help Texts ----------- +-------------- -Commands and options accept help arguments. In the case of commands, the -docstring of the function is automatically used if provided. +Commands and options accept help arguments. For commands, the docstring of the function is automatically used if provided. Simple example: .. click:example:: @click.command() - @click.option('--count', default=1, help='number of greetings') @click.argument('name') - def hello(count, name): - """This script prints hello NAME COUNT times.""" + @click.option('--count', default=1, help='number of greetings') + def hello(name: str, count: int): + """This script prints hello and a name one or more times.""" for x in range(count): - click.echo(f"Hello {name}!") - -And what it looks like: + if name: + click.echo(f"Hello {name}!") + else: + click.echo("Hello!") .. click:run:: @@ -35,15 +32,51 @@ And what it looks like: .. _documenting-arguments: +Command Short Help +------------------ + +For subcommands, a short help snippet is generated. By default, it's the first sentence of the docstring. If too long, then it will ellipsize what cannot be fit on a single line with ``...``. The short help snippet can also be overridden with ``short_help``: + +.. click:example:: + + @click.group() + def cli(): + """A simple command line tool.""" + + @cli.command('init', short_help='init the repo') + def init(): + """Initializes the repository.""" + + +.. click:run:: + + invoke(cli, args=['--help']) + +Command Epilog Help +------------------- + +The help epilog is printed at the end of the help and is useful for showing example command usages or referencing additional help resources. + +.. click:example:: + + @click.command( + epilog='See https://example.com for more details', + ) + def init(): + """Initializes the repository.""" + + +.. click:run:: + + invoke(init, args=['--help']) + Documenting Arguments ---------------------- -:func:`click.argument` does not take a ``help`` parameter. This is to -follow the general convention of Unix tools of using arguments for only -the most necessary things, and to document them in the command help text -by referring to them by name. +:class:`click.argument` does not take a ``help`` parameter. This follows the Unix Command Line Tools convention of using arguments only for necessary things and documenting them in the command help text +by name. This should then be done via the docstring. -You might prefer to reference the argument in the description: +A brief example: .. click:example:: @@ -53,13 +86,12 @@ You might prefer to reference the argument in the description: """Print FILENAME.""" click.echo(filename) -And what it looks like: .. click:run:: invoke(touch, args=['--help']) -Or you might prefer to explicitly provide a description of the argument: +Or more explicitly: .. click:example:: @@ -72,40 +104,81 @@ Or you might prefer to explicitly provide a description of the argument: """ click.echo(filename) -And what it looks like: - .. click:run:: invoke(touch, args=['--help']) -For more examples, see the examples in :doc:`/arguments`. +Showing Defaults +--------------------------- +To control the appearance of defaults pass ``show_default``. -Preventing Rewrapping ---------------------- +.. click:example:: -The default behavior of Click is to rewrap text based on the width of the -terminal, to a maximum 80 characters. In some circumstances, this can become -a problem. The main issue is when showing code examples, where newlines are -significant. + @click.command() + @click.option('--n', default=1, show_default=False, help='number of dots') + def dots(n): + click.echo('.' * n) -Rewrapping can be disabled on a per-paragraph basis by adding a line with -solely the ``\b`` escape marker in it. This line will be removed from the -help text and rewrapping will be disabled. +.. click:run:: -Example: + invoke(dots, args=['--help']) + +For single option boolean flags, the default remains hidden if the default value is False even if show default is true. .. click:example:: @click.command() - def cli(): - """First paragraph. + @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']) + + +Click's Wrapping Behavior +---------------------------- +Click's default wrapping ignores single new lines and rewraps the text based on the width of the terminal, to a maximum of 80 characters. In the example notice how the second grouping of three lines is rewrapped into a single paragraph. + +.. click:example:: - This is a very long second paragraph and as you + @click.command() + def cli(): + """ + This is a very long paragraph and as you can see wrapped very early in the source text but will be rewrapped to the terminal width in the final output. + This is + a paragraph + that is compacted. + """ + +.. click:run:: + + invoke(cli, args=['--help']) + +Escaping Click's Wrapping +--------------------------- +Sometimes Click's wrapping can be a problem, such as when showing code examples where newlines are significant. This behavior can be escaped on a per-paragraph basis by adding a line with only ``\b`` . The ``\b`` is removed from the rendered help text. + +Example: + +.. click:example:: + + @click.command() + def cli(): + """First paragraph. + \b This is a paragraph @@ -115,71 +188,55 @@ Example: that will be rewrapped again. """ -And what it looks like: .. click:run:: invoke(cli, args=['--help']) -To change the maximum width, pass ``max_content_width`` when calling the command. +To change the rendering maximum width, pass ``max_content_width`` when calling the command. .. code-block:: python cli(max_content_width=120) - .. _doc-meta-variables: Truncating Help Texts --------------------- -Click gets command help text from function docstrings. However if you -already use docstrings to document function arguments you may not want -to see :param: and :return: lines in your help text. - -You can use the ``\f`` escape marker to have Click truncate the help text -after the marker. +Click gets :class:`Command` help text from the docstring. If you do not want to include part of the docstring, add the ``\f`` escape marker to have Click truncate the help text after the marker. Example: .. click:example:: @click.command() - @click.pass_context - def cli(ctx): + def cli(): """First paragraph. - - This is a very long second - paragraph and not correctly - wrapped but it will be rewrapped. \f - :param click.core.Context ctx: Click context. + Words to not be included. """ -And what it looks like: - .. click:run:: invoke(cli, args=['--help']) -Meta Variables --------------- +Placeholder / Meta Variable +----------------------------- -Options and parameters accept a ``metavar`` argument that can change the -meta variable in the help page. The default version is the parameter name -in uppercase with underscores, but can be annotated differently if -desired. This can be customized at all levels: +The default placeholder variable (`meta variable `_) in the help pages is the parameter name in uppercase with underscores. This can be changed for Commands and Parameters with the ``options_metavar`` and ``metavar`` kwargs. .. click:example:: - @click.command(options_metavar='') + # This controls entry on the usage line. + @click.command(options_metavar='[[options]]') @click.option('--count', default=1, help='number of greetings', metavar='') @click.argument('name', metavar='') - def hello(count, name): - """This script prints hello times.""" + def hello(name: str, count: int) -> None: + """This script prints 'hello ' a total of times.""" for x in range(count): click.echo(f"Hello {name}!") @@ -189,65 +246,9 @@ Example: invoke(hello, args=['--help']) - -Command Short Help ------------------- - -For commands, a short help snippet is generated. By default, it's the first -sentence of the help message of the command, unless it's too long. This can -also be overridden: - -.. click:example:: - - @click.group() - def cli(): - """A simple command line tool.""" - - @cli.command('init', short_help='init the repo') - def init(): - """Initializes the repository.""" - - @cli.command('delete', short_help='delete the repo') - def delete(): - """Deletes the repository.""" - -And what it looks like: - -.. click:run:: - - invoke(cli, prog_name='repo.py') - -Command Epilog Help -------------------- - -The help epilog is like the help string but it's printed at the end of the help -page after everything else. Useful for showing example command usages or -referencing additional help resources. - -.. click:example:: - - @click.command(epilog='Check out our docs at https://click.palletsprojects.com/ for more details') - def init(): - """Initializes the repository.""" - -And what it looks like: - -.. click:run:: - - invoke(init, prog_name='repo.py', args=['--help']) - Help Parameter Customization ---------------------------- - -.. versionadded:: 2.0 - -The help parameter is implemented in Click in a very special manner. -Unlike regular parameters it's automatically added by Click for any -command and it performs automatic conflict resolution. By default it's -called ``--help``, but this can be changed. If a command itself implements -a parameter with the same name, the default help parameter stops accepting -it. There is a context setting that can be used to override the names of -the help parameters called :attr:`~Context.help_option_names`. +Help parameters are automatically added by Click for any command. The default is ``--help`` but can be override by the context setting :attr:`~Context.help_option_names`. Click also performs automatic conflict resolution on the default help parameter so if a command itself implements a parameter named ``help`` then the default help will not be run. This example changes the default parameters to ``-h`` and ``--help`` instead of just ``--help``: @@ -260,7 +261,6 @@ instead of just ``--help``: def cli(): pass -And what it looks like: .. click:run:: diff --git a/docs/entry-points.rst b/docs/entry-points.rst new file mode 100644 index 000000000..2354e01f4 --- /dev/null +++ b/docs/entry-points.rst @@ -0,0 +1,86 @@ +Packaging Entry Points +====================== + +It's recommended to write command line utilities as installable packages with +entry points instead telling users to run ``python hello.py``. + +A distribution package is a ``.whl`` file you install with pip or another Python +installer. You use a ``pyproject.toml`` file to describe the project and how it +is built into a package. You might upload this package to PyPI, or distribute it +to your users in another way. + +Python installers create executable scripts that will run a specified Python +function. These are known as "entry points". The installer knows how to create +an executable regardless of the operating system, so it will work on Linux, +Windows, MacOS, etc. + + +Project Files +------------- + +To install your app with an entry point, all you need is the script and a +``pyproject.toml`` file. Here's an example project directory: + +.. code-block:: text + + hello-project/ + src/ + hello/ + __init__.py + pyproject.toml + +Contents of ``hello.py``: + +.. click:example:: + + import click + + @click.command() + def cli(): + """Prints a greeting.""" + click.echo("Hello, World!") + +Contents of ``pyproject.toml``: + +.. code-block:: toml + + [project] + name = "hello" + version = "1.0.0" + description = "Hello CLI" + requires-python = ">=3.11" + dependencies = [ + "click>=8.1", + ] + + [project.scripts] + hello = "hello:cli" + + [build-system] + requires = ["flit_core<4"] + build-backend = "flit_core.buildapi" + +The magic is in the ``project.scripts`` section. Each line identifies one executable +script. The first part before the equals sign (``=``) is the name of the script that +should be generated, the second part is the import path followed by a colon +(``:``) with the function to call (the Click command). + + +Installation +------------ + +When your package is installed, the installer will create an executable script +based on the configuration. During development, you can install in editable +mode using the ``-e`` option. Remember to use a virtual environment! + +.. code-block:: console + + $ python -m venv .venv + $ . .venv/bin/activate + $ pip install -e . + +Afterwards, your command should be available: + +.. click:run:: + + invoke(cli, prog_name="hello") diff --git a/docs/exceptions.rst b/docs/exceptions.rst index 06ede94bc..dec89da8c 100644 --- a/docs/exceptions.rst +++ b/docs/exceptions.rst @@ -10,7 +10,7 @@ like incorrect usage. Where are Errors Handled? ------------------------- -Click's main error handling is happening in :meth:`BaseCommand.main`. In +Click's main error handling is happening in :meth:`Command.main`. In there it handles all subclasses of :exc:`ClickException` as well as the standard :exc:`EOFError` and :exc:`KeyboardInterrupt` exceptions. The latter are internally translated into an :exc:`Abort`. diff --git a/docs/index.rst b/docs/index.rst index 850b2625a..58628c8a3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -68,14 +68,17 @@ usage patterns. why quickstart + entry-points virtualenv setuptools parameters + parameter-types options + option-decorators arguments commands - prompts documentation + prompts complex advanced testing @@ -103,6 +106,5 @@ Miscellaneous Pages :maxdepth: 2 contrib - upgrading license changes diff --git a/docs/option-decorators.rst b/docs/option-decorators.rst new file mode 100644 index 000000000..404f608b3 --- /dev/null +++ b/docs/option-decorators.rst @@ -0,0 +1,80 @@ +Options Shortcut Decorators +=========================== + +.. currentmodule:: click + +For convenience commonly used combinations of options arguments are available as their own decorators. + +.. contents:: + :depth: 2 + :local: + +Password Option +------------------ + +Click supports hidden prompts and asking for confirmation. This is +useful for password input: + +.. click:example:: + + import codecs + + @click.command() + @click.option( + "--password", prompt=True, hide_input=True, + confirmation_prompt=True + ) + def encode(password): + click.echo(f"encoded: {codecs.encode(password, 'rot13')}") + +.. click:run:: + + invoke(encode, input=['secret', 'secret']) + +Because this combination of parameters is quite common, this can also be +replaced with the :func:`password_option` decorator: + +.. code-block:: python + + @click.command() + @click.password_option() + def encrypt(password): + click.echo(f"encoded: to {codecs.encode(password, 'rot13')}") + +Confirmation Option +-------------------- + +For dangerous operations, it's very useful to be able to ask a user for +confirmation. This can be done by adding a boolean ``--yes`` flag and +asking for confirmation if the user did not provide it and to fail in a +callback: + +.. click:example:: + + def abort_if_false(ctx, param, value): + if not value: + ctx.abort() + + @click.command() + @click.option('--yes', is_flag=True, callback=abort_if_false, + expose_value=False, + prompt='Are you sure you want to drop the db?') + def dropdb(): + click.echo('Dropped all tables!') + +And what it looks like on the command line: + +.. click:run:: + + invoke(dropdb, input=['n']) + invoke(dropdb, args=['--yes']) + +Because this combination of parameters is quite common, this can also be +replaced with the :func:`confirmation_option` decorator: + +.. click:example:: + + @click.command() + @click.confirmation_option(prompt='Are you sure you want to drop the db?') + def dropdb(): + click.echo('Dropped all tables!') diff --git a/docs/options.rst b/docs/options.rst index 5c23badba..1a80ef201 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -1,141 +1,124 @@ .. _options: Options -======= +========= .. currentmodule:: click -Adding options to commands can be accomplished by the :func:`option` -decorator. Since options can come in various different versions, there -are a ton of parameters to configure their behavior. Options in click are -distinct from :ref:`positional arguments `. +Adding options to commands can be accomplished with the :func:`option` +decorator. Options in Click are distinct from :ref:`positional arguments `. -Name Your Options ------------------ - -Options have a name that will be used as the Python argument name when -calling the decorated function. This can be inferred from the option -names or given explicitly. Names are given as position arguments to the -decorator. +Useful and often used kwargs are: -A name is chosen in the following order +* ``default``: Passes a default. +* ``help``: Sets help message. +* ``nargs``: Sets the number of arguments. +* ``required``: Makes option required. +* ``type``: Sets :ref:`parameter-types` -1. If a name is not prefixed, it is used as the Python argument name - and not treated as an option name on the command line. -2. If there is at least one name prefixed with two dashes, the first - one given is used as the name. -3. The first name prefixed with one dash is used otherwise. +.. contents:: + :depth: 2 + :local: -To get the Python argument name, the chosen name is converted to lower -case, up to two dashes are removed as the prefix, and other dashes are -converted to underscores. +Option Decorator +----------------- +Click expects you to pass at least two positional arguments to the option decorator. They are option name and function argument name. -.. code-block:: python +.. click:example:: @click.command() - @click.option('-s', '--string-to-echo') + @click.option('--string-to-echo', 'string_to_echo') def echo(string_to_echo): click.echo(string_to_echo) -.. code-block:: python +.. click:run:: - @click.command() - @click.option('-s', '--string-to-echo', 'string') - def echo(string): - click.echo(string) - -- ``"-f", "--foo-bar"``, the name is ``foo_bar`` -- ``"-x"``, the name is ``x`` -- ``"-f", "--filename", "dest"``, the name is ``dest`` -- ``"--CamelCase"``, the name is ``camelcase`` -- ``"-f", "-fb"``, the name is ``f`` -- ``"--f", "--foo-bar"``, the name is ``f`` -- ``"---f"``, the name is ``_f`` - -Basic Value Options -------------------- + invoke(echo, args=['--help']) -The most basic option is a value option. These options accept one -argument which is a value. If no type is provided, the type of the default -value is used. If no default value is provided, the type is assumed to be -:data:`STRING`. Unless a name is explicitly specified, the name of the -parameter is the first long option defined; otherwise the first short one is -used. By default, options are not required, however to make an option required, -simply pass in `required=True` as an argument to the decorator. +However, if you don't pass in the function argument name, then Click will try to infer it. A simple way to name your option is by taking the function argument, adding two dashes to the front and converting underscores to dashes. In this case, Click will infer the function argument name correctly so you can add only the option name. .. click:example:: @click.command() - @click.option('--n', default=1) - def dots(n): - click.echo('.' * n) + @click.option('--string-to-echo') + def echo(string_to_echo): + click.echo(string_to_echo) -.. click:example:: +.. click:run:: - # How to make an option required - @click.command() - @click.option('--n', required=True, type=int) - def dots(n): - click.echo('.' * n) + invoke(echo, args=['--string-to-echo', 'Hi!']) -.. click:example:: +More formally, Click will try to infer the function argument name by: - # How to use a Python reserved word such as `from` as a parameter - @click.command() - @click.option('--from', '-f', 'from_') - @click.option('--to', '-t') - def reserved_param_name(from_, to): - click.echo(f"from {from_} to {to}") +1. If a positional argument name does not have a prefix, it is chosen. +2. If a positional argument name starts with with two dashes, the first one given is chosen. +3. The first positional argument prefixed with one dash is chosen otherwise. -And on the command line: +The chosen positional argument is converted to lower case, up to two dashes are removed from the beginning, and other dashes are converted to underscores to get the function argument name. -.. click:run:: - - invoke(dots, args=['--n=2']) +.. list-table:: Examples + :widths: 15 10 + :header-rows: 1 -In this case the option is of type :data:`INT` because the default value -is an integer. + * - Decorator Arguments + - Function Name + * - ``"-f", "--foo-bar"`` + - foo_bar + * - ``"-x"`` + - x + * - ``"-f", "--filename", "dest"`` + - dest + * - ``"--CamelCase"`` + - camelcase + * - ``"-f", "-fb"`` + - f + * - ``"--f", "--foo-bar"`` + - f + * - ``"---f"`` + - _f -To show the default values when showing command help, use ``show_default=True`` +Basic Example +--------------- +A simple :class:`click.Option` takes one argument. This will assume the argument is not required. If the decorated function takes an positional argument then None is passed it. This will also assume the type is ``str``. .. click:example:: @click.command() - @click.option('--n', default=1, show_default=True) - def dots(n): - click.echo('.' * n) + @click.option('--text') + def print_this(text): + click.echo(text) + .. click:run:: - invoke(dots, args=['--help']) + invoke(print_this, args=['--text=this']) + + invoke(print_this, args=[]) -For single option boolean flags, the default remains hidden if the default -value is False. +.. click:run:: + + invoke(print_this, args=['--help']) + + +Setting a Default +--------------------------- +Instead of setting the ``type``, you may set a default and Click will try to infer the type. .. 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.option('--n', default=1) + def dots(n): click.echo('.' * n) - if br: - click.echo('-' * n) .. click:run:: - invoke(dots, args=['--help']) - + invoke(dots, args=['--help']) Multi Value Options ------------------- -Sometimes, you have options that take more than one argument. For options, -only a fixed number of arguments is supported. This can be configured by -the ``nargs`` parameter. The values are then stored as a tuple. +To make an option take multiple values, pass in ``nargs``. Note only a fixed number of arguments is supported. The values are passed to the underlying function as a tuple. .. click:example:: @@ -145,15 +128,14 @@ the ``nargs`` parameter. The values are then stored as a tuple. a, b = pos click.echo(f"{a} / {b}") -And on the command line: - .. click:run:: invoke(findme, args=['--pos', '2.0', '3.0']) + .. _tuple-type: -Tuples as Multi Value Options +Multi Value Options as Tuples ----------------------------- .. versionadded:: 4.0 @@ -192,15 +174,9 @@ used. The above example is thus equivalent to this: .. _multiple-options: Multiple Options ----------------- +----------------- -Similarly to ``nargs``, there is also the case of wanting to support a -parameter being provided multiple times and have all the values recorded -- -not just the last one. For instance, ``git commit -m foo -m bar`` would -record two lines for the commit message: ``foo`` and ``bar``. This can be -accomplished with the ``multiple`` flag: - -Example: +The multiple options format allows you to call the underlying function multiple times with one command line entry. If set, the default must be a list or tuple. Setting a string as a default will be interpreted as list of characters. .. click:example:: @@ -209,27 +185,13 @@ Example: def commit(message): click.echo('\n'.join(message)) -And on the command line: - .. click:run:: - invoke(commit, args=['-m', 'foo', '-m', 'bar']) - -When passing a ``default`` with ``multiple=True``, the default value -must be a list or tuple, otherwise it will be interpreted as a list of -single characters. - -.. code-block:: python - - @click.option("--format", multiple=True, default=["json"]) - + invoke(commit, args=['-m', 'foo', '-m', 'bar', '-m', 'here']) Counting -------- - -In some very rare circumstances, it is interesting to use the repetition -of options to count an integer up. This can be used for verbosity flags, -for instance: +To count the occurrence of an option pass in ``count=True``. If the option is not passed in, then the count is 0. Counting is commonly used for verbosity. .. click:example:: @@ -238,69 +200,57 @@ for instance: def log(verbose): click.echo(f"Verbosity: {verbose}") -And on the command line: - .. click:run:: + invoke(log, args=[]) invoke(log, args=['-vvv']) -Boolean Flags -------------- - -Boolean flags are options that can be enabled or disabled. This can be -accomplished by defining two flags in one go separated by a slash (``/``) -for enabling or disabling the option. (If a slash is in an option string, -Click automatically knows that it's a boolean flag and will pass -``is_flag=True`` implicitly.) Click always wants you to provide an enable -and disable flag so that you can change the default later. +Boolean +------------------------ -Example: +Boolean options (boolean flags) take the value True or False. The simplest case sets the default value to ``False`` if the flag is not passed, and ``True`` if it is. .. click:example:: import sys @click.command() - @click.option('--shout/--no-shout', default=False) + @click.option('--shout', is_flag=True) def info(shout): rv = sys.platform if shout: rv = rv.upper() + '!!!!111' click.echo(rv) -And on the command line: .. click:run:: - invoke(info, args=['--shout']) - invoke(info, args=['--no-shout']) invoke(info) + invoke(info, args=['--shout']) -If you really don't want an off-switch, you can just define one and -manually inform Click that something is a flag: + +To implement this more explicitly, pass in on-option ``/`` off-option. Click will automatically set ``is_flag=True``. Click always wants you to provide an enable +and disable flag so that you can change the default later. .. click:example:: import sys @click.command() - @click.option('--shout', is_flag=True) + @click.option('--shout/--no-shout', default=False) def info(shout): rv = sys.platform if shout: rv = rv.upper() + '!!!!111' click.echo(rv) -And on the command line: - .. click:run:: - invoke(info, args=['--shout']) invoke(info) + invoke(info, args=['--shout']) + invoke(info, args=['--no-shout']) -Note that if a slash is contained in your option already (for instance, if -you use Windows-style parameters where ``/`` is the prefix character), you -can alternatively split the parameters through ``;`` instead: +If a forward slash(``/``) is contained in your option name already, you can split the parameters using ``;``. In Windows ``/`` is commonly used as the prefix character. .. click:example:: @@ -309,22 +259,16 @@ can alternatively split the parameters through ``;`` instead: def log(debug): click.echo(f"debug={debug}") - if __name__ == '__main__': - log() - .. versionchanged:: 6.0 -If you want to define an alias for the second option only, then you will -need to use leading whitespace to disambiguate the format string: - -Example: +If you want to define an alias for the second option only, then you will need to use leading whitespace to disambiguate the format string. .. click:example:: import sys @click.command() - @click.option('--shout/--no-shout', ' /-S', default=False) + @click.option('--shout/--no-shout', ' /-N', default=False) def info(shout): rv = sys.platform if shout: @@ -335,8 +279,28 @@ Example: invoke(info, args=['--help']) +Flag Value +--------------- +To have an flag pass a value to the underlying function set ``is_flag=True`` and set ``flag_value`` to the value desired. This can be used to create patterns like this: + +.. click:example:: + + import sys + + @click.command() + @click.option('--upper', 'transformation', flag_value='upper') + @click.option('--lower', 'transformation', flag_value='lower') + def info(transformation): + click.echo(getattr(sys.platform, transformation)()) + +.. click:run:: + + invoke(info, args=['--help']) + invoke(info, args=['--upper']) + invoke(info, args=['--lower']) + Feature Switches ----------------- +--------------------------- In addition to boolean flags, there are also feature switches. These are implemented by setting multiple options to the same parameter name and @@ -357,60 +321,12 @@ the default. def info(transformation): click.echo(getattr(sys.platform, transformation)()) -And on the command line: - .. click:run:: invoke(info, args=['--upper']) - invoke(info, args=['--lower']) + invoke(info, args=['--help']) invoke(info) -.. _choice-opts: - -Choice Options --------------- - -Sometimes, you want to have a parameter be a choice of a list of values. -In that case you can use :class:`Choice` type. It can be instantiated -with a list of valid values. The originally passed choice will be returned, -not the str passed on the command line. Token normalization functions and -``case_sensitive=False`` can cause the two to be different but still match. - -Example: - -.. click:example:: - - @click.command() - @click.option('--hash-type', - type=click.Choice(['MD5', 'SHA1'], case_sensitive=False)) - def digest(hash_type): - click.echo(hash_type) - -What it looks like: - -.. click:run:: - - invoke(digest, args=['--hash-type=MD5']) - println() - invoke(digest, args=['--hash-type=md5']) - println() - invoke(digest, args=['--hash-type=foo']) - println() - invoke(digest, args=['--help']) - -Only pass the choices as list or tuple. Other iterables (like -generators) may lead to unexpected results. - -Choices work with options that have ``multiple=True``. If a ``default`` -value is given with ``multiple=True``, it should be a list or tuple of -valid choices. - -Choices should be unique after considering the effects of -``case_sensitive`` and any specified token normalization function. - -.. versionchanged:: 7.1 - The resulting value from an option will always be one of the - originally passed choices regardless of ``case_sensitive``. .. _option-prompting: @@ -460,40 +376,6 @@ By default, the user will be prompted for an input if one was not passed through the command line. To turn this behavior off, see :ref:`optional-value`. - -Password Prompts ----------------- - -Click also supports hidden prompts and asking for confirmation. This is -useful for password input: - -.. click:example:: - - import codecs - - @click.command() - @click.option( - "--password", prompt=True, hide_input=True, - confirmation_prompt=True - ) - def encode(password): - click.echo(f"encoded: {codecs.encode(password, 'rot13')}") - -.. click:run:: - - invoke(encode, input=['secret', 'secret']) - -Because this combination of parameters is quite common, this can also be -replaced with the :func:`password_option` decorator: - -.. code-block:: python - - @click.command() - @click.password_option() - def encrypt(password): - click.echo(f"encoded: to {codecs.encode(password, 'rot13')}") - - Dynamic Defaults for Prompts ---------------------------- @@ -564,7 +446,7 @@ 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: +Here's an example for a ``--version`` flag: .. click:example:: @@ -594,54 +476,6 @@ What it looks like: invoke(hello) invoke(hello, args=['--version']) -.. admonition:: Callback Signature Changes - - In Click 2.0 the signature for callbacks changed. For more - information about these changes see :ref:`upgrade-to-2.0`. - -Yes Parameters --------------- - -For dangerous operations, it's very useful to be able to ask a user for -confirmation. This can be done by adding a boolean ``--yes`` flag and -asking for confirmation if the user did not provide it and to fail in a -callback: - -.. click:example:: - - def abort_if_false(ctx, param, value): - if not value: - ctx.abort() - - @click.command() - @click.option('--yes', is_flag=True, callback=abort_if_false, - expose_value=False, - prompt='Are you sure you want to drop the db?') - def dropdb(): - click.echo('Dropped all tables!') - -And what it looks like on the command line: - -.. click:run:: - - invoke(dropdb, input=['n']) - invoke(dropdb, args=['--yes']) - -Because this combination of parameters is quite common, this can also be -replaced with the :func:`confirmation_option` decorator: - -.. click:example:: - - @click.command() - @click.confirmation_option(prompt='Are you sure you want to drop the db?') - def dropdb(): - click.echo('Dropped all tables!') - -.. admonition:: Callback Signature Changes - - In Click 2.0 the signature for callbacks changed. For more - information about these changes see :ref:`upgrade-to-2.0`. - Values from Environment Variables --------------------------------- @@ -809,40 +643,6 @@ boolean flag you need to separate it with ``;`` instead of ``/``: if __name__ == '__main__': log() -.. _ranges: - -Range Options -------------- - -The :class:`IntRange` type extends the :data:`INT` type to ensure the -value is contained in the given range. The :class:`FloatRange` type does -the same for :data:`FLOAT`. - -If ``min`` or ``max`` is omitted, that side is *unbounded*. Any value in -that direction is accepted. By default, both bounds are *closed*, which -means the boundary value is included in the accepted range. ``min_open`` -and ``max_open`` can be used to exclude that boundary from the range. - -If ``clamp`` mode is enabled, a value that is outside the range is set -to the boundary instead of failing. For example, the range ``0, 5`` -would return ``5`` for the value ``10``, or ``0`` for the value ``-1``. -When using :class:`FloatRange`, ``clamp`` can only be enabled if both -bounds are *closed* (the default). - -.. click:example:: - - @click.command() - @click.option("--count", type=click.IntRange(0, 20, clamp=True)) - @click.option("--digit", type=click.IntRange(0, 9)) - def repeat(count, digit): - click.echo(str(digit) * count) - -.. click:run:: - - invoke(repeat, args=['--count=100', '--digit=5']) - invoke(repeat, args=['--count=6', '--digit=12']) - - Callbacks for Validation ------------------------ diff --git a/docs/parameter-types.rst b/docs/parameter-types.rst new file mode 100644 index 000000000..cb174405f --- /dev/null +++ b/docs/parameter-types.rst @@ -0,0 +1,182 @@ +.. _parameter-types: + +Parameter Types +================== + +.. currentmodule:: click + +When the parameter type is set using ``type``, Click will leverage the type to make your life easier, for example adding data to your help pages. Most examples are done with options, but types are available to options and arguments. + +.. contents:: + :depth: 2 + :local: + +Built-in Types Examples +------------------------ + +.. _choice-opts: + +Choice +^^^^^^^^^^^^^^^^^^^^^^ + +Sometimes, you want to have a parameter be a choice of a list of values. +In that case you can use :class:`Choice` type. It can be instantiated +with a list of valid values. The originally passed choice will be returned, +not the str passed on the command line. Token normalization functions and +``case_sensitive=False`` can cause the two to be different but still match. +:meth:`Choice.normalize_choice` for more info. + +Example: + +.. click:example:: + + import enum + + class HashType(enum.Enum): + MD5 = 'MD5' + SHA1 = 'SHA1' + + @click.command() + @click.option('--hash-type', + type=click.Choice(HashType, case_sensitive=False)) + def digest(hash_type: HashType): + click.echo(hash_type) + +What it looks like: + +.. click:run:: + + invoke(digest, args=['--hash-type=MD5']) + println() + invoke(digest, args=['--hash-type=md5']) + println() + invoke(digest, args=['--hash-type=foo']) + println() + invoke(digest, args=['--help']) + +Since version 8.2.0 any iterable may be passed to :class:`Choice`, here +an ``Enum`` is used which will result in all enum values to be valid +choices. + +Choices work with options that have ``multiple=True``. If a ``default`` +value is given with ``multiple=True``, it should be a list or tuple of +valid choices. + +Choices should be unique after normalization, see +:meth:`Choice.normalize_choice` for more info. + +.. versionchanged:: 7.1 + The resulting value from an option will always be one of the + originally passed choices regardless of ``case_sensitive``. + +.. _ranges: + +Int and Float Ranges +^^^^^^^^^^^^^^^^^^^^^^^ + +The :class:`IntRange` type extends the :data:`INT` type to ensure the +value is contained in the given range. The :class:`FloatRange` type does +the same for :data:`FLOAT`. + +If ``min`` or ``max`` is omitted, that side is *unbounded*. Any value in +that direction is accepted. By default, both bounds are *closed*, which +means the boundary value is included in the accepted range. ``min_open`` +and ``max_open`` can be used to exclude that boundary from the range. + +If ``clamp`` mode is enabled, a value that is outside the range is set +to the boundary instead of failing. For example, the range ``0, 5`` +would return ``5`` for the value ``10``, or ``0`` for the value ``-1``. +When using :class:`FloatRange`, ``clamp`` can only be enabled if both +bounds are *closed* (the default). + +.. click:example:: + + @click.command() + @click.option("--count", type=click.IntRange(0, 20, clamp=True)) + @click.option("--digit", type=click.IntRange(0, 9)) + def repeat(count, digit): + click.echo(str(digit) * count) + +.. click:run:: + + invoke(repeat, args=['--count=100', '--digit=5']) + invoke(repeat, args=['--count=6', '--digit=12']) + + +Built-in Types Listing +----------------------- +The supported parameter :ref:`click-api-types` are: + +* ``str`` / :data:`click.STRING`: The default parameter type which indicates unicode strings. + +* ``int`` / :data:`click.INT`: A parameter that only accepts integers. + +* ``float`` / :data:`click.FLOAT`: A parameter that only accepts floating point values. + +* ``bool`` / :data:`click.BOOL`: A parameter that accepts boolean values. This is automatically used + for boolean flags. The string values "1", "true", "t", "yes", "y", + and "on" convert to ``True``. "0", "false", "f", "no", "n", and + "off" convert to ``False``. + +* :data:`click.UUID`: + A parameter that accepts UUID values. This is not automatically + guessed but represented as :class:`uuid.UUID`. + +* .. autoclass:: Choice + :noindex: + +* .. autoclass:: DateTime + :noindex: + +* .. autoclass:: File + :noindex: + +* .. autoclass:: FloatRange + :noindex: + +* .. autoclass:: IntRange + :noindex: + +* .. autoclass:: Path + :noindex: + +How to Implement Custom Types +------------------------------- + +To implement a custom type, you need to subclass the :class:`ParamType` class. For simple cases, passing a Python function that fails with a `ValueError` is also supported, though discouraged. Override the :meth:`~ParamType.convert` method to convert the value from a string to the correct type. + +The following code implements an integer type that accepts hex and octal +numbers in addition to normal integers, and converts them into regular +integers. + +.. code-block:: python + + import click + + class BasedIntParamType(click.ParamType): + name = "integer" + + def convert(self, value, param, ctx): + if isinstance(value, int): + return value + + try: + if value[:2].lower() == "0x": + return int(value[2:], 16) + elif value[:1] == "0": + return int(value, 8) + return int(value, 10) + except ValueError: + self.fail(f"{value!r} is not a valid integer", param, ctx) + + BASED_INT = BasedIntParamType() + +The :attr:`~ParamType.name` attribute is optional and is used for +documentation. Call :meth:`~ParamType.fail` if conversion fails. The +``param`` and ``ctx`` arguments may be ``None`` in some cases such as +prompts. + +Values from user input or the command line will be strings, but default +values and Python arguments may already be the correct type. The custom +type should check at the top if the value is already valid and pass it +through to support those cases. diff --git a/docs/parameters.rst b/docs/parameters.rst index 7291a68c4..66cf907c5 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -3,7 +3,7 @@ Parameters .. currentmodule:: click -Click supports only two types of parameters for scripts (by design): options and arguments. +Click supports only two principle types of parameters for scripts (by design): options and arguments. Options ---------------- @@ -25,6 +25,8 @@ Arguments * Are not fully documented by the help page since they may be too specific to be automatically documented. For more see :ref:`documenting-arguments`. * Can be pulled from environment variables but only explicitly named ones. For more see :ref:`environment-variables`. +On each principle type you can specify :ref:`parameter-types`. Specifying these types helps Click add details to your help pages and help with the handling of those types. + .. _parameter_names: Parameter Names @@ -52,86 +54,3 @@ And what it looks like when run: .. click:run:: invoke(multi_echo, ['--times=3', 'index.txt'], prog_name='multi_echo') - -Parameter Types ---------------- - -The supported parameter types are: - -``str`` / :data:`click.STRING`: - The default parameter type which indicates unicode strings. - -``int`` / :data:`click.INT`: - A parameter that only accepts integers. - -``float`` / :data:`click.FLOAT`: - A parameter that only accepts floating point values. - -``bool`` / :data:`click.BOOL`: - A parameter that accepts boolean values. This is automatically used - for boolean flags. The string values "1", "true", "t", "yes", "y", - and "on" convert to ``True``. "0", "false", "f", "no", "n", and - "off" convert to ``False``. - -:data:`click.UUID`: - A parameter that accepts UUID values. This is not automatically - guessed but represented as :class:`uuid.UUID`. - -.. autoclass:: File - :noindex: - -.. autoclass:: Path - :noindex: - -.. autoclass:: Choice - :noindex: - -.. autoclass:: IntRange - :noindex: - -.. autoclass:: FloatRange - :noindex: - -.. autoclass:: DateTime - :noindex: - -How to Implement Custom Types -------------------------------- - -To implement a custom type, you need to subclass the :class:`ParamType` class. For simple cases, passing a Python function that fails with a `ValueError` is also supported, though discouraged. Override the :meth:`~ParamType.convert` method to convert the value from a string to the correct type. - -The following code implements an integer type that accepts hex and octal -numbers in addition to normal integers, and converts them into regular -integers. - -.. code-block:: python - - import click - - class BasedIntParamType(click.ParamType): - name = "integer" - - def convert(self, value, param, ctx): - if isinstance(value, int): - return value - - try: - if value[:2].lower() == "0x": - return int(value[2:], 16) - elif value[:1] == "0": - return int(value, 8) - return int(value, 10) - except ValueError: - self.fail(f"{value!r} is not a valid integer", param, ctx) - - BASED_INT = BasedIntParamType() - -The :attr:`~ParamType.name` attribute is optional and is used for -documentation. Call :meth:`~ParamType.fail` if conversion fails. The -``param`` and ``ctx`` arguments may be ``None`` in some cases such as -prompts. - -Values from user input or the command line will be strings, but default -values and Python arguments may already be the correct type. The custom -type should check at the top if the value is already valid and pass it -through to support those cases. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index f240bf916..310e2b251 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -21,7 +21,7 @@ Some standalone examples of Click applications are packaged with Click. They are * `naval `_ : Port of the `docopt `_ naval example. * `colors `_ : A simple example that colorizes text. Uses colorama on Windows. * `aliases `_ : An advanced example that implements :ref:`aliases`. -* `imagepipe `_ : A complex example that implements some :ref:`multi-command-chaining` . It chains together image processing instructions. Requires pillow. +* `imagepipe `_ : A complex example that implements some :ref:`command-pipelines` . It chains together image processing instructions. Requires pillow. * `repo `_ : An advanced example that implements a Git-/Mercurial-like command line interface. * `complex `_ : A very advanced example that implements loading subcommands dynamically from a plugin folder. * `termui `_ : A simple example that showcases terminal UI helpers provided by click. @@ -131,8 +131,7 @@ script can instead be written like this: def dropdb(): click.echo('Dropped the database') -You would then invoke the :class:`Group` in your setuptools entry points or -other invocations:: +You would then invoke the :class:`Group` in your entry points or other invocations:: if __name__ == '__main__': cli() @@ -181,30 +180,27 @@ What it looks like: invoke(hello, args=['--help'], prog_name='python hello.py') -.. _switching-to-setuptools: - -Switching to Setuptools ------------------------ +Switching to Entry Points +------------------------- In the code you wrote so far there is a block at the end of the file which looks like this: ``if __name__ == '__main__':``. This is traditionally how a standalone Python file looks like. With Click you can continue -doing that, but there are better ways through setuptools. +doing that, but a better way is to package your app with an entry point. There are two main (and many more) reasons for this: -The first one is that setuptools automatically generates executable +The first one is that installers automatically generate executable wrappers for Windows so your command line utilities work on Windows too. -The second reason is that setuptools scripts work with virtualenv on Unix +The second reason is that entry point scripts work with virtualenv on Unix without the virtualenv having to be activated. This is a very useful concept which allows you to bundle your scripts with all requirements into a virtualenv. Click is perfectly equipped to work with that and in fact the rest of the -documentation will assume that you are writing applications through -setuptools. +documentation will assume that you are writing applications as distributed +packages. -I strongly recommend to have a look at the :ref:`setuptools-integration` -chapter before reading the rest as the examples assume that you will -be using setuptools. +Look at the :doc:`entry-points` chapter before reading the rest as the examples +assume that you will be using entry points. diff --git a/docs/setuptools.rst b/docs/setuptools.rst index 1b41414a1..de91b65d3 100644 --- a/docs/setuptools.rst +++ b/docs/setuptools.rst @@ -1,155 +1,6 @@ -.. _setuptools-integration: +:orphan: Setuptools Integration ====================== -When writing command line utilities, it's recommended to write them as -modules that are distributed with setuptools instead of using Unix -shebangs. - -Why would you want to do that? There are a bunch of reasons: - -1. One of the problems with the traditional approach is that the first - module the Python interpreter loads has an incorrect name. This might - sound like a small issue but it has quite significant implications. - - The first module is not called by its actual name, but the - interpreter renames it to ``__main__``. While that is a perfectly - valid name it means that if another piece of code wants to import from - that module it will trigger the import a second time under its real - name and all of a sudden your code is imported twice. - -2. Not on all platforms are things that easy to execute. On Linux and OS - X you can add a comment to the beginning of the file (``#!/usr/bin/env - python``) and your script works like an executable (assuming it has - the executable bit set). This however does not work on Windows. - While on Windows you can associate interpreters with file extensions - (like having everything ending in ``.py`` execute through the Python - interpreter) you will then run into issues if you want to use the - script in a virtualenv. - - In fact running a script in a virtualenv is an issue with OS X and - Linux as well. With the traditional approach you need to have the - whole virtualenv activated so that the correct Python interpreter is - used. Not very user friendly. - -3. The main trick only works if the script is a Python module. If your - application grows too large and you want to start using a package you - will run into issues. - -Introduction ------------- - -To bundle your script with setuptools, all you need is the script in a -Python package and a ``setup.py`` file. - -Imagine this directory structure: - -.. code-block:: text - - yourscript.py - setup.py - -Contents of ``yourscript.py``: - -.. click:example:: - - import click - - @click.command() - def cli(): - """Example script.""" - click.echo('Hello World!') - -Contents of ``setup.py``: - -.. code-block:: python - - from setuptools import setup - - setup( - name='yourscript', - version='0.1.0', - py_modules=['yourscript'], - install_requires=[ - 'Click', - ], - entry_points={ - 'console_scripts': [ - 'yourscript = yourscript:cli', - ], - }, - ) - -The magic is in the ``entry_points`` parameter. Read the full -`entry_points `_ -specification for more details. Below ``console_scripts``, each -line identifies one console script. The first part before the -equals sign (``=``) is the name of the script that should be -generated, the second part is the import path followed by a colon -(``:``) with the Click command. - -That's it. - -Testing The Script ------------------- - -To test the script, you can make a new virtualenv and then install your -package: - -.. code-block:: console - - $ python3 -m venv .venv - $ . .venv/bin/activate - $ pip install --editable . - -Afterwards, your command should be available: - -.. click:run:: - - invoke(cli, prog_name='yourscript') - -Scripts in Packages -------------------- - -If your script is growing and you want to switch over to your script being -contained in a Python package the changes necessary are minimal. Let's -assume your directory structure changed to this: - -.. code-block:: text - - project/ - yourpackage/ - __init__.py - main.py - utils.py - scripts/ - __init__.py - yourscript.py - setup.py - -In this case instead of using ``py_modules`` in your ``setup.py`` file you -can use ``packages`` and the automatic package finding support of -setuptools. In addition to that it's also recommended to include other -package data. - -These would be the modified contents of ``setup.py``: - -.. code-block:: python - - from setuptools import setup, find_packages - - setup( - name='yourpackage', - version='0.1.0', - packages=find_packages(), - include_package_data=True, - install_requires=[ - 'Click', - ], - entry_points={ - 'console_scripts': [ - 'yourscript = yourpackage.scripts.yourscript:cli', - ], - }, - ) +Moved to :doc:`entry-points`. diff --git a/docs/shell-completion.rst b/docs/shell-completion.rst index dd9ddd439..bb69b78eb 100644 --- a/docs/shell-completion.rst +++ b/docs/shell-completion.rst @@ -25,7 +25,7 @@ Enabling Completion Completion is only available if a script is installed and invoked through an entry point, not through the ``python`` command. See -:doc:`/setuptools`. Once the executable is installed, calling it with +:doc:`entry-points`. Once the executable is installed, calling it with a special environment variable will put Click in completion mode. To enable shell completion, the user needs to register a special diff --git a/docs/upgrading.rst b/docs/upgrading.rst deleted file mode 100644 index c6fa5545f..000000000 --- a/docs/upgrading.rst +++ /dev/null @@ -1,131 +0,0 @@ -Upgrading To Newer Releases -=========================== - -Click attempts the highest level of backwards compatibility but sometimes -this is not entirely possible. In case we need to break backwards -compatibility this document gives you information about how to upgrade or -handle backwards compatibility properly. - -.. _upgrade-to-7.0: - -Upgrading to 7.0 ----------------- - -Commands that take their name from the decorated function now replace -underscores with dashes. For example, the Python function ``run_server`` -will get the command name ``run-server`` now. There are a few options -to address this: - -- To continue with the new behavior, pin your dependency to - ``Click>=7`` and update any documentation to use dashes. -- To keep existing behavior, add an explicit command name with - underscores, like ``@click.command("run_server")``. -- To try a name with dashes if the name with underscores was not - found, pass a ``token_normalize_func`` to the context: - - .. code-block:: python - - def normalize(name): - return name.replace("_", "-") - - @click.group(context_settings={"token_normalize_func": normalize}) - def group(): - ... - - @group.command() - def run_server(): - ... - - -.. _upgrade-to-3.2: - -Upgrading to 3.2 ----------------- - -Click 3.2 had to perform two changes to multi commands which were -triggered by a change between Click 2 and Click 3 that had bigger -consequences than anticipated. - -Context Invokes -``````````````` - -Click 3.2 contains a fix for the :meth:`Context.invoke` function when used -with other commands. The original intention of this function was to -invoke the other command as as if it came from the command line when it -was passed a context object instead of a function. This use was only -documented in a single place in the documentation before and there was no -proper explanation for the method in the API documentation. - -The core issue is that before 3.2 this call worked against intentions:: - - ctx.invoke(other_command, 'arg1', 'arg2') - -This was never intended to work as it does not allow Click to operate on -the parameters. Given that this pattern was never documented and ill -intended the decision was made to change this behavior in a bugfix release -before it spreads by accident and developers depend on it. - -The correct invocation for the above command is the following:: - - ctx.invoke(other_command, name_of_arg1='arg1', name_of_arg2='arg2') - -This also allowed us to fix the issue that defaults were not handled -properly by this function. - -Multicommand Chaining API -````````````````````````` - -Click 3 introduced multicommand chaining. This required a change in how -Click internally dispatches. Unfortunately this change was not correctly -implemented and it appeared that it was possible to provide an API that -can inform the super command about all the subcommands that will be -invoked. - -This assumption however does not work with one of the API guarantees that -have been given in the past. As such this functionality has been removed -in 3.2 as it was already broken. Instead the accidentally broken -functionality of the :attr:`Context.invoked_subcommand` attribute was -restored. - -If you do require the know which exact commands will be invoked there are -different ways to cope with this. The first one is to let the subcommands -all return functions and then to invoke the functions in a -:meth:`Context.result_callback`. - - -.. _upgrade-to-2.0: - -Upgrading to 2.0 ----------------- - -Click 2.0 has one breaking change which is the signature for parameter -callbacks. Before 2.0, the callback was invoked with ``(ctx, value)`` -whereas now it's ``(ctx, param, value)``. This change was necessary as it -otherwise made reusing callbacks too complicated. - -To ease the transition Click will still accept old callbacks. Starting -with Click 3.0 it will start to issue a warning to stderr to encourage you -to upgrade. - -In case you want to support both Click 1.0 and Click 2.0, you can make a -simple decorator that adjusts the signatures:: - - import click - from functools import update_wrapper - - def compatcallback(f): - # Click 1.0 does not have a version string stored, so we need to - # use getattr here to be safe. - if getattr(click, '__version__', '0.0') >= '2.0': - return f - return update_wrapper(lambda ctx, value: f(ctx, None, value), f) - -With that helper you can then write something like this:: - - @compatcallback - def callback(ctx, param, value): - return value.upper() - -Note that because Click 1.0 did not pass a parameter, the `param` argument -here would be `None`, so a compatibility callback could not use that -argument. diff --git a/examples/README b/examples/README index 566153fc9..652dbecb0 100644 --- a/examples/README +++ b/examples/README @@ -3,10 +3,3 @@ Click Examples This folder contains various Click examples. Note that all of these are not runnable by themselves but should be installed into a virtualenv. - - This is done this way so that scripts also properly work - on Windows and in virtualenvs without accidentally executing - through the wrong interpreter. - - For more information about this see the documentation: - https://click.palletsprojects.com/setuptools/ diff --git a/examples/aliases/pyproject.toml b/examples/aliases/pyproject.toml new file mode 100644 index 000000000..7232ec6f8 --- /dev/null +++ b/examples/aliases/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "click-example-aliases" +version = "1.0.0" +description = "Click aliases example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", +] + +[project.scripts] +aliases = "aliases:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "aliases" diff --git a/examples/aliases/setup.py b/examples/aliases/setup.py deleted file mode 100644 index a3d04e700..000000000 --- a/examples/aliases/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-aliases", - version="1.0", - py_modules=["aliases"], - include_package_data=True, - install_requires=["click"], - entry_points=""" - [console_scripts] - aliases=aliases:cli - """, -) diff --git a/examples/colors/pyproject.toml b/examples/colors/pyproject.toml new file mode 100644 index 000000000..4ddda141f --- /dev/null +++ b/examples/colors/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "click-example-colors" +version = "1.0.0" +description = "Click colors example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", +] + +[project.scripts] +colors = "colors:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "colors" diff --git a/examples/colors/setup.py b/examples/colors/setup.py deleted file mode 100644 index 3e1a59492..000000000 --- a/examples/colors/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-colors", - version="1.0", - py_modules=["colors"], - include_package_data=True, - install_requires=["click"], - entry_points=""" - [console_scripts] - colors=colors:cli - """, -) diff --git a/examples/completion/README b/examples/completion/README index 372b1d4ca..e2aeca328 100644 --- a/examples/completion/README +++ b/examples/completion/README @@ -26,3 +26,8 @@ For Fish: eval (env _COMPLETION_COMPLETE=fish_source completion) Now press tab (maybe twice) after typing something to see completions. + +.. code-block:: python + + $ completion + $ completion gr diff --git a/examples/completion/pyproject.toml b/examples/completion/pyproject.toml new file mode 100644 index 000000000..ab7cb3b26 --- /dev/null +++ b/examples/completion/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "click-example-completion" +version = "1.0.0" +description = "Click completion example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", +] + +[project.scripts] +completion = "completion:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "completion" diff --git a/examples/completion/setup.py b/examples/completion/setup.py deleted file mode 100644 index a78d14087..000000000 --- a/examples/completion/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-completion", - version="1.0", - py_modules=["completion"], - include_package_data=True, - install_requires=["click"], - entry_points=""" - [console_scripts] - completion=completion:cli - """, -) diff --git a/examples/complex/complex/cli.py b/examples/complex/complex/cli.py index 5d00dba50..81af075c4 100644 --- a/examples/complex/complex/cli.py +++ b/examples/complex/complex/cli.py @@ -28,7 +28,7 @@ def vlog(self, msg, *args): cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands")) -class ComplexCLI(click.MultiCommand): +class ComplexCLI(click.Group): def list_commands(self, ctx): rv = [] for filename in os.listdir(cmd_folder): diff --git a/examples/complex/pyproject.toml b/examples/complex/pyproject.toml new file mode 100644 index 000000000..a69e5d722 --- /dev/null +++ b/examples/complex/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "click-example-complex" +version = "1.0.0" +description = "Click complex example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", +] + +[project.scripts] +complex = "complex.cli:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "complex" diff --git a/examples/complex/setup.py b/examples/complex/setup.py deleted file mode 100644 index afe97289f..000000000 --- a/examples/complex/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-complex", - version="1.0", - packages=["complex", "complex.commands"], - include_package_data=True, - install_requires=["click"], - entry_points=""" - [console_scripts] - complex=complex.cli:cli - """, -) diff --git a/examples/imagepipe/README b/examples/imagepipe/README index 91ec0cd26..5f1046c3e 100644 --- a/examples/imagepipe/README +++ b/examples/imagepipe/README @@ -1,7 +1,7 @@ $ imagepipe_ imagepipe is an example application that implements some - multi commands that chain image processing instructions + commands that chain image processing instructions together. This requires pillow. diff --git a/examples/imagepipe/pyproject.toml b/examples/imagepipe/pyproject.toml new file mode 100644 index 000000000..6e30bf96d --- /dev/null +++ b/examples/imagepipe/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "click-example-imagepipe" +version = "1.0.0" +description = "Click imagepipe example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", + "pillow", +] + +[project.scripts] +imagepipe = "imagepipe:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "imagepipe" diff --git a/examples/imagepipe/setup.py b/examples/imagepipe/setup.py deleted file mode 100644 index c42b5ff60..000000000 --- a/examples/imagepipe/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-imagepipe", - version="1.0", - py_modules=["imagepipe"], - include_package_data=True, - install_requires=["click", "pillow"], - entry_points=""" - [console_scripts] - imagepipe=imagepipe:cli - """, -) diff --git a/examples/inout/pyproject.toml b/examples/inout/pyproject.toml new file mode 100644 index 000000000..2a221472b --- /dev/null +++ b/examples/inout/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "click-example-inout" +version = "1.0.0" +description = "Click inout example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", +] + +[project.scripts] +inout = "inout:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "inout" diff --git a/examples/inout/setup.py b/examples/inout/setup.py deleted file mode 100644 index ff673e3b9..000000000 --- a/examples/inout/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-inout", - version="0.1", - py_modules=["inout"], - include_package_data=True, - install_requires=["click"], - entry_points=""" - [console_scripts] - inout=inout:cli - """, -) diff --git a/examples/naval/pyproject.toml b/examples/naval/pyproject.toml new file mode 100644 index 000000000..ff0328aa9 --- /dev/null +++ b/examples/naval/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "click-example-naval" +version = "1.0.0" +description = "Click naval example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", +] + +[project.scripts] +naval = "naval:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "naval" diff --git a/examples/naval/setup.py b/examples/naval/setup.py deleted file mode 100644 index 37b39f5d2..000000000 --- a/examples/naval/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-naval", - version="2.0", - py_modules=["naval"], - include_package_data=True, - install_requires=["click"], - entry_points=""" - [console_scripts] - naval=naval:cli - """, -) diff --git a/examples/repo/pyproject.toml b/examples/repo/pyproject.toml new file mode 100644 index 000000000..c0cac15d1 --- /dev/null +++ b/examples/repo/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "click-example-repo" +version = "1.0.0" +description = "Click repo example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", +] + +[project.scripts] +repo = "repo:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "repo" diff --git a/examples/repo/setup.py b/examples/repo/setup.py deleted file mode 100644 index 3028020d8..000000000 --- a/examples/repo/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-repo", - version="0.1", - py_modules=["repo"], - include_package_data=True, - install_requires=["click"], - entry_points=""" - [console_scripts] - repo=repo:cli - """, -) diff --git a/examples/termui/pyproject.toml b/examples/termui/pyproject.toml new file mode 100644 index 000000000..37b80aee2 --- /dev/null +++ b/examples/termui/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "click-example-termui" +version = "1.0.0" +description = "Click termui example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", +] + +[project.scripts] +termui = "termui:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "termui" diff --git a/examples/termui/setup.py b/examples/termui/setup.py deleted file mode 100644 index c1ac109fe..000000000 --- a/examples/termui/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-termui", - version="1.0", - py_modules=["termui"], - include_package_data=True, - install_requires=["click"], - entry_points=""" - [console_scripts] - termui=termui:cli - """, -) diff --git a/examples/validation/pyproject.toml b/examples/validation/pyproject.toml new file mode 100644 index 000000000..65184197b --- /dev/null +++ b/examples/validation/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "click-example-validation" +version = "1.0.0" +description = "Click validation example" +requires-python = ">=3.8" +dependencies = [ + "click>=8.1", +] + +[project.scripts] +validation = "validation:cli" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "validation" diff --git a/examples/validation/setup.py b/examples/validation/setup.py deleted file mode 100644 index b7698f61b..000000000 --- a/examples/validation/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup - -setup( - name="click-example-validation", - version="1.0", - py_modules=["validation"], - include_package_data=True, - install_requires=["click"], - entry_points=""" - [console_scripts] - validation=validation:cli - """, -) diff --git a/pyproject.toml b/pyproject.toml index 358f5245f..a8f3655c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [project] name = "click" +version = "8.2.0.dev" description = "Composable command line interface toolkit" readme = "README.md" license = {file = "LICENSE.txt"} @@ -12,12 +13,10 @@ classifiers = [ "Programming Language :: Python", "Typing :: Typed", ] -requires-python = ">=3.7" +requires-python = ">=3.8" dependencies = [ "colorama; platform_system == 'Windows'", - "importlib-metadata; python_version < '3.8'", ] -dynamic = ["version"] [project.urls] Donate = "https://palletsprojects.com/donate" diff --git a/requirements/dev.in b/requirements/dev.in index 99f5942f8..1efde82b1 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -1,6 +1,5 @@ --r docs.in --r tests.in --r typing.in -pip-compile-multi +-r docs.txt +-r tests.txt +-r typing.txt pre-commit tox diff --git a/requirements/docs.in b/requirements/docs.in index 3ee050af0..fd5708f74 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1,5 +1,4 @@ -Pallets-Sphinx-Themes -Sphinx -sphinx-issues +pallets-sphinx-themes +sphinx sphinxcontrib-log-cabinet sphinx-tabs diff --git a/requirements/tests37.txt b/requirements/tests37.txt deleted file mode 100644 index d490541c8..000000000 --- a/requirements/tests37.txt +++ /dev/null @@ -1,26 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.7 -# by the following command: -# -# pip-compile --output-file=tests37.txt tests.in -# -exceptiongroup==1.2.2 - # via pytest -importlib-metadata==6.7.0 - # via - # pluggy - # pytest -iniconfig==2.0.0 - # via pytest -packaging==24.0 - # via pytest -pluggy==1.2.0 - # via pytest -pytest==7.4.4 - # via -r tests.in -tomli==2.0.1 - # via pytest -typing-extensions==4.7.1 - # via importlib-metadata -zipp==3.15.0 - # via importlib-metadata diff --git a/requirements/typing.in b/requirements/typing.in index a20f06c42..8be59c5dc 100644 --- a/requirements/typing.in +++ b/requirements/typing.in @@ -1,2 +1,3 @@ mypy pyright +pytest diff --git a/src/click/__init__.py b/src/click/__init__.py index 46f99e951..f2360fa15 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -5,13 +5,13 @@ composable. """ +from __future__ import annotations + from .core import Argument as Argument -from .core import BaseCommand as BaseCommand from .core import Command as Command from .core import CommandCollection as CommandCollection from .core import Context as Context from .core import Group as Group -from .core import MultiCommand as MultiCommand from .core import Option as Option from .core import Parameter as Parameter from .decorators import argument as argument @@ -38,7 +38,6 @@ from .formatting import HelpFormatter as HelpFormatter from .formatting import wrap_text as wrap_text from .globals import get_current_context as get_current_context -from .parser import OptionParser as OptionParser from .termui import clear as clear from .termui import confirm as confirm from .termui import echo_via_pager as echo_via_pager @@ -72,4 +71,54 @@ from .utils import get_text_stream as get_text_stream from .utils import open_file as open_file -__version__ = "8.1.7" + +def __getattr__(name: str) -> object: + import warnings + + if name == "BaseCommand": + from .core import _BaseCommand + + warnings.warn( + "'BaseCommand' is deprecated and will be removed in Click 9.0. Use" + " 'Command' instead.", + DeprecationWarning, + stacklevel=2, + ) + return _BaseCommand + + if name == "MultiCommand": + from .core import _MultiCommand + + warnings.warn( + "'MultiCommand' is deprecated and will be removed in Click 9.0. Use" + " 'Group' instead.", + DeprecationWarning, + stacklevel=2, + ) + return _MultiCommand + + if name == "OptionParser": + from .parser import _OptionParser + + warnings.warn( + "'OptionParser' is deprecated and will be removed in Click 9.0. The" + " old parser is available in 'optparse'.", + DeprecationWarning, + stacklevel=2, + ) + return _OptionParser + + if name == "__version__": + import importlib.metadata + import warnings + + warnings.warn( + "The '__version__' attribute is deprecated and will be removed in" + " Click 9.1. Use feature detection or" + " 'importlib.metadata.version(\"click\")' instead.", + DeprecationWarning, + stacklevel=2, + ) + return importlib.metadata.version("click") + + raise AttributeError(name) diff --git a/src/click/_compat.py b/src/click/_compat.py index 9153d150c..f2726b93a 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -1,21 +1,25 @@ +from __future__ import annotations + import codecs +import collections.abc as cabc import io import os import re import sys import typing as t +from types import TracebackType from weakref import WeakKeyDictionary CYGWIN = sys.platform.startswith("cygwin") WIN = sys.platform.startswith("win") -auto_wrap_for_ansi: t.Optional[t.Callable[[t.TextIO], t.TextIO]] = None +auto_wrap_for_ansi: t.Callable[[t.TextIO], t.TextIO] | None = None _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") def _make_text_stream( stream: t.BinaryIO, - encoding: t.Optional[str], - errors: t.Optional[str], + encoding: str | None, + errors: str | None, force_readable: bool = False, force_writable: bool = False, ) -> t.TextIO: @@ -53,8 +57,8 @@ class _NonClosingTextIOWrapper(io.TextIOWrapper): def __init__( self, stream: t.BinaryIO, - encoding: t.Optional[str], - errors: t.Optional[str], + encoding: str | None, + errors: str | None, force_readable: bool = False, force_writable: bool = False, **extra: t.Any, @@ -125,7 +129,7 @@ def writable(self) -> bool: if x is not None: return t.cast(bool, x()) try: - self._stream.write("") # type: ignore + self._stream.write(b"") except Exception: try: self._stream.write(b"") @@ -166,7 +170,7 @@ def _is_binary_writer(stream: t.IO[t.Any], default: bool = False) -> bool: return True -def _find_binary_reader(stream: t.IO[t.Any]) -> t.Optional[t.BinaryIO]: +def _find_binary_reader(stream: t.IO[t.Any]) -> t.BinaryIO | None: # We need to figure out if the given stream is already binary. # This can happen because the official docs recommend detaching # the streams to get binary streams. Some code might do this, so @@ -184,7 +188,7 @@ def _find_binary_reader(stream: t.IO[t.Any]) -> t.Optional[t.BinaryIO]: return None -def _find_binary_writer(stream: t.IO[t.Any]) -> t.Optional[t.BinaryIO]: +def _find_binary_writer(stream: t.IO[t.Any]) -> t.BinaryIO | None: # We need to figure out if the given stream is already binary. # This can happen because the official docs recommend detaching # the streams to get binary streams. Some code might do this, so @@ -211,7 +215,7 @@ def _stream_is_misconfigured(stream: t.TextIO) -> bool: return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii") -def _is_compat_stream_attr(stream: t.TextIO, attr: str, value: t.Optional[str]) -> bool: +def _is_compat_stream_attr(stream: t.TextIO, attr: str, value: str | None) -> bool: """A stream attribute is compatible if it is equal to the desired value or the desired value is unset and the attribute has a value. @@ -221,7 +225,7 @@ def _is_compat_stream_attr(stream: t.TextIO, attr: str, value: t.Optional[str]) def _is_compatible_text_stream( - stream: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str] + stream: t.TextIO, encoding: str | None, errors: str | None ) -> bool: """Check if a stream's encoding and errors attributes are compatible with the desired values. @@ -233,10 +237,10 @@ def _is_compatible_text_stream( def _force_correct_text_stream( text_stream: t.IO[t.Any], - encoding: t.Optional[str], - errors: t.Optional[str], + encoding: str | None, + errors: str | None, is_binary: t.Callable[[t.IO[t.Any], bool], bool], - find_binary: t.Callable[[t.IO[t.Any]], t.Optional[t.BinaryIO]], + find_binary: t.Callable[[t.IO[t.Any]], t.BinaryIO | None], force_readable: bool = False, force_writable: bool = False, ) -> t.TextIO: @@ -279,8 +283,8 @@ def _force_correct_text_stream( def _force_correct_text_reader( text_reader: t.IO[t.Any], - encoding: t.Optional[str], - errors: t.Optional[str], + encoding: str | None, + errors: str | None, force_readable: bool = False, ) -> t.TextIO: return _force_correct_text_stream( @@ -295,8 +299,8 @@ def _force_correct_text_reader( def _force_correct_text_writer( text_writer: t.IO[t.Any], - encoding: t.Optional[str], - errors: t.Optional[str], + encoding: str | None, + errors: str | None, force_writable: bool = False, ) -> t.TextIO: return _force_correct_text_stream( @@ -330,27 +334,21 @@ def get_binary_stderr() -> t.BinaryIO: return writer -def get_text_stdin( - encoding: t.Optional[str] = None, errors: t.Optional[str] = None -) -> t.TextIO: +def get_text_stdin(encoding: str | None = None, errors: str | None = None) -> t.TextIO: rv = _get_windows_console_stream(sys.stdin, encoding, errors) if rv is not None: return rv return _force_correct_text_reader(sys.stdin, encoding, errors, force_readable=True) -def get_text_stdout( - encoding: t.Optional[str] = None, errors: t.Optional[str] = None -) -> t.TextIO: +def get_text_stdout(encoding: str | None = None, errors: str | None = None) -> t.TextIO: rv = _get_windows_console_stream(sys.stdout, encoding, errors) if rv is not None: return rv return _force_correct_text_writer(sys.stdout, encoding, errors, force_writable=True) -def get_text_stderr( - encoding: t.Optional[str] = None, errors: t.Optional[str] = None -) -> t.TextIO: +def get_text_stderr(encoding: str | None = None, errors: str | None = None) -> t.TextIO: rv = _get_windows_console_stream(sys.stderr, encoding, errors) if rv is not None: return rv @@ -358,10 +356,10 @@ def get_text_stderr( def _wrap_io_open( - file: t.Union[str, "os.PathLike[str]", int], + file: str | os.PathLike[str] | int, mode: str, - encoding: t.Optional[str], - errors: t.Optional[str], + encoding: str | None, + errors: str | None, ) -> t.IO[t.Any]: """Handles not passing ``encoding`` and ``errors`` in binary mode.""" if "b" in mode: @@ -371,12 +369,12 @@ def _wrap_io_open( def open_stream( - filename: "t.Union[str, os.PathLike[str]]", + filename: str | os.PathLike[str], mode: str = "r", - encoding: t.Optional[str] = None, - errors: t.Optional[str] = "strict", + encoding: str | None = None, + errors: str | None = "strict", atomic: bool = False, -) -> t.Tuple[t.IO[t.Any], bool]: +) -> tuple[t.IO[t.Any], bool]: binary = "b" in mode filename = os.fspath(filename) @@ -416,7 +414,7 @@ def open_stream( import random try: - perm: t.Optional[int] = os.stat(filename).st_mode + perm: int | None = os.stat(filename).st_mode except OSError: perm = None @@ -472,10 +470,15 @@ def close(self, delete: bool = False) -> None: def __getattr__(self, name: str) -> t.Any: return getattr(self._f, name) - def __enter__(self) -> "_AtomicFile": + def __enter__(self) -> _AtomicFile: return self - def __exit__(self, exc_type: t.Optional[t.Type[BaseException]], *_: t.Any) -> None: + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: self.close(delete=exc_type is not None) def __repr__(self) -> str: @@ -494,7 +497,7 @@ def _is_jupyter_kernel_output(stream: t.IO[t.Any]) -> bool: def should_strip_ansi( - stream: t.Optional[t.IO[t.Any]] = None, color: t.Optional[bool] = None + stream: t.IO[t.Any] | None = None, color: bool | None = None ) -> bool: if color is None: if stream is None: @@ -514,11 +517,9 @@ def _get_argv_encoding() -> str: return locale.getpreferredencoding() - _ansi_stream_wrappers: t.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() + _ansi_stream_wrappers: cabc.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() - def auto_wrap_for_ansi( - stream: t.TextIO, color: t.Optional[bool] = None - ) -> t.TextIO: + def auto_wrap_for_ansi(stream: t.TextIO, color: bool | None = None) -> t.TextIO: """Support ANSI color and style codes on Windows by wrapping a stream with colorama. """ @@ -537,14 +538,14 @@ def auto_wrap_for_ansi( rv = t.cast(t.TextIO, ansi_wrapper.stream) _write = rv.write - def _safe_write(s): + def _safe_write(s: str) -> int: try: return _write(s) except BaseException: ansi_wrapper.reset_all() raise - rv.write = _safe_write + rv.write = _safe_write # type: ignore[method-assign] try: _ansi_stream_wrappers[stream] = rv @@ -559,8 +560,8 @@ def _get_argv_encoding() -> str: return getattr(sys.stdin, "encoding", None) or sys.getfilesystemencoding() def _get_windows_console_stream( - f: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str] - ) -> t.Optional[t.TextIO]: + f: t.TextIO, encoding: str | None, errors: str | None + ) -> t.TextIO | None: return None @@ -576,12 +577,12 @@ def isatty(stream: t.IO[t.Any]) -> bool: def _make_cached_stream_func( - src_func: t.Callable[[], t.Optional[t.TextIO]], + src_func: t.Callable[[], t.TextIO | None], wrapper_func: t.Callable[[], t.TextIO], -) -> t.Callable[[], t.Optional[t.TextIO]]: - cache: t.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() +) -> t.Callable[[], t.TextIO | None]: + cache: cabc.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() - def func() -> t.Optional[t.TextIO]: + def func() -> t.TextIO | None: stream = src_func() if stream is None: @@ -608,15 +609,13 @@ def func() -> t.Optional[t.TextIO]: _default_text_stderr = _make_cached_stream_func(lambda: sys.stderr, get_text_stderr) -binary_streams: t.Mapping[str, t.Callable[[], t.BinaryIO]] = { +binary_streams: cabc.Mapping[str, t.Callable[[], t.BinaryIO]] = { "stdin": get_binary_stdin, "stdout": get_binary_stdout, "stderr": get_binary_stderr, } -text_streams: t.Mapping[ - str, t.Callable[[t.Optional[str], t.Optional[str]], t.TextIO] -] = { +text_streams: cabc.Mapping[str, t.Callable[[str | None, str | None], t.TextIO]] = { "stdin": get_text_stdin, "stdout": get_text_stdout, "stderr": get_text_stderr, diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index ad9f8f6c9..833b9fe5d 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -4,6 +4,9 @@ placed in this module and only imported as needed. """ +from __future__ import annotations + +import collections.abc as cabc import contextlib import math import os @@ -12,7 +15,6 @@ import typing as t from gettext import gettext as _ from io import StringIO -from shutil import which from types import TracebackType from ._compat import _default_text_stdout @@ -39,19 +41,20 @@ class ProgressBar(t.Generic[V]): def __init__( self, - iterable: t.Optional[t.Iterable[V]], - length: t.Optional[int] = None, + iterable: cabc.Iterable[V] | None, + length: int | None = None, fill_char: str = "#", empty_char: str = " ", bar_template: str = "%(bar)s", info_sep: str = " ", + hidden: bool = False, show_eta: bool = True, - show_percent: t.Optional[bool] = None, + show_percent: bool | None = None, show_pos: bool = False, - item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None, - label: t.Optional[str] = None, - file: t.Optional[t.TextIO] = None, - color: t.Optional[bool] = None, + item_show_func: t.Callable[[V | None], str | None] | None = None, + label: str | None = None, + file: t.TextIO | None = None, + color: bool | None = None, update_min_steps: int = 1, width: int = 30, ) -> None: @@ -59,6 +62,7 @@ def __init__( self.empty_char = empty_char self.bar_template = bar_template self.info_sep = info_sep + self.hidden = hidden self.show_eta = show_eta self.show_percent = show_percent self.show_pos = show_pos @@ -90,36 +94,36 @@ def __init__( if iterable is None: if length is None: raise TypeError("iterable or length is required") - iterable = t.cast(t.Iterable[V], range(length)) - self.iter: t.Iterable[V] = iter(iterable) + iterable = t.cast("cabc.Iterable[V]", range(length)) + self.iter: cabc.Iterable[V] = iter(iterable) self.length = length self.pos = 0 - self.avg: t.List[float] = [] + self.avg: list[float] = [] self.last_eta: float self.start: float self.start = self.last_eta = time.time() self.eta_known: bool = False self.finished: bool = False - self.max_width: t.Optional[int] = None + self.max_width: int | None = None self.entered: bool = False - self.current_item: t.Optional[V] = None - self.is_hidden: bool = not isatty(self.file) - self._last_line: t.Optional[str] = None + self.current_item: V | None = None + self._is_atty = isatty(self.file) + self._last_line: str | None = None - def __enter__(self) -> "ProgressBar[V]": + def __enter__(self) -> ProgressBar[V]: self.entered = True self.render_progress() return self def __exit__( self, - exc_type: t.Optional[t.Type[BaseException]], - exc_value: t.Optional[BaseException], - tb: t.Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, ) -> None: self.render_finish() - def __iter__(self) -> t.Iterator[V]: + def __iter__(self) -> cabc.Iterator[V]: if not self.entered: raise RuntimeError("You need to use progress bars in a with block.") self.render_progress() @@ -134,7 +138,7 @@ def __next__(self) -> V: return next(iter(self)) def render_finish(self) -> None: - if self.is_hidden: + if self.hidden or not self._is_atty: return self.file.write(AFTER_BAR) self.file.flush() @@ -230,13 +234,14 @@ def format_progress_line(self) -> str: def render_progress(self) -> None: import shutil - if self.is_hidden: - # Only output the label as it changes if the output is not a - # TTY. Use file=stderr if you expect to be piping stdout. + if self.hidden: + return + + if not self._is_atty: + # Only output the label once if the output is not a TTY. if self._last_line != self.label: self._last_line = self.label echo(self.label, file=self.file, color=self.color) - return buf = [] @@ -246,9 +251,9 @@ def render_progress(self) -> None: self.width = 0 clutter_length = term_len(self.format_progress_line()) new_width = max(0, shutil.get_terminal_size().columns - clutter_length) - if new_width < old_width: + if new_width < old_width and self.max_width is not None: buf.append(BEFORE_BAR) - buf.append(" " * self.max_width) # type: ignore + buf.append(" " * self.max_width) self.max_width = new_width self.width = new_width @@ -294,7 +299,7 @@ def make_step(self, n_steps: int) -> None: self.eta_known = self.length is not None - def update(self, n_steps: int, current_item: t.Optional[V] = None) -> None: + def update(self, n_steps: int, current_item: V | None = None) -> None: """Update the progress bar by advancing a specified number of steps, and optionally set the ``current_item`` for this new position. @@ -325,7 +330,7 @@ def finish(self) -> None: self.current_item = None self.finished = True - def generator(self) -> t.Iterator[V]: + def generator(self) -> cabc.Iterator[V]: """Return a generator which yields the items added to the bar during construction, and updates the progress bar *after* the yielded block returns. @@ -340,7 +345,7 @@ def generator(self) -> t.Iterator[V]: if not self.entered: raise RuntimeError("You need to use progress bars in a with block.") - if self.is_hidden: + if not self._is_atty: yield from self.iter else: for rv in self.iter: @@ -359,7 +364,7 @@ def generator(self) -> t.Iterator[V]: self.render_progress() -def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None: +def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None: """Decide what method to use for paging through text.""" stdout = _default_text_stdout() @@ -373,42 +378,31 @@ def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None: pager_cmd = (os.environ.get("PAGER", None) or "").strip() if pager_cmd: if WIN: - if _tempfilepager(generator, pager_cmd, color): - return - elif _pipepager(generator, pager_cmd, color): - return + return _tempfilepager(generator, pager_cmd, color) + return _pipepager(generator, pager_cmd, color) if os.environ.get("TERM") in ("dumb", "emacs"): return _nullpager(stdout, generator, color) - if (WIN or sys.platform.startswith("os2")) and _tempfilepager( - generator, "more", color - ): - return - if _pipepager(generator, "less", color): - return + if WIN or sys.platform.startswith("os2"): + return _tempfilepager(generator, "more <", color) + if hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0: + return _pipepager(generator, "less", color) import tempfile fd, filename = tempfile.mkstemp() os.close(fd) try: - if _pipepager(generator, "more", color): - return + if hasattr(os, "system") and os.system(f'more "{filename}"') == 0: + return _pipepager(generator, "more", color) return _nullpager(stdout, generator, color) finally: os.unlink(filename) -def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> bool: +def _pipepager(generator: cabc.Iterable[str], cmd: str, color: bool | None) -> None: """Page through text by feeding it to another program. Invoking a pager through this might support colors. - - Returns True if the command was found, False otherwise and thus another - pager should be attempted. """ - cmd_absolute = which(cmd) - if cmd_absolute is None: - return False - import subprocess env = dict(os.environ) @@ -424,61 +418,53 @@ def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> elif "r" in less_flags or "R" in less_flags: color = True - c = subprocess.Popen( - [cmd_absolute], - shell=True, - stdin=subprocess.PIPE, - env=env, - errors="replace", - text=True, - ) - assert c.stdin is not None + c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env) + stdin = t.cast(t.BinaryIO, c.stdin) + encoding = get_best_encoding(stdin) try: for text in generator: if not color: text = strip_ansi(text) - c.stdin.write(text) - except (OSError, KeyboardInterrupt): + stdin.write(text.encode(encoding, "replace")) + except BrokenPipeError: + # In case the pager exited unexpectedly, ignore the broken pipe error. pass - else: - c.stdin.close() - - # Less doesn't respect ^C, but catches it for its own UI purposes (aborting - # search or other commands inside less). - # - # That means when the user hits ^C, the parent process (click) terminates, - # but less is still alive, paging the output and messing up the terminal. - # - # If the user wants to make the pager exit on ^C, they should set - # `LESS='-K'`. It's not our decision to make. - while True: + except Exception as e: + # In case there is an exception we want to close the pager immediately + # and let the caller handle it. + # Otherwise the pager will keep running, and the user may not notice + # the error message, or worse yet it may leave the terminal in a broken state. + c.terminate() + raise e + finally: + # We must close stdin and wait for the pager to exit before we continue try: - c.wait() - except KeyboardInterrupt: + stdin.close() + # Close implies flush, so it might throw a BrokenPipeError if the pager + # process exited already. + except BrokenPipeError: pass - else: - break - - return True + # Less doesn't respect ^C, but catches it for its own UI purposes (aborting + # search or other commands inside less). + # + # That means when the user hits ^C, the parent process (click) terminates, + # but less is still alive, paging the output and messing up the terminal. + # + # If the user wants to make the pager exit on ^C, they should set + # `LESS='-K'`. It's not our decision to make. + while True: + try: + c.wait() + except KeyboardInterrupt: + pass + else: + break -def _tempfilepager( - generator: t.Iterable[str], - cmd: str, - color: t.Optional[bool], -) -> bool: - """Page through text by invoking a program on a temporary file. - - Returns True if the command was found, False otherwise and thus another - pager should be attempted. - """ - # Which is necessary for Windows, it is also recommended in the Popen docs. - cmd_absolute = which(cmd) - if cmd_absolute is None: - return False - import subprocess +def _tempfilepager(generator: cabc.Iterable[str], cmd: str, color: bool | None) -> None: + """Page through text by invoking a program on a temporary file.""" import tempfile fd, filename = tempfile.mkstemp() @@ -490,19 +476,14 @@ def _tempfilepager( with open_stream(filename, "wb")[0] as f: f.write(text.encode(encoding)) try: - subprocess.call([cmd_absolute, filename]) - except OSError: - # Command not found - pass + os.system(f'{cmd} "{filename}"') finally: os.close(fd) os.unlink(filename) - return True - def _nullpager( - stream: t.TextIO, generator: t.Iterable[str], color: t.Optional[bool] + stream: t.TextIO, generator: cabc.Iterable[str], color: bool | None ) -> None: """Simply print unformatted text. This is the ultimate fallback.""" for text in generator: @@ -514,8 +495,8 @@ def _nullpager( class Editor: def __init__( self, - editor: t.Optional[str] = None, - env: t.Optional[t.Mapping[str, str]] = None, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, require_save: bool = True, extension: str = ".txt", ) -> None: @@ -534,22 +515,26 @@ def get_editor(self) -> str: if WIN: return "notepad" for editor in "sensible-editor", "vim", "nano": - if which(editor) is not None: + if os.system(f"which {editor} >/dev/null 2>&1") == 0: return editor return "vi" - def edit_file(self, filename: str) -> None: + def edit_files(self, filenames: cabc.Iterable[str]) -> None: import subprocess editor = self.get_editor() - environ: t.Optional[t.Dict[str, str]] = None + environ: dict[str, str] | None = None if self.env: environ = os.environ.copy() environ.update(self.env) + exc_filename = " ".join(f'"{filename}"' for filename in filenames) + try: - c = subprocess.Popen(f'{editor} "{filename}"', env=environ, shell=True) + c = subprocess.Popen( + args=f"{editor} {exc_filename}", env=environ, shell=True + ) exit_code = c.wait() if exit_code != 0: raise ClickException( @@ -560,10 +545,18 @@ def edit_file(self, filename: str) -> None: _("{editor}: Editing failed: {e}").format(editor=editor, e=e) ) from e - def edit(self, text: t.Optional[t.AnyStr]) -> t.Optional[t.AnyStr]: + @t.overload + def edit(self, text: bytes | bytearray) -> bytes | None: ... + + # We cannot know whether or not the type expected is str or bytes when None + # is passed, so str is returned as that was what was done before. + @t.overload + def edit(self, text: str | None) -> str | None: ... + + def edit(self, text: str | bytes | bytearray | None) -> str | bytes | None: import tempfile - if not text: + if text is None: data = b"" elif isinstance(text, (bytes, bytearray)): data = text @@ -592,7 +585,7 @@ def edit(self, text: t.Optional[t.AnyStr]) -> t.Optional[t.AnyStr]: # recorded, so get the new recorded value. timestamp = os.path.getmtime(name) - self.edit_file(name) + self.edit_files((name,)) if self.require_save and os.path.getmtime(name) == timestamp: return None @@ -603,7 +596,7 @@ def edit(self, text: t.Optional[t.AnyStr]) -> t.Optional[t.AnyStr]: if isinstance(text, (bytes, bytearray)): return rv - return rv.decode("utf-8-sig").replace("\r\n", "\n") # type: ignore + return rv.decode("utf-8-sig").replace("\r\n", "\n") finally: os.unlink(name) @@ -633,33 +626,22 @@ def _unquote_file(url: str) -> str: null.close() elif WIN: if locate: - url = _unquote_file(url) - args = ["explorer", f"/select,{url}"] + url = _unquote_file(url.replace('"', "")) + args = f'explorer /select,"{url}"' else: - args = ["start"] - if wait: - args.append("/WAIT") - args.append("") - args.append(url) - try: - return subprocess.call(args) - except OSError: - # Command not found - return 127 + url = url.replace('"', "") + wait_str = "/WAIT" if wait else "" + args = f'start {wait_str} "" "{url}"' + return os.system(args) elif CYGWIN: if locate: - url = _unquote_file(url) - args = ["cygstart", os.path.dirname(url)] + url = os.path.dirname(_unquote_file(url).replace('"', "")) + args = f'cygstart "{url}"' else: - args = ["cygstart"] - if wait: - args.append("-w") - args.append(url) - try: - return subprocess.call(args) - except OSError: - # Command not found - return 127 + url = url.replace('"', "") + wait_str = "-w" if wait else "" + args = f'cygstart {wait_str} "{url}"' + return os.system(args) try: if locate: @@ -679,7 +661,7 @@ def _unquote_file(url: str) -> str: return 1 -def _translate_ch_to_exc(ch: str) -> t.Optional[BaseException]: +def _translate_ch_to_exc(ch: str) -> None: if ch == "\x03": raise KeyboardInterrupt() @@ -692,11 +674,11 @@ def _translate_ch_to_exc(ch: str) -> t.Optional[BaseException]: return None -if WIN: +if sys.platform == "win32": import msvcrt @contextlib.contextmanager - def raw_terminal() -> t.Iterator[int]: + def raw_terminal() -> cabc.Iterator[int]: yield -1 def getchar(echo: bool) -> str: @@ -729,12 +711,11 @@ def getchar(echo: bool) -> str: # # Anyway, Click doesn't claim to do this Right(tm), and using `getwch` # is doing the right thing in more situations than with `getch`. - func: t.Callable[[], str] if echo: - func = msvcrt.getwche # type: ignore + func = t.cast(t.Callable[[], str], msvcrt.getwche) else: - func = msvcrt.getwch # type: ignore + func = t.cast(t.Callable[[], str], msvcrt.getwch) rv = func() @@ -751,8 +732,8 @@ def getchar(echo: bool) -> str: import tty @contextlib.contextmanager - def raw_terminal() -> t.Iterator[int]: - f: t.Optional[t.TextIO] + def raw_terminal() -> cabc.Iterator[int]: + f: t.TextIO | None fd: int if not isatty(sys.stdin): diff --git a/src/click/_textwrap.py b/src/click/_textwrap.py index b47dcbd42..97fbee3dc 100644 --- a/src/click/_textwrap.py +++ b/src/click/_textwrap.py @@ -1,13 +1,15 @@ +from __future__ import annotations + +import collections.abc as cabc import textwrap -import typing as t from contextlib import contextmanager class TextWrapper(textwrap.TextWrapper): def _handle_long_word( self, - reversed_chunks: t.List[str], - cur_line: t.List[str], + reversed_chunks: list[str], + cur_line: list[str], cur_len: int, width: int, ) -> None: @@ -23,7 +25,7 @@ def _handle_long_word( cur_line.append(reversed_chunks.pop()) @contextmanager - def extra_indent(self, indent: str) -> t.Iterator[None]: + def extra_indent(self, indent: str) -> cabc.Iterator[None]: old_initial_indent = self.initial_indent old_subsequent_indent = self.subsequent_indent self.initial_indent += indent diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index 6b20df315..b01035b29 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -6,10 +6,14 @@ # compared to the original patches as we do not need to patch # the entire interpreter but just work in our little world of # echo and prompt. +from __future__ import annotations + +import collections.abc as cabc import io import sys import time import typing as t +from ctypes import Array from ctypes import byref from ctypes import c_char from ctypes import c_char_p @@ -64,6 +68,14 @@ EOF = b"\x1a" MAX_BYTES_WRITTEN = 32767 +if t.TYPE_CHECKING: + try: + # Using `typing_extensions.Buffer` instead of `collections.abc` + # on Windows for some reason does not have `Sized` implemented. + from collections.abc import Buffer # type: ignore + except ImportError: + from typing_extensions import Buffer + try: from ctypes import pythonapi except ImportError: @@ -73,7 +85,7 @@ else: class Py_buffer(Structure): - _fields_ = [ + _fields_ = [ # noqa: RUF012 ("buf", c_void_p), ("obj", py_object), ("len", c_ssize_t), @@ -90,32 +102,32 @@ class Py_buffer(Structure): PyObject_GetBuffer = pythonapi.PyObject_GetBuffer PyBuffer_Release = pythonapi.PyBuffer_Release - def get_buffer(obj, writable=False): + def get_buffer(obj: Buffer, writable: bool = False) -> Array[c_char]: buf = Py_buffer() - flags = PyBUF_WRITABLE if writable else PyBUF_SIMPLE + flags: int = PyBUF_WRITABLE if writable else PyBUF_SIMPLE PyObject_GetBuffer(py_object(obj), byref(buf), flags) try: - buffer_type = c_char * buf.len + buffer_type: Array[c_char] = c_char * buf.len return buffer_type.from_address(buf.buf) finally: PyBuffer_Release(byref(buf)) class _WindowsConsoleRawIOBase(io.RawIOBase): - def __init__(self, handle): + def __init__(self, handle: int | None) -> None: self.handle = handle - def isatty(self): + def isatty(self) -> t.Literal[True]: super().isatty() return True class _WindowsConsoleReader(_WindowsConsoleRawIOBase): - def readable(self): + def readable(self) -> t.Literal[True]: return True - def readinto(self, b): + def readinto(self, b: Buffer) -> int: bytes_to_be_read = len(b) if not bytes_to_be_read: return 0 @@ -147,18 +159,18 @@ def readinto(self, b): class _WindowsConsoleWriter(_WindowsConsoleRawIOBase): - def writable(self): + def writable(self) -> t.Literal[True]: return True @staticmethod - def _get_error_message(errno): + def _get_error_message(errno: int) -> str: if errno == ERROR_SUCCESS: return "ERROR_SUCCESS" elif errno == ERROR_NOT_ENOUGH_MEMORY: return "ERROR_NOT_ENOUGH_MEMORY" return f"Windows error {errno}" - def write(self, b): + def write(self, b: Buffer) -> int: bytes_to_be_written = len(b) buf = get_buffer(b) code_units_to_be_written = min(bytes_to_be_written, MAX_BYTES_WRITTEN) // 2 @@ -196,7 +208,7 @@ def write(self, x: t.AnyStr) -> int: pass return self.buffer.write(x) - def writelines(self, lines: t.Iterable[t.AnyStr]) -> None: + def writelines(self, lines: cabc.Iterable[t.AnyStr]) -> None: for line in lines: self.write(line) @@ -206,7 +218,7 @@ def __getattr__(self, name: str) -> t.Any: def isatty(self) -> bool: return self.buffer.isatty() - def __repr__(self): + def __repr__(self) -> str: return f"" @@ -240,7 +252,7 @@ def _get_text_stderr(buffer_stream: t.BinaryIO) -> t.TextIO: return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) -_stream_factories: t.Mapping[int, t.Callable[[t.BinaryIO], t.TextIO]] = { +_stream_factories: cabc.Mapping[int, t.Callable[[t.BinaryIO], t.TextIO]] = { 0: _get_text_stdin, 1: _get_text_stdout, 2: _get_text_stderr, @@ -261,19 +273,23 @@ def _is_console(f: t.TextIO) -> bool: def _get_windows_console_stream( - f: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str] -) -> t.Optional[t.TextIO]: + f: t.TextIO, encoding: str | None, errors: str | None +) -> t.TextIO | None: if ( - get_buffer is not None - and encoding in {"utf-16-le", None} - and errors in {"strict", None} - and _is_console(f) + get_buffer is None + or encoding not in {"utf-16-le", None} + or errors not in {"strict", None} + or not _is_console(f) ): - func = _stream_factories.get(f.fileno()) - if func is not None: - b = getattr(f, "buffer", None) + return None + + func = _stream_factories.get(f.fileno()) + if func is None: + return None + + b = getattr(f, "buffer", None) - if b is None: - return None + if b is None: + return None - return func(b) + return func(b) diff --git a/src/click/core.py b/src/click/core.py index e6305011a..176a7ca60 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import collections.abc as cabc import enum import errno import inspect @@ -5,6 +8,8 @@ import sys import typing as t from collections import abc +from collections import Counter +from contextlib import AbstractContextManager from contextlib import contextmanager from contextlib import ExitStack from functools import update_wrapper @@ -19,14 +24,15 @@ from .exceptions import ClickException from .exceptions import Exit from .exceptions import MissingParameter +from .exceptions import NoArgsIsHelpError from .exceptions import UsageError from .formatting import HelpFormatter from .formatting import join_options from .globals import pop_context from .globals import push_context from .parser import _flag_needs_value -from .parser import OptionParser -from .parser import split_opt +from .parser import _OptionParser +from .parser import _split_opt from .termui import confirm from .termui import prompt from .termui import style @@ -38,25 +44,22 @@ from .utils import PacifyFlushWrapper if t.TYPE_CHECKING: - import typing_extensions as te - - from .decorators import HelpOption from .shell_completion import CompletionItem -F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +F = t.TypeVar("F", bound="t.Callable[..., t.Any]") V = t.TypeVar("V") def _complete_visible_commands( - ctx: "Context", incomplete: str -) -> t.Iterator[t.Tuple[str, "Command"]]: + ctx: Context, incomplete: str +) -> cabc.Iterator[tuple[str, Command]]: """List all the subcommands of a group that start with the incomplete value and aren't hidden. :param ctx: Invocation context for the group. :param incomplete: Value being completed. May be empty. """ - multi = t.cast(MultiCommand, ctx.command) + multi = t.cast(Group, ctx.command) for name in multi.list_commands(ctx): if name.startswith(incomplete): @@ -66,38 +69,34 @@ def _complete_visible_commands( yield name, command -def _check_multicommand( - base_command: "MultiCommand", cmd_name: str, cmd: "Command", register: bool = False +def _check_nested_chain( + base_command: Group, cmd_name: str, cmd: Command, register: bool = False ) -> None: - if not base_command.chain or not isinstance(cmd, MultiCommand): + if not base_command.chain or not isinstance(cmd, Group): return + if register: - hint = ( - "It is not possible to add multi commands as children to" - " another multi command that is in chain mode." + message = ( + f"It is not possible to add the group {cmd_name!r} to another" + f" group {base_command.name!r} that is in chain mode." ) else: - hint = ( - "Found a multi command as subcommand to a multi command" - " that is in chain mode. This is not supported." + message = ( + f"Found the group {cmd_name!r} as subcommand to another group " + f" {base_command.name!r} that is in chain mode. This is not supported." ) - raise RuntimeError( - f"{hint}. Command {base_command.name!r} is set to chain and" - f" {cmd_name!r} was added as a subcommand but it in itself is a" - f" multi command. ({cmd_name!r} is a {type(cmd).__name__}" - f" within a chained {type(base_command).__name__} named" - f" {base_command.name!r})." - ) + + raise RuntimeError(message) -def batch(iterable: t.Iterable[V], batch_size: int) -> t.List[t.Tuple[V, ...]]: +def batch(iterable: cabc.Iterable[V], batch_size: int) -> list[tuple[V, ...]]: return list(zip(*repeat(iter(iterable), batch_size))) @contextmanager def augment_usage_errors( - ctx: "Context", param: t.Optional["Parameter"] = None -) -> t.Iterator[None]: + ctx: Context, param: Parameter | None = None +) -> cabc.Iterator[None]: """Context manager that attaches extra information to exceptions.""" try: yield @@ -114,22 +113,15 @@ def augment_usage_errors( def iter_params_for_processing( - invocation_order: t.Sequence["Parameter"], - declaration_order: t.Sequence["Parameter"], -) -> t.List["Parameter"]: - """Returns all declared parameters in the order they should be processed. - - The declared parameters are re-shuffled depending on the order in which - they were invoked, as well as the eagerness of each parameters. - - The invocation order takes precedence over the declaration order. I.e. the - order in which the user provided them to the CLI is respected. - - This behavior and its effect on callback evaluation is detailed at: - https://click.palletsprojects.com/en/stable/advanced/#callback-evaluation-order + invocation_order: cabc.Sequence[Parameter], + declaration_order: cabc.Sequence[Parameter], +) -> list[Parameter]: + """Given a sequence of parameters in the order as should be considered + for processing and an iterable of parameters that exist, this returns + a list in the correct order as they should be processed. """ - def sort_key(item: "Parameter") -> t.Tuple[bool, float]: + def sort_key(item: Parameter) -> tuple[bool, float]: try: idx: float = invocation_order.index(item) except ValueError: @@ -237,6 +229,10 @@ class Context: context. ``Command.show_default`` overrides this default for the specific command. + .. versionchanged:: 8.2 + The ``protected_args`` attribute is deprecated and will be removed in + Click 9.0. ``args`` will contain remaining unparsed tokens. + .. versionchanged:: 8.1 The ``show_default`` parameter is overridden by ``Command.show_default``, instead of the other way around. @@ -264,26 +260,26 @@ class Context: #: The formatter class to create with :meth:`make_formatter`. #: #: .. versionadded:: 8.0 - formatter_class: t.Type["HelpFormatter"] = HelpFormatter + formatter_class: type[HelpFormatter] = HelpFormatter def __init__( self, - command: "Command", - parent: t.Optional["Context"] = None, - info_name: t.Optional[str] = None, - obj: t.Optional[t.Any] = None, - auto_envvar_prefix: t.Optional[str] = None, - default_map: t.Optional[t.MutableMapping[str, t.Any]] = None, - terminal_width: t.Optional[int] = None, - max_content_width: t.Optional[int] = None, + command: Command, + parent: Context | None = None, + info_name: str | None = None, + obj: t.Any | None = None, + auto_envvar_prefix: str | None = None, + default_map: cabc.MutableMapping[str, t.Any] | None = None, + terminal_width: int | None = None, + max_content_width: int | None = None, resilient_parsing: bool = False, - allow_extra_args: t.Optional[bool] = None, - allow_interspersed_args: t.Optional[bool] = None, - ignore_unknown_options: t.Optional[bool] = None, - help_option_names: t.Optional[t.List[str]] = None, - token_normalize_func: t.Optional[t.Callable[[str], str]] = None, - color: t.Optional[bool] = None, - show_default: t.Optional[bool] = None, + allow_extra_args: bool | None = None, + allow_interspersed_args: bool | None = None, + ignore_unknown_options: bool | None = None, + help_option_names: list[str] | None = None, + token_normalize_func: t.Callable[[str], str] | None = None, + color: bool | None = None, + show_default: bool | None = None, ) -> None: #: the parent context or `None` if none exists. self.parent = parent @@ -293,23 +289,23 @@ def __init__( self.info_name = info_name #: Map of parameter names to their parsed values. Parameters #: with ``expose_value=False`` are not stored. - self.params: t.Dict[str, t.Any] = {} + self.params: dict[str, t.Any] = {} #: the leftover arguments. - self.args: t.List[str] = [] + self.args: list[str] = [] #: protected arguments. These are arguments that are prepended #: to `args` when certain parsing scenarios are encountered but #: must be never propagated to another arguments. This is used #: to implement nested parsing. - self.protected_args: t.List[str] = [] + self._protected_args: list[str] = [] #: the collected prefixes of the command's options. - self._opt_prefixes: t.Set[str] = set(parent._opt_prefixes) if parent else set() + self._opt_prefixes: set[str] = set(parent._opt_prefixes) if parent else set() if obj is None and parent is not None: obj = parent.obj #: the user object stored. self.obj: t.Any = obj - self._meta: t.Dict[str, t.Any] = getattr(parent, "meta", {}) + self._meta: dict[str, t.Any] = getattr(parent, "meta", {}) #: A dictionary (-like object) with defaults for parameters. if ( @@ -320,7 +316,7 @@ def __init__( ): default_map = parent.default_map.get(info_name) - self.default_map: t.Optional[t.MutableMapping[str, t.Any]] = default_map + self.default_map: cabc.MutableMapping[str, t.Any] | None = default_map #: This flag indicates if a subcommand is going to be executed. A #: group callback can use this information to figure out if it's @@ -332,20 +328,20 @@ def __init__( #: any commands are executed. It is however not possible to #: figure out which ones. If you require this knowledge you #: should use a :func:`result_callback`. - self.invoked_subcommand: t.Optional[str] = None + self.invoked_subcommand: str | None = None if terminal_width is None and parent is not None: terminal_width = parent.terminal_width #: The width of the terminal (None is autodetection). - self.terminal_width: t.Optional[int] = terminal_width + self.terminal_width: int | None = terminal_width if max_content_width is None and parent is not None: max_content_width = parent.max_content_width #: The maximum width of formatted content (None implies a sensible #: default which is 80 for most things). - self.max_content_width: t.Optional[int] = max_content_width + self.max_content_width: int | None = max_content_width if allow_extra_args is None: allow_extra_args = command.allow_extra_args @@ -385,16 +381,14 @@ def __init__( help_option_names = ["--help"] #: The names for the help options. - self.help_option_names: t.List[str] = help_option_names + self.help_option_names: list[str] = help_option_names if token_normalize_func is None and parent is not None: token_normalize_func = parent.token_normalize_func #: An optional normalization function for tokens. This is #: options, choices, commands etc. - self.token_normalize_func: t.Optional[t.Callable[[str], str]] = ( - token_normalize_func - ) + self.token_normalize_func: t.Callable[[str], str] | None = token_normalize_func #: Indicates if resilient parsing is enabled. In that case Click #: will do its best to not cause any failures and default values @@ -419,26 +413,38 @@ def __init__( if auto_envvar_prefix is not None: auto_envvar_prefix = auto_envvar_prefix.replace("-", "_") - self.auto_envvar_prefix: t.Optional[str] = auto_envvar_prefix + self.auto_envvar_prefix: str | None = auto_envvar_prefix if color is None and parent is not None: color = parent.color #: Controls if styling output is wanted or not. - self.color: t.Optional[bool] = color + self.color: bool | None = color if show_default is None and parent is not None: show_default = parent.show_default #: Show option default values when formatting help text. - self.show_default: t.Optional[bool] = show_default + self.show_default: bool | None = show_default - self._close_callbacks: t.List[t.Callable[[], t.Any]] = [] + self._close_callbacks: list[t.Callable[[], t.Any]] = [] self._depth = 0 - self._parameter_source: t.Dict[str, ParameterSource] = {} + self._parameter_source: dict[str, ParameterSource] = {} self._exit_stack = ExitStack() - def to_info_dict(self) -> t.Dict[str, t.Any]: + @property + def protected_args(self) -> list[str]: + import warnings + + warnings.warn( + "'protected_args' is deprecated and will be removed in Click 9.0." + " 'args' will contain remaining unparsed tokens.", + DeprecationWarning, + stacklevel=2, + ) + return self._protected_args + + def to_info_dict(self) -> dict[str, t.Any]: """Gather information that could be useful for a tool generating user-facing documentation. This traverses the entire CLI structure. @@ -459,16 +465,16 @@ def to_info_dict(self) -> t.Dict[str, t.Any]: "auto_envvar_prefix": self.auto_envvar_prefix, } - def __enter__(self) -> "Context": + def __enter__(self) -> Context: self._depth += 1 push_context(self) return self def __exit__( self, - exc_type: t.Optional[t.Type[BaseException]], - exc_value: t.Optional[BaseException], - tb: t.Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, ) -> None: self._depth -= 1 if self._depth == 0: @@ -476,7 +482,7 @@ def __exit__( pop_context() @contextmanager - def scope(self, cleanup: bool = True) -> t.Iterator["Context"]: + def scope(self, cleanup: bool = True) -> cabc.Iterator[Context]: """This helper method can be used with the context object to promote it to the current thread local (see :func:`get_current_context`). The default behavior of this is to invoke the cleanup functions which @@ -514,7 +520,7 @@ def scope(self, cleanup: bool = True) -> t.Iterator["Context"]: self._depth -= 1 @property - def meta(self) -> t.Dict[str, t.Any]: + def meta(self) -> dict[str, t.Any]: """This is a dictionary which is shared with all the contexts that are nested. It exists so that click utilities can store some state here if they need to. It is however the responsibility of @@ -555,7 +561,7 @@ def make_formatter(self) -> HelpFormatter: width=self.terminal_width, max_width=self.max_content_width ) - def with_resource(self, context_manager: t.ContextManager[V]) -> V: + def with_resource(self, context_manager: AbstractContextManager[V]) -> V: """Register a resource as if it were used in a ``with`` statement. The resource will be cleaned up when the context is popped. @@ -624,16 +630,16 @@ def command_path(self) -> str: rv = f"{' '.join(parent_command_path)} {rv}" return rv.lstrip() - def find_root(self) -> "Context": + def find_root(self) -> Context: """Finds the outermost context.""" node = self while node.parent is not None: node = node.parent return node - def find_object(self, object_type: t.Type[V]) -> t.Optional[V]: + def find_object(self, object_type: type[V]) -> V | None: """Finds the closest object of a given type.""" - node: t.Optional[Context] = self + node: Context | None = self while node is not None: if isinstance(node.obj, object_type): @@ -643,7 +649,7 @@ def find_object(self, object_type: t.Type[V]) -> t.Optional[V]: return None - def ensure_object(self, object_type: t.Type[V]) -> V: + def ensure_object(self, object_type: type[V]) -> V: """Like :meth:`find_object` but sets the innermost object to a new instance of `object_type` if it does not exist. """ @@ -654,15 +660,15 @@ def ensure_object(self, object_type: t.Type[V]) -> V: @t.overload def lookup_default( - self, name: str, call: "te.Literal[True]" = True - ) -> t.Optional[t.Any]: ... + self, name: str, call: t.Literal[True] = True + ) -> t.Any | None: ... @t.overload def lookup_default( - self, name: str, call: "te.Literal[False]" = ... - ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: ... + self, name: str, call: t.Literal[False] = ... + ) -> t.Any | t.Callable[[], t.Any] | None: ... - def lookup_default(self, name: str, call: bool = True) -> t.Optional[t.Any]: + def lookup_default(self, name: str, call: bool = True) -> t.Any | None: """Get the default for a parameter from :attr:`default_map`. :param name: Name of the parameter. @@ -682,7 +688,7 @@ def lookup_default(self, name: str, call: bool = True) -> t.Optional[t.Any]: return None - def fail(self, message: str) -> "te.NoReturn": + def fail(self, message: str) -> t.NoReturn: """Aborts the execution of the program with a specific error message. @@ -690,12 +696,18 @@ def fail(self, message: str) -> "te.NoReturn": """ raise UsageError(message, self) - def abort(self) -> "te.NoReturn": + def abort(self) -> t.NoReturn: """Aborts the script.""" raise Abort() - def exit(self, code: int = 0) -> "te.NoReturn": - """Exits the application with a given exit code.""" + def exit(self, code: int = 0) -> t.NoReturn: + """Exits the application with a given exit code. + + .. versionchanged:: 8.2 + Callbacks and context managers registered with :meth:`call_on_close` + and :meth:`with_resource` are closed before exiting. + """ + self.close() raise Exit(code) def get_usage(self) -> str: @@ -710,7 +722,7 @@ def get_help(self) -> str: """ return self.command.get_help(self) - def _make_sub_context(self, command: "Command") -> "Context": + def _make_sub_context(self, command: Command) -> Context: """Create a new context of the same type as this context, but for a new command. @@ -720,26 +732,15 @@ def _make_sub_context(self, command: "Command") -> "Context": @t.overload def invoke( - __self, - __callback: "t.Callable[..., V]", - *args: t.Any, - **kwargs: t.Any, + self, callback: t.Callable[..., V], /, *args: t.Any, **kwargs: t.Any ) -> V: ... @t.overload - def invoke( - __self, - __callback: "Command", - *args: t.Any, - **kwargs: t.Any, - ) -> t.Any: ... + def invoke(self, callback: Command, /, *args: t.Any, **kwargs: t.Any) -> t.Any: ... def invoke( - __self, - __callback: t.Union["Command", "t.Callable[..., V]"], - *args: t.Any, - **kwargs: t.Any, - ) -> t.Union[t.Any, V]: + self, callback: Command | t.Callable[..., V], /, *args: t.Any, **kwargs: t.Any + ) -> t.Any | V: """Invokes a command callback in exactly the way it expects. There are two ways to invoke this method: @@ -750,26 +751,24 @@ def invoke( (options and click arguments) must be keyword arguments and Click will fill in defaults. - Note that before Click 3.2 keyword arguments were not properly filled - in against the intention of this code and no context was created. For - more information about this change and why it was done in a bugfix - release see :ref:`upgrade-to-3.2`. - .. versionchanged:: 8.0 All ``kwargs`` are tracked in :attr:`params` so they will be passed if :meth:`forward` is called at multiple levels. + + .. versionchanged:: 3.2 + A new context is created, and missing arguments use default values. """ - if isinstance(__callback, Command): - other_cmd = __callback + if isinstance(callback, Command): + other_cmd = callback if other_cmd.callback is None: raise TypeError( "The given command does not have a callback that can be invoked." ) else: - __callback = t.cast("t.Callable[..., V]", other_cmd.callback) + callback = t.cast("t.Callable[..., V]", other_cmd.callback) - ctx = __self._make_sub_context(other_cmd) + ctx = self._make_sub_context(other_cmd) for param in other_cmd.params: if param.name not in kwargs and param.expose_value: @@ -781,13 +780,13 @@ def invoke( # them on in subsequent calls. ctx.params.update(kwargs) else: - ctx = __self + ctx = self - with augment_usage_errors(__self): + with augment_usage_errors(self): with ctx: - return __callback(*args, **kwargs) + return callback(*args, **kwargs) - def forward(__self, __cmd: "Command", *args: t.Any, **kwargs: t.Any) -> t.Any: + def forward(self, cmd: Command, /, *args: t.Any, **kwargs: t.Any) -> t.Any: """Similar to :meth:`invoke` but fills in default keyword arguments from the current context if the other command expects it. This cannot invoke callbacks directly, only other commands. @@ -797,14 +796,14 @@ def forward(__self, __cmd: "Command", *args: t.Any, **kwargs: t.Any) -> t.Any: passed if ``forward`` is called at multiple levels. """ # Can only forward to other commands, not direct callbacks. - if not isinstance(__cmd, Command): + if not isinstance(cmd, Command): raise TypeError("Callback is not a command.") - for param in __self.params: + for param in self.params: if param not in kwargs: - kwargs[param] = __self.params[param] + kwargs[param] = self.params[param] - return __self.invoke(__cmd, *args, **kwargs) + return self.invoke(cmd, *args, **kwargs) def set_parameter_source(self, name: str, source: ParameterSource) -> None: """Set the source of a parameter. This indicates the location @@ -815,7 +814,7 @@ def set_parameter_source(self, name: str, source: ParameterSource) -> None: """ self._parameter_source[name] = source - def get_parameter_source(self, name: str) -> t.Optional[ParameterSource]: + def get_parameter_source(self, name: str) -> ParameterSource | None: """Get the source of a parameter. This indicates the location from which the value of the parameter was obtained. @@ -834,43 +833,82 @@ def get_parameter_source(self, name: str) -> t.Optional[ParameterSource]: return self._parameter_source.get(name) -class BaseCommand: - """The base command implements the minimal API contract of commands. - Most code will never use this as it does not implement a lot of useful - functionality but it can act as the direct subclass of alternative - parsing methods that do not depend on the Click parser. - - For instance, this can be used to bridge Click and other systems like - argparse or docopt. - - Because base commands do not implement a lot of the API that other - parts of Click take for granted, they are not supported for all - operations. For instance, they cannot be used with the decorators - usually and they have no built-in callback system. - - .. versionchanged:: 2.0 - Added the `context_settings` parameter. +class Command: + """Commands are the basic building block of command line interfaces in + Click. A basic command handles command line parsing and might dispatch + more parsing to commands nested below it. :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. + :param callback: the callback to invoke. This is optional. + :param params: the parameters to register with this command. This can + be either :class:`Option` or :class:`Argument` objects. + :param help: the help string to use for this command. + :param epilog: like the help string but it's printed at the end of the + help page after everything else. + :param short_help: the short help to use for this command. This is + shown on the command listing of the parent command. + :param add_help_option: by default each command registers a ``--help`` + option. This can be disabled by this parameter. + :param no_args_is_help: this controls what happens if no arguments are + provided. This option is disabled by default. + If enabled this will add ``--help`` as argument + if no arguments are passed + :param hidden: hide this command from help outputs. + :param deprecated: If ``True`` or non-empty string, issues a message + indicating that the command is deprecated and highlights + its deprecation in --help. The message can be customized + by using a string as the value. + + .. versionchanged:: 8.2 + This is the base class for all commands, not ``BaseCommand``. + ``deprecated`` can be set to a string as well to customize the + deprecation message. + + .. 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. """ #: The context class to create with :meth:`make_context`. #: #: .. versionadded:: 8.0 - context_class: t.Type[Context] = Context + context_class: type[Context] = Context + #: the default for the :attr:`Context.allow_extra_args` flag. allow_extra_args = False + #: the default for the :attr:`Context.allow_interspersed_args` flag. allow_interspersed_args = True + #: the default for the :attr:`Context.ignore_unknown_options` flag. ignore_unknown_options = False def __init__( self, - name: t.Optional[str], - context_settings: t.Optional[t.MutableMapping[str, t.Any]] = None, + name: str | None, + context_settings: cabc.MutableMapping[str, t.Any] | None = None, + callback: t.Callable[..., t.Any] | None = None, + params: list[Parameter] | None = None, + help: str | None = None, + epilog: str | None = None, + short_help: str | None = None, + options_metavar: str | None = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool | str = False, ) -> None: #: the name the command thinks it has. Upon registering a command #: on a :class:`Group` the group will default the command name @@ -882,36 +920,226 @@ def __init__( context_settings = {} #: an optional dictionary with defaults passed to the context. - self.context_settings: t.MutableMapping[str, t.Any] = context_settings - - def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: - """Gather information that could be useful for a tool generating - user-facing documentation. This traverses the entire structure - below this command. - - Use :meth:`click.Context.to_info_dict` to traverse the entire - CLI structure. + self.context_settings: cabc.MutableMapping[str, t.Any] = context_settings - :param ctx: A :class:`Context` representing this command. + #: the callback to execute when the command fires. This might be + #: `None` in which case nothing happens. + self.callback = callback + #: the list of parameters for this command in the order they + #: should show up in the help page and execute. Eager parameters + #: will automatically be handled before non eager ones. + self.params: list[Parameter] = params or [] + self.help = help + self.epilog = epilog + self.options_metavar = options_metavar + self.short_help = short_help + self.add_help_option = add_help_option + self.no_args_is_help = no_args_is_help + self.hidden = hidden + self.deprecated = deprecated - .. versionadded:: 8.0 - """ - return {"name": self.name} + def to_info_dict(self, ctx: Context) -> dict[str, t.Any]: + return { + "name": self.name, + "params": [param.to_info_dict() for param in self.get_params(ctx)], + "help": self.help, + "epilog": self.epilog, + "short_help": self.short_help, + "hidden": self.hidden, + "deprecated": self.deprecated, + } def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.name}>" def get_usage(self, ctx: Context) -> str: - raise NotImplementedError("Base commands cannot get usage") + """Formats the usage line into a string and returns it. + + Calls :meth:`format_usage` internally. + """ + formatter = ctx.make_formatter() + self.format_usage(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_params(self, ctx: Context) -> list[Parameter]: + params = self.params + help_option = self.get_help_option(ctx) + + if help_option is not None: + params = [*params, help_option] + + if __debug__: + import warnings + + opts = [opt for param in params for opt in param.opts] + opts_counter = Counter(opts) + duplicate_opts = (opt for opt, count in opts_counter.items() if count > 1) + + for duplicate_opt in duplicate_opts: + warnings.warn( + ( + f"The parameter {duplicate_opt} is used more than once. " + "Remove its duplicate as parameters should be unique." + ), + stacklevel=3, + ) + + return params + + def format_usage(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the usage line into the formatter. + + This is a low-level method called by :meth:`get_usage`. + """ + pieces = self.collect_usage_pieces(ctx) + formatter.write_usage(ctx.command_path, " ".join(pieces)) + + def collect_usage_pieces(self, ctx: Context) -> list[str]: + """Returns all the pieces that go into the usage line and returns + it as a list of strings. + """ + rv = [self.options_metavar] if self.options_metavar else [] + + for param in self.get_params(ctx): + rv.extend(param.get_usage_pieces(ctx)) + + return rv + + def get_help_option_names(self, ctx: Context) -> list[str]: + """Returns the names for the help option.""" + all_names = set(ctx.help_option_names) + for param in self.params: + all_names.difference_update(param.opts) + all_names.difference_update(param.secondary_opts) + return list(all_names) + + def get_help_option(self, ctx: Context) -> Option | None: + """Returns the help option object.""" + help_options = self.get_help_option_names(ctx) + + if not help_options or not self.add_help_option: + return None + + def show_help(ctx: Context, param: Parameter, value: str) -> None: + if value and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + return Option( + help_options, + is_flag=True, + is_eager=True, + expose_value=False, + callback=show_help, + help=_("Show this message and exit."), + ) + + def make_parser(self, ctx: Context) -> _OptionParser: + """Creates the underlying option parser for this command.""" + parser = _OptionParser(ctx) + for param in self.get_params(ctx): + param.add_to_parser(parser, ctx) + return parser def get_help(self, ctx: Context) -> str: - raise NotImplementedError("Base commands cannot get help") + """Formats the help into a string and returns it. + + Calls :meth:`format_help` internally. + """ + formatter = ctx.make_formatter() + self.format_help(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_short_help_str(self, limit: int = 45) -> str: + """Gets short help for the command or makes it by shortening the + long help string. + """ + 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: + deprecated_message = ( + f"(DEPRECATED: {self.deprecated})" + if isinstance(self.deprecated, str) + else "(DEPRECATED)" + ) + text = _("{text} {deprecated_message}").format( + text=text, deprecated_message=deprecated_message + ) + + return text.strip() + + def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the help into the formatter if it exists. + + This is a low-level method called by :meth:`get_help`. + + This calls the following methods: + + - :meth:`format_usage` + - :meth:`format_help_text` + - :meth:`format_options` + - :meth:`format_epilog` + """ + self.format_usage(ctx, formatter) + self.format_help_text(ctx, formatter) + self.format_options(ctx, formatter) + self.format_epilog(ctx, formatter) + + def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the help text to the formatter if it exists.""" + if self.help is not None: + # truncate the help text to the first form feed + text = inspect.cleandoc(self.help).partition("\f")[0] + else: + text = "" + + if self.deprecated: + deprecated_message = ( + f"(DEPRECATED: {self.deprecated})" + if isinstance(self.deprecated, str) + else "(DEPRECATED)" + ) + text = _("{text} {deprecated_message}").format( + text=text, deprecated_message=deprecated_message + ) + + if text: + formatter.write_paragraph() + + with formatter.indentation(): + formatter.write_text(text) + + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes all the options into the formatter if they exist.""" + opts = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None: + opts.append(rv) + + if opts: + with formatter.section(_("Options")): + formatter.write_dl(opts) + + 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(epilog) def make_context( self, - info_name: t.Optional[str], - args: t.List[str], - parent: t.Optional[Context] = None, + info_name: str | None, + args: list[str], + parent: Context | None = None, **extra: t.Any, ) -> Context: """This function when given an info name and arguments will kick @@ -938,37 +1166,58 @@ def make_context( if key not in extra: extra[key] = value - ctx = self.context_class( - self, # type: ignore[arg-type] - info_name=info_name, - parent=parent, - **extra, - ) + ctx = self.context_class(self, info_name=info_name, parent=parent, **extra) with ctx.scope(cleanup=False): self.parse_args(ctx, args) return ctx - def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: - """Given a context and a list of arguments this creates the parser - and parses the arguments, then modifies the context as necessary. - This is automatically invoked by :meth:`make_context`. - """ - raise NotImplementedError("Base commands do not know how to parse arguments.") + def parse_args(self, ctx: Context, args: list[str]) -> list[str]: + if not args and self.no_args_is_help and not ctx.resilient_parsing: + raise NoArgsIsHelpError(ctx) + + parser = self.make_parser(ctx) + opts, args, param_order = parser.parse_args(args=args) + + for param in iter_params_for_processing(param_order, self.get_params(ctx)): + value, args = param.handle_parse_result(ctx, opts, args) + + if args and not ctx.allow_extra_args and not ctx.resilient_parsing: + ctx.fail( + ngettext( + "Got unexpected extra argument ({args})", + "Got unexpected extra arguments ({args})", + len(args), + ).format(args=" ".join(map(str, args))) + ) + + ctx.args = args + ctx._opt_prefixes.update(parser._opt_prefixes) + return args def invoke(self, ctx: Context) -> t.Any: - """Given a context, this invokes the command. The default - implementation is raising a not implemented error. + """Given a context, this invokes the attached callback (if it exists) + in the right way. """ - raise NotImplementedError("Base commands are not invocable by default") + if self.deprecated: + extra_message = ( + f" {self.deprecated}" if isinstance(self.deprecated, str) else "" + ) + message = _( + "DeprecationWarning: The command {name!r} is deprecated." + "{extra_message}" + ).format(name=self.name, extra_message=extra_message) + echo(style(message, fg="red"), err=True) + + if self.callback is not None: + return ctx.invoke(self.callback, **ctx.params) - def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]: """Return a list of completions for the incomplete value. Looks - at the names of chained multi-commands. + at the names of options and chained multi-commands. Any command could be part of a chained multi-command, so sibling - commands are valid at any point during command completion. Other - command classes will return more completions. + commands are valid at any point during command completion. :param ctx: Invocation context for this command. :param incomplete: Value being completed. May be empty. @@ -977,16 +1226,35 @@ def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionIte """ from click.shell_completion import CompletionItem - results: t.List[CompletionItem] = [] + results: list[CompletionItem] = [] - while ctx.parent is not None: - ctx = ctx.parent + if incomplete and not incomplete[0].isalnum(): + for param in self.get_params(ctx): + if ( + not isinstance(param, Option) + or param.hidden + or ( + not param.multiple + and ctx.get_parameter_source(param.name) # type: ignore + is ParameterSource.COMMANDLINE + ) + ): + continue - if isinstance(ctx.command, MultiCommand) and ctx.command.chain: + results.extend( + CompletionItem(name, help=param.help) + for name in [*param.opts, *param.secondary_opts] + if name.startswith(incomplete) + ) + + while ctx.parent is not None: + ctx = ctx.parent + + if isinstance(ctx.command, Group) and ctx.command.chain: results.extend( CompletionItem(name, help=command.get_short_help_str()) for name, command in _complete_visible_commands(ctx, incomplete) - if name not in ctx.protected_args + if name not in ctx._protected_args ) return results @@ -994,28 +1262,28 @@ def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionIte @t.overload def main( self, - args: t.Optional[t.Sequence[str]] = None, - prog_name: t.Optional[str] = None, - complete_var: t.Optional[str] = None, - standalone_mode: "te.Literal[True]" = True, + args: cabc.Sequence[str] | None = None, + prog_name: str | None = None, + complete_var: str | None = None, + standalone_mode: t.Literal[True] = True, **extra: t.Any, - ) -> "te.NoReturn": ... + ) -> t.NoReturn: ... @t.overload def main( self, - args: t.Optional[t.Sequence[str]] = None, - prog_name: t.Optional[str] = None, - complete_var: t.Optional[str] = None, + args: cabc.Sequence[str] | None = None, + prog_name: str | None = None, + complete_var: str | None = None, standalone_mode: bool = ..., **extra: t.Any, ) -> t.Any: ... def main( self, - args: t.Optional[t.Sequence[str]] = None, - prog_name: t.Optional[str] = None, - complete_var: t.Optional[str] = None, + args: cabc.Sequence[str] | None = None, + prog_name: str | None = None, + complete_var: str | None = None, standalone_mode: bool = True, windows_expand_args: bool = True, **extra: t.Any, @@ -1126,9 +1394,9 @@ def main( def _main_shell_completion( self, - ctx_args: t.MutableMapping[str, t.Any], + ctx_args: cabc.MutableMapping[str, t.Any], prog_name: str, - complete_var: t.Optional[str] = None, + complete_var: str | None = None, ) -> None: """Check if the shell is asking for tab completion, process that, then exit early. Called from :meth:`main` before the @@ -1161,416 +1429,250 @@ def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any: return self.main(*args, **kwargs) -class Command(BaseCommand): - """Commands are the basic building block of command line interfaces in - Click. A basic command handles command line parsing and might dispatch - more parsing to commands nested below it. - - :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. - :param callback: the callback to invoke. This is optional. - :param params: the parameters to register with this command. This can - be either :class:`Option` or :class:`Argument` objects. - :param help: the help string to use for this command. - :param epilog: like the help string but it's printed at the end of the - help page after everything else. - :param short_help: the short help to use for this command. This is - shown on the command listing of the parent command. - :param add_help_option: by default each command registers a ``--help`` - option. This can be disabled by this parameter. - :param no_args_is_help: this controls what happens if no arguments are - provided. This option is disabled by default. - If enabled this will add ``--help`` as argument - if no arguments are passed - :param hidden: hide this command from help outputs. - - :param deprecated: issues a message indicating that - the command is deprecated. +class _FakeSubclassCheck(type): + def __subclasscheck__(cls, subclass: type) -> bool: + return issubclass(subclass, cls.__bases__[0]) - .. 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. + def __instancecheck__(cls, instance: t.Any) -> bool: + return isinstance(instance, cls.__bases__[0]) - .. 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. +class _BaseCommand(Command, metaclass=_FakeSubclassCheck): + """ + .. deprecated:: 8.2 + Will be removed in Click 9.0. Use ``Command`` instead. """ - def __init__( - self, - name: t.Optional[str], - context_settings: t.Optional[t.MutableMapping[str, t.Any]] = None, - callback: t.Optional[t.Callable[..., t.Any]] = None, - params: t.Optional[t.List["Parameter"]] = None, - help: t.Optional[str] = None, - epilog: t.Optional[str] = None, - short_help: t.Optional[str] = None, - options_metavar: t.Optional[str] = "[OPTIONS]", - add_help_option: bool = True, - no_args_is_help: bool = False, - hidden: bool = False, - deprecated: bool = False, - ) -> None: - super().__init__(name, context_settings) - #: the callback to execute when the command fires. This might be - #: `None` in which case nothing happens. - self.callback = callback - #: the list of parameters for this command in the order they - #: should show up in the help page and execute. Eager parameters - #: will automatically be handled before non eager ones. - self.params: t.List[Parameter] = params or [] - self.help = help - self.epilog = epilog - self.options_metavar = options_metavar - self.short_help = short_help - self.add_help_option = add_help_option - self._help_option: t.Optional[HelpOption] = None - self.no_args_is_help = no_args_is_help - self.hidden = hidden - self.deprecated = deprecated - - def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: - info_dict = super().to_info_dict(ctx) - info_dict.update( - params=[param.to_info_dict() for param in self.get_params(ctx)], - help=self.help, - epilog=self.epilog, - short_help=self.short_help, - hidden=self.hidden, - deprecated=self.deprecated, - ) - return info_dict - - def get_usage(self, ctx: Context) -> str: - """Formats the usage line into a string and returns it. - - Calls :meth:`format_usage` internally. - """ - formatter = ctx.make_formatter() - self.format_usage(ctx, formatter) - return formatter.getvalue().rstrip("\n") - - def get_params(self, ctx: Context) -> t.List["Parameter"]: - rv = self.params - help_option = self.get_help_option(ctx) - - if help_option is not None: - rv = [*rv, help_option] - - return rv - - def format_usage(self, ctx: Context, formatter: HelpFormatter) -> None: - """Writes the usage line into the formatter. - This is a low-level method called by :meth:`get_usage`. - """ - pieces = self.collect_usage_pieces(ctx) - formatter.write_usage(ctx.command_path, " ".join(pieces)) +class Group(Command): + """A group is a command that nests other commands (or more groups). - def collect_usage_pieces(self, ctx: Context) -> t.List[str]: - """Returns all the pieces that go into the usage line and returns - it as a list of strings. - """ - rv = [self.options_metavar] if self.options_metavar else [] + :param name: The name of the group command. + :param commands: Map names to :class:`Command` objects. Can be a list, which + will use :attr:`Command.name` as the keys. + :param invoke_without_command: Invoke the group's callback even if a + subcommand is not given. + :param no_args_is_help: If no arguments are given, show the group's help and + exit. Defaults to the opposite of ``invoke_without_command``. + :param subcommand_metavar: How to represent the subcommand argument in help. + The default will represent whether ``chain`` is set or not. + :param chain: Allow passing more than one subcommand argument. After parsing + a command's arguments, if any arguments remain another command will be + matched, and so on. + :param result_callback: A function to call after the group's and + subcommand's callbacks. The value returned by the subcommand is passed. + If ``chain`` is enabled, the value will be a list of values returned by + all the commands. If ``invoke_without_command`` is enabled, the value + will be the value returned by the group's callback, or an empty list if + ``chain`` is enabled. + :param kwargs: Other arguments passed to :class:`Command`. - for param in self.get_params(ctx): - rv.extend(param.get_usage_pieces(ctx)) + .. versionchanged:: 8.0 + The ``commands`` argument can be a list of command objects. - return rv + .. versionchanged:: 8.2 + Merged with and replaces the ``MultiCommand`` base class. + """ - def get_help_option_names(self, ctx: Context) -> t.List[str]: - """Returns the names for the help option.""" - all_names = set(ctx.help_option_names) - for param in self.params: - all_names.difference_update(param.opts) - all_names.difference_update(param.secondary_opts) - return list(all_names) + allow_extra_args = True + allow_interspersed_args = False - def get_help_option(self, ctx: Context) -> t.Optional["Option"]: - """Returns the help option object. + #: If set, this is used by the group's :meth:`command` decorator + #: as the default :class:`Command` class. This is useful to make all + #: subcommands use a custom command class. + #: + #: .. versionadded:: 8.0 + command_class: type[Command] | None = None - Unless ``add_help_option`` is ``False``. + #: If set, this is used by the group's :meth:`group` decorator + #: as the default :class:`Group` class. This is useful to make all + #: subgroups use a custom group class. + #: + #: If set to the special value :class:`type` (literally + #: ``group_class = type``), this group's class will be used as the + #: default class. This makes a custom group class continue to make + #: custom groups. + #: + #: .. versionadded:: 8.0 + group_class: type[Group] | type[type] | None = None + # Literal[type] isn't valid, so use Type[type] - .. versionchanged:: 8.1.8 - The help option is now cached to avoid creating it multiple times. - """ - help_options = self.get_help_option_names(ctx) + def __init__( + self, + name: str | None = None, + commands: cabc.MutableMapping[str, Command] + | cabc.Sequence[Command] + | None = None, + invoke_without_command: bool = False, + no_args_is_help: bool | None = None, + subcommand_metavar: str | None = None, + chain: bool = False, + result_callback: t.Callable[..., t.Any] | None = None, + **kwargs: t.Any, + ) -> None: + super().__init__(name, **kwargs) - if not help_options or not self.add_help_option: - return None + if commands is None: + commands = {} + elif isinstance(commands, abc.Sequence): + commands = {c.name: c for c in commands if c.name is not None} - # Cache the help option object in private _help_option attribute to - # avoid creating it multiple times. Not doing this will break the - # callback odering by iter_params_for_processing(), which relies on - # object comparison. - if self._help_option is None: - # Avoid circular import. - from .decorators import HelpOption + #: The registered subcommands by their exported names. + self.commands: cabc.MutableMapping[str, Command] = commands - self._help_option = HelpOption(help_options) + if no_args_is_help is None: + no_args_is_help = not invoke_without_command - return self._help_option + self.no_args_is_help = no_args_is_help + self.invoke_without_command = invoke_without_command - def make_parser(self, ctx: Context) -> OptionParser: - """Creates the underlying option parser for this command.""" - parser = OptionParser(ctx) - for param in self.get_params(ctx): - param.add_to_parser(parser, ctx) - return parser + if subcommand_metavar is None: + if chain: + subcommand_metavar = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." + else: + subcommand_metavar = "COMMAND [ARGS]..." - def get_help(self, ctx: Context) -> str: - """Formats the help into a string and returns it. + self.subcommand_metavar = subcommand_metavar + self.chain = chain + # The result callback that is stored. This can be set or + # overridden with the :func:`result_callback` decorator. + self._result_callback = result_callback - Calls :meth:`format_help` internally. - """ - formatter = ctx.make_formatter() - self.format_help(ctx, formatter) - return formatter.getvalue().rstrip("\n") + if self.chain: + for param in self.params: + if isinstance(param, Argument) and not param.required: + raise RuntimeError( + "A group in chain mode cannot have optional arguments." + ) - def get_short_help_str(self, limit: int = 45) -> str: - """Gets short help for the command or makes it by shortening the - long help string. - """ - if self.short_help: - text = inspect.cleandoc(self.short_help) - elif self.help: - text = make_default_short_help(self.help, limit) - else: - text = "" + def to_info_dict(self, ctx: Context) -> dict[str, t.Any]: + info_dict = super().to_info_dict(ctx) + commands = {} - if self.deprecated: - text = _("(Deprecated) {text}").format(text=text) + for name in self.list_commands(ctx): + command = self.get_command(ctx, name) - return text.strip() + if command is None: + continue - def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: - """Writes the help into the formatter if it exists. + sub_ctx = ctx._make_sub_context(command) - This is a low-level method called by :meth:`get_help`. + with sub_ctx.scope(cleanup=False): + commands[name] = command.to_info_dict(sub_ctx) - This calls the following methods: + info_dict.update(commands=commands, chain=self.chain) + return info_dict - - :meth:`format_usage` - - :meth:`format_help_text` - - :meth:`format_options` - - :meth:`format_epilog` + def add_command(self, cmd: Command, name: str | None = None) -> None: + """Registers another :class:`Command` with this group. If the name + is not provided, the name of the command is used. """ - self.format_usage(ctx, formatter) - self.format_help_text(ctx, formatter) - self.format_options(ctx, formatter) - self.format_epilog(ctx, formatter) - - def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None: - """Writes the help text to the formatter if it exists.""" - if self.help is not None: - # truncate the help text to the first form feed - text = inspect.cleandoc(self.help).partition("\f")[0] - else: - text = "" - - if self.deprecated: - text = _("(Deprecated) {text}").format(text=text) - - if text: - formatter.write_paragraph() - - with formatter.indentation(): - formatter.write_text(text) - - def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: - """Writes all the options into the formatter if they exist.""" - opts = [] - for param in self.get_params(ctx): - rv = param.get_help_record(ctx) - if rv is not None: - opts.append(rv) - - if opts: - with formatter.section(_("Options")): - formatter.write_dl(opts) - - 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(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: - echo(ctx.get_help(), color=ctx.color) - ctx.exit() - - parser = self.make_parser(ctx) - opts, args, param_order = parser.parse_args(args=args) - - for param in iter_params_for_processing(param_order, self.get_params(ctx)): - value, args = param.handle_parse_result(ctx, opts, args) - - if args and not ctx.allow_extra_args and not ctx.resilient_parsing: - ctx.fail( - ngettext( - "Got unexpected extra argument ({args})", - "Got unexpected extra arguments ({args})", - len(args), - ).format(args=" ".join(map(str, args))) - ) + name = name or cmd.name + if name is None: + raise TypeError("Command has no name.") + _check_nested_chain(self, name, cmd, register=True) + self.commands[name] = cmd - ctx.args = args - ctx._opt_prefixes.update(parser._opt_prefixes) - return args + @t.overload + def command(self, __func: t.Callable[..., t.Any]) -> Command: ... - def invoke(self, ctx: Context) -> t.Any: - """Given a context, this invokes the attached callback (if it exists) - in the right way. - """ - if self.deprecated: - message = _( - "DeprecationWarning: The command {name!r} is deprecated." - ).format(name=self.name) - echo(style(message, fg="red"), err=True) + @t.overload + def command( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], Command]: ... - if self.callback is not None: - return ctx.invoke(self.callback, **ctx.params) + def command( + self, *args: t.Any, **kwargs: t.Any + ) -> 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 + calling :meth:`add_command`. - def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: - """Return a list of completions for the incomplete value. Looks - at the names of options and chained multi-commands. + To customize the command class used, set the + :attr:`command_class` attribute. - :param ctx: Invocation context for this command. - :param incomplete: Value being completed. May be empty. + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. - .. versionadded:: 8.0 + .. versionchanged:: 8.0 + Added the :attr:`command_class` attribute. """ - from click.shell_completion import CompletionItem - - results: t.List[CompletionItem] = [] - - if incomplete and not incomplete[0].isalnum(): - for param in self.get_params(ctx): - if ( - not isinstance(param, Option) - or param.hidden - or ( - not param.multiple - and ctx.get_parameter_source(param.name) # type: ignore - is ParameterSource.COMMANDLINE - ) - ): - continue - - results.extend( - CompletionItem(name, help=param.help) - for name in [*param.opts, *param.secondary_opts] - if name.startswith(incomplete) - ) - - results.extend(super().shell_complete(ctx, incomplete)) - return results - + from .decorators import command -class MultiCommand(Command): - """A multi command is the basic implementation of a command that - dispatches to subcommands. The most common version is the - :class:`Group`. + func: t.Callable[..., t.Any] | None = None - :param invoke_without_command: this controls how the multi command itself - is invoked. By default it's only invoked - if a subcommand is provided. - :param no_args_is_help: this controls what happens if no arguments are - provided. This option is enabled by default if - `invoke_without_command` is disabled or disabled - if it's enabled. If enabled this will add - ``--help`` as argument if no arguments are - passed. - :param subcommand_metavar: the string that is used in the documentation - to indicate the subcommand place. - :param chain: if this is set to `True` chaining of multiple subcommands - is enabled. This restricts the form of commands in that - they cannot have optional arguments but it allows - multiple commands to be chained together. - :param result_callback: The result callback to attach to this multi - command. This can be set or changed later with the - :meth:`result_callback` decorator. - :param attrs: Other command arguments described in :class:`Command`. - """ + if args and callable(args[0]): + assert ( + len(args) == 1 and not kwargs + ), "Use 'command(**kwargs)(callable)' to provide arguments." + (func,) = args + args = () - allow_extra_args = True - allow_interspersed_args = False + if self.command_class and kwargs.get("cls") is None: + kwargs["cls"] = self.command_class - def __init__( - self, - name: t.Optional[str] = None, - invoke_without_command: bool = False, - no_args_is_help: t.Optional[bool] = None, - subcommand_metavar: t.Optional[str] = None, - chain: bool = False, - result_callback: t.Optional[t.Callable[..., t.Any]] = None, - **attrs: t.Any, - ) -> None: - super().__init__(name, **attrs) + def decorator(f: t.Callable[..., t.Any]) -> Command: + cmd: Command = command(*args, **kwargs)(f) + self.add_command(cmd) + return cmd - if no_args_is_help is None: - no_args_is_help = not invoke_without_command + if func is not None: + return decorator(func) - self.no_args_is_help = no_args_is_help - self.invoke_without_command = invoke_without_command + return decorator - if subcommand_metavar is None: - if chain: - subcommand_metavar = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." - else: - subcommand_metavar = "COMMAND [ARGS]..." + @t.overload + def group(self, __func: t.Callable[..., t.Any]) -> Group: ... - self.subcommand_metavar = subcommand_metavar - self.chain = chain - # The result callback that is stored. This can be set or - # overridden with the :func:`result_callback` decorator. - self._result_callback = result_callback + @t.overload + def group( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], Group]: ... - if self.chain: - for param in self.params: - if isinstance(param, Argument) and not param.required: - raise RuntimeError( - "Multi commands in chain mode cannot have" - " optional arguments." - ) + def group( + self, *args: t.Any, **kwargs: t.Any + ) -> 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 + calling :meth:`add_command`. - def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: - info_dict = super().to_info_dict(ctx) - commands = {} + To customize the group class used, set the :attr:`group_class` + attribute. - for name in self.list_commands(ctx): - command = self.get_command(ctx, name) + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. - if command is None: - continue + .. versionchanged:: 8.0 + Added the :attr:`group_class` attribute. + """ + from .decorators import group - sub_ctx = ctx._make_sub_context(command) + func: t.Callable[..., t.Any] | None = None - with sub_ctx.scope(cleanup=False): - commands[name] = command.to_info_dict(sub_ctx) + if args and callable(args[0]): + assert ( + len(args) == 1 and not kwargs + ), "Use 'group(**kwargs)(callable)' to provide arguments." + (func,) = args + args = () - info_dict.update(commands=commands, chain=self.chain) - return info_dict + 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 collect_usage_pieces(self, ctx: Context) -> t.List[str]: - rv = super().collect_usage_pieces(ctx) - rv.append(self.subcommand_metavar) - return rv + def decorator(f: t.Callable[..., t.Any]) -> Group: + cmd: Group = group(*args, **kwargs)(f) + self.add_command(cmd) + return cmd - def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: - super().format_options(ctx, formatter) - self.format_commands(ctx, formatter) + if func is not None: + return decorator(func) + + return decorator def result_callback(self, replace: bool = False) -> t.Callable[[F], F]: """Adds a result callback to the command. By default if a @@ -1608,8 +1710,8 @@ def decorator(f: F) -> F: self._result_callback = f return f - def function(__value, *args, **kwargs): # type: ignore - inner = old_callback(__value, *args, **kwargs) + def function(value: t.Any, /, *args: t.Any, **kwargs: t.Any) -> t.Any: + inner = old_callback(value, *args, **kwargs) return f(inner, *args, **kwargs) self._result_callback = rv = update_wrapper(t.cast(F, function), f) @@ -1617,6 +1719,25 @@ def function(__value, *args, **kwargs): # type: ignore return decorator + def get_command(self, ctx: Context, cmd_name: str) -> Command | None: + """Given a context and a command name, this returns a :class:`Command` + object if it exists or returns ``None``. + """ + return self.commands.get(cmd_name) + + def list_commands(self, ctx: Context) -> list[str]: + """Returns a list of subcommand names in the order they should appear.""" + return sorted(self.commands) + + def collect_usage_pieces(self, ctx: Context) -> list[str]: + rv = super().collect_usage_pieces(ctx) + rv.append(self.subcommand_metavar) + return rv + + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: + super().format_options(ctx, formatter) + self.format_commands(ctx, formatter) + def format_commands(self, ctx: Context, formatter: HelpFormatter) -> None: """Extra format methods for multi methods that adds all the commands after the options. @@ -1645,18 +1766,17 @@ def format_commands(self, ctx: Context, formatter: HelpFormatter) -> None: with formatter.section(_("Commands")): formatter.write_dl(rows) - def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: + def parse_args(self, ctx: Context, args: list[str]) -> list[str]: if not args and self.no_args_is_help and not ctx.resilient_parsing: - echo(ctx.get_help(), color=ctx.color) - ctx.exit() + raise NoArgsIsHelpError(ctx) rest = super().parse_args(ctx, args) if self.chain: - ctx.protected_args = rest + ctx._protected_args = rest ctx.args = [] elif rest: - ctx.protected_args, ctx.args = rest[:1], rest[1:] + ctx._protected_args, ctx.args = rest[:1], rest[1:] return ctx.args @@ -1666,7 +1786,7 @@ def _process_result(value: t.Any) -> t.Any: value = ctx.invoke(self._result_callback, value, **ctx.params) return value - if not ctx.protected_args: + if not ctx._protected_args: if self.invoke_without_command: # No subcommand was invoked, so the result callback is # invoked with the group return value for regular @@ -1677,9 +1797,9 @@ def _process_result(value: t.Any) -> t.Any: ctx.fail(_("Missing command.")) # Fetch args back out - args = [*ctx.protected_args, *ctx.args] + args = [*ctx._protected_args, *ctx.args] ctx.args = [] - ctx.protected_args = [] + ctx._protected_args = [] # If we're not in chain mode, we only allow the invocation of a # single command but we also inform the current context about the @@ -1729,8 +1849,8 @@ def _process_result(value: t.Any) -> t.Any: return _process_result(rv) def resolve_command( - self, ctx: Context, args: t.List[str] - ) -> t.Tuple[t.Optional[str], t.Optional[Command], t.List[str]]: + self, ctx: Context, args: list[str] + ) -> tuple[str | None, Command | None, list[str]]: cmd_name = make_str(args[0]) original_cmd_name = cmd_name @@ -1750,24 +1870,12 @@ def resolve_command( # resolve things like --help which now should go to the main # place. if cmd is None and not ctx.resilient_parsing: - if split_opt(cmd_name)[0]: - self.parse_args(ctx, ctx.args) + if _split_opt(cmd_name)[0]: + self.parse_args(ctx, args) ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name)) return cmd_name if cmd else None, cmd, args[1:] - def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: - """Given a context and a command name, this returns a - :class:`Command` object if it exists or returns `None`. - """ - raise NotImplementedError - - def list_commands(self, ctx: Context) -> t.List[str]: - """Returns a list of subcommand names in the order they should - appear. - """ - return [] - - def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]: """Return a list of completions for the incomplete value. Looks at the names of options, subcommands, and chained multi-commands. @@ -1787,216 +1895,62 @@ def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionIte return results -class Group(MultiCommand): - """A group allows a command to have subcommands attached. This is - the most common way to implement nesting in Click. - - :param name: The name of the group command. - :param commands: A dict mapping names to :class:`Command` objects. - Can also be a list of :class:`Command`, which will use - :attr:`Command.name` to create the dict. - :param attrs: Other command arguments described in - :class:`MultiCommand`, :class:`Command`, and - :class:`BaseCommand`. - - .. versionchanged:: 8.0 - The ``commands`` argument can be a list of command objects. +class _MultiCommand(Group, metaclass=_FakeSubclassCheck): + """ + .. deprecated:: 8.2 + Will be removed in Click 9.0. Use ``Group`` instead. """ - #: If set, this is used by the group's :meth:`command` decorator - #: as the default :class:`Command` class. This is useful to make all - #: subcommands use a custom command class. - #: - #: .. versionadded:: 8.0 - command_class: t.Optional[t.Type[Command]] = None - - #: If set, this is used by the group's :meth:`group` decorator - #: as the default :class:`Group` class. This is useful to make all - #: subgroups use a custom group class. - #: - #: If set to the special value :class:`type` (literally - #: ``group_class = type``), this group's class will be used as the - #: default class. This makes a custom group class continue to make - #: custom groups. - #: - #: .. versionadded:: 8.0 - group_class: t.Optional[t.Union[t.Type["Group"], t.Type[type]]] = None - # Literal[type] isn't valid, so use Type[type] - - def __init__( - self, - name: t.Optional[str] = None, - commands: t.Optional[ - t.Union[t.MutableMapping[str, Command], t.Sequence[Command]] - ] = None, - **attrs: t.Any, - ) -> None: - super().__init__(name, **attrs) - - if commands is None: - commands = {} - elif isinstance(commands, abc.Sequence): - commands = {c.name: c for c in commands if c.name is not None} - - #: The registered subcommands by their exported names. - self.commands: t.MutableMapping[str, Command] = commands - - def add_command(self, cmd: Command, name: t.Optional[str] = None) -> None: - """Registers another :class:`Command` with this group. If the name - is not provided, the name of the command is used. - """ - name = name or cmd.name - if name is None: - raise TypeError("Command has no name.") - _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 - calling :meth:`add_command`. - - 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 - - func: t.Optional[t.Callable[..., t.Any]] = None - - if args and callable(args[0]): - assert ( - len(args) == 1 and not kwargs - ), "Use 'command(**kwargs)(callable)' to provide arguments." - (func,) = args - args = () - - if self.command_class and kwargs.get("cls") is None: - kwargs["cls"] = self.command_class - - def decorator(f: t.Callable[..., t.Any]) -> Command: - 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 - calling :meth:`add_command`. - - 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 - - func: t.Optional[t.Callable[..., t.Any]] = 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 = 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]: - return self.commands.get(cmd_name) - - def list_commands(self, ctx: Context) -> t.List[str]: - return sorted(self.commands) +class CommandCollection(Group): + """A :class:`Group` that looks up subcommands on other groups. If a command + is not found on this group, each registered source is checked in order. + Parameters on a source are not added to this group, and a source's callback + is not invoked when invoking its commands. In other words, this "flattens" + commands in many groups into this one group. -class CommandCollection(MultiCommand): - """A command collection is a multi command that merges multiple multi - commands together into one. This is a straightforward implementation - that accepts a list of different multi commands as sources and - provides all the commands for each of them. + :param name: The name of the group command. + :param sources: A list of :class:`Group` objects to look up commands from. + :param kwargs: Other arguments passed to :class:`Group`. - See :class:`MultiCommand` and :class:`Command` for the description of - ``name`` and ``attrs``. + .. versionchanged:: 8.2 + This is a subclass of ``Group``. Commands are looked up first on this + group, then each of its sources. """ def __init__( self, - name: t.Optional[str] = None, - sources: t.Optional[t.List[MultiCommand]] = None, - **attrs: t.Any, + name: str | None = None, + sources: list[Group] | None = None, + **kwargs: t.Any, ) -> None: - super().__init__(name, **attrs) - #: The list of registered multi commands. - self.sources: t.List[MultiCommand] = sources or [] + super().__init__(name, **kwargs) + #: The list of registered groups. + self.sources: list[Group] = sources or [] + + def add_source(self, group: Group) -> None: + """Add a group as a source of commands.""" + self.sources.append(group) + + def get_command(self, ctx: Context, cmd_name: str) -> Command | None: + rv = super().get_command(ctx, cmd_name) - def add_source(self, multi_cmd: MultiCommand) -> None: - """Adds a new multi command to the chain dispatcher.""" - self.sources.append(multi_cmd) + if rv is not None: + return rv - def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: for source in self.sources: rv = source.get_command(ctx, cmd_name) if rv is not None: if self.chain: - _check_multicommand(self, cmd_name, rv) + _check_nested_chain(self, cmd_name, rv) return rv return None - def list_commands(self, ctx: Context) -> t.List[str]: - rv: t.Set[str] = set() + def list_commands(self, ctx: Context) -> list[str]: + rv: set[str] = set(super().list_commands(ctx)) for source in self.sources: rv.update(source.list_commands(ctx)) @@ -2004,7 +1958,7 @@ def list_commands(self, ctx: Context) -> t.List[str]: return sorted(rv) -def _check_iter(value: t.Any) -> t.Iterator[t.Any]: +def _check_iter(value: t.Any) -> cabc.Iterator[t.Any]: """Check if the value is iterable but not a string. Raises a type error, or return an iterator over the value. """ @@ -2055,6 +2009,22 @@ class Parameter: given. Takes ``ctx, param, incomplete`` and must return a list of :class:`~click.shell_completion.CompletionItem` or a list of strings. + :param deprecated: If ``True`` or non-empty string, issues a message + indicating that the argument is deprecated and highlights + its deprecation in --help. The message can be customized + by using a string as the value. A deprecated parameter + cannot be required, a ValueError will be raised otherwise. + + .. versionchanged:: 8.2.0 + Introduction of ``deprecated``. + + .. versionchanged:: 8.2 + Adding duplicate parameter names to a :class:`~click.core.Command` will + result in a ``UserWarning`` being shown. + + .. versionchanged:: 8.2 + Adding duplicate parameter names to a :class:`~click.core.Command` will + result in a ``UserWarning`` being shown. .. versionchanged:: 8.0 ``process_value`` validates required parameters and bounded @@ -2092,27 +2062,26 @@ class Parameter: def __init__( self, - param_decls: t.Optional[t.Sequence[str]] = None, - type: t.Optional[t.Union[types.ParamType, t.Any]] = None, + param_decls: cabc.Sequence[str] | None = None, + type: types.ParamType | t.Any | None = None, required: bool = False, - default: t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]] = None, - callback: t.Optional[t.Callable[[Context, "Parameter", t.Any], t.Any]] = None, - nargs: t.Optional[int] = None, + default: t.Any | t.Callable[[], t.Any] | None = None, + callback: t.Callable[[Context, Parameter, t.Any], t.Any] | None = None, + nargs: int | None = None, multiple: bool = False, - metavar: t.Optional[str] = None, + metavar: str | None = None, expose_value: bool = True, is_eager: bool = False, - envvar: t.Optional[t.Union[str, t.Sequence[str]]] = None, - shell_complete: t.Optional[ - t.Callable[ - [Context, "Parameter", str], - t.Union[t.List["CompletionItem"], t.List[str]], - ] - ] = None, + envvar: str | cabc.Sequence[str] | None = None, + shell_complete: t.Callable[ + [Context, Parameter, str], list[CompletionItem] | list[str] + ] + | None = None, + deprecated: bool | str = False, ) -> None: - self.name: t.Optional[str] - self.opts: t.List[str] - self.secondary_opts: t.List[str] + self.name: str | None + self.opts: list[str] + self.secondary_opts: list[str] self.name, self.opts, self.secondary_opts = self._parse_decls( param_decls or (), expose_value ) @@ -2136,6 +2105,7 @@ def __init__( self.metavar = metavar self.envvar = envvar self._custom_shell_complete = shell_complete + self.deprecated = deprecated if __debug__: if self.type.is_composite and nargs != self.type.arity: @@ -2178,7 +2148,14 @@ def __init__( f"'default' {subject} must match nargs={nargs}." ) - def to_info_dict(self) -> t.Dict[str, t.Any]: + if required and deprecated: + raise ValueError( + f"The {self.param_type_name} '{self.human_readable_name}' " + "is deprecated and still required. A deprecated " + f"{self.param_type_name} cannot be required." + ) + + def to_info_dict(self) -> dict[str, t.Any]: """Gather information that could be useful for a tool generating user-facing documentation. @@ -2204,8 +2181,8 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.name}>" def _parse_decls( - self, decls: t.Sequence[str], expose_value: bool - ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: + self, decls: cabc.Sequence[str], expose_value: bool + ) -> tuple[str | None, list[str], list[str]]: raise NotImplementedError() @property @@ -2215,11 +2192,11 @@ def human_readable_name(self) -> str: """ return self.name # type: ignore - def make_metavar(self) -> str: + def make_metavar(self, ctx: Context) -> str: if self.metavar is not None: return self.metavar - metavar = self.type.get_metavar(self) + metavar = self.type.get_metavar(param=self, ctx=ctx) if metavar is None: metavar = self.type.name.upper() @@ -2231,17 +2208,17 @@ def make_metavar(self) -> str: @t.overload def get_default( - self, ctx: Context, call: "te.Literal[True]" = True - ) -> t.Optional[t.Any]: ... + self, ctx: Context, call: t.Literal[True] = True + ) -> t.Any | None: ... @t.overload def get_default( self, ctx: Context, call: bool = ... - ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: ... + ) -> t.Any | t.Callable[[], t.Any] | None: ... def get_default( self, ctx: Context, call: bool = True - ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ) -> t.Any | t.Callable[[], t.Any] | None: """Get the default for the parameter. Tries :meth:`Context.lookup_default` first, then the local default. @@ -2272,12 +2249,12 @@ def get_default( return value - def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: + def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None: raise NotImplementedError() def consume_value( - self, ctx: Context, opts: t.Mapping[str, t.Any] - ) -> t.Tuple[t.Any, ParameterSource]: + self, ctx: Context, opts: cabc.Mapping[str, t.Any] + ) -> tuple[t.Any, ParameterSource]: value = opts.get(self.name) # type: ignore source = ParameterSource.COMMANDLINE @@ -2302,7 +2279,7 @@ def type_cast_value(self, ctx: Context, value: t.Any) -> t.Any: if value is None: return () if self.multiple or self.nargs == -1 else None - def check_iter(value: t.Any) -> t.Iterator[t.Any]: + def check_iter(value: t.Any) -> cabc.Iterator[t.Any]: try: return _check_iter(value) except TypeError: @@ -2320,12 +2297,12 @@ def convert(value: t.Any) -> t.Any: elif self.nargs == -1: - def convert(value: t.Any) -> t.Any: # t.Tuple[t.Any, ...] + def convert(value: t.Any) -> t.Any: # tuple[t.Any, ...] return tuple(self.type(x, self, ctx) for x in check_iter(value)) else: # nargs > 1 - def convert(value: t.Any) -> t.Any: # t.Tuple[t.Any, ...] + def convert(value: t.Any) -> t.Any: # tuple[t.Any, ...] value = tuple(check_iter(value)) if len(value) != self.nargs: @@ -2366,7 +2343,7 @@ def process_value(self, ctx: Context, value: t.Any) -> t.Any: return value - def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: + def resolve_envvar_value(self, ctx: Context) -> str | None: if self.envvar is None: return None @@ -2384,8 +2361,8 @@ def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: return None - def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: - rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx) + def value_from_envvar(self, ctx: Context) -> t.Any | None: + rv: t.Any | None = self.resolve_envvar_value(ctx) if rv is not None and self.nargs != 1: rv = self.type.split_envvar_value(rv) @@ -2393,10 +2370,33 @@ def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: return rv def handle_parse_result( - self, ctx: Context, opts: t.Mapping[str, t.Any], args: t.List[str] - ) -> t.Tuple[t.Any, t.List[str]]: + self, ctx: Context, opts: cabc.Mapping[str, t.Any], args: list[str] + ) -> tuple[t.Any, list[str]]: with augment_usage_errors(ctx, param=self): value, source = self.consume_value(ctx, opts) + + if ( + self.deprecated + and value is not None + and source + not in ( + ParameterSource.DEFAULT, + ParameterSource.DEFAULT_MAP, + ) + ): + extra_message = ( + f" {self.deprecated}" if isinstance(self.deprecated, str) else "" + ) + message = _( + "DeprecationWarning: The {param_type} {name!r} is deprecated." + "{extra_message}" + ).format( + param_type=self.param_type_name, + name=self.human_readable_name, + extra_message=extra_message, + ) + echo(style(message, fg="red"), err=True) + ctx.set_parameter_source(self.name, source) # type: ignore try: @@ -2412,10 +2412,10 @@ def handle_parse_result( return value, args - def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]: + def get_help_record(self, ctx: Context) -> tuple[str, str] | None: pass - def get_usage_pieces(self, ctx: Context) -> t.List[str]: + def get_usage_pieces(self, ctx: Context) -> list[str]: return [] def get_error_hint(self, ctx: Context) -> str: @@ -2425,7 +2425,7 @@ def get_error_hint(self, ctx: Context) -> str: hint_list = self.opts or [self.human_readable_name] return " / ".join(f"'{x}'" for x in hint_list) - def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]: """Return a list of completions for the incomplete value. If a ``shell_complete`` function was given during init, it is used. Otherwise, the :attr:`type` @@ -2444,7 +2444,7 @@ def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionIte results = [CompletionItem(c) for c in results] - return t.cast(t.List["CompletionItem"], results) + return t.cast("list[CompletionItem]", results) return self.type.shell_complete(ctx, self, incomplete) @@ -2463,11 +2463,12 @@ class Option(Parameter): 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. + shown on the help page and error messages. + 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. + will be the option name capitalized. A deprecated option cannot be + prompted. :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. @@ -2494,15 +2495,19 @@ class Option(Parameter): :param hidden: hide this option from help outputs. :param attrs: Other command arguments described in :class:`Parameter`. - .. versionchanged:: 8.1.0 + .. versionchanged:: 8.2 + ``envvar`` used with ``flag_value`` will always use the ``flag_value``, + previously it would use the value of the environment variable. + + .. versionchanged:: 8.1 Help text indentation is cleaned here instead of only in the ``@option`` decorator. - .. versionchanged:: 8.1.0 + .. versionchanged:: 8.1 The ``show_default`` parameter overrides ``Context.show_default``. - .. versionchanged:: 8.1.0 + .. versionchanged:: 8.1 The default of a single option boolean flag is not shown if the default value is ``False``. @@ -2514,40 +2519,51 @@ class Option(Parameter): def __init__( self, - param_decls: t.Optional[t.Sequence[str]] = None, - show_default: t.Union[bool, str, None] = None, - prompt: t.Union[bool, str] = False, - confirmation_prompt: t.Union[bool, str] = False, + param_decls: cabc.Sequence[str] | None = None, + show_default: bool | str | None = None, + prompt: bool | str = False, + confirmation_prompt: bool | str = False, prompt_required: bool = True, hide_input: bool = False, - is_flag: t.Optional[bool] = None, - flag_value: t.Optional[t.Any] = None, + is_flag: bool | None = None, + flag_value: t.Any | None = None, multiple: bool = False, count: bool = False, allow_from_autoenv: bool = True, - type: t.Optional[t.Union[types.ParamType, t.Any]] = None, - help: t.Optional[str] = None, + type: types.ParamType | t.Any | None = None, + help: str | None = None, hidden: bool = False, show_choices: bool = True, show_envvar: bool = False, + deprecated: bool | str = 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) + super().__init__( + param_decls, type=type, multiple=multiple, deprecated=deprecated, **attrs + ) if prompt is True: if self.name is None: raise TypeError("'name' is required with 'prompt=True'.") - prompt_text: t.Optional[str] = self.name.replace("_", " ").capitalize() + prompt_text: str | None = self.name.replace("_", " ").capitalize() elif prompt is False: prompt_text = None else: prompt_text = prompt + if deprecated: + deprecated_message = ( + f"(DEPRECATED: {deprecated})" + if isinstance(deprecated, str) + else "(DEPRECATED)" + ) + help = help + deprecated_message if help is not None else deprecated_message + self.prompt = prompt_text self.confirmation_prompt = confirmation_prompt self.prompt_required = prompt_required @@ -2573,7 +2589,7 @@ def __init__( # flag if flag_value is set. self._flag_needs_value = flag_value is not None - self.default: t.Union[t.Any, t.Callable[[], t.Any]] + self.default: t.Any | t.Callable[[], t.Any] if is_flag and default_is_missing and not self.required: if multiple: @@ -2581,11 +2597,10 @@ def __init__( else: self.default = False - if flag_value is None: - flag_value = not self.default - self.type: types.ParamType if is_flag and type is None: + if flag_value is None: + flag_value = not self.default # Re-guess the type from the flag value instead of the # default. self.type = types.convert_type(None, flag_value) @@ -2609,6 +2624,9 @@ def __init__( self.show_envvar = show_envvar if __debug__: + if deprecated and prompt: + raise ValueError("`deprecated` options cannot use `prompt`.") + if self.nargs == -1: raise TypeError("nargs=-1 is not supported for options.") @@ -2630,7 +2648,7 @@ def __init__( if self.is_flag: raise TypeError("'count' is not valid with 'is_flag'.") - def to_info_dict(self) -> t.Dict[str, t.Any]: + def to_info_dict(self) -> dict[str, t.Any]: info_dict = super().to_info_dict() info_dict.update( help=self.help, @@ -2642,9 +2660,15 @@ def to_info_dict(self) -> t.Dict[str, t.Any]: ) return info_dict + def get_error_hint(self, ctx: Context) -> str: + result = super().get_error_hint(ctx) + if self.show_envvar: + result += f" (env var: '{self.envvar}')" + return result + def _parse_decls( - self, decls: t.Sequence[str], expose_value: bool - ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: + self, decls: cabc.Sequence[str], expose_value: bool + ) -> tuple[str | None, list[str], list[str]]: opts = [] secondary_opts = [] name = None @@ -2661,7 +2685,7 @@ def _parse_decls( first, second = decl.split(split_char, 1) first = first.rstrip() if first: - possible_names.append(split_opt(first)) + possible_names.append(_split_opt(first)) opts.append(first) second = second.lstrip() if second: @@ -2672,7 +2696,7 @@ def _parse_decls( " same flag for true/false." ) else: - possible_names.append(split_opt(decl)) + possible_names.append(_split_opt(decl)) opts.append(decl) if name is None and possible_names: @@ -2697,7 +2721,7 @@ def _parse_decls( return name, opts, secondary_opts - def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: + def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None: if self.multiple: action = "append" elif self.count: @@ -2736,13 +2760,13 @@ def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: nargs=self.nargs, ) - def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]: + def get_help_record(self, ctx: Context) -> tuple[str, str] | None: if self.hidden: return None any_prefix_is_slash = False - def _write_opts(opts: t.Sequence[str]) -> str: + def _write_opts(opts: cabc.Sequence[str]) -> str: nonlocal any_prefix_is_slash rv, any_slashes = join_options(opts) @@ -2751,7 +2775,7 @@ def _write_opts(opts: t.Sequence[str]) -> str: any_prefix_is_slash = True if not self.is_flag and not self.count: - rv += f" {self.make_metavar()}" + rv += f" {self.make_metavar(ctx=ctx)}" return rv @@ -2761,7 +2785,28 @@ def _write_opts(opts: t.Sequence[str]) -> str: rv.append(_write_opts(self.secondary_opts)) help = self.help or "" - extra = [] + + extra = self.get_help_extra(ctx) + extra_items = [] + if "envvars" in extra: + extra_items.append( + _("env var: {var}").format(var=", ".join(extra["envvars"])) + ) + if "default" in extra: + extra_items.append(_("default: {default}").format(default=extra["default"])) + if "range" in extra: + extra_items.append(extra["range"]) + if "required" in extra: + extra_items.append(_(extra["required"])) + + if extra_items: + extra_str = "; ".join(extra_items) + help = f"{help} [{extra_str}]" if help else f"[{extra_str}]" + + return ("; " if any_prefix_is_slash else " / ").join(rv), help + + def get_help_extra(self, ctx: Context) -> types.OptionHelpExtra: + extra: types.OptionHelpExtra = {} if self.show_envvar: envvar = self.envvar @@ -2775,12 +2820,10 @@ def _write_opts(opts: t.Sequence[str]) -> str: envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" if envvar is not None: - var_str = ( - envvar - if isinstance(envvar, str) - else ", ".join(str(d) for d in envvar) - ) - extra.append(_("env var: {var}").format(var=var_str)) + if isinstance(envvar, str): + extra["envvars"] = (envvar,) + else: + extra["envvars"] = tuple(str(d) for d in envvar) # Temporarily enable resilient parsing to avoid type casting # failing for the default. Might be possible to extend this to @@ -2814,7 +2857,7 @@ def _write_opts(opts: t.Sequence[str]) -> str: elif self.is_bool_flag and self.secondary_opts: # For boolean flags that have distinct True/False opts, # use the opt without prefix instead of the value. - default_string = split_opt( + default_string = _split_opt( (self.opts if default_value else self.secondary_opts)[0] )[1] elif self.is_bool_flag and not self.secondary_opts and not default_value: @@ -2825,7 +2868,7 @@ def _write_opts(opts: t.Sequence[str]) -> str: default_string = str(default_value) if default_string: - extra.append(_("default: {default}").format(default=default_string)) + extra["default"] = default_string if ( isinstance(self.type, types._NumberRangeBase) @@ -2835,30 +2878,26 @@ def _write_opts(opts: t.Sequence[str]) -> str: range_str = self.type._describe_range() if range_str: - extra.append(range_str) + extra["range"] = range_str if self.required: - extra.append(_("required")) - - if extra: - extra_str = "; ".join(extra) - help = f"{help} [{extra_str}]" if help else f"[{extra_str}]" + extra["required"] = "required" - return ("; " if any_prefix_is_slash else " / ").join(rv), help + return extra @t.overload def get_default( - self, ctx: Context, call: "te.Literal[True]" = True - ) -> t.Optional[t.Any]: ... + self, ctx: Context, call: t.Literal[True] = True + ) -> t.Any | None: ... @t.overload def get_default( self, ctx: Context, call: bool = ... - ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: ... + ) -> t.Any | t.Callable[[], t.Any] | None: ... def get_default( self, ctx: Context, call: bool = True - ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ) -> t.Any | t.Callable[[], t.Any] | None: # 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 default one in which case we return the flag @@ -2888,6 +2927,12 @@ def prompt_for_value(self, ctx: Context) -> t.Any: if self.is_bool_flag: return confirm(self.prompt, default) + # If show_default is set to True/False, provide this to `prompt` as well. For + # non-bool values of `show_default`, we use `prompt`'s default behavior + prompt_kwargs: t.Any = {} + if isinstance(self.show_default, bool): + prompt_kwargs["show_default"] = self.show_default + return prompt( self.prompt, default=default, @@ -2896,12 +2941,15 @@ def prompt_for_value(self, ctx: Context) -> t.Any: show_choices=self.show_choices, confirmation_prompt=self.confirmation_prompt, value_proc=lambda x: self.process_value(ctx, x), + **prompt_kwargs, ) - def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: + def resolve_envvar_value(self, ctx: Context) -> str | None: rv = super().resolve_envvar_value(ctx) if rv is not None: + if self.is_flag and self.flag_value: + return str(self.flag_value) return rv if ( @@ -2917,8 +2965,8 @@ def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: return None - def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: - rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx) + def value_from_envvar(self, ctx: Context) -> t.Any | None: + rv: t.Any | None = self.resolve_envvar_value(ctx) if rv is None: return None @@ -2934,8 +2982,8 @@ def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: return rv def consume_value( - self, ctx: Context, opts: t.Mapping[str, "Parameter"] - ) -> t.Tuple[t.Any, ParameterSource]: + self, ctx: Context, opts: cabc.Mapping[str, Parameter] + ) -> tuple[t.Any, ParameterSource]: value, source = super().consume_value(ctx, opts) # The parser will emit a sentinel value if the option can be @@ -2983,8 +3031,8 @@ class Argument(Parameter): def __init__( self, - param_decls: t.Sequence[str], - required: t.Optional[bool] = None, + param_decls: cabc.Sequence[str], + required: bool | None = None, **attrs: t.Any, ) -> None: if required is None: @@ -3008,12 +3056,14 @@ def human_readable_name(self) -> str: return self.metavar return self.name.upper() # type: ignore - def make_metavar(self) -> str: + def make_metavar(self, ctx: Context) -> str: if self.metavar is not None: return self.metavar - var = self.type.get_metavar(self) + var = self.type.get_metavar(param=self, ctx=ctx) if not var: var = self.name.upper() # type: ignore + if self.deprecated: + var += "!" if not self.required: var = f"[{var}]" if self.nargs != 1: @@ -3021,8 +3071,8 @@ def make_metavar(self) -> str: return var def _parse_decls( - self, decls: t.Sequence[str], expose_value: bool - ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: + self, decls: cabc.Sequence[str], expose_value: bool + ) -> tuple[str | None, list[str], list[str]]: if not decls: if not expose_value: return None, [], [] @@ -3033,15 +3083,39 @@ def _parse_decls( else: raise TypeError( "Arguments take exactly one parameter declaration, got" - f" {len(decls)}." + f" {len(decls)}: {decls}." ) return name, [arg], [] - def get_usage_pieces(self, ctx: Context) -> t.List[str]: - return [self.make_metavar()] + def get_usage_pieces(self, ctx: Context) -> list[str]: + return [self.make_metavar(ctx)] def get_error_hint(self, ctx: Context) -> str: - return f"'{self.make_metavar()}'" + return f"'{self.make_metavar(ctx)}'" - def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: + def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) + + +def __getattr__(name: str) -> object: + import warnings + + if name == "BaseCommand": + warnings.warn( + "'BaseCommand' is deprecated and will be removed in Click 9.0. Use" + " 'Command' instead.", + DeprecationWarning, + stacklevel=2, + ) + return _BaseCommand + + if name == "MultiCommand": + warnings.warn( + "'MultiCommand' is deprecated and will be removed in Click 9.0. Use" + " 'Group' instead.", + DeprecationWarning, + stacklevel=2, + ) + return _MultiCommand + + raise AttributeError(name) diff --git a/src/click/decorators.py b/src/click/decorators.py index bcf8906e7..901f831ad 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import inspect -import types import typing as t +from collections import abc from functools import update_wrapper from gettext import gettext as _ @@ -21,35 +23,35 @@ R = t.TypeVar("R") T = t.TypeVar("T") _AnyCallable = t.Callable[..., t.Any] -FC = t.TypeVar("FC", bound=t.Union[_AnyCallable, Command]) +FC = t.TypeVar("FC", bound="_AnyCallable | Command") -def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]": +def pass_context(f: t.Callable[te.Concatenate[Context, P], R]) -> t.Callable[P, R]: """Marks a callback as wanting to receive the current context object as first argument. """ - def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R": + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: return f(get_current_context(), *args, **kwargs) return update_wrapper(new_func, f) -def pass_obj(f: "t.Callable[te.Concatenate[t.Any, P], R]") -> "t.Callable[P, R]": +def pass_obj(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]: """Similar to :func:`pass_context`, but only pass the object on the context onwards (:attr:`Context.obj`). This is useful if that object represents the state of a nested system. """ - def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R": + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: return f(get_current_context().obj, *args, **kwargs) return update_wrapper(new_func, f) def make_pass_decorator( - object_type: t.Type[T], ensure: bool = False -) -> t.Callable[["t.Callable[te.Concatenate[T, P], R]"], "t.Callable[P, R]"]: + object_type: type[T], ensure: bool = False +) -> t.Callable[[t.Callable[te.Concatenate[T, P], R]], t.Callable[P, R]]: """Given an object type this creates a decorator that will work similar to :func:`pass_obj` but instead of passing the object of the current context, it will find the innermost context of type @@ -72,11 +74,11 @@ def new_func(ctx, *args, **kwargs): remembered on the context if it's not there yet. """ - def decorator(f: "t.Callable[te.Concatenate[T, P], R]") -> "t.Callable[P, R]": - def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R": + def decorator(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]: + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: ctx = get_current_context() - obj: t.Optional[T] + obj: T | None if ensure: obj = ctx.ensure_object(object_type) else: @@ -97,8 +99,8 @@ def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R": def pass_meta_key( - key: str, *, doc_description: t.Optional[str] = None -) -> "t.Callable[[t.Callable[te.Concatenate[t.Any, P], R]], t.Callable[P, R]]": + key: str, *, doc_description: str | None = None +) -> t.Callable[[t.Callable[te.Concatenate[T, P], R]], t.Callable[P, R]]: """Create a decorator that passes a key from :attr:`click.Context.meta` as the first argument to the decorated function. @@ -111,8 +113,8 @@ def pass_meta_key( .. versionadded:: 8.0 """ - def decorator(f: "t.Callable[te.Concatenate[t.Any, P], R]") -> "t.Callable[P, R]": - def new_func(*args: "P.args", **kwargs: "P.kwargs") -> R: + def decorator(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]: + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: ctx = get_current_context() obj = ctx.meta[key] return ctx.invoke(f, obj, *args, **kwargs) @@ -141,8 +143,8 @@ def command(name: _AnyCallable) -> Command: ... # @command(namearg, CommandCls, ...) or @command(namearg, cls=CommandCls, ...) @t.overload def command( - name: t.Optional[str], - cls: t.Type[CmdType], + name: str | None, + cls: type[CmdType], **attrs: t.Any, ) -> t.Callable[[_AnyCallable], CmdType]: ... @@ -152,7 +154,7 @@ def command( def command( name: None = None, *, - cls: t.Type[CmdType], + cls: type[CmdType], **attrs: t.Any, ) -> t.Callable[[_AnyCallable], CmdType]: ... @@ -160,22 +162,23 @@ def command( # variant: with optional string name, no cls argument provided. @t.overload def command( - name: t.Optional[str] = ..., cls: None = None, **attrs: t.Any + name: str | None = ..., cls: None = None, **attrs: t.Any ) -> t.Callable[[_AnyCallable], Command]: ... def command( - name: t.Union[t.Optional[str], _AnyCallable] = None, - cls: t.Optional[t.Type[CmdType]] = None, + name: str | _AnyCallable | None = None, + cls: type[CmdType] | None = None, **attrs: t.Any, -) -> t.Union[Command, t.Callable[[_AnyCallable], t.Union[Command, CmdType]]]: +) -> Command | t.Callable[[_AnyCallable], Command | CmdType]: 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. - The name of the command defaults to the name of the function with - underscores replaced by dashes. If you want to change that, you can - pass the intended name as the first argument. + The name of the command defaults to the name of the function, converted to + lowercase, with underscores ``_`` replaced by dashes ``-``, and the suffixes + ``_command``, ``_cmd``, ``_group``, and ``_grp`` are removed. For example, + ``init_data_command`` becomes ``init-data``. All keyword arguments are forwarded to the underlying command class. For the ``params`` argument, any decorated params are appended to @@ -185,10 +188,13 @@ def command( that can be invoked as a command line utility or be attached to a command :class:`Group`. - :param name: the name of the command. This defaults to the function - name with underscores replaced by dashes. - :param cls: the command class to instantiate. This defaults to - :class:`Command`. + :param name: The name of the command. Defaults to modifying the function's + name as described above. + :param cls: The command class to create. Defaults to :class:`Command`. + + .. versionchanged:: 8.2 + The suffixes ``_command``, ``_cmd``, ``_group``, and ``_grp`` are + removed when generating the name. .. versionchanged:: 8.1 This decorator can be applied without parentheses. @@ -198,7 +204,7 @@ def command( appended to the end of the list. """ - func: t.Optional[t.Callable[[_AnyCallable], t.Any]] = None + func: t.Callable[[_AnyCallable], t.Any] | None = None if callable(name): func = name @@ -207,7 +213,7 @@ def command( assert not attrs, "Use 'command(**kwargs)(callable)' to provide arguments." if cls is None: - cls = t.cast(t.Type[CmdType], Command) + cls = t.cast("type[CmdType]", Command) def decorator(f: _AnyCallable) -> CmdType: if isinstance(f, Command): @@ -231,12 +237,16 @@ def decorator(f: _AnyCallable) -> CmdType: assert cls is not None assert not callable(name) - cmd = cls( - name=name or f.__name__.lower().replace("_", "-"), - callback=f, - params=params, - **attrs, - ) + if name is not None: + cmd_name = name + else: + cmd_name = f.__name__.lower().replace("_", "-") + cmd_left, sep, suffix = cmd_name.rpartition("-") + + if sep and suffix in {"command", "cmd", "group", "grp"}: + cmd_name = cmd_left + + cmd = cls(name=cmd_name, callback=f, params=params, **attrs) cmd.__doc__ = f.__doc__ return cmd @@ -258,8 +268,8 @@ def group(name: _AnyCallable) -> Group: ... # @group(namearg, GroupCls, ...) or @group(namearg, cls=GroupCls, ...) @t.overload def group( - name: t.Optional[str], - cls: t.Type[GrpType], + name: str | None, + cls: type[GrpType], **attrs: t.Any, ) -> t.Callable[[_AnyCallable], GrpType]: ... @@ -269,7 +279,7 @@ def group( def group( name: None = None, *, - cls: t.Type[GrpType], + cls: type[GrpType], **attrs: t.Any, ) -> t.Callable[[_AnyCallable], GrpType]: ... @@ -277,15 +287,15 @@ def group( # variant: with optional string name, no cls argument provided. @t.overload def group( - name: t.Optional[str] = ..., cls: None = None, **attrs: t.Any + name: str | None = ..., cls: None = None, **attrs: t.Any ) -> t.Callable[[_AnyCallable], Group]: ... def group( - name: t.Union[str, _AnyCallable, None] = None, - cls: t.Optional[t.Type[GrpType]] = None, + name: str | _AnyCallable | None = None, + cls: type[GrpType] | None = None, **attrs: t.Any, -) -> t.Union[Group, t.Callable[[_AnyCallable], t.Union[Group, GrpType]]]: +) -> Group | t.Callable[[_AnyCallable], Group | GrpType]: """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`. @@ -294,7 +304,7 @@ def group( This decorator can be applied without parentheses. """ if cls is None: - cls = t.cast(t.Type[GrpType], Group) + cls = t.cast("type[GrpType]", Group) if callable(name): return command(cls=cls, **attrs)(name) @@ -313,7 +323,7 @@ def _param_memo(f: t.Callable[..., t.Any], param: Parameter) -> None: def argument( - *param_decls: str, cls: t.Optional[t.Type[Argument]] = None, **attrs: t.Any + *param_decls: str, cls: type[Argument] | None = None, **attrs: t.Any ) -> t.Callable[[FC], FC]: """Attaches an argument to the command. All positional arguments are passed as parameter declarations to :class:`Argument`; all keyword @@ -341,7 +351,7 @@ def decorator(f: FC) -> FC: def option( - *param_decls: str, cls: t.Optional[t.Type[Option]] = None, **attrs: t.Any + *param_decls: str, cls: type[Option] | None = None, **attrs: t.Any ) -> t.Callable[[FC], FC]: """Attaches an option to the command. All positional arguments are passed as parameter declarations to :class:`Option`; all keyword @@ -410,11 +420,11 @@ def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: def version_option( - version: t.Optional[str] = None, + version: str | None = None, *param_decls: str, - package_name: t.Optional[str] = None, - prog_name: t.Optional[str] = None, - message: t.Optional[str] = None, + package_name: str | None = None, + prog_name: str | None = None, + message: str | None = None, **kwargs: t.Any, ) -> t.Callable[[FC], FC]: """Add a ``--version`` option which immediately prints the version @@ -422,8 +432,7 @@ def version_option( If ``version`` is not provided, Click will try to detect it using :func:`importlib.metadata.version` to get the version for the - ``package_name``. On Python < 3.8, the ``importlib_metadata`` - backport must be installed. + ``package_name``. If ``package_name`` is not provided, Click will try to detect it by inspecting the stack frames. This will be used to detect the @@ -484,17 +493,11 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: prog_name = ctx.find_root().info_name if version is None and package_name is not None: - metadata: t.Optional[types.ModuleType] - - try: - from importlib import metadata - except ImportError: - # Python < 3.8 - import importlib_metadata as metadata # type: ignore + import importlib.metadata try: - version = metadata.version(package_name) # type: ignore - except metadata.PackageNotFoundError: # type: ignore + version = importlib.metadata.version(package_name) + except importlib.metadata.PackageNotFoundError: raise RuntimeError( f"{package_name!r} is not installed. Try passing" " 'package_name' instead." @@ -529,7 +532,7 @@ class HelpOption(Option): def __init__( self, - param_decls: t.Optional[t.Sequence[str]] = None, + param_decls: abc.Sequence[str] | None = None, **kwargs: t.Any, ) -> None: if not param_decls: diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 0b8315166..f141a832e 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import collections.abc as cabc import typing as t from gettext import gettext as _ from gettext import ngettext @@ -13,9 +16,7 @@ from .core import Parameter -def _join_param_hints( - param_hint: t.Optional[t.Union[t.Sequence[str], str]], -) -> t.Optional[str]: +def _join_param_hints(param_hint: cabc.Sequence[str] | str | None) -> str | None: if param_hint is not None and not isinstance(param_hint, str): return " / ".join(repr(x) for x in param_hint) @@ -32,7 +33,7 @@ def __init__(self, message: str) -> None: super().__init__(message) # The context will be removed by the time we print the message, so cache # the color settings here to be used later on (in `show`) - self.show_color: t.Optional[bool] = resolve_color_default() + self.show_color: bool | None = resolve_color_default() self.message = message def format_message(self) -> str: @@ -41,7 +42,7 @@ def format_message(self) -> str: def __str__(self) -> str: return self.message - def show(self, file: t.Optional[t.IO[t.Any]] = None) -> None: + def show(self, file: t.IO[t.Any] | None = None) -> None: if file is None: file = get_text_stderr() @@ -63,12 +64,12 @@ class UsageError(ClickException): exit_code = 2 - def __init__(self, message: str, ctx: t.Optional["Context"] = None) -> None: + def __init__(self, message: str, ctx: Context | None = None) -> None: super().__init__(message) self.ctx = ctx - self.cmd: t.Optional[Command] = self.ctx.command if self.ctx else None + self.cmd: Command | None = self.ctx.command if self.ctx else None - def show(self, file: t.Optional[t.IO[t.Any]] = None) -> None: + def show(self, file: t.IO[t.Any] | None = None) -> None: if file is None: file = get_text_stderr() color = None @@ -112,9 +113,9 @@ class BadParameter(UsageError): def __init__( self, message: str, - ctx: t.Optional["Context"] = None, - param: t.Optional["Parameter"] = None, - param_hint: t.Optional[str] = None, + ctx: Context | None = None, + param: Parameter | None = None, + param_hint: str | None = None, ) -> None: super().__init__(message, ctx) self.param = param @@ -147,18 +148,18 @@ class MissingParameter(BadParameter): def __init__( self, - message: t.Optional[str] = None, - ctx: t.Optional["Context"] = None, - param: t.Optional["Parameter"] = None, - param_hint: t.Optional[str] = None, - param_type: t.Optional[str] = None, + message: str | None = None, + ctx: Context | None = None, + param: Parameter | None = None, + param_hint: str | None = None, + param_type: str | None = None, ) -> None: super().__init__(message or "", ctx, param, param_hint) self.param_type = param_type def format_message(self) -> str: if self.param_hint is not None: - param_hint: t.Optional[str] = self.param_hint + param_hint: str | None = self.param_hint elif self.param is not None: param_hint = self.param.get_error_hint(self.ctx) # type: ignore else: @@ -173,7 +174,9 @@ def format_message(self) -> str: msg = self.message if self.param is not None: - msg_extra = self.param.type.get_missing_message(self.param) + msg_extra = self.param.type.get_missing_message( + param=self.param, ctx=self.ctx + ) if msg_extra: if msg: msg += f". {msg_extra}" @@ -212,9 +215,9 @@ class NoSuchOption(UsageError): def __init__( self, option_name: str, - message: t.Optional[str] = None, - possibilities: t.Optional[t.Sequence[str]] = None, - ctx: t.Optional["Context"] = None, + message: str | None = None, + possibilities: cabc.Sequence[str] | None = None, + ctx: Context | None = None, ) -> None: if message is None: message = _("No such option: {name}").format(name=option_name) @@ -247,7 +250,7 @@ class BadOptionUsage(UsageError): """ def __init__( - self, option_name: str, message: str, ctx: t.Optional["Context"] = None + self, option_name: str, message: str, ctx: Context | None = None ) -> None: super().__init__(message, ctx) self.option_name = option_name @@ -262,10 +265,19 @@ class BadArgumentUsage(UsageError): """ +class NoArgsIsHelpError(UsageError): + def __init__(self, ctx: Context) -> None: + self.ctx: Context + super().__init__(ctx.get_help(), ctx=ctx) + + def show(self, file: t.IO[t.Any] | None = None) -> None: + echo(self.format_message(), file=file, err=True, color=self.ctx.color) + + class FileError(ClickException): """Raised if a file cannot be opened.""" - def __init__(self, filename: str, hint: t.Optional[str] = None) -> None: + def __init__(self, filename: str, hint: str | None = None) -> None: if hint is None: hint = _("unknown error") diff --git a/src/click/formatting.py b/src/click/formatting.py index ddd2a2f82..a6e78fe04 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -1,16 +1,18 @@ -import typing as t +from __future__ import annotations + +import collections.abc as cabc from contextlib import contextmanager from gettext import gettext as _ from ._compat import term_len -from .parser import split_opt +from .parser import _split_opt # Can force a width. This is used by the test system -FORCED_WIDTH: t.Optional[int] = None +FORCED_WIDTH: int | None = None -def measure_table(rows: t.Iterable[t.Tuple[str, str]]) -> t.Tuple[int, ...]: - widths: t.Dict[int, int] = {} +def measure_table(rows: cabc.Iterable[tuple[str, str]]) -> tuple[int, ...]: + widths: dict[int, int] = {} for row in rows: for idx, col in enumerate(row): @@ -20,8 +22,8 @@ def measure_table(rows: t.Iterable[t.Tuple[str, str]]) -> t.Tuple[int, ...]: def iter_rows( - rows: t.Iterable[t.Tuple[str, str]], col_count: int -) -> t.Iterator[t.Tuple[str, ...]]: + rows: cabc.Iterable[tuple[str, str]], col_count: int +) -> cabc.Iterator[tuple[str, ...]]: for row in rows: yield row + ("",) * (col_count - len(row)) @@ -63,8 +65,8 @@ def wrap_text( if not preserve_paragraphs: return wrapper.fill(text) - p: t.List[t.Tuple[int, bool, str]] = [] - buf: t.List[str] = [] + p: list[tuple[int, bool, str]] = [] + buf: list[str] = [] indent = None def _flush_par() -> None: @@ -114,8 +116,8 @@ class HelpFormatter: def __init__( self, indent_increment: int = 2, - width: t.Optional[int] = None, - max_width: t.Optional[int] = None, + width: int | None = None, + max_width: int | None = None, ) -> None: import shutil @@ -128,7 +130,7 @@ def __init__( width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50) self.width = width self.current_indent = 0 - self.buffer: t.List[str] = [] + self.buffer: list[str] = [] def write(self, string: str) -> None: """Writes a unicode string into the internal buffer.""" @@ -142,9 +144,7 @@ def dedent(self) -> None: """Decreases the indentation.""" self.current_indent -= self.indent_increment - def write_usage( - self, prog: str, args: str = "", prefix: t.Optional[str] = None - ) -> None: + def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> None: """Writes a usage line into the buffer. :param prog: the program name. @@ -209,7 +209,7 @@ def write_text(self, text: str) -> None: def write_dl( self, - rows: t.Sequence[t.Tuple[str, str]], + rows: cabc.Sequence[tuple[str, str]], col_max: int = 30, col_spacing: int = 2, ) -> None: @@ -252,7 +252,7 @@ def write_dl( self.write("\n") @contextmanager - def section(self, name: str) -> t.Iterator[None]: + def section(self, name: str) -> cabc.Iterator[None]: """Helpful context manager that writes a paragraph, a heading, and the indents. @@ -267,7 +267,7 @@ def section(self, name: str) -> t.Iterator[None]: self.dedent() @contextmanager - def indentation(self) -> t.Iterator[None]: + def indentation(self) -> cabc.Iterator[None]: """A context manager that increases the indentation.""" self.indent() try: @@ -280,7 +280,7 @@ def getvalue(self) -> str: return "".join(self.buffer) -def join_options(options: t.Sequence[str]) -> t.Tuple[str, bool]: +def join_options(options: cabc.Sequence[str]) -> tuple[str, bool]: """Given a list of option strings this joins them in the most appropriate way and returns them in the form ``(formatted_string, any_prefix_is_slash)`` where the second item in the tuple is a flag that @@ -290,7 +290,7 @@ def join_options(options: t.Sequence[str]) -> t.Tuple[str, bool]: any_prefix_is_slash = False for opt in options: - prefix = split_opt(opt)[0] + prefix = _split_opt(opt)[0] if prefix == "/": any_prefix_is_slash = True diff --git a/src/click/globals.py b/src/click/globals.py index 191e712db..a2f91723d 100644 --- a/src/click/globals.py +++ b/src/click/globals.py @@ -1,23 +1,23 @@ +from __future__ import annotations + import typing as t from threading import local if t.TYPE_CHECKING: - import typing_extensions as te - from .core import Context _local = local() @t.overload -def get_current_context(silent: "te.Literal[False]" = False) -> "Context": ... +def get_current_context(silent: t.Literal[False] = False) -> Context: ... @t.overload -def get_current_context(silent: bool = ...) -> t.Optional["Context"]: ... +def get_current_context(silent: bool = ...) -> Context | None: ... -def get_current_context(silent: bool = False) -> t.Optional["Context"]: +def get_current_context(silent: bool = False) -> Context | None: """Returns the current click context. This can be used as a way to access the current context object from anywhere. This is a more implicit alternative to the :func:`pass_context` decorator. This function is @@ -41,7 +41,7 @@ def get_current_context(silent: bool = False) -> t.Optional["Context"]: return None -def push_context(ctx: "Context") -> None: +def push_context(ctx: Context) -> None: """Pushes a new context to the current stack.""" _local.__dict__.setdefault("stack", []).append(ctx) @@ -51,7 +51,7 @@ def pop_context() -> None: _local.stack.pop() -def resolve_color_default(color: t.Optional[bool] = None) -> t.Optional[bool]: +def resolve_color_default(color: bool | None = None) -> bool | None: """Internal helper to get the default value of the color flag. If a value is passed it's returned unchanged, otherwise it's looked up from the current context. diff --git a/src/click/parser.py b/src/click/parser.py index 600b8436d..a8b7d2634 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -22,6 +22,9 @@ # maintained by the Python Software Foundation. # Copyright 2001-2006 Gregory P. Ward # Copyright 2002-2006 Python Software Foundation +from __future__ import annotations + +import collections.abc as cabc import typing as t from collections import deque from gettext import gettext as _ @@ -33,8 +36,6 @@ from .exceptions import UsageError if t.TYPE_CHECKING: - import typing_extensions as te - from .core import Argument as CoreArgument from .core import Context from .core import Option as CoreOption @@ -49,8 +50,8 @@ def _unpack_args( - args: t.Sequence[str], nargs_spec: t.Sequence[int] -) -> t.Tuple[t.Sequence[t.Union[str, t.Sequence[t.Optional[str]], None]], t.List[str]]: + args: cabc.Sequence[str], nargs_spec: cabc.Sequence[int] +) -> tuple[cabc.Sequence[str | cabc.Sequence[str | None] | None], list[str]]: """Given an iterable of arguments and an iterable of nargs specifications, it returns a tuple with all the unpacked arguments at the first index and all remaining arguments as the second. @@ -62,10 +63,10 @@ def _unpack_args( """ args = deque(args) nargs_spec = deque(nargs_spec) - rv: t.List[t.Union[str, t.Tuple[t.Optional[str], ...], None]] = [] - spos: t.Optional[int] = None + rv: list[str | tuple[str | None, ...] | None] = [] + spos: int | None = None - def _fetch(c: "te.Deque[V]") -> t.Optional[V]: + def _fetch(c: deque[V]) -> V | None: try: if spos is None: return c.popleft() @@ -108,7 +109,7 @@ def _fetch(c: "te.Deque[V]") -> t.Optional[V]: return tuple(rv), list(args) -def split_opt(opt: str) -> t.Tuple[str, str]: +def _split_opt(opt: str) -> tuple[str, str]: first = opt[:1] if first.isalnum(): return "", opt @@ -117,63 +118,29 @@ def split_opt(opt: str) -> t.Tuple[str, str]: return first, opt[1:] -def normalize_opt(opt: str, ctx: t.Optional["Context"]) -> str: +def _normalize_opt(opt: str, ctx: Context | None) -> str: if ctx is None or ctx.token_normalize_func is None: return opt - prefix, opt = split_opt(opt) + prefix, opt = _split_opt(opt) return f"{prefix}{ctx.token_normalize_func(opt)}" -def split_arg_string(string: str) -> t.List[str]: - """Split an argument string as with :func:`shlex.split`, but don't - fail if the string is incomplete. Ignores a missing closing quote or - incomplete escape sequence and uses the partial token as-is. - - .. code-block:: python - - split_arg_string("example 'my file") - ["example", "my file"] - - split_arg_string("example my\\") - ["example", "my"] - - :param string: String to split. - """ - import shlex - - lex = shlex.shlex(string, posix=True) - lex.whitespace_split = True - lex.commenters = "" - out = [] - - try: - for token in lex: - out.append(token) - except ValueError: - # Raised when end-of-string is reached in an invalid state. Use - # the partial token as-is. The quote or escape character is in - # lex.state, not lex.token. - out.append(lex.token) - - return out - - -class Option: +class _Option: def __init__( self, - obj: "CoreOption", - opts: t.Sequence[str], - dest: t.Optional[str], - action: t.Optional[str] = None, + obj: CoreOption, + opts: cabc.Sequence[str], + dest: str | None, + action: str | None = None, nargs: int = 1, - const: t.Optional[t.Any] = None, + const: t.Any | None = None, ): self._short_opts = [] self._long_opts = [] - self.prefixes: t.Set[str] = set() + self.prefixes: set[str] = set() for opt in opts: - prefix, value = split_opt(opt) + prefix, value = _split_opt(opt) if not prefix: raise ValueError(f"Invalid start character for option ({opt})") self.prefixes.add(prefix[0]) @@ -196,7 +163,7 @@ def __init__( def takes_value(self) -> bool: return self.action in ("store", "append") - def process(self, value: t.Any, state: "ParsingState") -> None: + def process(self, value: t.Any, state: _ParsingState) -> None: if self.action == "store": state.opts[self.dest] = value # type: ignore elif self.action == "store_const": @@ -212,16 +179,16 @@ def process(self, value: t.Any, state: "ParsingState") -> None: state.order.append(self.obj) -class Argument: - def __init__(self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1): +class _Argument: + def __init__(self, obj: CoreArgument, dest: str | None, nargs: int = 1): self.dest = dest self.nargs = nargs self.obj = obj def process( self, - value: t.Union[t.Optional[str], t.Sequence[t.Optional[str]]], - state: "ParsingState", + value: str | cabc.Sequence[str | None] | None, + state: _ParsingState, ) -> None: if self.nargs > 1: assert value is not None @@ -244,15 +211,15 @@ def process( state.order.append(self.obj) -class ParsingState: - def __init__(self, rargs: t.List[str]) -> None: - self.opts: t.Dict[str, t.Any] = {} - self.largs: t.List[str] = [] +class _ParsingState: + def __init__(self, rargs: list[str]) -> None: + self.opts: dict[str, t.Any] = {} + self.largs: list[str] = [] self.rargs = rargs - self.order: t.List[CoreParameter] = [] + self.order: list[CoreParameter] = [] -class OptionParser: +class _OptionParser: """The option parser is an internal class that is ultimately used to parse options and arguments. It's modelled after optparse and brings a similar but vastly simplified API. It should generally not be used @@ -264,9 +231,12 @@ class OptionParser: :param ctx: optionally the :class:`~click.Context` where this parser should go with. + + .. deprecated:: 8.2 + Will be removed in Click 9.0. """ - def __init__(self, ctx: t.Optional["Context"] = None) -> None: + def __init__(self, ctx: Context | None = None) -> None: #: The :class:`~click.Context` for this parser. This might be #: `None` for some advanced use cases. self.ctx = ctx @@ -285,19 +255,19 @@ def __init__(self, ctx: t.Optional["Context"] = None) -> None: self.allow_interspersed_args = ctx.allow_interspersed_args self.ignore_unknown_options = ctx.ignore_unknown_options - self._short_opt: t.Dict[str, Option] = {} - self._long_opt: t.Dict[str, Option] = {} + self._short_opt: dict[str, _Option] = {} + self._long_opt: dict[str, _Option] = {} self._opt_prefixes = {"-", "--"} - self._args: t.List[Argument] = [] + self._args: list[_Argument] = [] def add_option( self, - obj: "CoreOption", - opts: t.Sequence[str], - dest: t.Optional[str], - action: t.Optional[str] = None, + obj: CoreOption, + opts: cabc.Sequence[str], + dest: str | None, + action: str | None = None, nargs: int = 1, - const: t.Optional[t.Any] = None, + const: t.Any | None = None, ) -> None: """Adds a new option named `dest` to the parser. The destination is not inferred (unlike with optparse) and needs to be explicitly @@ -307,34 +277,32 @@ def add_option( The `obj` can be used to identify the option in the order list that is returned from the parser. """ - opts = [normalize_opt(opt, self.ctx) for opt in opts] - option = Option(obj, opts, dest, action=action, nargs=nargs, const=const) + opts = [_normalize_opt(opt, self.ctx) for opt in opts] + option = _Option(obj, opts, dest, action=action, nargs=nargs, const=const) self._opt_prefixes.update(option.prefixes) for opt in option._short_opts: self._short_opt[opt] = option for opt in option._long_opts: self._long_opt[opt] = option - def add_argument( - self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1 - ) -> None: + def add_argument(self, obj: CoreArgument, dest: str | None, nargs: int = 1) -> None: """Adds a positional argument named `dest` to the parser. The `obj` can be used to identify the option in the order list that is returned from the parser. """ - self._args.append(Argument(obj, dest=dest, nargs=nargs)) + self._args.append(_Argument(obj, dest=dest, nargs=nargs)) def parse_args( - self, args: t.List[str] - ) -> t.Tuple[t.Dict[str, t.Any], t.List[str], t.List["CoreParameter"]]: + self, args: list[str] + ) -> tuple[dict[str, t.Any], list[str], list[CoreParameter]]: """Parses positional arguments and returns ``(values, args, order)`` for the parsed options and arguments as well as the leftover arguments if there are any. The order is a list of objects as they appear on the command line. If arguments appear multiple times they will be memorized multiple times as well. """ - state = ParsingState(args) + state = _ParsingState(args) try: self._process_args_for_options(state) self._process_args_for_args(state) @@ -343,7 +311,7 @@ def parse_args( raise return state.opts, state.largs, state.order - def _process_args_for_args(self, state: ParsingState) -> None: + def _process_args_for_args(self, state: _ParsingState) -> None: pargs, args = _unpack_args( state.largs + state.rargs, [x.nargs for x in self._args] ) @@ -354,7 +322,7 @@ def _process_args_for_args(self, state: ParsingState) -> None: state.largs = args state.rargs = [] - def _process_args_for_options(self, state: ParsingState) -> None: + def _process_args_for_options(self, state: _ParsingState) -> None: while state.rargs: arg = state.rargs.pop(0) arglen = len(arg) @@ -391,7 +359,7 @@ def _process_args_for_options(self, state: ParsingState) -> None: # not a very interesting subset! def _match_long_opt( - self, opt: str, explicit_value: t.Optional[str], state: ParsingState + self, opt: str, explicit_value: str | None, state: _ParsingState ) -> None: if opt not in self._long_opt: from difflib import get_close_matches @@ -420,14 +388,14 @@ def _match_long_opt( option.process(value, state) - def _match_short_opt(self, arg: str, state: ParsingState) -> None: + def _match_short_opt(self, arg: str, state: _ParsingState) -> None: stop = False i = 1 prefix = arg[0] unknown_options = [] for ch in arg[1:]: - opt = normalize_opt(f"{prefix}{ch}", self.ctx) + opt = _normalize_opt(f"{prefix}{ch}", self.ctx) option = self._short_opt.get(opt) i += 1 @@ -461,7 +429,7 @@ def _match_short_opt(self, arg: str, state: ParsingState) -> None: state.largs.append(f"{prefix}{''.join(unknown_options)}") def _get_value_from_state( - self, option_name: str, option: Option, state: ParsingState + self, option_name: str, option: _Option, state: _ParsingState ) -> t.Any: nargs = option.nargs @@ -498,7 +466,7 @@ def _get_value_from_state( return value - def _process_opts(self, arg: str, state: ParsingState) -> None: + def _process_opts(self, arg: str, state: _ParsingState) -> None: explicit_value = None # Long option handling happens in two parts. The first part is # supporting explicitly attached values. In any case, we will try @@ -507,7 +475,7 @@ def _process_opts(self, arg: str, state: ParsingState) -> None: long_opt, explicit_value = arg.split("=", 1) else: long_opt = arg - norm_long_opt = normalize_opt(long_opt, self.ctx) + norm_long_opt = _normalize_opt(long_opt, self.ctx) # At this point we will match the (assumed) long option through # the long option matching code. Note that this allows options @@ -529,3 +497,36 @@ def _process_opts(self, arg: str, state: ParsingState) -> None: raise state.largs.append(arg) + + +def __getattr__(name: str) -> object: + import warnings + + if name in { + "OptionParser", + "Argument", + "Option", + "split_opt", + "normalize_opt", + "ParsingState", + }: + warnings.warn( + f"'parser.{name}' is deprecated and will be removed in Click 9.0." + " The old parser is available in 'optparse'.", + DeprecationWarning, + stacklevel=2, + ) + return globals()[f"_{name}"] + + if name == "split_arg_string": + from .shell_completion import split_arg_string + + warnings.warn( + "Importing 'parser.split_arg_string' is deprecated, it will only be" + " available in 'shell_completion' in Click 9.0.", + DeprecationWarning, + stacklevel=2, + ) + return split_arg_string + + raise AttributeError(name) diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index 9ed0bb12c..b29e442fe 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -1,22 +1,24 @@ +from __future__ import annotations + +import collections.abc as cabc import os import re import typing as t from gettext import gettext as _ from .core import Argument -from .core import BaseCommand +from .core import Command from .core import Context -from .core import MultiCommand +from .core import Group from .core import Option from .core import Parameter from .core import ParameterSource -from .parser import split_arg_string from .utils import echo def shell_complete( - cli: BaseCommand, - ctx_args: t.MutableMapping[str, t.Any], + cli: Command, + ctx_args: cabc.MutableMapping[str, t.Any], prog_name: str, complete_var: str, instruction: str, @@ -77,12 +79,12 @@ def __init__( self, value: t.Any, type: str = "plain", - help: t.Optional[str] = None, + help: str | None = None, **kwargs: t.Any, ) -> None: self.value: t.Any = value self.type: str = type - self.help: t.Optional[str] = help + self.help: str | None = help self._info = kwargs def __getattr__(self, name: str) -> t.Any: @@ -215,8 +217,8 @@ class ShellComplete: def __init__( self, - cli: BaseCommand, - ctx_args: t.MutableMapping[str, t.Any], + cli: Command, + ctx_args: cabc.MutableMapping[str, t.Any], prog_name: str, complete_var: str, ) -> None: @@ -233,7 +235,7 @@ def func_name(self) -> str: safe_name = re.sub(r"\W*", "", self.prog_name.replace("-", "_"), flags=re.ASCII) return f"_{safe_name}_completion" - def source_vars(self) -> t.Dict[str, t.Any]: + def source_vars(self) -> dict[str, t.Any]: """Vars for formatting :attr:`source_template`. By default this provides ``complete_func``, ``complete_var``, @@ -253,16 +255,14 @@ def source(self) -> str: """ return self.source_template % self.source_vars() - def get_completion_args(self) -> t.Tuple[t.List[str], str]: + def get_completion_args(self) -> tuple[list[str], str]: """Use the env vars defined by the shell script to return a tuple of ``args, incomplete``. This must be implemented by subclasses. """ raise NotImplementedError - def get_completions( - self, args: t.List[str], incomplete: str - ) -> t.List[CompletionItem]: + def get_completions(self, args: list[str], incomplete: str) -> list[CompletionItem]: """Determine the context and last complete command or parameter from the complete args. Call that object's ``shell_complete`` method to get the completions for the incomplete value. @@ -338,7 +338,7 @@ def source(self) -> str: self._check_version() return super().source() - def get_completion_args(self) -> t.Tuple[t.List[str], str]: + def get_completion_args(self) -> tuple[list[str], str]: cwords = split_arg_string(os.environ["COMP_WORDS"]) cword = int(os.environ["COMP_CWORD"]) args = cwords[1:cword] @@ -360,7 +360,7 @@ class ZshComplete(ShellComplete): name = "zsh" source_template = _SOURCE_ZSH - def get_completion_args(self) -> t.Tuple[t.List[str], str]: + def get_completion_args(self) -> tuple[list[str], str]: cwords = split_arg_string(os.environ["COMP_WORDS"]) cword = int(os.environ["COMP_CWORD"]) args = cwords[1:cword] @@ -382,7 +382,7 @@ class FishComplete(ShellComplete): name = "fish" source_template = _SOURCE_FISH - def get_completion_args(self) -> t.Tuple[t.List[str], str]: + def get_completion_args(self) -> tuple[list[str], str]: cwords = split_arg_string(os.environ["COMP_WORDS"]) incomplete = os.environ["COMP_CWORD"] args = cwords[1:] @@ -401,10 +401,10 @@ def format_completion(self, item: CompletionItem) -> str: return f"{item.type},{item.value}" -ShellCompleteType = t.TypeVar("ShellCompleteType", bound=t.Type[ShellComplete]) +ShellCompleteType = t.TypeVar("ShellCompleteType", bound="type[ShellComplete]") -_available_shells: t.Dict[str, t.Type[ShellComplete]] = { +_available_shells: dict[str, type[ShellComplete]] = { "bash": BashComplete, "fish": FishComplete, "zsh": ZshComplete, @@ -412,7 +412,7 @@ def format_completion(self, item: CompletionItem) -> str: def add_completion_class( - cls: ShellCompleteType, name: t.Optional[str] = None + cls: ShellCompleteType, name: str | None = None ) -> ShellCompleteType: """Register a :class:`ShellComplete` subclass under the given name. The name will be provided by the completion instruction environment @@ -431,7 +431,7 @@ def add_completion_class( return cls -def get_completion_class(shell: str) -> t.Optional[t.Type[ShellComplete]]: +def get_completion_class(shell: str) -> type[ShellComplete] | None: """Look up a registered :class:`ShellComplete` subclass by the name provided by the completion instruction environment variable. If the name isn't registered, returns ``None``. @@ -441,6 +441,43 @@ def get_completion_class(shell: str) -> t.Optional[t.Type[ShellComplete]]: return _available_shells.get(shell) +def split_arg_string(string: str) -> list[str]: + """Split an argument string as with :func:`shlex.split`, but don't + fail if the string is incomplete. Ignores a missing closing quote or + incomplete escape sequence and uses the partial token as-is. + + .. code-block:: python + + split_arg_string("example 'my file") + ["example", "my file"] + + split_arg_string("example my\\") + ["example", "my"] + + :param string: String to split. + + .. versionchanged:: 8.2 + Moved to ``shell_completion`` from ``parser``. + """ + import shlex + + lex = shlex.shlex(string, posix=True) + lex.whitespace_split = True + lex.commenters = "" + out = [] + + try: + for token in lex: + out.append(token) + except ValueError: + # Raised when end-of-string is reached in an invalid state. Use + # the partial token as-is. The quote or escape character is in + # lex.state, not lex.token. + out.append(lex.token) + + return out + + def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool: """Determine if the given parameter is an argument that can still accept values. @@ -475,7 +512,7 @@ def _start_of_option(ctx: Context, value: str) -> bool: return c in ctx._opt_prefixes -def _is_incomplete_option(ctx: Context, args: t.List[str], param: Parameter) -> bool: +def _is_incomplete_option(ctx: Context, args: 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. @@ -501,10 +538,10 @@ def _is_incomplete_option(ctx: Context, args: t.List[str], param: Parameter) -> def _resolve_context( - cli: BaseCommand, - ctx_args: t.MutableMapping[str, t.Any], + cli: Command, + ctx_args: cabc.MutableMapping[str, t.Any], prog_name: str, - args: t.List[str], + args: list[str], ) -> Context: """Produce the context hierarchy starting with the command and traversing the complete arguments. This only follows the commands, @@ -515,51 +552,55 @@ def _resolve_context( :param args: List of complete args before the incomplete value. """ ctx_args["resilient_parsing"] = True - ctx = cli.make_context(prog_name, args.copy(), **ctx_args) - args = ctx.protected_args + ctx.args - - while args: - command = ctx.command + with cli.make_context(prog_name, args.copy(), **ctx_args) as ctx: + args = ctx._protected_args + ctx.args - if isinstance(command, MultiCommand): - if not command.chain: - name, cmd, args = command.resolve_command(ctx, args) + while args: + command = ctx.command - if cmd is None: - return ctx - - ctx = cmd.make_context(name, args, parent=ctx, resilient_parsing=True) - args = ctx.protected_args + ctx.args - else: - sub_ctx = ctx - - while args: + if isinstance(command, Group): + if not command.chain: name, cmd, args = command.resolve_command(ctx, args) if cmd is None: return ctx - sub_ctx = cmd.make_context( - name, - args, - parent=ctx, - allow_extra_args=True, - allow_interspersed_args=False, - resilient_parsing=True, - ) - args = sub_ctx.args - - ctx = sub_ctx - args = [*sub_ctx.protected_args, *sub_ctx.args] - else: - break + with cmd.make_context( + name, args, parent=ctx, resilient_parsing=True + ) as sub_ctx: + args = ctx._protected_args + ctx.args + ctx = sub_ctx + else: + sub_ctx = ctx + + while args: + name, cmd, args = command.resolve_command(ctx, args) + + if cmd is None: + return ctx + + with cmd.make_context( + name, + args, + parent=ctx, + allow_extra_args=True, + allow_interspersed_args=False, + resilient_parsing=True, + ) as sub_sub_ctx: + args = sub_ctx.args + sub_ctx = sub_sub_ctx + + ctx = sub_ctx + args = [*sub_ctx._protected_args, *sub_ctx.args] + else: + break return ctx def _resolve_incomplete( - ctx: Context, args: t.List[str], incomplete: str -) -> t.Tuple[t.Union[BaseCommand, Parameter], str]: + ctx: Context, args: list[str], incomplete: str +) -> tuple[Command | Parameter, str]: """Find the Click object that will handle the completion of the incomplete value. Return the object and the incomplete value. diff --git a/src/click/termui.py b/src/click/termui.py index c084f1965..dcbb22216 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -1,8 +1,12 @@ +from __future__ import annotations + +import collections.abc as cabc import inspect import io import itertools import sys import typing as t +from contextlib import AbstractContextManager from gettext import gettext as _ from ._compat import isatty @@ -57,9 +61,9 @@ def _build_prompt( text: str, suffix: str, show_default: bool = False, - default: t.Optional[t.Any] = None, + default: t.Any | None = None, show_choices: bool = True, - type: t.Optional[ParamType] = None, + type: ParamType | None = None, ) -> str: prompt = text if type is not None and show_choices and isinstance(type, Choice): @@ -78,11 +82,11 @@ def _format_default(default: t.Any) -> t.Any: def prompt( text: str, - default: t.Optional[t.Any] = None, + default: t.Any | None = None, hide_input: bool = False, - confirmation_prompt: t.Union[bool, str] = False, - type: t.Optional[t.Union[ParamType, t.Any]] = None, - value_proc: t.Optional[t.Callable[[str], t.Any]] = None, + confirmation_prompt: bool | str = False, + type: ParamType | t.Any | None = None, + value_proc: t.Callable[[str], t.Any] | None = None, prompt_suffix: str = ": ", show_default: bool = True, err: bool = False, @@ -189,7 +193,7 @@ def prompt_func(text: str) -> str: def confirm( text: str, - default: t.Optional[bool] = False, + default: bool | None = False, abort: bool = False, prompt_suffix: str = ": ", show_default: bool = True, @@ -249,8 +253,8 @@ def confirm( def echo_via_pager( - text_or_generator: t.Union[t.Iterable[str], t.Callable[[], t.Iterable[str]], str], - color: t.Optional[bool] = None, + text_or_generator: cabc.Iterable[str] | t.Callable[[], cabc.Iterable[str]] | str, + color: bool | None = None, ) -> None: """This function takes a text and shows it via an environment specific pager on stdout. @@ -266,11 +270,11 @@ def echo_via_pager( color = resolve_color_default(color) if inspect.isgeneratorfunction(text_or_generator): - i = t.cast(t.Callable[[], t.Iterable[str]], text_or_generator)() + i = t.cast("t.Callable[[], cabc.Iterable[str]]", text_or_generator)() elif isinstance(text_or_generator, str): i = [text_or_generator] else: - i = iter(t.cast(t.Iterable[str], text_or_generator)) + i = iter(t.cast("cabc.Iterable[str]", text_or_generator)) # convert every element of i to a text type if necessary text_generator = (el if isinstance(el, str) else str(el) for el in i) @@ -280,23 +284,65 @@ def echo_via_pager( return pager(itertools.chain(text_generator, "\n"), color) +@t.overload +def progressbar( + *, + length: int, + label: str | None = None, + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: t.TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, +) -> ProgressBar[int]: ... + + +@t.overload +def progressbar( + iterable: cabc.Iterable[V] | None = None, + length: int | None = None, + label: str | None = None, + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + item_show_func: t.Callable[[V | None], str | None] | None = None, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: t.TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, +) -> ProgressBar[V]: ... + + def progressbar( - iterable: t.Optional[t.Iterable[V]] = None, - length: t.Optional[int] = None, - label: t.Optional[str] = None, + iterable: cabc.Iterable[V] | None = None, + length: int | None = None, + label: str | None = None, + hidden: bool = False, show_eta: bool = True, - show_percent: t.Optional[bool] = None, + show_percent: bool | None = None, show_pos: bool = False, - item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None, + item_show_func: t.Callable[[V | None], str | None] | None = None, fill_char: str = "#", empty_char: str = "-", bar_template: str = "%(label)s [%(bar)s] %(info)s", info_sep: str = " ", width: int = 36, - file: t.Optional[t.TextIO] = None, - color: t.Optional[bool] = None, + file: t.TextIO | None = None, + color: bool | None = None, update_min_steps: int = 1, -) -> "ProgressBar[V]": +) -> ProgressBar[V]: """This function creates an iterable context manager that can be used to iterate over something while showing a progress bar. It will either iterate over the `iterable` or `length` items (that are counted @@ -359,6 +405,9 @@ def progressbar( length. If an iterable is not provided the progress bar will iterate over a range of that length. :param label: the label to show next to the progress bar. + :param hidden: hide the progressbar. Defaults to ``False``. When no tty is + detected, it will only print the progressbar label. Setting this to + ``False`` also disables that. :param show_eta: enables or disables the estimated time display. This is automatically disabled if the length cannot be determined. @@ -391,6 +440,9 @@ def progressbar( :param update_min_steps: Render only when this many updates have completed. This allows tuning for very fast iterators. + .. versionadded:: 8.2 + The ``hidden`` argument. + .. versionchanged:: 8.0 Output is shown even if execution time is less than 0.5 seconds. @@ -402,11 +454,10 @@ def progressbar( in 7.0 that removed all output. .. versionadded:: 8.0 - Added the ``update_min_steps`` parameter. + The ``update_min_steps`` parameter. - .. versionchanged:: 4.0 - Added the ``color`` parameter. Added the ``update`` method to - the object. + .. versionadded:: 4.0 + The ``color`` parameter and ``update`` method. .. versionadded:: 2.0 """ @@ -416,6 +467,7 @@ def progressbar( return ProgressBar( iterable=iterable, length=length, + hidden=hidden, show_eta=show_eta, show_percent=show_percent, show_pos=show_pos, @@ -446,9 +498,7 @@ def clear() -> None: echo("\033[2J\033[1;1H", nl=False) -def _interpret_color( - color: t.Union[int, t.Tuple[int, int, int], str], offset: int = 0 -) -> str: +def _interpret_color(color: int | tuple[int, int, int] | str, offset: int = 0) -> str: if isinstance(color, int): return f"{38 + offset};5;{color:d}" @@ -461,16 +511,16 @@ def _interpret_color( def style( text: t.Any, - fg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, - bg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, - bold: t.Optional[bool] = None, - dim: t.Optional[bool] = None, - underline: t.Optional[bool] = None, - overline: t.Optional[bool] = None, - italic: t.Optional[bool] = None, - blink: t.Optional[bool] = None, - reverse: t.Optional[bool] = None, - strikethrough: t.Optional[bool] = None, + fg: int | tuple[int, int, int] | str | None = None, + bg: int | tuple[int, int, int] | str | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, reset: bool = True, ) -> str: """Styles a text with ANSI styles and returns the new string. By @@ -601,11 +651,11 @@ def unstyle(text: str) -> str: def secho( - message: t.Optional[t.Any] = None, - file: t.Optional[t.IO[t.AnyStr]] = None, + message: t.Any | None = None, + file: t.IO[t.AnyStr] | None = None, nl: bool = True, err: bool = False, - color: t.Optional[bool] = None, + color: bool | None = None, **styles: t.Any, ) -> None: """This function combines :func:`echo` and :func:`style` into one @@ -634,14 +684,45 @@ def secho( return echo(message, file=file, nl=nl, err=err, color=color) +@t.overload +def edit( + text: bytes | bytearray, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, + require_save: bool = False, + extension: str = ".txt", +) -> bytes | None: ... + + +@t.overload +def edit( + text: str, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, + require_save: bool = True, + extension: str = ".txt", +) -> str | None: ... + + +@t.overload def edit( - text: t.Optional[t.AnyStr] = None, - editor: t.Optional[str] = None, - env: t.Optional[t.Mapping[str, str]] = None, + text: None = None, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, require_save: bool = True, extension: str = ".txt", - filename: t.Optional[str] = None, -) -> t.Optional[t.AnyStr]: + filename: str | cabc.Iterable[str] | None = None, +) -> None: ... + + +def edit( + text: str | bytes | bytearray | None = None, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, + require_save: bool = True, + extension: str = ".txt", + filename: str | cabc.Iterable[str] | None = None, +) -> str | bytes | bytearray | None: r"""Edits the given text in the defined editor. If an editor is given (should be the full path to the executable but the regular operating system search path is used for finding the executable) it overrides @@ -667,7 +748,16 @@ def edit( highlighting. :param filename: if provided it will edit this file instead of the provided text contents. It will not use a temporary - file as an indirection in that case. + file as an indirection in that case. If the editor supports + editing multiple files at once, a sequence of files may be + passed as well. Invoke `click.file` once per file instead + if multiple files cannot be managed at once or editing the + files serially is desired. + + .. versionchanged:: 8.2.0 + ``filename`` now accepts any ``Iterable[str]`` in addition to a ``str`` + if the ``editor`` supports editing multiple files at once. + """ from ._termui_impl import Editor @@ -676,7 +766,10 @@ def edit( if filename is None: return ed.edit(text) - ed.edit_file(filename) + if isinstance(filename, str): + filename = (filename,) + + ed.edit_files(filenames=filename) return None @@ -711,7 +804,7 @@ def launch(url: str, wait: bool = False, locate: bool = False) -> int: # If this is provided, getchar() calls into this instead. This is used # for unittesting purposes. -_getchar: t.Optional[t.Callable[[bool], str]] = None +_getchar: t.Callable[[bool], str] | None = None def getchar(echo: bool = False) -> str: @@ -744,13 +837,13 @@ def getchar(echo: bool = False) -> str: return _getchar(echo) -def raw_terminal() -> t.ContextManager[int]: +def raw_terminal() -> AbstractContextManager[int]: from ._termui_impl import raw_terminal as f return f() -def pause(info: t.Optional[str] = None, err: bool = False) -> None: +def pause(info: str | None = None, err: bool = False) -> None: """This command stops execution and waits for the user to press any key to continue. This is similar to the Windows batch "pause" command. If the program is not run through a terminal, this command diff --git a/src/click/testing.py b/src/click/testing.py index 772b2159c..d19c103a9 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import collections.abc as cabc import contextlib import io import os @@ -15,7 +18,9 @@ from ._compat import _find_binary_reader if t.TYPE_CHECKING: - from .core import BaseCommand + from _typeshed import ReadableBuffer + + from .core import Command class EchoingStdin: @@ -42,10 +47,10 @@ def read1(self, n: int = -1) -> bytes: def readline(self, n: int = -1) -> bytes: return self._echo(self._input.readline(n)) - def readlines(self) -> t.List[bytes]: + def readlines(self) -> list[bytes]: return [self._echo(x) for x in self._input.readlines()] - def __iter__(self) -> t.Iterator[bytes]: + def __iter__(self) -> cabc.Iterator[bytes]: return iter(self._echo(x) for x in self._input) def __repr__(self) -> str: @@ -53,7 +58,7 @@ def __repr__(self) -> str: @contextlib.contextmanager -def _pause_echo(stream: t.Optional[EchoingStdin]) -> t.Iterator[None]: +def _pause_echo(stream: EchoingStdin | None) -> cabc.Iterator[None]: if stream is None: yield else: @@ -62,6 +67,39 @@ def _pause_echo(stream: t.Optional[EchoingStdin]) -> t.Iterator[None]: stream._paused = False +class BytesIOCopy(io.BytesIO): + """Patch ``io.BytesIO`` to let the written stream be copied to another. + + .. versionadded:: 8.2 + """ + + def __init__(self, copy_to: io.BytesIO) -> None: + super().__init__() + self.copy_to = copy_to + + def flush(self) -> None: + super().flush() + self.copy_to.flush() + + def write(self, b: ReadableBuffer) -> int: + self.copy_to.write(b) + return super().write(b) + + +class StreamMixer: + """Mixes `` and `` streams. + + The result is available in the ``output`` attribute. + + .. versionadded:: 8.2 + """ + + def __init__(self) -> None: + self.output: io.BytesIO = io.BytesIO() + self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output) + self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output) + + class _NamedTextIOWrapper(io.TextIOWrapper): def __init__( self, buffer: t.BinaryIO, name: str, mode: str, **kwargs: t.Any @@ -80,11 +118,11 @@ def mode(self) -> str: def make_input_stream( - input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]], charset: str + input: str | bytes | t.IO[t.Any] | None, charset: str ) -> t.BinaryIO: # Is already an input stream. if hasattr(input, "read"): - rv = _find_binary_reader(t.cast(t.IO[t.Any], input)) + rv = _find_binary_reader(t.cast("t.IO[t.Any]", input)) if rv is not None: return rv @@ -100,41 +138,59 @@ def make_input_stream( class Result: - """Holds the captured result of an invoked CLI script.""" + """Holds the captured result of an invoked CLI script. + + :param runner: The runner that created the result + :param stdout_bytes: The standard output as bytes. + :param stderr_bytes: The standard error as bytes. + :param output_bytes: A mix of ``stdout_bytes`` and ``stderr_bytes``, as the + user would see it in its terminal. + :param return_value: The value returned from the invoked command. + :param exit_code: The exit code as integer. + :param exception: The exception that happened if one did. + :param exc_info: Exception information (exception type, exception instance, + traceback type). + + .. versionchanged:: 8.2 + ``stderr_bytes`` no longer optional, ``output_bytes`` introduced and + ``mix_stderr`` has been removed. + + .. versionadded:: 8.0 + Added ``return_value``. + """ def __init__( self, - runner: "CliRunner", + runner: CliRunner, stdout_bytes: bytes, - stderr_bytes: t.Optional[bytes], + stderr_bytes: bytes, + output_bytes: bytes, return_value: t.Any, exit_code: int, - exception: t.Optional[BaseException], - exc_info: t.Optional[ - t.Tuple[t.Type[BaseException], BaseException, TracebackType] - ] = None, + exception: BaseException | None, + exc_info: tuple[type[BaseException], BaseException, TracebackType] + | None = None, ): - #: The runner that created the result self.runner = runner - #: The standard output as bytes. self.stdout_bytes = stdout_bytes - #: The standard error as bytes, or None if not available self.stderr_bytes = stderr_bytes - #: The value returned from the invoked command. - #: - #: .. versionadded:: 8.0 + self.output_bytes = output_bytes self.return_value = return_value - #: The exit code as integer. self.exit_code = exit_code - #: The exception that happened if one did. self.exception = exception - #: The traceback self.exc_info = exc_info @property def output(self) -> str: - """The (standard) output as unicode string.""" - return self.stdout + """The terminal output as unicode string, as the user would see it. + + .. versionchanged:: 8.2 + No longer a proxy for ``self.stdout``. Now has its own independent stream + that is mixing `` and ``, in the order they were written. + """ + return self.output_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) @property def stdout(self) -> str: @@ -145,9 +201,11 @@ def stdout(self) -> str: @property def stderr(self) -> str: - """The standard error as unicode string.""" - if self.stderr_bytes is None: - raise ValueError("stderr not separately captured") + """The standard error as unicode string. + + .. versionchanged:: 8.2 + No longer raise an exception, always returns the `` string. + """ return self.stderr_bytes.decode(self.runner.charset, "replace").replace( "\r\n", "\n" ) @@ -165,30 +223,33 @@ class CliRunner: :param charset: the character set for the input and output data. :param env: a dictionary with environment variables for overriding. - :param echo_stdin: if this is set to `True`, then reading from stdin writes - to stdout. This is useful for showing examples in + :param echo_stdin: if this is set to `True`, then reading from `` writes + to ``. This is useful for showing examples in some circumstances. Note that regular prompts will automatically echo the input. - :param mix_stderr: if this is set to `False`, then stdout and stderr are - preserved as independent streams. This is useful for - Unix-philosophy apps that have predictable stdout and - noisy stderr, such that each may be measured - independently + :param catch_exceptions: Whether to catch any exceptions other than + ``SystemExit`` when running :meth:`~CliRunner.invoke`. + + .. versionchanged:: 8.2 + Added the ``catch_exceptions`` parameter. + + .. versionchanged:: 8.2 + ``mix_stderr`` parameter has been removed. """ def __init__( self, charset: str = "utf-8", - env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, + env: cabc.Mapping[str, str | None] | None = None, echo_stdin: bool = False, - mix_stderr: bool = True, + catch_exceptions: bool = True, ) -> None: self.charset = charset - self.env: t.Mapping[str, t.Optional[str]] = env or {} + self.env: cabc.Mapping[str, str | None] = env or {} self.echo_stdin = echo_stdin - self.mix_stderr = mix_stderr + self.catch_exceptions = catch_exceptions - def get_default_prog_name(self, cli: "BaseCommand") -> str: + def get_default_prog_name(self, cli: Command) -> str: """Given a command object it will return the default program name for it. The default is the `name` attribute or ``"root"`` if not set. @@ -196,8 +257,8 @@ def get_default_prog_name(self, cli: "BaseCommand") -> str: return cli.name or "root" def make_env( - self, overrides: t.Optional[t.Mapping[str, t.Optional[str]]] = None - ) -> t.Mapping[str, t.Optional[str]]: + self, overrides: cabc.Mapping[str, str | None] | None = None + ) -> cabc.Mapping[str, str | None]: """Returns the environment overrides for invoking a script.""" rv = dict(self.env) if overrides: @@ -207,25 +268,32 @@ def make_env( @contextlib.contextmanager def isolation( self, - input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]] = None, - env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, + input: str | bytes | t.IO[t.Any] | None = None, + env: cabc.Mapping[str, str | None] | None = None, color: bool = False, - ) -> t.Iterator[t.Tuple[io.BytesIO, t.Optional[io.BytesIO]]]: + ) -> cabc.Iterator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]: """A context manager that sets up the isolation for invoking of a - command line tool. This sets up stdin with the given input data + command line tool. This sets up `` with the given input data and `os.environ` with the overrides from the given dictionary. This also rebinds some internals in Click to be mocked (like the prompt functionality). This is automatically done in the :meth:`invoke` method. - :param input: the input stream to put into sys.stdin. + :param input: the input stream to put into `sys.stdin`. :param env: the environment overrides as dictionary. :param color: whether the output should contain color codes. The application can still override this explicitly. + .. versionadded:: 8.2 + An additional output stream is returned, which is a mix of + `` and `` streams. + + .. versionchanged:: 8.2 + Always returns the `` stream. + .. versionchanged:: 8.0 - ``stderr`` is opened with ``errors="backslashreplace"`` + `` is opened with ``errors="backslashreplace"`` instead of the default ``"strict"``. .. versionchanged:: 4.0 @@ -242,11 +310,11 @@ def isolation( env = self.make_env(env) - bytes_output = io.BytesIO() + stream_mixer = StreamMixer() if self.echo_stdin: bytes_input = echo_input = t.cast( - t.BinaryIO, EchoingStdin(bytes_input, bytes_output) + t.BinaryIO, EchoingStdin(bytes_input, stream_mixer.stdout) ) sys.stdin = text_input = _NamedTextIOWrapper( @@ -259,24 +327,19 @@ def isolation( text_input._CHUNK_SIZE = 1 # type: ignore sys.stdout = _NamedTextIOWrapper( - bytes_output, encoding=self.charset, name="", mode="w" + stream_mixer.stdout, encoding=self.charset, name="", mode="w" ) - bytes_error = None - if self.mix_stderr: - sys.stderr = sys.stdout - else: - bytes_error = io.BytesIO() - sys.stderr = _NamedTextIOWrapper( - bytes_error, - encoding=self.charset, - name="", - mode="w", - errors="backslashreplace", - ) + sys.stderr = _NamedTextIOWrapper( + stream_mixer.stderr, + encoding=self.charset, + name="", + mode="w", + errors="backslashreplace", + ) @_pause_echo(echo_input) # type: ignore - def visible_input(prompt: t.Optional[str] = None) -> str: + def visible_input(prompt: str | None = None) -> str: sys.stdout.write(prompt or "") val = text_input.readline().rstrip("\r\n") sys.stdout.write(f"{val}\n") @@ -284,7 +347,7 @@ def visible_input(prompt: t.Optional[str] = None) -> str: return val @_pause_echo(echo_input) # type: ignore - def hidden_input(prompt: t.Optional[str] = None) -> str: + def hidden_input(prompt: str | None = None) -> str: sys.stdout.write(f"{prompt or ''}\n") sys.stdout.flush() return text_input.readline().rstrip("\r\n") @@ -302,7 +365,7 @@ def _getchar(echo: bool) -> str: default_color = color def should_strip_ansi( - stream: t.Optional[t.IO[t.Any]] = None, color: t.Optional[bool] = None + stream: t.IO[t.Any] | None = None, color: bool | None = None ) -> bool: if color is None: return not default_color @@ -330,7 +393,7 @@ def should_strip_ansi( pass else: os.environ[key] = value - yield (bytes_output, bytes_error) + yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output) finally: for key, value in old_env.items(): if value is None: @@ -352,11 +415,11 @@ def should_strip_ansi( def invoke( self, - cli: "BaseCommand", - args: t.Optional[t.Union[str, t.Sequence[str]]] = None, - input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]] = None, - env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, - catch_exceptions: bool = True, + cli: Command, + args: str | cabc.Sequence[str] | None = None, + input: str | bytes | t.IO[t.Any] | None = None, + env: cabc.Mapping[str, str | None] | None = None, + catch_exceptions: bool | None = None, color: bool = False, **extra: t.Any, ) -> Result: @@ -375,11 +438,20 @@ def invoke( :param input: the input data for `sys.stdin`. :param env: the environment overrides. :param catch_exceptions: Whether to catch any other exceptions than - ``SystemExit``. + ``SystemExit``. If :data:`None`, the value + from :class:`CliRunner` is used. :param extra: the keyword arguments to pass to :meth:`main`. :param color: whether the output should contain color codes. The application can still override this explicitly. + .. versionadded:: 8.2 + The result object has the ``output_bytes`` attribute with + the mix of ``stdout_bytes`` and ``stderr_bytes``, as the user would + see it in its terminal. + + .. versionchanged:: 8.2 + The result object always returns the ``stderr_bytes`` stream. + .. versionchanged:: 8.0 The result object has the ``return_value`` attribute with the value returned from the invoked command. @@ -395,9 +467,12 @@ def invoke( traceback if available. """ exc_info = None + if catch_exceptions is None: + catch_exceptions = self.catch_exceptions + with self.isolation(input=input, env=env, color=color) as outstreams: return_value = None - exception: t.Optional[BaseException] = None + exception: BaseException | None = None exit_code = 0 if isinstance(args, str): @@ -412,7 +487,7 @@ def invoke( return_value = cli.main(args=args or (), prog_name=prog_name, **extra) except SystemExit as e: exc_info = sys.exc_info() - e_code = t.cast(t.Optional[t.Union[int, t.Any]], e.code) + e_code = t.cast("int | t.Any | None", e.code) if e_code is None: e_code = 0 @@ -436,15 +511,14 @@ def invoke( finally: sys.stdout.flush() stdout = outstreams[0].getvalue() - if self.mix_stderr: - stderr = None - else: - stderr = outstreams[1].getvalue() # type: ignore + stderr = outstreams[1].getvalue() + output = outstreams[2].getvalue() return Result( runner=self, stdout_bytes=stdout, stderr_bytes=stderr, + output_bytes=output, return_value=return_value, exit_code=exit_code, exception=exception, @@ -453,8 +527,8 @@ def invoke( @contextlib.contextmanager def isolated_filesystem( - self, temp_dir: t.Optional[t.Union[str, "os.PathLike[str]"]] = None - ) -> t.Iterator[str]: + self, temp_dir: str | os.PathLike[str] | None = None + ) -> cabc.Iterator[str]: """A context manager that creates a temporary directory and changes the current working directory to it. This isolates tests that affect the contents of the CWD to prevent them from diff --git a/src/click/types.py b/src/click/types.py index c310377a0..d0a2715d2 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +import collections.abc as cabc +import enum import os import stat import sys @@ -20,6 +24,8 @@ from .core import Parameter from .shell_completion import CompletionItem +ParamTypeValue = t.TypeVar("ParamTypeValue") + class ParamType: """Represents the type of a parameter. Validates and converts values @@ -51,9 +57,9 @@ class ParamType: #: whitespace splits them up. The exception are paths and files which #: are split by ``os.path.pathsep`` by default (":" on Unix and ";" on #: Windows). - envvar_list_splitter: t.ClassVar[t.Optional[str]] = None + envvar_list_splitter: t.ClassVar[str | None] = None - def to_info_dict(self) -> t.Dict[str, t.Any]: + def to_info_dict(self) -> dict[str, t.Any]: """Gather information that could be useful for a tool generating user-facing documentation. @@ -77,16 +83,16 @@ def to_info_dict(self) -> t.Dict[str, t.Any]: def __call__( self, value: t.Any, - param: t.Optional["Parameter"] = None, - ctx: t.Optional["Context"] = None, + param: Parameter | None = None, + ctx: Context | None = None, ) -> t.Any: if value is not None: return self.convert(value, param, ctx) - def get_metavar(self, param: "Parameter") -> t.Optional[str]: + def get_metavar(self, param: Parameter, ctx: Context) -> str | None: """Returns the metavar default for this param if it provides one.""" - def get_missing_message(self, param: "Parameter") -> t.Optional[str]: + def get_missing_message(self, param: Parameter, ctx: Context | None) -> str | None: """Optionally might return extra information about a missing parameter. @@ -94,7 +100,7 @@ def get_missing_message(self, param: "Parameter") -> t.Optional[str]: """ def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: """Convert the value to the correct type. This is not called if the value is ``None`` (the missing value). @@ -117,7 +123,7 @@ def convert( """ return value - def split_envvar_value(self, rv: str) -> t.Sequence[str]: + def split_envvar_value(self, rv: str) -> cabc.Sequence[str]: """Given a value from an environment variable this splits it up into small chunks depending on the defined envvar list splitter. @@ -130,15 +136,15 @@ def split_envvar_value(self, rv: str) -> t.Sequence[str]: def fail( self, message: str, - param: t.Optional["Parameter"] = None, - ctx: t.Optional["Context"] = None, - ) -> "t.NoReturn": + param: Parameter | None = None, + ctx: Context | None = None, + ) -> t.NoReturn: """Helper method to fail with an invalid value message.""" raise BadParameter(message, ctx=ctx, param=param) def shell_complete( - self, ctx: "Context", param: "Parameter", incomplete: str - ) -> t.List["CompletionItem"]: + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: """Return a list of :class:`~click.shell_completion.CompletionItem` objects for the incomplete value. Most types do not provide completions, but @@ -167,13 +173,13 @@ def __init__(self, func: t.Callable[[t.Any], t.Any]) -> None: self.name: str = func.__name__ self.func = func - def to_info_dict(self) -> t.Dict[str, t.Any]: + def to_info_dict(self) -> dict[str, t.Any]: info_dict = super().to_info_dict() info_dict["func"] = self.func return info_dict def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: try: return self.func(value) @@ -190,7 +196,7 @@ class UnprocessedParamType(ParamType): name = "text" def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: return value @@ -202,7 +208,7 @@ class StringParamType(ParamType): name = "text" def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: if isinstance(value, bytes): enc = _get_argv_encoding() @@ -224,37 +230,91 @@ def __repr__(self) -> str: return "STRING" -class Choice(ParamType): +class Choice(ParamType, t.Generic[ParamTypeValue]): """The choice type allows a value to be checked against a fixed set - of supported values. All of these values have to be strings. + of supported values. - You should only pass a list or tuple of choices. Other iterables - (like generators) may lead to surprising results. + You may pass any iterable value which will be converted to a tuple + and thus will only be iterated once. - The resulting value will always be one of the originally passed choices - regardless of ``case_sensitive`` or any ``ctx.token_normalize_func`` - being specified. - - See :ref:`choice-opts` for an example. + The resulting value will always be one of the originally passed choices. + See :meth:`normalize_choice` for more info on the mapping of strings + to choices. See :ref:`choice-opts` for an example. :param case_sensitive: Set to false to make choices case insensitive. Defaults to true. + + .. versionchanged:: 8.2.0 + Non-``str`` ``choices`` are now supported. It can additionally be any + iterable. Before you were not recommended to pass anything but a list or + tuple. + + .. versionadded:: 8.2.0 + Choice normalization can be overridden via :meth:`normalize_choice`. """ name = "choice" - def __init__(self, choices: t.Sequence[str], case_sensitive: bool = True) -> None: - self.choices = choices + def __init__( + self, choices: cabc.Iterable[ParamTypeValue], case_sensitive: bool = True + ) -> None: + self.choices: cabc.Sequence[ParamTypeValue] = tuple(choices) self.case_sensitive = case_sensitive - def to_info_dict(self) -> t.Dict[str, t.Any]: + def to_info_dict(self) -> dict[str, t.Any]: info_dict = super().to_info_dict() info_dict["choices"] = self.choices info_dict["case_sensitive"] = self.case_sensitive return info_dict - def get_metavar(self, param: "Parameter") -> str: - choices_str = "|".join(self.choices) + def _normalized_mapping( + self, ctx: Context | None = None + ) -> cabc.Mapping[ParamTypeValue, str]: + """ + Returns mapping where keys are the original choices and the values are + the normalized values that are accepted via the command line. + + This is a simple wrapper around :meth:`normalize_choice`, use that + instead which is supported. + """ + return { + choice: self.normalize_choice( + choice=choice, + ctx=ctx, + ) + for choice in self.choices + } + + def normalize_choice(self, choice: ParamTypeValue, ctx: Context | None) -> str: + """ + Normalize a choice value, used to map a passed string to a choice. + Each choice must have a unique normalized value. + + By default uses :meth:`Context.token_normalize_func` and if not case + sensitive, convert it to a casefolded value. + + .. versionadded:: 8.2.0 + """ + normed_value = choice.name if isinstance(choice, enum.Enum) else str(choice) + + if ctx is not None and ctx.token_normalize_func is not None: + normed_value = ctx.token_normalize_func(normed_value) + + if not self.case_sensitive: + normed_value = normed_value.casefold() + + return normed_value + + def get_metavar(self, param: Parameter, ctx: Context) -> str | None: + if param.param_type_name == "option" and not param.show_choices: # type: ignore + choice_metavars = [ + convert_type(type(choice)).name.upper() for choice in self.choices + ] + choices_str = "|".join([*dict.fromkeys(choice_metavars)]) + else: + choices_str = "|".join( + [str(i) for i in self._normalized_mapping(ctx=ctx).values()] + ) # Use curly braces to indicate a required argument. if param.required and param.param_type_name == "argument": @@ -263,53 +323,60 @@ def get_metavar(self, param: "Parameter") -> str: # Use square braces to indicate an option or optional argument. return f"[{choices_str}]" - def get_missing_message(self, param: "Parameter") -> str: - return _("Choose from:\n\t{choices}").format(choices=",\n\t".join(self.choices)) + def get_missing_message(self, param: Parameter, ctx: Context | None) -> str: + """ + Message shown when no choice is passed. + + .. versionchanged:: 8.2.0 Added ``ctx`` argument. + """ + return _("Choose from:\n\t{choices}").format( + choices=",\n\t".join(self._normalized_mapping(ctx=ctx).values()) + ) def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] - ) -> t.Any: - # Match through normalization and case sensitivity - # first do token_normalize_func, then lowercase - # preserve original `value` to produce an accurate message in - # `self.fail` - normed_value = value - normed_choices = {choice: choice for choice in self.choices} + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> ParamTypeValue: + """ + For a given value from the parser, normalize it and find its + matching normalized value in the list of choices. Then return the + matched "original" choice. + """ + normed_value = self.normalize_choice(choice=value, ctx=ctx) + normalized_mapping = self._normalized_mapping(ctx=ctx) - if ctx is not None and ctx.token_normalize_func is not None: - normed_value = ctx.token_normalize_func(value) - normed_choices = { - ctx.token_normalize_func(normed_choice): original - for normed_choice, original in normed_choices.items() - } + try: + return next( + original + for original, normalized in normalized_mapping.items() + if normalized == normed_value + ) + except StopIteration: + self.fail( + self.get_invalid_choice_message(value=value, ctx=ctx), + param=param, + ctx=ctx, + ) - if not self.case_sensitive: - normed_value = normed_value.casefold() - normed_choices = { - normed_choice.casefold(): original - for normed_choice, original in normed_choices.items() - } + def get_invalid_choice_message(self, value: t.Any, ctx: Context | None) -> str: + """Get the error message when the given choice is invalid. - if normed_value in normed_choices: - return normed_choices[normed_value] + :param value: The invalid value. - choices_str = ", ".join(map(repr, self.choices)) - self.fail( - ngettext( - "{value!r} is not {choice}.", - "{value!r} is not one of {choices}.", - len(self.choices), - ).format(value=value, choice=choices_str, choices=choices_str), - param, - ctx, - ) + .. versionadded:: 8.2 + """ + choices_str = ", ".join(map(repr, self._normalized_mapping(ctx=ctx).values())) + return ngettext( + "{value!r} is not {choice}.", + "{value!r} is not one of {choices}.", + len(self.choices), + ).format(value=value, choice=choices_str, choices=choices_str) def __repr__(self) -> str: return f"Choice({list(self.choices)})" def shell_complete( - self, ctx: "Context", param: "Parameter", incomplete: str - ) -> t.List["CompletionItem"]: + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: """Complete choices that start with the incomplete value. :param ctx: Invocation context for this command. @@ -354,29 +421,29 @@ class DateTime(ParamType): name = "datetime" - def __init__(self, formats: t.Optional[t.Sequence[str]] = None): - self.formats: t.Sequence[str] = formats or [ + def __init__(self, formats: cabc.Sequence[str] | None = None): + self.formats: cabc.Sequence[str] = formats or [ "%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", ] - def to_info_dict(self) -> t.Dict[str, t.Any]: + def to_info_dict(self) -> dict[str, t.Any]: info_dict = super().to_info_dict() info_dict["formats"] = self.formats return info_dict - def get_metavar(self, param: "Parameter") -> str: + def get_metavar(self, param: Parameter, ctx: Context) -> str | None: return f"[{'|'.join(self.formats)}]" - def _try_to_convert_date(self, value: t.Any, format: str) -> t.Optional[datetime]: + def _try_to_convert_date(self, value: t.Any, format: str) -> datetime | None: try: return datetime.strptime(value, format) except ValueError: return None def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: if isinstance(value, datetime): return value @@ -403,10 +470,10 @@ def __repr__(self) -> str: class _NumberParamTypeBase(ParamType): - _number_class: t.ClassVar[t.Type[t.Any]] + _number_class: t.ClassVar[type[t.Any]] def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: try: return self._number_class(value) @@ -423,8 +490,8 @@ def convert( class _NumberRangeBase(_NumberParamTypeBase): def __init__( self, - min: t.Optional[float] = None, - max: t.Optional[float] = None, + min: float | None = None, + max: float | None = None, min_open: bool = False, max_open: bool = False, clamp: bool = False, @@ -435,7 +502,7 @@ def __init__( self.max_open = max_open self.clamp = clamp - def to_info_dict(self) -> t.Dict[str, t.Any]: + def to_info_dict(self) -> dict[str, t.Any]: info_dict = super().to_info_dict() info_dict.update( min=self.min, @@ -447,7 +514,7 @@ def to_info_dict(self) -> t.Dict[str, t.Any]: return info_dict def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: import operator @@ -477,7 +544,7 @@ def convert( return rv - def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float: + def _clamp(self, bound: float, dir: t.Literal[1, -1], open: bool) -> float: """Find the valid value to clamp to bound in the given direction. @@ -532,7 +599,7 @@ class IntRange(_NumberRangeBase, IntParamType): name = "integer range" def _clamp( # type: ignore - self, bound: int, dir: "te.Literal[1, -1]", open: bool + self, bound: int, dir: t.Literal[1, -1], open: bool ) -> int: if not open: return bound @@ -568,8 +635,8 @@ class FloatRange(_NumberRangeBase, FloatParamType): def __init__( self, - min: t.Optional[float] = None, - max: t.Optional[float] = None, + min: float | None = None, + max: float | None = None, min_open: bool = False, max_open: bool = False, clamp: bool = False, @@ -581,7 +648,7 @@ def __init__( if (min_open or max_open) and clamp: raise TypeError("Clamping is not supported for open bounds.") - def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float: + def _clamp(self, bound: float, dir: t.Literal[1, -1], open: bool) -> float: if not open: return bound @@ -595,7 +662,7 @@ class BoolParamType(ParamType): name = "boolean" def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: if value in {False, True}: return bool(value) @@ -620,7 +687,7 @@ class UUIDParameterType(ParamType): name = "uuid" def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: import uuid @@ -673,9 +740,9 @@ class File(ParamType): def __init__( self, mode: str = "r", - encoding: t.Optional[str] = None, - errors: t.Optional[str] = "strict", - lazy: t.Optional[bool] = None, + encoding: str | None = None, + errors: str | None = "strict", + lazy: bool | None = None, atomic: bool = False, ) -> None: self.mode = mode @@ -684,12 +751,12 @@ def __init__( self.lazy = lazy self.atomic = atomic - def to_info_dict(self) -> t.Dict[str, t.Any]: + def to_info_dict(self) -> dict[str, t.Any]: info_dict = super().to_info_dict() info_dict.update(mode=self.mode, encoding=self.encoding) return info_dict - def resolve_lazy_flag(self, value: "t.Union[str, os.PathLike[str]]") -> bool: + def resolve_lazy_flag(self, value: str | os.PathLike[str]) -> bool: if self.lazy is not None: return self.lazy if os.fspath(value) == "-": @@ -700,14 +767,14 @@ def resolve_lazy_flag(self, value: "t.Union[str, os.PathLike[str]]") -> bool: def convert( self, - value: t.Union[str, "os.PathLike[str]", t.IO[t.Any]], - param: t.Optional["Parameter"], - ctx: t.Optional["Context"], + value: str | os.PathLike[str] | t.IO[t.Any], + param: Parameter | None, + ctx: Context | None, ) -> t.IO[t.Any]: if _is_file_like(value): return value - value = t.cast("t.Union[str, os.PathLike[str]]", value) + value = t.cast("str | os.PathLike[str]", value) try: lazy = self.resolve_lazy_flag(value) @@ -720,7 +787,7 @@ def convert( if ctx is not None: ctx.call_on_close(lf.close_intelligently) - return t.cast(t.IO[t.Any], lf) + return t.cast("t.IO[t.Any]", lf) f, should_close = open_stream( value, self.mode, self.encoding, self.errors, atomic=self.atomic @@ -742,8 +809,8 @@ def convert( self.fail(f"'{format_filename(value)}': {e.strerror}", param, ctx) def shell_complete( - self, ctx: "Context", param: "Parameter", incomplete: str - ) -> t.List["CompletionItem"]: + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: """Return a special completion marker that tells the completion system to use the shell to provide file path completions. @@ -758,7 +825,7 @@ def shell_complete( return [CompletionItem(incomplete, type="file")] -def _is_file_like(value: t.Any) -> "te.TypeGuard[t.IO[t.Any]]": +def _is_file_like(value: t.Any) -> te.TypeGuard[t.IO[t.Any]]: return hasattr(value, "read") or hasattr(value, "write") @@ -806,7 +873,7 @@ def __init__( readable: bool = True, resolve_path: bool = False, allow_dash: bool = False, - path_type: t.Optional[t.Type[t.Any]] = None, + path_type: type[t.Any] | None = None, executable: bool = False, ): self.exists = exists @@ -826,7 +893,7 @@ def __init__( else: self.name = _("path") - def to_info_dict(self) -> t.Dict[str, t.Any]: + def to_info_dict(self) -> dict[str, t.Any]: info_dict = super().to_info_dict() info_dict.update( exists=self.exists, @@ -839,8 +906,8 @@ def to_info_dict(self) -> t.Dict[str, t.Any]: return info_dict def coerce_path_result( - self, value: "t.Union[str, os.PathLike[str]]" - ) -> "t.Union[str, bytes, os.PathLike[str]]": + self, value: str | os.PathLike[str] + ) -> str | bytes | os.PathLike[str]: if self.type is not None and not isinstance(value, self.type): if self.type is str: return os.fsdecode(value) @@ -853,21 +920,17 @@ def coerce_path_result( def convert( self, - value: "t.Union[str, os.PathLike[str]]", - param: t.Optional["Parameter"], - ctx: t.Optional["Context"], - ) -> "t.Union[str, bytes, os.PathLike[str]]": + value: str | os.PathLike[str], + param: Parameter | None, + ctx: Context | None, + ) -> str | bytes | os.PathLike[str]: rv = value is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") if not is_dash: if self.resolve_path: - # os.path.realpath doesn't resolve symlinks on Windows - # until Python 3.8. Use pathlib for now. - import pathlib - - rv = os.fsdecode(pathlib.Path(rv).resolve()) + rv = os.path.realpath(rv) try: st = os.stat(rv) @@ -929,8 +992,8 @@ def convert( return self.coerce_path_result(rv) def shell_complete( - self, ctx: "Context", param: "Parameter", incomplete: str - ) -> t.List["CompletionItem"]: + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: """Return a special completion marker that tells the completion system to use the shell to provide path completions for only directories or any paths. @@ -961,10 +1024,10 @@ class Tuple(CompositeParamType): :param types: a list of types that should be used for the tuple items. """ - def __init__(self, types: t.Sequence[t.Union[t.Type[t.Any], ParamType]]) -> None: - self.types: t.Sequence[ParamType] = [convert_type(ty) for ty in types] + def __init__(self, types: cabc.Sequence[type[t.Any] | ParamType]) -> None: + self.types: cabc.Sequence[ParamType] = [convert_type(ty) for ty in types] - def to_info_dict(self) -> t.Dict[str, t.Any]: + def to_info_dict(self) -> dict[str, t.Any]: info_dict = super().to_info_dict() info_dict["types"] = [t.to_info_dict() for t in self.types] return info_dict @@ -978,7 +1041,7 @@ def arity(self) -> int: # type: ignore return len(self.types) def convert( - self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + self, value: t.Any, param: Parameter | None, ctx: Context | None ) -> t.Any: len_type = len(self.types) len_value = len(value) @@ -997,7 +1060,7 @@ def convert( return tuple(ty(x, param, ctx) for ty, x in zip(self.types, value)) -def convert_type(ty: t.Optional[t.Any], default: t.Optional[t.Any] = None) -> ParamType: +def convert_type(ty: t.Any | None, default: t.Any | None = None) -> ParamType: """Find the most appropriate :class:`ParamType` for the given Python type. If the type isn't provided, it can be inferred from a default value. @@ -1088,3 +1151,10 @@ def convert_type(ty: t.Optional[t.Any], default: t.Optional[t.Any] = None) -> Pa #: A UUID parameter. UUID = UUIDParameterType() + + +class OptionHelpExtra(t.TypedDict, total=False): + envvars: tuple[str, ...] + default: str + range: str + required: str diff --git a/src/click/utils.py b/src/click/utils.py index 836c6f21a..ab2fe5889 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import collections.abc as cabc import os import re import sys @@ -30,10 +33,10 @@ def _posixify(name: str) -> str: return "-".join(name.split()).lower() -def safecall(func: "t.Callable[P, R]") -> "t.Callable[P, t.Optional[R]]": +def safecall(func: t.Callable[P, R]) -> t.Callable[P, R | None]: """Wraps a function so that it swallows exceptions.""" - def wrapper(*args: "P.args", **kwargs: "P.kwargs") -> t.Optional[R]: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None: try: return func(*args, **kwargs) except Exception: @@ -112,10 +115,10 @@ class LazyFile: def __init__( self, - filename: t.Union[str, "os.PathLike[str]"], + filename: str | os.PathLike[str], mode: str = "r", - encoding: t.Optional[str] = None, - errors: t.Optional[str] = "strict", + encoding: str | None = None, + errors: str | None = "strict", atomic: bool = False, ): self.name: str = os.fspath(filename) @@ -123,7 +126,7 @@ def __init__( self.encoding = encoding self.errors = errors self.atomic = atomic - self._f: t.Optional[t.IO[t.Any]] + self._f: t.IO[t.Any] | None self.should_close: bool if self.name == "-": @@ -175,18 +178,18 @@ def close_intelligently(self) -> None: if self.should_close: self.close() - def __enter__(self) -> "LazyFile": + def __enter__(self) -> LazyFile: return self def __exit__( self, - exc_type: t.Optional[t.Type[BaseException]], - exc_value: t.Optional[BaseException], - tb: t.Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, ) -> None: self.close_intelligently() - def __iter__(self) -> t.Iterator[t.AnyStr]: + def __iter__(self) -> cabc.Iterator[t.AnyStr]: self.open() return iter(self._f) # type: ignore @@ -198,30 +201,30 @@ def __init__(self, file: t.IO[t.Any]) -> None: def __getattr__(self, name: str) -> t.Any: return getattr(self._file, name) - def __enter__(self) -> "KeepOpenFile": + def __enter__(self) -> KeepOpenFile: return self def __exit__( self, - exc_type: t.Optional[t.Type[BaseException]], - exc_value: t.Optional[BaseException], - tb: t.Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, ) -> None: pass def __repr__(self) -> str: return repr(self._file) - def __iter__(self) -> t.Iterator[t.AnyStr]: + def __iter__(self) -> cabc.Iterator[t.AnyStr]: return iter(self._file) def echo( - message: t.Optional[t.Any] = None, - file: t.Optional[t.IO[t.Any]] = None, + message: t.Any | None = None, + file: t.IO[t.Any] | None = None, nl: bool = True, err: bool = False, - color: t.Optional[bool] = None, + color: bool | None = None, ) -> None: """Print a message and newline to stdout or a file. This should be used instead of :func:`print` because it provides better support @@ -274,7 +277,7 @@ def echo( # Convert non bytes/text into the native string type. if message is not None and not isinstance(message, (str, bytes, bytearray)): - out: t.Optional[t.Union[str, bytes]] = str(message) + out: str | bytes | None = str(message) else: out = message @@ -319,7 +322,7 @@ def echo( file.flush() -def get_binary_stream(name: "te.Literal['stdin', 'stdout', 'stderr']") -> t.BinaryIO: +def get_binary_stream(name: t.Literal["stdin", "stdout", "stderr"]) -> t.BinaryIO: """Returns a system stream for byte processing. :param name: the name of the stream to open. Valid names are ``'stdin'``, @@ -332,9 +335,9 @@ def get_binary_stream(name: "te.Literal['stdin', 'stdout', 'stderr']") -> t.Bina def get_text_stream( - name: "te.Literal['stdin', 'stdout', 'stderr']", - encoding: t.Optional[str] = None, - errors: t.Optional[str] = "strict", + name: t.Literal["stdin", "stdout", "stderr"], + encoding: str | None = None, + errors: str | None = "strict", ) -> t.TextIO: """Returns a system stream for text processing. This usually returns a wrapped stream around a binary stream returned from @@ -353,10 +356,10 @@ def get_text_stream( def open_file( - filename: t.Union[str, "os.PathLike[str]"], + filename: str | os.PathLike[str], mode: str = "r", - encoding: t.Optional[str] = None, - errors: t.Optional[str] = "strict", + encoding: str | None = None, + errors: str | None = "strict", lazy: bool = False, atomic: bool = False, ) -> t.IO[t.Any]: @@ -390,19 +393,19 @@ def open_file( """ if lazy: return t.cast( - t.IO[t.Any], LazyFile(filename, mode, encoding, errors, atomic=atomic) + "t.IO[t.Any]", 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[t.Any], KeepOpenFile(f)) + f = t.cast("t.IO[t.Any]", KeepOpenFile(f)) return f def format_filename( - filename: "t.Union[str, bytes, os.PathLike[str], os.PathLike[bytes]]", + filename: str | bytes | os.PathLike[str] | os.PathLike[bytes], shorten: bool = False, ) -> str: """Format a filename as a string for display. Ensures the filename can be @@ -518,7 +521,7 @@ def __getattr__(self, attr: str) -> t.Any: def _detect_program_name( - path: t.Optional[str] = None, _main: t.Optional[ModuleType] = None + path: str | None = None, _main: ModuleType | None = 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 @@ -573,12 +576,12 @@ def _detect_program_name( def _expand_args( - args: t.Iterable[str], + args: cabc.Iterable[str], *, user: bool = True, env: bool = True, glob_recursive: bool = True, -) -> t.List[str]: +) -> list[str]: """Simulate Unix shell expansion with Python functions. See :func:`glob.glob`, :func:`os.path.expanduser`, and diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 7f6629fba..8c1ff0064 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -198,6 +198,19 @@ def cmd(arg): assert result.return_value == expect +def test_envvar_flag_value(runner): + @click.command() + # is_flag is implicitly true + @click.option("--upper", flag_value="upper", envvar="UPPER") + def cmd(upper): + click.echo(upper) + return upper + + # For whatever value of the `env` variable, if it exists, the flag should be `upper` + result = runner.invoke(cmd, env={"UPPER": "whatever"}) + assert result.output.strip() == "upper" + + def test_nargs_envvar_only_if_values_empty(runner): @click.command() @click.argument("arg", envvar="X", nargs=-1) @@ -262,6 +275,44 @@ def cli(f): assert result.output == "test\n" +def test_deprecated_usage(runner): + @click.command() + @click.argument("f", required=False, deprecated=True) + def cli(f): + click.echo(f) + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + assert "[F!]" in result.output + + +@pytest.mark.parametrize("deprecated", [True, "USE B INSTEAD"]) +def test_deprecated_warning(runner, deprecated): + @click.command() + @click.argument( + "my-argument", required=False, deprecated=deprecated, default="default argument" + ) + def cli(my_argument: str): + click.echo(f"{my_argument}") + + # defaults should not give a deprecated warning + result = runner.invoke(cli, []) + assert result.exit_code == 0, result.output + assert "is deprecated" not in result.output + + result = runner.invoke(cli, ["hello"]) + assert result.exit_code == 0, result.output + assert "argument 'MY_ARGUMENT' is deprecated" in result.output + + if isinstance(deprecated, str): + assert deprecated in result.output + + +def test_deprecated_required(runner): + with pytest.raises(ValueError, match="is deprecated and still required"): + click.Argument(["a"], required=True, deprecated=True) + + def test_eat_options(runner): @click.command() @click.option("-f") @@ -401,3 +452,23 @@ def bar(arg): assert isinstance(foo.params[0], CustomArgument) assert isinstance(bar.params[0], CustomArgument) + + +@pytest.mark.parametrize( + "args_one,args_two", + [ + ( + ("aardvark",), + ("aardvark",), + ), + ], +) +def test_duplicate_names_warning(runner, args_one, args_two): + @click.command() + @click.argument(*args_one) + @click.argument(*args_two) + def cli(one, two): + pass + + with pytest.warns(UserWarning): + runner.invoke(cli, []) diff --git a/tests/test_basic.py b/tests/test_basic.py index d68b96299..b84ae73d6 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import enum import os from itertools import chain @@ -403,6 +406,82 @@ def cli(method): assert "{foo|bar|baz}" in result.output +def test_choice_argument_enum(runner): + class MyEnum(str, enum.Enum): + FOO = "foo-value" + BAR = "bar-value" + BAZ = "baz-value" + + @click.command() + @click.argument("method", type=click.Choice(MyEnum, case_sensitive=False)) + def cli(method: MyEnum): + assert isinstance(method, MyEnum) + click.echo(method) + + result = runner.invoke(cli, ["foo"]) + assert result.output == "foo-value\n" + assert not result.exception + + result = runner.invoke(cli, ["meh"]) + assert result.exit_code == 2 + assert ( + "Invalid value for '{foo|bar|baz}': 'meh' is not one of 'foo'," + " 'bar', 'baz'." in result.output + ) + + result = runner.invoke(cli, ["--help"]) + assert "{foo|bar|baz}" in result.output + + +def test_choice_argument_custom_type(runner): + class MyClass: + def __init__(self, value: str) -> None: + self.value = value + + def __str__(self) -> str: + return self.value + + @click.command() + @click.argument( + "method", type=click.Choice([MyClass("foo"), MyClass("bar"), MyClass("baz")]) + ) + def cli(method: MyClass): + assert isinstance(method, MyClass) + click.echo(method) + + result = runner.invoke(cli, ["foo"]) + assert not result.exception + assert result.output == "foo\n" + + result = runner.invoke(cli, ["meh"]) + assert result.exit_code == 2 + assert ( + "Invalid value for '{foo|bar|baz}': 'meh' is not one of 'foo'," + " 'bar', 'baz'." in result.output + ) + + result = runner.invoke(cli, ["--help"]) + assert "{foo|bar|baz}" in result.output + + +def test_choice_argument_none(runner): + @click.command() + @click.argument( + "method", type=click.Choice(["not-none", None], case_sensitive=False) + ) + def cli(method: str | None): + assert isinstance(method, str) or method is None + click.echo(method) + + result = runner.invoke(cli, ["not-none"]) + assert not result.exception + assert result.output == "not-none\n" + + # None is not yet supported. + result = runner.invoke(cli, ["none"]) + assert result.exception + + def test_datetime_option_default(runner): @click.command() @click.option("--start_date", type=click.DateTime()) diff --git a/tests/test_chain.py b/tests/test_chain.py index 6b2eae305..702eaaa3e 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -189,7 +189,7 @@ def c(): assert result.output.splitlines() == ["cli=", "a=", "b=", "c="] -def test_multicommand_arg_behavior(runner): +def test_group_arg_behavior(runner): with pytest.raises(RuntimeError): @click.group(chain=True) @@ -219,7 +219,7 @@ def a(): @pytest.mark.xfail -def test_multicommand_chaining(runner): +def test_group_chaining(runner): @click.group(chain=True) def cli(): debug() diff --git a/tests/test_command_decorators.py b/tests/test_command_decorators.py index cfdab3c31..5bd268c0a 100644 --- a/tests/test_command_decorators.py +++ b/tests/test_command_decorators.py @@ -1,3 +1,5 @@ +import pytest + import click @@ -69,3 +71,22 @@ def cli(a, b): assert cli.params[1].name == "b" result = runner.invoke(cli, ["1", "2"]) assert result.output == "1 2\n" + + +@pytest.mark.parametrize( + "name", + [ + "init_data", + "init_data_command", + "init_data_cmd", + "init_data_group", + "init_data_grp", + ], +) +def test_generate_name(name: str) -> None: + def f(): + pass + + f.__name__ = name + f = click.command(f) + assert f.name == "init-data" diff --git a/tests/test_commands.py b/tests/test_commands.py index dcf66acef..a5aa43f8a 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -95,13 +95,9 @@ def long(): ) -def test_no_args_is_help(runner): - @click.command(no_args_is_help=True) - def cli(): - pass - - result = runner.invoke(cli, []) - assert result.exit_code == 0 +def test_command_no_args_is_help(runner): + result = runner.invoke(click.Command("test", no_args_is_help=True)) + assert result.exit_code == 2 assert "Show this message and exit." in result.output @@ -127,7 +123,7 @@ def foo(name): (["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."), + ([], 2, "Show this message and exit."), ], ) def test_group_with_args(runner, args, exit_code, expect): @@ -145,14 +141,14 @@ def move(): assert expect in result.output -def test_base_command(runner): +def test_custom_parser(runner): import optparse @click.group() def cli(): pass - class OptParseCommand(click.BaseCommand): + class OptParseCommand(click.Command): def __init__(self, name, parser, callback): super().__init__(name) self.parser = parser @@ -249,7 +245,7 @@ def other_cmd(ctx, a, b, c): result = runner.invoke(cli, standalone_mode=False) # invoke should type cast default values, str becomes int, empty # multiple should be empty tuple instead of None - assert result.return_value == ("other-cmd", 42, 15, ()) + assert result.return_value == ("other", 42, 15, ()) def test_invoked_subcommand(runner): @@ -454,23 +450,31 @@ def cli(verbose, args): @pytest.mark.parametrize("doc", ["CLI HELP", None]) -def test_deprecated_in_help_messages(runner, doc): - @click.command(deprecated=True, help=doc) +@pytest.mark.parametrize("deprecated", [True, "USE OTHER COMMAND INSTEAD"]) +def test_deprecated_in_help_messages(runner, doc, deprecated): + @click.command(deprecated=deprecated, help=doc) def cli(): pass result = runner.invoke(cli, ["--help"]) - assert "(Deprecated)" in result.output + assert "(DEPRECATED" in result.output + + if isinstance(deprecated, str): + assert deprecated in result.output -def test_deprecated_in_invocation(runner): - @click.command(deprecated=True) +@pytest.mark.parametrize("deprecated", [True, "USE OTHER COMMAND INSTEAD"]) +def test_deprecated_in_invocation(runner, deprecated): + @click.command(deprecated=deprecated) def deprecated_cmd(): pass result = runner.invoke(deprecated_cmd) assert "DeprecationWarning:" in result.output + if isinstance(deprecated, str): + assert deprecated in result.output + def test_command_parse_args_collects_option_prefixes(): @click.command() diff --git a/tests/test_context.py b/tests/test_context.py index df8b497e0..5bd618bd4 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,9 +1,14 @@ +import logging from contextlib import contextmanager import pytest import click +from click import Context +from click import Option +from click import Parameter from click.core import ParameterSource +from click.decorators import help_option from click.decorators import pass_meta_key @@ -237,6 +242,171 @@ def foo(): assert called == [True] +def test_close_before_exit(runner): + called = [] + + @click.command() + @click.pass_context + def cli(ctx): + ctx.obj = "test" + + @ctx.call_on_close + def foo(): + assert click.get_current_context().obj == "test" + called.append(True) + + ctx.exit() + + click.echo("aha!") + + result = runner.invoke(cli, []) + assert not result.exception + assert not result.output + assert called == [True] + + +@pytest.mark.parametrize( + ("cli_args", "expect"), + [ + pytest.param( + ("--option-with-callback", "--force-exit"), + ["ExitingOption", "NonExitingOption"], + id="natural_order", + ), + pytest.param( + ("--force-exit", "--option-with-callback"), + ["ExitingOption"], + id="eagerness_precedence", + ), + ], +) +def test_multiple_eager_callbacks(runner, cli_args, expect): + """Checks all callbacks are called on exit, even the nasty ones hidden within + callbacks. + + Also checks the order in which they're called. + """ + # Keeps track of callback calls. + called = [] + + class NonExitingOption(Option): + def reset_state(self): + called.append(self.__class__.__name__) + + def set_state(self, ctx: Context, param: Parameter, value: str) -> str: + ctx.call_on_close(self.reset_state) + return value + + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("expose_value", False) + kwargs.setdefault("callback", self.set_state) + super().__init__(*args, **kwargs) + + class ExitingOption(NonExitingOption): + def set_state(self, ctx: Context, param: Parameter, value: str) -> str: + value = super().set_state(ctx, param, value) + ctx.exit() + return value + + @click.command() + @click.option("--option-with-callback", is_eager=True, cls=NonExitingOption) + @click.option("--force-exit", is_eager=True, cls=ExitingOption) + def cli(): + click.echo("This will never be printed as we forced exit via --force-exit") + + result = runner.invoke(cli, cli_args) + assert not result.exception + assert not result.output + + assert called == expect + + +def test_no_state_leaks(runner): + """Demonstrate state leaks with a specific case of the generic test above. + + Use a logger as a real-world example of a common fixture which, due to its global + nature, can leak state if not clean-up properly in a callback. + """ + # Keeps track of callback calls. + called = [] + + class DebugLoggerOption(Option): + """A custom option to set the name of the debug logger.""" + + logger_name: str + """The ID of the logger to use.""" + + def reset_loggers(self): + """Forces logger managed by the option to be reset to the default level.""" + logger = logging.getLogger(self.logger_name) + logger.setLevel(logging.NOTSET) + + # Logger has been properly reset to its initial state. + assert logger.level == logging.NOTSET + assert logger.getEffectiveLevel() == logging.WARNING + + called.append(True) + + def set_level(self, ctx: Context, param: Parameter, value: str) -> None: + """Set the logger to DEBUG level.""" + # Keep the logger name around so we can reset it later when winding down + # the option. + self.logger_name = value + + # Get the global logger object. + logger = logging.getLogger(self.logger_name) + + # Check pre-conditions: new logger is not set, but inherits its level from + # default logger. That's the exact same state we are expecting our + # logger to be in after being messed with by the CLI. + assert logger.level == logging.NOTSET + assert logger.getEffectiveLevel() == logging.WARNING + + logger.setLevel(logging.DEBUG) + ctx.call_on_close(self.reset_loggers) + return value + + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("callback", self.set_level) + super().__init__(*args, **kwargs) + + @click.command() + @click.option("--debug-logger-name", is_eager=True, cls=DebugLoggerOption) + @help_option() + @click.pass_context + def messing_with_logger(ctx, debug_logger_name): + # Introspect context to make sure logger name are aligned. + assert debug_logger_name == ctx.command.params[0].logger_name + + logger = logging.getLogger(debug_logger_name) + + # Logger's level has been properly set to DEBUG by DebugLoggerOption. + assert logger.level == logging.DEBUG + assert logger.getEffectiveLevel() == logging.DEBUG + + logger.debug("Blah blah blah") + + ctx.exit() + + click.echo("This will never be printed as we exited early") + + # Call the CLI to mess with the custom logger. + result = runner.invoke( + messing_with_logger, ["--debug-logger-name", "my_logger", "--help"] + ) + + assert called == [True] + + # Check the custom logger has been reverted to it initial state by the option + # callback after being messed with by the CLI. + logger = logging.getLogger("my_logger") + assert logger.level == logging.NOTSET + assert logger.getEffectiveLevel() == logging.WARNING + + assert not result.exception + assert result.output.startswith("Usage: messing-with-logger [OPTIONS]") + + def test_with_resource(): @contextmanager def manager(): diff --git a/tests/test_defaults.py b/tests/test_defaults.py index 5c5e168ab..3f293e881 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -5,7 +5,7 @@ def test_basic_defaults(runner): @click.command() @click.option("--foo", default=42, type=click.FLOAT) def cli(foo): - assert type(foo) is float # noqa E721 + assert isinstance(foo, float) click.echo(f"FOO:[{foo}]") result = runner.invoke(cli, []) @@ -18,7 +18,7 @@ def test_multiple_defaults(runner): @click.option("--foo", default=[23, 42], type=click.FLOAT, multiple=True) def cli(foo): for item in foo: - assert type(item) is float # noqa E721 + assert isinstance(item, float) click.echo(item) result = runner.invoke(cli, []) diff --git a/tests/test_formatting.py b/tests/test_formatting.py index fe7e7bad1..c79f6577f 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -248,7 +248,7 @@ def cmd(arg): def test_formatting_custom_type_metavar(runner): class MyType(click.ParamType): - def get_metavar(self, param): + def get_metavar(self, param: click.Parameter, ctx: click.Context): return "MY_TYPE" @click.command("foo") diff --git a/tests/test_imports.py b/tests/test_imports.py index aaf294e63..e5e5119f2 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -26,10 +26,12 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, """ ALLOWED_IMPORTS = { + "__future__", "weakref", "os", "struct", "collections", + "collections.abc", "sys", "contextlib", "functools", diff --git a/tests/test_info_dict.py b/tests/test_info_dict.py index 79d39ee51..20fe68cc1 100644 --- a/tests/test_info_dict.py +++ b/tests/test_info_dict.py @@ -58,7 +58,7 @@ "help": None, "prompt": None, "is_flag": False, - "flag_value": False, + "flag_value": None, "count": False, "hidden": False, }, @@ -106,11 +106,11 @@ ), pytest.param(*STRING_PARAM_TYPE, id="STRING ParamType"), pytest.param( - click.Choice(["a", "b"]), + click.Choice(("a", "b")), { "param_type": "Choice", "name": "choice", - "choices": ["a", "b"], + "choices": ("a", "b"), "case_sensitive": True, }, id="Choice ParamType", diff --git a/tests/test_normalization.py b/tests/test_normalization.py index 502e654a3..442b638f4 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -17,12 +17,37 @@ def cli(foo, x): def test_choice_normalization(runner): @click.command(context_settings=CONTEXT_SETTINGS) - @click.option("--choice", type=click.Choice(["Foo", "Bar"])) - def cli(choice): - click.echo(choice) - - result = runner.invoke(cli, ["--CHOICE", "FOO"]) - assert result.output == "Foo\n" + @click.option( + "--method", + type=click.Choice( + ["SCREAMING_SNAKE_CASE", "snake_case", "PascalCase", "kebab-case"], + case_sensitive=False, + ), + ) + def cli(method): + click.echo(method) + + result = runner.invoke(cli, ["--METHOD=snake_case"]) + assert not result.exception, result.output + assert result.output == "snake_case\n" + + # Even though it's case sensitive, the choice's original value is preserved + result = runner.invoke(cli, ["--method=pascalcase"]) + assert not result.exception, result.output + assert result.output == "PascalCase\n" + + result = runner.invoke(cli, ["--method=meh"]) + assert result.exit_code == 2 + assert ( + "Invalid value for '--method': 'meh' is not one of " + "'screaming_snake_case', 'snake_case', 'pascalcase', 'kebab-case'." + ) in result.output + + result = runner.invoke(cli, ["--help"]) + assert ( + "--method [screaming_snake_case|snake_case|pascalcase|kebab-case]" + in result.output + ) def test_command_normalization(runner): diff --git a/tests/test_options.py b/tests/test_options.py index 7397f3667..b7267c182 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -33,6 +33,52 @@ def test_invalid_option(runner): assert "'--foo'" in message +@pytest.mark.parametrize("deprecated", [True, "USE B INSTEAD"]) +def test_deprecated_usage(runner, deprecated): + @click.command() + @click.option("--foo", default="bar", deprecated=deprecated) + def cmd(foo): + click.echo(foo) + + result = runner.invoke(cmd, ["--help"]) + assert "(DEPRECATED" in result.output + + if isinstance(deprecated, str): + assert deprecated in result.output + + +@pytest.mark.parametrize("deprecated", [True, "USE B INSTEAD"]) +def test_deprecated_warning(runner, deprecated): + @click.command() + @click.option( + "--my-option", required=False, deprecated=deprecated, default="default option" + ) + def cli(my_option: str): + click.echo(f"{my_option}") + + # defaults should not give a deprecated warning + result = runner.invoke(cli, []) + assert result.exit_code == 0, result.output + assert "is deprecated" not in result.output + + result = runner.invoke(cli, ["--my-option", "hello"]) + assert result.exit_code == 0, result.output + assert "option 'my_option' is deprecated" in result.output + + if isinstance(deprecated, str): + assert deprecated in result.output + + +def test_deprecated_required(runner): + with pytest.raises(ValueError, match="is deprecated and still required"): + click.Option(["--a"], required=True, deprecated=True) + + +def test_deprecated_prompt(runner): + with pytest.raises(ValueError, match="`deprecated` options cannot use `prompt`"): + click.Option(["--a"], prompt=True, deprecated=True) + + def test_invalid_nargs(runner): with pytest.raises(TypeError, match="nargs=-1"): @@ -316,6 +362,7 @@ def __str__(self): opt = click.Option(["-a"], default=Value(), show_default=True) ctx = click.Context(click.Command("cli")) + assert opt.get_help_extra(ctx) == {"default": "special value"} assert "special value" in opt.get_help_record(ctx)[1] @@ -331,6 +378,7 @@ def __str__(self): def test_intrange_default_help_text(type, expect): option = click.Option(["--num"], type=type, show_default=True, default=2) context = click.Context(click.Command("test")) + assert option.get_help_extra(context) == {"default": "2", "range": expect} result = option.get_help_record(context)[1] assert expect in result @@ -339,6 +387,7 @@ def test_count_default_type_help(): """A count option with the default type should not show >=0 in help.""" option = click.Option(["--count"], count=True, help="some words") context = click.Context(click.Command("test")) + assert option.get_help_extra(context) == {} result = option.get_help_record(context)[1] assert result == "some words" @@ -354,6 +403,7 @@ def test_file_type_help_default(): ["--in"], type=click.File(), default=__file__, show_default=True ) context = click.Context(click.Command("test")) + assert option.get_help_extra(context) == {"default": __file__} result = option.get_help_record(context)[1] assert __file__ in result @@ -527,6 +577,22 @@ def cmd(foo): assert "bar" in choices +def test_missing_envvar(runner): + cli = click.Command( + "cli", params=[click.Option(["--foo"], envvar="bar", required=True)] + ) + result = runner.invoke(cli) + assert result.exit_code == 2 + assert "Error: Missing option '--foo'." in result.output + cli = click.Command( + "cli", + params=[click.Option(["--foo"], envvar="bar", show_envvar=True, required=True)], + ) + result = runner.invoke(cli) + assert result.exit_code == 2 + assert "Error: Missing option '--foo' (env var: 'bar')." in result.output + + def test_case_insensitive_choice(runner): @click.command() @click.option("--foo", type=click.Choice(["Orange", "Apple"], case_sensitive=False)) @@ -741,6 +807,7 @@ def test_show_default_boolean_flag_name(runner, default, expect): help="Enable/Disable the cache.", ) ctx = click.Context(click.Command("test")) + assert opt.get_help_extra(ctx) == {"default": expect} message = opt.get_help_record(ctx)[1] assert f"[default: {expect}]" in message @@ -757,6 +824,7 @@ def test_show_true_default_boolean_flag_value(runner): help="Enable the cache.", ) ctx = click.Context(click.Command("test")) + assert opt.get_help_extra(ctx) == {"default": "True"} message = opt.get_help_record(ctx)[1] assert "[default: True]" in message @@ -774,6 +842,7 @@ def test_hide_false_default_boolean_flag_value(runner, default): help="Enable the cache.", ) ctx = click.Context(click.Command("test")) + assert opt.get_help_extra(ctx) == {} message = opt.get_help_record(ctx)[1] assert "[default: " not in message @@ -782,6 +851,7 @@ def test_show_default_string(runner): """When show_default is a string show that value as default.""" opt = click.Option(["--limit"], show_default="unlimited") ctx = click.Context(click.Command("cli")) + assert opt.get_help_extra(ctx) == {"default": "(unlimited)"} message = opt.get_help_record(ctx)[1] assert "[default: (unlimited)]" in message @@ -798,6 +868,7 @@ def test_do_not_show_no_default(runner): """When show_default is True and no default is set do not show None.""" opt = click.Option(["--limit"], show_default=True) ctx = click.Context(click.Command("cli")) + assert opt.get_help_extra(ctx) == {} message = opt.get_help_record(ctx)[1] assert "[default: None]" not in message @@ -808,28 +879,30 @@ def test_do_not_show_default_empty_multiple(): """ opt = click.Option(["-a"], multiple=True, help="values", show_default=True) ctx = click.Context(click.Command("cli")) + assert opt.get_help_extra(ctx) == {} message = opt.get_help_record(ctx)[1] assert message == "values" @pytest.mark.parametrize( - ("ctx_value", "opt_value", "expect"), + ("ctx_value", "opt_value", "extra_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), + (None, None, {}, False), + (None, False, {}, False), + (None, True, {"default": "1"}, True), + (False, None, {}, False), + (False, False, {}, False), + (False, True, {"default": "1"}, True), + (True, None, {"default": "1"}, True), + (True, False, {}, False), + (True, True, {"default": "1"}, True), + (False, "one", {"default": "(one)"}, True), ], ) -def test_show_default_precedence(ctx_value, opt_value, expect): +def test_show_default_precedence(ctx_value, opt_value, extra_value, expect): ctx = click.Context(click.Command("test"), show_default=ctx_value) opt = click.Option("-a", default=1, help="value", show_default=opt_value) + assert opt.get_help_extra(ctx) == extra_value help = opt.get_help_record(ctx)[1] assert ("default:" in help) is expect @@ -926,3 +999,65 @@ def test_invalid_flag_combinations(runner, kwargs, message): click.Option(["-a"], **kwargs) assert message in str(e.value) + + +@pytest.mark.parametrize( + ("choices", "metavars"), + [ + pytest.param(["foo", "bar"], "[TEXT]", id="text choices"), + pytest.param([1, 2], "[INTEGER]", id="int choices"), + pytest.param([1.0, 2.0], "[FLOAT]", id="float choices"), + pytest.param([True, False], "[BOOLEAN]", id="bool choices"), + pytest.param(["foo", 1], "[TEXT|INTEGER]", id="text/int choices"), + ], +) +def test_usage_show_choices(runner, choices, metavars): + """When show_choices=False is set, the --help output + should print choice metavars instead of values. + """ + + @click.command() + @click.option("-g", type=click.Choice(choices)) + def cli_with_choices(g): + pass + + @click.command() + @click.option( + "-g", + type=click.Choice(choices), + show_choices=False, + ) + def cli_without_choices(g): + pass + + result = runner.invoke(cli_with_choices, ["--help"]) + assert f"[{'|'.join([str(i) for i in choices])}]" in result.output + + result = runner.invoke(cli_without_choices, ["--help"]) + assert metavars in result.output + + +@pytest.mark.parametrize( + "opts_one,opts_two", + [ + # No duplicate shortnames + ( + ("-a", "--aardvark"), + ("-a", "--avocado"), + ), + # No duplicate long names + ( + ("-a", "--aardvark"), + ("-b", "--aardvark"), + ), + ], +) +def test_duplicate_names_warning(runner, opts_one, opts_two): + @click.command() + @click.option(*opts_one) + @click.option(*opts_two) + def cli(one, two): + pass + + with pytest.warns(UserWarning): + runner.invoke(cli, []) diff --git a/tests/test_parser.py b/tests/test_parser.py index f69491696..f2a3ad593 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,8 +1,8 @@ import pytest import click -from click.parser import OptionParser -from click.parser import split_arg_string +from click.parser import _OptionParser +from click.shell_completion import split_arg_string @pytest.mark.parametrize( @@ -20,13 +20,13 @@ def test_split_arg_string(value, expect): def test_parser_default_prefixes(): - parser = OptionParser() + parser = _OptionParser() assert parser._opt_prefixes == {"-", "--"} def test_parser_collects_prefixes(): ctx = click.Context(click.Command("test")) - parser = OptionParser(ctx) + 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 df2d60739..830dc92da 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -1,3 +1,5 @@ +import warnings + import pytest import click.shell_completion @@ -427,3 +429,27 @@ class MyshComplete(ShellComplete): # Using `add_completion_class` as a decorator adds the new shell immediately assert "mysh" in click.shell_completion._available_shells assert click.shell_completion._available_shells["mysh"] is MyshComplete + + +# Don't make the ResourceWarning give an error +@pytest.mark.filterwarnings("default") +def test_files_closed(runner) -> None: + with runner.isolated_filesystem(): + config_file = "foo.txt" + with open(config_file, "w") as f: + f.write("bar") + + @click.group() + @click.option( + "--config-file", + default=config_file, + type=click.File(mode="r"), + ) + @click.pass_context + def cli(ctx, config_file): + pass + + with warnings.catch_warnings(record=True) as current_warnings: + assert not current_warnings, "There should be no warnings to start" + _get_completions(cli, args=[], incomplete="") + assert not current_warnings, "There should be no warnings after either" diff --git a/tests/test_termui.py b/tests/test_termui.py index 7cfa93994..ad9d0a66c 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -1,4 +1,5 @@ import platform +import tempfile import time import pytest @@ -71,7 +72,7 @@ def cli(): assert result.exception is None -def test_progressbar_hidden(runner, monkeypatch): +def test_progressbar_no_tty(runner, monkeypatch): @click.command() def cli(): with _create_progress(label="working") as progress: @@ -82,6 +83,17 @@ def cli(): assert runner.invoke(cli, []).output == "working\n" +def test_progressbar_hidden_manual(runner, monkeypatch): + @click.command() + def cli(): + with _create_progress(label="see nothing", hidden=True) as progress: + for _ in progress: + pass + + monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) + assert runner.invoke(cli, []).output == "" + + @pytest.mark.parametrize("avg, expected", [([], 0.0), ([1, 4], 2.5)]) def test_progressbar_time_per_iteration(runner, avg, expected): with _create_progress(2, avg=avg) as progress: @@ -246,7 +258,7 @@ def test_secho(runner): ("value", "expect"), [(123, b"\x1b[45m123\x1b[0m"), (b"test", b"test")] ) def test_secho_non_text(runner, value, expect): - with runner.isolation() as (out, _): + with runner.isolation() as (out, _, _): click.secho(value, nl=False, color=True, bg="magenta") result = out.getvalue() assert result == expect @@ -369,6 +381,20 @@ def test_fast_edit(runner): assert result == "aTest\nbTest\n" +@pytest.mark.skipif(platform.system() == "Windows", reason="No sed on Windows.") +def test_edit(runner): + with tempfile.NamedTemporaryFile(mode="w") as named_tempfile: + named_tempfile.write("a\nb") + named_tempfile.flush() + + result = click.edit(filename=named_tempfile.name, editor="sed -i~ 's/$/Test/'") + assert result is None + + # We need ot reopen the file as it becomes unreadable after the edit. + with open(named_tempfile.name) as reopened_file: + assert reopened_file.read() == "aTest\nbTest" + + @pytest.mark.parametrize( ("prompt_required", "required", "args", "expect"), [ @@ -447,3 +473,15 @@ def cli(password): if prompt == "Confirm Password": assert "Confirm Password: " in result.output + + +def test_false_show_default_cause_no_default_display_in_prompt(runner): + @click.command() + @click.option("--arg1", show_default=False, prompt=True, default="my-default-value") + def cmd(arg1): + pass + + # Confirm that the default value is not included in the output when `show_default` + # is False + result = runner.invoke(cmd, input="my-input", standalone_mode=False) + assert "my-default-value" not in result.output diff --git a/tests/test_testing.py b/tests/test_testing.py index 0d227f2a0..55d64701f 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -184,6 +184,28 @@ def cli(): assert result.exit_code == 1 +def test_catch_exceptions_cli_runner(): + """Test that invoke `catch_exceptions` takes the value from CliRunner if not set + explicitly.""" + + class CustomError(Exception): + pass + + @click.command() + def cli(): + raise CustomError(1) + + runner = CliRunner(catch_exceptions=False) + + result = runner.invoke(cli, catch_exceptions=True) + assert isinstance(result.exception, CustomError) + assert type(result.exc_info) is tuple + assert len(result.exc_info) == 3 + + with pytest.raises(CustomError): + runner.invoke(cli) + + def test_with_color(): @click.command() def cli(): @@ -322,32 +344,23 @@ def cli_env(): def test_stderr(): @click.command() def cli_stderr(): - click.echo("stdout") - click.echo("stderr", err=True) + click.echo("1 - stdout") + click.echo("2 - stderr", err=True) + click.echo("3 - stdout") + click.echo("4 - stderr", err=True) - runner = CliRunner(mix_stderr=False) - - result = runner.invoke(cli_stderr) - - assert result.output == "stdout\n" - assert result.stdout == "stdout\n" - assert result.stderr == "stderr\n" - - runner_mix = CliRunner(mix_stderr=True) + runner_mix = CliRunner() result_mix = runner_mix.invoke(cli_stderr) - assert result_mix.output == "stdout\nstderr\n" - assert result_mix.stdout == "stdout\nstderr\n" - - with pytest.raises(ValueError): - result_mix.stderr # noqa B018 + assert result_mix.output == "1 - stdout\n2 - stderr\n3 - stdout\n4 - stderr\n" + assert result_mix.stdout == "1 - stdout\n3 - stdout\n" + assert result_mix.stderr == "2 - stderr\n4 - stderr\n" @click.command() def cli_empty_stderr(): click.echo("stdout") - runner = CliRunner(mix_stderr=False) - + runner = CliRunner() result = runner.invoke(cli_empty_stderr) assert result.output == "stdout\n" @@ -431,9 +444,9 @@ def test_isolation_stderr_errors(): """Writing to stderr should escape invalid characters instead of raising a UnicodeEncodeError. """ - runner = CliRunner(mix_stderr=False) + runner = CliRunner() - with runner.isolation() as (_, err): + with runner.isolation() as (_, err, _): click.echo("\udce2", err=True, nl=False) assert err.getvalue() == b"\\udce2" diff --git a/tests/test_types.py b/tests/test_types.py index 79068e189..c287e371c 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -244,3 +244,9 @@ def test_invalid_path_with_esc_sequence(): click.Path(dir_okay=False).convert(tempdir, None, None) assert "my\\ndir" in exc_info.value.message + + +def test_choice_get_invalid_choice_message(): + choice = click.Choice(["a", "b", "c"]) + message = choice.get_invalid_choice_message("d", ctx=None) + assert message == "'d' is not one of 'a', 'b', 'c'." diff --git a/tests/test_utils.py b/tests/test_utils.py index d80aee716..9adab7798 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,9 @@ import pathlib import stat import sys +from collections import namedtuple from io import StringIO +from tempfile import tempdir import pytest @@ -179,31 +181,172 @@ def _test_gen_func(): yield "abc" -@pytest.mark.parametrize("cat", ["cat", "cat ", "cat "]) +def _test_gen_func_fails(): + yield "test" + raise RuntimeError("This is a test.") + + +def _test_gen_func_echo(file=None): + yield "test" + click.echo("hello", file=file) + yield "test" + + +def _test_simulate_keyboard_interrupt(file=None): + yield "output_before_keyboard_interrupt" + raise KeyboardInterrupt() + + +EchoViaPagerTest = namedtuple( + "EchoViaPagerTest", + ( + "description", + "test_input", + "expected_pager", + "expected_stdout", + "expected_stderr", + "expected_error", + ), +) + + +@pytest.mark.skipif(WIN, reason="Different behavior on windows.") @pytest.mark.parametrize( "test", [ - # We need lambda here, because pytest will - # reuse the parameters, and then the generators - # are already used and will not yield anymore - ("just text\n", lambda: "just text"), - ("iterable\n", lambda: ["itera", "ble"]), - ("abcabc\n", lambda: _test_gen_func), - ("abcabc\n", lambda: _test_gen_func()), - ("012345\n", lambda: (c for c in range(6))), + # We need to pass a parameter function instead of a plain param + # as pytest.mark.parametrize will reuse the parameters causing the + # generators to be used up so they will not yield anymore + EchoViaPagerTest( + description="Plain string argument", + test_input=lambda: "just text", + expected_pager="just text\n", + expected_stdout="", + expected_stderr="", + expected_error=None, + ), + EchoViaPagerTest( + description="Iterable argument", + test_input=lambda: ["itera", "ble"], + expected_pager="iterable\n", + expected_stdout="", + expected_stderr="", + expected_error=None, + ), + EchoViaPagerTest( + description="Generator function argument", + test_input=lambda: _test_gen_func, + expected_pager="abcabc\n", + expected_stdout="", + expected_stderr="", + expected_error=None, + ), + EchoViaPagerTest( + description="String generator argument", + test_input=lambda: _test_gen_func(), + expected_pager="abcabc\n", + expected_stdout="", + expected_stderr="", + expected_error=None, + ), + EchoViaPagerTest( + description="Number generator expression argument", + test_input=lambda: (c for c in range(6)), + expected_pager="012345\n", + expected_stdout="", + expected_stderr="", + expected_error=None, + ), + EchoViaPagerTest( + description="Exception in generator function argument", + test_input=lambda: _test_gen_func_fails, + # Because generator throws early on, the pager did not have + # a chance yet to write the file. + expected_pager=None, + expected_stdout="", + expected_stderr="", + expected_error=RuntimeError, + ), + EchoViaPagerTest( + description="Exception in generator argument", + test_input=lambda: _test_gen_func_fails, + # Because generator throws early on, the pager did not have a + # chance yet to write the file. + expected_pager=None, + expected_stdout="", + expected_stderr="", + expected_error=RuntimeError, + ), + EchoViaPagerTest( + description="Keyboard interrupt should not terminate the pager", + test_input=lambda: _test_simulate_keyboard_interrupt(), + # Due to the keyboard interrupt during pager execution, click program + # should abort, but the pager should stay open. + # This allows users to cancel the program and search in the pager + # output, before they decide to terminate the pager. + expected_pager="output_before_keyboard_interrupt", + expected_stdout="", + expected_stderr="", + expected_error=KeyboardInterrupt, + ), + EchoViaPagerTest( + description="Writing to stdout during generator execution", + test_input=lambda: _test_gen_func_echo(), + expected_pager="testtest\n", + expected_stdout="hello\n", + expected_stderr="", + expected_error=None, + ), + EchoViaPagerTest( + description="Writing to stderr during generator execution", + test_input=lambda: _test_gen_func_echo(file=sys.stderr), + expected_pager="testtest\n", + expected_stdout="", + expected_stderr="hello\n", + expected_error=None, + ), ], ) -def test_echo_via_pager(monkeypatch, capfd, cat, test): - monkeypatch.setitem(os.environ, "PAGER", cat) +def test_echo_via_pager(monkeypatch, capfd, test): + pager_out_tmp = f"{tempdir}/pager_out.txt" + + if os.path.exists(pager_out_tmp): + os.remove(pager_out_tmp) + + monkeypatch.setitem(os.environ, "PAGER", f"cat > {pager_out_tmp}") monkeypatch.setattr(click._termui_impl, "isatty", lambda x: True) - expected_output = test[0] - test_input = test[1]() + test_input = test.test_input() + expected_pager = test.expected_pager + expected_stdout = test.expected_stdout + expected_stderr = test.expected_stderr + expected_error = test.expected_error - click.echo_via_pager(test_input) + if expected_error: + with pytest.raises(expected_error): + click.echo_via_pager(test_input) + else: + click.echo_via_pager(test_input) out, err = capfd.readouterr() - assert out == expected_output + + if os.path.exists(pager_out_tmp): + with open(pager_out_tmp) as f: + pager = f.read() + else: + # The pager process was not started or has been + # terminated before it could finish writing + pager = None + + assert ( + pager == expected_pager + ), f"Unexpected pager output in test case '{test.description}'" + assert ( + out == expected_stdout + ), f"Unexpected stdout in test case '{test.description}'" + assert ( + err == expected_stderr + ), f"Unexpected stderr in test case '{test.description}'" def test_echo_color_flag(monkeypatch, capfd): @@ -244,7 +387,7 @@ def test_prompt_cast_default(capfd, monkeypatch): monkeypatch.setattr(sys, "stdin", StringIO("\n")) value = click.prompt("value", default="100", type=int) capfd.readouterr() - assert type(value) is int # noqa E721 + assert isinstance(value, int) @pytest.mark.skipif(WIN, reason="Test too complex to make work windows.") diff --git a/tests/typing/typing_progressbar.py b/tests/typing/typing_progressbar.py new file mode 100644 index 000000000..98ca1703b --- /dev/null +++ b/tests/typing/typing_progressbar.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing_extensions import assert_type + +from click import progressbar +from click._termui_impl import ProgressBar + + +def test_length_is_int() -> None: + with progressbar(length=5) as bar: + assert_type(bar, ProgressBar[int]) + for i in bar: + assert_type(i, int) + + +def it() -> tuple[str, ...]: + return ("hello", "world") + + +def test_generic_on_iterable() -> None: + with progressbar(it()) as bar: + assert_type(bar, ProgressBar[str]) + for s in bar: + assert_type(s, str) diff --git a/tox.ini b/tox.ini index 84ef184c0..744b162e6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py3{13,12,11,10,9,8,7} + py3{13,12,11,10,9,8} pypy310 style typing @@ -15,9 +15,6 @@ use_frozen_constraints = true deps = -r requirements/tests.txt commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs} -[testenv:py37,py3.7] -deps = -r requirements/tests37.txt - [testenv:style] deps = pre-commit skip_install = true @@ -26,9 +23,15 @@ commands = pre-commit run --all-files [testenv:typing] deps = -r requirements/typing.txt commands = - mypy - pyright tests/typing - pyright --verifytypes click --ignoreexternal + mypy --platform linux + mypy --platform darwin + mypy --platform win32 + pyright tests/typing --pythonplatform Linux + pyright tests/typing --pythonplatform Darwin + pyright tests/typing --pythonplatform Windows + pyright --verifytypes click --ignoreexternal --pythonplatform Linux + pyright --verifytypes click --ignoreexternal --pythonplatform Darwin + pyright --verifytypes click --ignoreexternal --pythonplatform Windows [testenv:docs] deps = -r requirements/docs.txt @@ -56,11 +59,3 @@ commands = pip-compile tests.in -q {posargs:-U} pip-compile typing.in -q {posargs:-U} pip-compile dev.in -q {posargs:-U} - -[testenv:update-requirements37] -base_python = 3.7 -labels = update -deps = pip-tools -skip_install = true -change_dir = requirements -commands = pip-compile tests.in -q -o tests37.txt {posargs:-U}