From 9f0bcdf0d868edfc71435cd0aac2dcc08cf94828 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Wed, 2 Sep 2026 16:23:32 +0200 Subject: [PATCH] Add `Parameter.spec` and deprecate `human_readable_name` --- CHANGES.md | 13 ++++ docs/parameters.md | 46 +++++++++---- docs/upgrade-guides.md | 3 +- src/click/core.py | 133 +++++++++++++++++++++++++++++++++---- tests/test_arguments.py | 30 +++++++++ tests/test_deprecations.py | 36 ++++++++++ tests/test_options.py | 31 ++++++++- 7 files changed, 266 insertions(+), 26 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8853860214..0c26e0108d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,19 @@ Unreleased keyword, is deprecated and raises `TypeError` in Click 9.0. {pr}`3866` - An {class}`Option` name written as a Python identifier is deprecated when it is not already lower-cased. {pr}`3866` +- Add {attr}`Parameter.spec`, which returns how the user spells the parameter on + the command line: `--times` for an option declared as `-t`, `--times`, and + `FILENAME` for an argument declared as `filename`. {pr}`3877` +- Move {meth}`get_help_spec` from {class}`Option` to {class}`Parameter`, and give + {class}`Argument` an override that returns its metavar. {pr}`3877` +- Error messages and deprecation warnings name an option by its spec instead of + its internal name, so they read `The option '--my-option' is deprecated.` where + they read `The option 'my_option' is deprecated.` before. {pr}`3877` +- {attr}`Parameter.human_readable_name` is deprecated and will be removed in + Click 9.0. Use {attr}`Parameter.spec` instead. It returned the internal name + for options, so its value changes from `my_option` to `--my-option`. Click no + longer reads it, and a subclass that overrides it gets a warning when the class + is defined. {pr}`3877` ## Version 8.5.1 diff --git a/docs/parameters.md b/docs/parameters.md index 58f3219896..501b6c0a60 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -31,25 +31,47 @@ On each principle type you can specify {ref}`parameter-types`. Specifying these ## Parameter Names -Parameters (options and arguments) have a name that will be used as -the Python argument name when calling the decorated function with -values. +Three distinct strings designate a parameter, and each one has its own audience: -In the example, the argument's name is `filename`. The name must match the python arg name. To provide a different name for use in help text, see {ref}`doc-meta-variables`. -The option's names are `-t` and `--times`. More names are available for options and are covered in {ref}`options`. +- `decls` are the declarations you pass to the decorator. Click parses them to derive every other string in this section. +- `spec` is what the user reads in `--help` and types on the command line. Read it from {attr}`Parameter.spec`, or from {meth}`Parameter.get_help_spec` for the longer form the help page lays out in its left column. +- `name` is what Click uses internally to identify the parameter. It is used as a Python argument and is passed to the decorated function. Read it from {attr}`Parameter.name`. + +Click also keeps the parsed declarations on the parameter. {attr}`Parameter.opts` holds every spelling an option answers to, and {attr}`Parameter.secondary_opts` the ones that set a boolean flag to false. ```{eval-rst} .. click:example:: @click.command() - @click.argument('filename') - @click.option('-t', '--times', type=int) - def multi_echo(filename, times): - """Print value filename multiple times.""" - for x in range(times): - click.echo(filename) + @click.argument('recipe') + @click.option('-g', '--gluten-free/--no-gluten-free', default=False) + def bake(recipe, gluten_free): + """Bake the RECIPE, with or without gluten.""" + note = 'no gluten' if gluten_free else 'with gluten' + click.echo(f'{recipe}: {note}') + +.. click:run:: + + invoke(bake, ['--gluten-free', 'brioche'], prog_name='bake') + .. click:run:: - invoke(multi_echo, ['--times=3', 'index.txt'], prog_name='multi_echo') + invoke(bake, ['--help'], prog_name='bake') ``` + +The example holds one argument and one option. The table lists all five strings for each of them: + +| | `@click.argument('recipe')` | `@click.option('-g', '--gluten-free/--no-gluten-free')` | +|---|---|---| +| `decls` | `('recipe',)` | `('-g', '--gluten-free/--no-gluten-free')` | +| `spec` | `RECIPE` | `--gluten-free` | +| `name` | `recipe` | `gluten_free` | +| `opts` | `['recipe']` | `['-g', '--gluten-free']` | +| `secondary_opts` | `[]` | `['--no-gluten-free']` | + +Click derives an option's `name` from the declaration with the longest prefix, so it picks `--gluten-free` over `-g`. It then lowercases that declaration and replaces its dashes with underscores, which gives `gluten_free`. The name must match the Python argument name of the decorated function. More declarations are available for options and are covered in {ref}`options`. + +An argument has a single declaration. It becomes the only entry in `opts`, and `name` is that declaration lowercased, with dashes replaced by underscores. The `spec` is the name in upper case. `secondary_opts` stays empty, since the parser binds an argument by name and never matches a spelling. To choose the spec an argument shows in help text, see {ref}`doc-meta-variables`. + +The help page above shows each `spec` where the user reads it: `RECIPE` in the usage line, and `-g, --gluten-free / --no-gluten-free` in the left column of the options list, which is the longer form {meth}`Parameter.get_help_spec` returns. diff --git a/docs/upgrade-guides.md b/docs/upgrade-guides.md index 19dcf6748d..14d14c7c24 100644 --- a/docs/upgrade-guides.md +++ b/docs/upgrade-guides.md @@ -16,6 +16,7 @@ For each deprecation, provide a brief explanation, and direct users to new funct - A parameter name that is not a valid Python identifier, or that is a [Python keyword](https://docs.python.org/3/reference/lexical_analysis.html#keywords), is deprecated and raises `TypeError` in 9.0. [`str.isidentifier()`](https://docs.python.org/3/library/stdtypes.html#str.isidentifier) decides the first case, so `click.argument("0foo")` fails it. It accepts a keyword, so `click.option("--from")` names a parameter `from`, which no callback can declare. [Soft keywords](https://docs.python.org/3/reference/lexical_analysis.html#soft-keywords) such as `match` and `type` are contextual and unaffected, and `--True` and `--None` lower case out of the keyword set. To migrate, pass an explicit name (`click.option("--from", "source")`, `click.option("--0-file", "zero_file")`). An argument takes one declaration and has no explicit-name channel, so rename it and pass `metavar` to keep its old display (`click.argument("zero_file", metavar="0-FILE")`). See [#3827](https://github.com/pallets/click/pull/3827) and [#3866](https://github.com/pallets/click/pull/3866). - An option name written as a Python identifier is deprecated when it is not already lower cased: `click.option("--x", "Foo_Bar")` names `foo_bar` rather than `Foo_Bar`. To migrate, spell the name the way the callback declares it. See [#3827](https://github.com/pallets/click/pull/3827). - `CliRunner.isolated_filesystem()` is deprecated and will be removed in Click 9.0. The helper predates Python 3 and modern pytest, and it relies on `os.chdir`, which mutates process-global state and is therefore not thread-safe. Replace it with a temporary directory (`tempfile.TemporaryDirectory`, or pytest's `tmp_path` fixture) and pass absolute paths to the command instead of relying on the current working directory. To run commands in parallel, use process-based isolation (such as `pytest-xdist`) rather than threads, since `CliRunner.invoke()` also redirects the process-global standard streams and other interpreter-wide state. See [#3700](https://github.com/pallets/click/issues/3700), [#3501](https://github.com/pallets/click/issues/3501) and the [testing guide](testing.md#running-tests-in-parallel). +- `Parameter.human_readable_name` is deprecated and will be removed in Click 9.0. Use `Parameter.spec` instead, which returns how the user spells the parameter on the command line. For an argument the two agree, since both return the metavar. For an option the old property returned the internal `name`, so the value changes from `my_option` to `--my-option`. Click no longer reads the property, so an override in a subclass has no effect: override `Parameter.spec` instead. Defining a subclass that still overrides `human_readable_name` emits a `DeprecationWarning`. See the [parameter names](parameters.md#parameter-names) section for the three strings that designate a parameter. ### Removals with prior deprecation @@ -29,7 +30,7 @@ The changes were not able to be deprecated prior to removal. Explain clearly why ### Changes -- TBD +- Error messages and deprecation warnings name an option by its spec rather than its internal name, so `The option 'my_option' is deprecated.` now reads `The option '--my-option' is deprecated.` Tests asserting on the old wording need updating. ### Fixes diff --git a/src/click/core.py b/src/click/core.py index 1f9323a696..59ed94eee7 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -105,9 +105,13 @@ def _echo_aborted() -> None: echo(_("Aborted!"), file=sys.stderr) -def _outside_click_stacklevel() -> int: +def _outside_click_stacklevel(skip: cabc.Container[str] = ()) -> int: """Depth of the first stack frame outside Click. + :param skip: Other module names to walk past. ``abc`` is one: it sits + between ``__init_subclass__`` and the ``class`` statement that the + warning must point at. + .. versionadded:: 8.6.0 """ frame: FrameType | None = sys._getframe(1) @@ -116,7 +120,7 @@ def _outside_click_stacklevel() -> int: while frame is not None: module = frame.f_globals.get("__name__", "") - if module != "click" and not module.startswith("click."): + if module != "click" and not module.startswith("click.") and module not in skip: return level frame = frame.f_back @@ -2242,6 +2246,17 @@ class Parameter(ABC): Some settings are supported by both options and arguments. + Three distinct strings designate a parameter, each with its own audience: + + ``decls`` + What the developer passes to the constructor as ``param_decls``. + ``spec`` + What the user reads in ``--help`` and types on the command line. See + :attr:`spec` and :meth:`get_help_spec`. + ``name`` + What Click uses internally to identify the parameter and to pass its + value to the command callback. See :attr:`name`. + :param param_decls: the parameter declarations for this option or argument. This is a list of flags or argument names. @@ -2334,8 +2349,29 @@ class Parameter(ABC): param_type_name = "parameter" name: str + """What Click uses internally to identify the parameter, and the Python + argument name it passes to the command callback. Click derives it from the + declarations, lower cased and with dashes replaced by underscores. An + :class:`Option` takes the declaration with the longest prefix, so + ``"-t", "--times"`` names ``times``, while an :class:`Argument` takes its + single declaration. + """ opts: list[str] + """Every command-line spelling that designates this parameter. + + For an :class:`Option` these are the prefixed declarations the parser + matches, in declaration order: ``["-t", "--times"]``. :attr:`spec` is the + canonical one. + + For an :class:`Argument` this holds the single declaration the developer + wrote, ``["filename"]``. The parser never reads it: it binds the value by + :attr:`name`. The string the user reads is :attr:`spec`, ``FILENAME``. + """ secondary_opts: list[str] + """The spellings that set a boolean :class:`Option` to ``False``, taken from + the right side of a ``--color/--no-color`` declaration. Always empty for an + :class:`Argument`. + """ # `Parameter.type` is annotated in `__init__` to avoid confusing mypy required: bool callback: t.Callable[[Context, Parameter, t.Any], t.Any] | None @@ -2353,6 +2389,20 @@ class Parameter(ABC): deprecated: bool | str help: str | None + def __init_subclass__(cls, **kwargs: t.Any) -> None: + super().__init_subclass__(**kwargs) + + if "human_readable_name" in cls.__dict__: + import warnings + + warnings.warn( + f"{cls.__name__} overrides 'human_readable_name'. Click no" + " longer reads that property. Override 'spec' instead. Click" + " will remove 'human_readable_name' in 9.0.", + DeprecationWarning, + stacklevel=_outside_click_stacklevel(skip=("abc",)), + ) + def __init__( self, param_decls: cabc.Sequence[str] | None = None, @@ -2432,7 +2482,7 @@ def __init__( if required and deprecated: raise ValueError( - f"The {self.param_type_name} '{self.human_readable_name}' " + f"The {self.param_type_name} '{self.spec}' " "is deprecated and still required. A deprecated " f"{self.param_type_name} cannot be required." ) @@ -2516,12 +2566,56 @@ def _check_name_is_usable(self, name: str, decls: cabc.Sequence[str]) -> None: stacklevel=_outside_click_stacklevel(), ) + @property + def spec(self) -> str: + """Returns how the user spells this parameter on the command line. + + This is the declaration with the longest prefix, so an option declared + as ``"-t", "--times"`` returns ``--times``. Click derives :attr:`name` + from that same declaration, unless the developer passed an explicit + name. A parameter without declarations returns its :attr:`name`. + + .. versionadded:: 8.6.0 + """ + if not self.opts: + return self.name + + return max(self.opts, key=lambda opt: len(_split_opt(opt)[0])) + @property def human_readable_name(self) -> str: - """Returns the human readable name of this parameter. This is the - same as the name for options, but the metavar for arguments. + """Returns how the user spells this parameter on the command line. + + .. deprecated:: 8.6.0 + Will be removed in Click 9.0. Use :attr:`spec` instead. For an + option this returned the internal :attr:`name`, so the value + changes from ``times`` to ``--times``. Click no longer reads this + property, so an override in a subclass has no effect. """ - return self.name + import warnings + + warnings.warn( + "'human_readable_name' is deprecated and will be removed in Click" + " 9.0. Use 'spec' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.spec + + def get_help_spec(self, ctx: Context) -> str: + """Returns the left column of this parameter's help record. + + This implementation returns :attr:`spec`. :class:`Option` returns every + spelling, plus the metavar when the option takes a value. + :class:`Argument` returns its metavar, which already carries the markers + for an optional, repeated or deprecated value. + + Unlike ``get_help_record()``, the spec is produced even when the + parameter is left out of the help page, like a hidden option. + + .. versionadded:: 8.6.0 + """ + return self.spec def make_metavar(self, ctx: Context) -> str: if self.metavar is not None: @@ -2873,7 +2967,7 @@ def handle_parse_result( "{extra_message}" ).format( param_type=self.param_type_name, - name=self.human_readable_name, + name=self.spec, extra_message=_format_deprecated_suffix(self.deprecated), ) echo(style(message, fg="red"), err=True) @@ -2933,7 +3027,7 @@ def get_error_hint(self, ctx: Context | None) -> str: .. versionchanged:: 8.4.0 ``ctx`` can be ``None``. """ - hint_list = self.opts or [self.human_readable_name] + hint_list = self.opts or [self.spec] return " / ".join(f"'{x}'" for x in hint_list) def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]: @@ -3835,7 +3929,14 @@ def __init__( super().__init__(param_decls, required=required, help=help, **attrs) @property - def human_readable_name(self) -> str: + def spec(self) -> str: + """Returns the argument's metavar, which is how the user reads it in + ``--help``. An argument has no flag to spell, so this is the + :attr:`metavar` if one was given, and the uppercased :attr:`name` + otherwise. + + .. versionadded:: 8.6.0 + """ if self.metavar is not None: return self.metavar return self.name.upper() @@ -3883,6 +3984,14 @@ def _parse_decls( def get_usage_pieces(self, ctx: Context) -> list[str]: return [self.make_metavar(ctx)] + def get_help_spec(self, ctx: Context) -> str: + """Returns the left column of the argument's help record: its metavar, + like ``FILENAME`` or ``[FILENAME]``. + + .. versionadded:: 8.6.0 + """ + return self.make_metavar(ctx) + def get_help_record(self, ctx: Context) -> tuple[str, str]: """Returns the argument's help row: its metavar and its help text. @@ -3894,12 +4003,12 @@ def get_help_record(self, ctx: Context) -> tuple[str, str]: Always returns a tuple. It used to return ``None`` when ``help`` was not set. """ - return self.make_metavar(ctx), self.help or "" + return self.get_help_spec(ctx), self.help or "" def get_error_hint(self, ctx: Context | None) -> str: if ctx is not None: - return f"'{self.make_metavar(ctx)}'" - return f"'{self.human_readable_name}'" + return f"'{self.get_help_spec(ctx)}'" + return f"'{self.spec}'" def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 75f72889a0..5921905cd6 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -862,6 +862,36 @@ def cli(f): assert "(DEPRECATED)" in result.output +@pytest.mark.parametrize( + ("kwargs", "expected_spec", "expected_help_spec"), + [ + ({}, "FILENAME", "FILENAME"), + ({"required": False}, "FILENAME", "[FILENAME]"), + ({"nargs": -1}, "FILENAME", "[FILENAME]..."), + ({"metavar": ""}, "", ""), + ], +) +def test_spec_and_help_spec(kwargs, expected_spec, expected_help_spec): + """The spec names the argument, while the help spec decorates it with the + optionality and repetition markers shown on the help page. + """ + arg = click.Argument(["filename"], **kwargs) + ctx = click.Context(click.Command("cli")) + assert arg.spec == expected_spec + assert arg.get_help_spec(ctx) == expected_help_spec + + +@pytest.mark.parametrize("help_text", [None, "path to the file"]) +def test_help_spec_matches_help_record(help_text): + """The help spec is the left column of the argument's help record, whether + or not the argument documents itself. + """ + arg = click.Argument(["filename"], help=help_text) + ctx = click.Context(click.Command("cli")) + assert arg.get_help_spec(ctx) == "FILENAME" + assert arg.get_help_record(ctx)[0] == arg.get_help_spec(ctx) + + @pytest.mark.parametrize( ("deprecated", "expected"), [(True, "(DEPRECATED)"), ("USE B INSTEAD", "(DEPRECATED: USE B INSTEAD)")], diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 49e709a44a..231dd6103b 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -68,6 +68,42 @@ def test_context_protected_args_deprecated(): assert ctx.protected_args == [] +@pytest.mark.parametrize( + ("param", "expected"), + [ + (click.Option(["-t", "--times"]), "--times"), + (click.Argument(["filename"]), "FILENAME"), + ], + ids=["option", "argument"], +) +def test_parameter_human_readable_name_deprecated(param, expected): + with pytest.warns(DeprecationWarning, match="human_readable_name"): + assert param.human_readable_name == expected + + assert param.spec == expected + + +@pytest.mark.parametrize("base", [click.Parameter, click.Option, click.Argument]) +def test_parameter_human_readable_name_override_deprecated(base): + """Click reads ``spec``, so an override of the old property has no effect.""" + with pytest.warns(DeprecationWarning, match="Override 'spec' instead"): + + class Custom(base): # type: ignore[misc, valid-type] + @property + def human_readable_name(self) -> str: + return "custom" + + +@pytest.mark.parametrize("base", [click.Parameter, click.Option, click.Argument]) +def test_parameter_subclass_without_override_is_silent(base): + """The warning names a subclass that overrides the property, and no other.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + + class Custom(base): # type: ignore[misc, valid-type] + pass + + def test_isolated_filesystem_deprecated(runner): with pytest.warns(DeprecationWarning, match="isolated_filesystem"): with runner.isolated_filesystem(): diff --git a/tests/test_options.py b/tests/test_options.py index 472ec1cc60..3434ce64f2 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -17,6 +17,7 @@ from click import Option from click import UNPROCESSED from click._utils import UNSET +from click.parser import _split_opt from click.testing import CliRunner @@ -98,6 +99,34 @@ def test_help_spec(param_decls, kwargs, hidden, expected): assert opt.get_help_record(ctx)[0] == expected +@pytest.mark.parametrize( + ("param_decls", "expected_spec", "expected_name"), + [ + (["-t", "--times"], "--times", "times"), + (["--times", "-t"], "--times", "times"), + (["-t"], "-t", "t"), + (["-t", "--times", "--repeat"], "--times", "times"), + (["--color/--no-color"], "--color", "color"), + (["/debug;/no-debug"], "/debug", "debug"), + ], +) +def test_spec(param_decls, expected_spec, expected_name): + """The spec is the declaration the name was derived from, so the longest + prefix wins over declaration order. + """ + opt = click.Option(param_decls) + assert opt.spec == expected_spec + assert opt.name == expected_name + assert _split_opt(opt.spec)[1].replace("-", "_").lower() == opt.name + + +def test_spec_with_explicit_name(): + """An explicit name declaration leaves the spec to the remaining flags.""" + opt = click.Option(["--times", "repeat_count"]) + assert opt.spec == "--times" + assert opt.name == "repeat_count" + + @pytest.mark.parametrize("deprecated", [True, "USE B INSTEAD"]) def test_deprecated_warning(runner, deprecated): @click.command() @@ -114,7 +143,7 @@ def cli(my_option: str): result = runner.invoke(cli, ["--my-option", "hello"]) assert result.exit_code == 0, result.output - assert "option 'my_option' is deprecated" in result.output + assert "option '--my-option' is deprecated" in result.output if isinstance(deprecated, str): assert deprecated in result.output