diff --git a/CHANGES.rst b/CHANGES.rst index 057d700df..e3662514c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,8 +7,8 @@ Unreleased - Drop support for Python 3.7. :pr:`2588` - Use modern packaging metadata with ``pyproject.toml`` instead of ``setup.cfg``. - :pr:`326` -- Use ``flit_core`` instead of ``setuptools`` as build backend. + :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 @@ -41,7 +41,7 @@ Unreleased :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 + 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` @@ -50,7 +50,7 @@ Unreleased ``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`` + 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` @@ -60,7 +60,7 @@ Unreleased 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 - ``ResourceWarning``s when using ``click.File``. :issue:`2644` :pr:`2800` + 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 @@ -88,13 +88,16 @@ Unreleased - 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 :class:`CliRunner`. If - ``catch_exceptions`` is not passed to :meth:`CliRunner.invoke`, - the value from :class:`CliRunner`. :issue:`2817` :pr:`2818` +- 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 ------------- diff --git a/docs/api.rst b/docs/api.rst index 7c070e8ea..0d5e04229 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -113,6 +113,7 @@ Context :members: :member-order: bysource +.. _click-api-types: Types ----- diff --git a/docs/documentation.rst b/docs/documentation.rst index 9de9fac5b..1b7210eab 100644 --- a/docs/documentation.rst +++ b/docs/documentation.rst @@ -108,6 +108,42 @@ Or more explicitly: invoke(touch, args=['--help']) + +Showing Defaults +--------------------------- +To control the appearance of defaults pass ``show_default``. + +.. click:example:: + + @click.command() + @click.option('--n', default=1, show_default=False, help='number of dots') + def dots(n): + click.echo('.' * n) + +.. click:run:: + + 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() + @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. diff --git a/docs/index.rst b/docs/index.rst index e03f0f660..f655105d0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -72,10 +72,12 @@ usage patterns. virtualenv setuptools parameters - arguments + parameter-types options - commands-and-groups + option-decorators + arguments commands + commands-and-groups documentation prompts complex 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 39f66c4cd..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 ---------------------------- @@ -594,46 +476,6 @@ What it looks like: invoke(hello) invoke(hello, args=['--version']) - -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!') - - Values from Environment Variables --------------------------------- @@ -801,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 3caac4780..9f7cc78e6 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -5,7 +5,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 ---------------- @@ -27,6 +27,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 @@ -54,86 +56,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/src/click/core.py b/src/click/core.py index e783729c7..176a7ca60 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2192,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() @@ -2775,7 +2775,7 @@ def _write_opts(opts: cabc.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 @@ -3056,10 +3056,10 @@ 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: @@ -3088,10 +3088,10 @@ def _parse_decls( return name, [arg], [] def get_usage_pieces(self, ctx: Context) -> list[str]: - return [self.make_metavar()] + 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: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) diff --git a/src/click/exceptions.py b/src/click/exceptions.py index c41c20676..f141a832e 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -174,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}" diff --git a/src/click/types.py b/src/click/types.py index 354c7e381..d0a2715d2 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -1,6 +1,7 @@ from __future__ import annotations import collections.abc as cabc +import enum import os import stat import sys @@ -23,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 @@ -86,10 +89,10 @@ def __call__( if value is not None: return self.convert(value, param, ctx) - def get_metavar(self, param: Parameter) -> str | None: + 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) -> str | None: + def get_missing_message(self, param: Parameter, ctx: Context | None) -> str | None: """Optionally might return extra information about a missing parameter. @@ -227,29 +230,35 @@ 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. - - You should only pass a list or tuple of choices. Other iterables - (like generators) may lead to surprising results. + of supported values. - The resulting value will always be one of the originally passed choices - regardless of ``case_sensitive`` or any ``ctx.token_normalize_func`` - being specified. + You may pass any iterable value which will be converted to a tuple + and thus will only be iterated once. - 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: cabc.Sequence[str], case_sensitive: bool = True + self, choices: cabc.Iterable[ParamTypeValue], case_sensitive: bool = True ) -> None: - self.choices = choices + self.choices: cabc.Sequence[ParamTypeValue] = tuple(choices) self.case_sensitive = case_sensitive def to_info_dict(self) -> dict[str, t.Any]: @@ -258,14 +267,54 @@ def to_info_dict(self) -> dict[str, t.Any]: info_dict["case_sensitive"] = self.case_sensitive return info_dict - def get_metavar(self, param: Parameter) -> str: + 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.choices]) + 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": @@ -274,46 +323,48 @@ 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: Parameter | None, ctx: Context | None - ) -> 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} - - 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() - } - - if not self.case_sensitive: - normed_value = normed_value.casefold() - normed_choices = { - normed_choice.casefold(): original - for normed_choice, original in normed_choices.items() - } - - if normed_value in normed_choices: - return normed_choices[normed_value] + ) -> 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) - self.fail(self.get_invalid_choice_message(value), param, ctx) + 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, + ) - def get_invalid_choice_message(self, value: t.Any) -> str: + def get_invalid_choice_message(self, value: t.Any, ctx: Context | None) -> str: """Get the error message when the given choice is invalid. :param value: The invalid value. .. versionadded:: 8.2 """ - choices_str = ", ".join(map(repr, self.choices)) + 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}.", @@ -382,7 +433,7 @@ def to_info_dict(self) -> dict[str, t.Any]: 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) -> datetime | None: 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_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_info_dict.py b/tests/test_info_dict.py index 11b670311..20fe68cc1 100644 --- a/tests/test_info_dict.py +++ b/tests/test_info_dict.py @@ -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_types.py b/tests/test_types.py index 667953a47..c287e371c 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -248,5 +248,5 @@ def test_invalid_path_with_esc_sequence(): def test_choice_get_invalid_choice_message(): choice = click.Choice(["a", "b", "c"]) - message = choice.get_invalid_choice_message("d") + message = choice.get_invalid_choice_message("d", ctx=None) assert message == "'d' is not one of 'a', 'b', 'c'."