Skip to content

Commit 5961d31

Browse files
Introduce Parameter.deprecated + Command.deprecated message customization (pallets#2271)
Co-authored-by: Andreas Backx <andreas@backx.org>
1 parent fde47b4 commit 5961d31

File tree

5 files changed

+198
-14
lines changed

5 files changed

+198
-14
lines changed

CHANGES.rst

+10
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ Unreleased
7979
allows the user to search for future output of the generator when
8080
using less and then aborting the program using ctrl-c.
8181

82+
- ``deprecated: bool | str`` can now be used on options and arguments. This
83+
previously was only available for ``Command``. The message can now also be
84+
customised by using a ``str`` instead of a ``bool``. :issue:`2263` :pr:`2271`
85+
86+
- ``Command.deprecated`` formatting in ``--help`` changed from
87+
``(Deprecated) help`` to ``help (DEPRECATED)``.
88+
- Parameters cannot be required nor prompted or an error is raised.
89+
- A warning will be printed when something deprecated is used.
90+
91+
8292
Version 8.1.8
8393
-------------
8494

src/click/core.py

+91-9
Original file line numberDiff line numberDiff line change
@@ -856,12 +856,15 @@ class Command:
856856
If enabled this will add ``--help`` as argument
857857
if no arguments are passed
858858
:param hidden: hide this command from help outputs.
859-
860-
:param deprecated: issues a message indicating that
861-
the command is deprecated.
859+
:param deprecated: If ``True`` or non-empty string, issues a message
860+
indicating that the command is deprecated and highlights
861+
its deprecation in --help. The message can be customized
862+
by using a string as the value.
862863
863864
.. versionchanged:: 8.2
864865
This is the base class for all commands, not ``BaseCommand``.
866+
``deprecated`` can be set to a string as well to customize the
867+
deprecation message.
865868
866869
.. versionchanged:: 8.1
867870
``help``, ``epilog``, and ``short_help`` are stored unprocessed,
@@ -905,7 +908,7 @@ def __init__(
905908
add_help_option: bool = True,
906909
no_args_is_help: bool = False,
907910
hidden: bool = False,
908-
deprecated: bool = False,
911+
deprecated: bool | str = False,
909912
) -> None:
910913
#: the name the command thinks it has. Upon registering a command
911914
#: on a :class:`Group` the group will default the command name
@@ -1059,7 +1062,14 @@ def get_short_help_str(self, limit: int = 45) -> str:
10591062
text = ""
10601063

10611064
if self.deprecated:
1062-
text = _("(Deprecated) {text}").format(text=text)
1065+
deprecated_message = (
1066+
f"(DEPRECATED: {self.deprecated})"
1067+
if isinstance(self.deprecated, str)
1068+
else "(DEPRECATED)"
1069+
)
1070+
text = _("{text} {deprecated_message}").format(
1071+
text=text, deprecated_message=deprecated_message
1072+
)
10631073

10641074
return text.strip()
10651075

@@ -1089,7 +1099,14 @@ def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None:
10891099
text = ""
10901100

10911101
if self.deprecated:
1092-
text = _("(Deprecated) {text}").format(text=text)
1102+
deprecated_message = (
1103+
f"(DEPRECATED: {self.deprecated})"
1104+
if isinstance(self.deprecated, str)
1105+
else "(DEPRECATED)"
1106+
)
1107+
text = _("{text} {deprecated_message}").format(
1108+
text=text, deprecated_message=deprecated_message
1109+
)
10931110

10941111
if text:
10951112
formatter.write_paragraph()
@@ -1183,9 +1200,13 @@ def invoke(self, ctx: Context) -> t.Any:
11831200
in the right way.
11841201
"""
11851202
if self.deprecated:
1203+
extra_message = (
1204+
f" {self.deprecated}" if isinstance(self.deprecated, str) else ""
1205+
)
11861206
message = _(
11871207
"DeprecationWarning: The command {name!r} is deprecated."
1188-
).format(name=self.name)
1208+
"{extra_message}"
1209+
).format(name=self.name, extra_message=extra_message)
11891210
echo(style(message, fg="red"), err=True)
11901211

11911212
if self.callback is not None:
@@ -1988,6 +2009,18 @@ class Parameter:
19882009
given. Takes ``ctx, param, incomplete`` and must return a list
19892010
of :class:`~click.shell_completion.CompletionItem` or a list of
19902011
strings.
2012+
:param deprecated: If ``True`` or non-empty string, issues a message
2013+
indicating that the argument is deprecated and highlights
2014+
its deprecation in --help. The message can be customized
2015+
by using a string as the value. A deprecated parameter
2016+
cannot be required, a ValueError will be raised otherwise.
2017+
2018+
.. versionchanged:: 8.2.0
2019+
Introduction of ``deprecated``.
2020+
2021+
.. versionchanged:: 8.2
2022+
Adding duplicate parameter names to a :class:`~click.core.Command` will
2023+
result in a ``UserWarning`` being shown.
19912024
19922025
.. versionchanged:: 8.2
19932026
Adding duplicate parameter names to a :class:`~click.core.Command` will
@@ -2044,6 +2077,7 @@ def __init__(
20442077
[Context, Parameter, str], list[CompletionItem] | list[str]
20452078
]
20462079
| None = None,
2080+
deprecated: bool | str = False,
20472081
) -> None:
20482082
self.name: str | None
20492083
self.opts: list[str]
@@ -2071,6 +2105,7 @@ def __init__(
20712105
self.metavar = metavar
20722106
self.envvar = envvar
20732107
self._custom_shell_complete = shell_complete
2108+
self.deprecated = deprecated
20742109

20752110
if __debug__:
20762111
if self.type.is_composite and nargs != self.type.arity:
@@ -2113,6 +2148,13 @@ def __init__(
21132148
f"'default' {subject} must match nargs={nargs}."
21142149
)
21152150

2151+
if required and deprecated:
2152+
raise ValueError(
2153+
f"The {self.param_type_name} '{self.human_readable_name}' "
2154+
"is deprecated and still required. A deprecated "
2155+
f"{self.param_type_name} cannot be required."
2156+
)
2157+
21162158
def to_info_dict(self) -> dict[str, t.Any]:
21172159
"""Gather information that could be useful for a tool generating
21182160
user-facing documentation.
@@ -2332,6 +2374,29 @@ def handle_parse_result(
23322374
) -> tuple[t.Any, list[str]]:
23332375
with augment_usage_errors(ctx, param=self):
23342376
value, source = self.consume_value(ctx, opts)
2377+
2378+
if (
2379+
self.deprecated
2380+
and value is not None
2381+
and source
2382+
not in (
2383+
ParameterSource.DEFAULT,
2384+
ParameterSource.DEFAULT_MAP,
2385+
)
2386+
):
2387+
extra_message = (
2388+
f" {self.deprecated}" if isinstance(self.deprecated, str) else ""
2389+
)
2390+
message = _(
2391+
"DeprecationWarning: The {param_type} {name!r} is deprecated."
2392+
"{extra_message}"
2393+
).format(
2394+
param_type=self.param_type_name,
2395+
name=self.human_readable_name,
2396+
extra_message=extra_message,
2397+
)
2398+
echo(style(message, fg="red"), err=True)
2399+
23352400
ctx.set_parameter_source(self.name, source) # type: ignore
23362401

23372402
try:
@@ -2402,7 +2467,8 @@ class Option(Parameter):
24022467
Normally, environment variables are not shown.
24032468
:param prompt: If set to ``True`` or a non empty string then the
24042469
user will be prompted for input. If set to ``True`` the prompt
2405-
will be the option name capitalized.
2470+
will be the option name capitalized. A deprecated option cannot be
2471+
prompted.
24062472
:param confirmation_prompt: Prompt a second time to confirm the
24072473
value if it was prompted for. Can be set to a string instead of
24082474
``True`` to customize the message.
@@ -2469,13 +2535,16 @@ def __init__(
24692535
hidden: bool = False,
24702536
show_choices: bool = True,
24712537
show_envvar: bool = False,
2538+
deprecated: bool | str = False,
24722539
**attrs: t.Any,
24732540
) -> None:
24742541
if help:
24752542
help = inspect.cleandoc(help)
24762543

24772544
default_is_missing = "default" not in attrs
2478-
super().__init__(param_decls, type=type, multiple=multiple, **attrs)
2545+
super().__init__(
2546+
param_decls, type=type, multiple=multiple, deprecated=deprecated, **attrs
2547+
)
24792548

24802549
if prompt is True:
24812550
if self.name is None:
@@ -2487,6 +2556,14 @@ def __init__(
24872556
else:
24882557
prompt_text = prompt
24892558

2559+
if deprecated:
2560+
deprecated_message = (
2561+
f"(DEPRECATED: {deprecated})"
2562+
if isinstance(deprecated, str)
2563+
else "(DEPRECATED)"
2564+
)
2565+
help = help + deprecated_message if help is not None else deprecated_message
2566+
24902567
self.prompt = prompt_text
24912568
self.confirmation_prompt = confirmation_prompt
24922569
self.prompt_required = prompt_required
@@ -2548,6 +2625,9 @@ def __init__(
25482625
self.show_envvar = show_envvar
25492626

25502627
if __debug__:
2628+
if deprecated and prompt:
2629+
raise ValueError("`deprecated` options cannot use `prompt`.")
2630+
25512631
if self.nargs == -1:
25522632
raise TypeError("nargs=-1 is not supported for options.")
25532633

@@ -2983,6 +3063,8 @@ def make_metavar(self) -> str:
29833063
var = self.type.get_metavar(self)
29843064
if not var:
29853065
var = self.name.upper() # type: ignore
3066+
if self.deprecated:
3067+
var += "!"
29863068
if not self.required:
29873069
var = f"[{var}]"
29883070
if self.nargs != 1:

tests/test_arguments.py

+38
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,44 @@ def cli(f):
275275
assert result.output == "test\n"
276276

277277

278+
def test_deprecated_usage(runner):
279+
@click.command()
280+
@click.argument("f", required=False, deprecated=True)
281+
def cli(f):
282+
click.echo(f)
283+
284+
result = runner.invoke(cli, ["--help"])
285+
assert result.exit_code == 0, result.output
286+
assert "[F!]" in result.output
287+
288+
289+
@pytest.mark.parametrize("deprecated", [True, "USE B INSTEAD"])
290+
def test_deprecated_warning(runner, deprecated):
291+
@click.command()
292+
@click.argument(
293+
"my-argument", required=False, deprecated=deprecated, default="default argument"
294+
)
295+
def cli(my_argument: str):
296+
click.echo(f"{my_argument}")
297+
298+
# defaults should not give a deprecated warning
299+
result = runner.invoke(cli, [])
300+
assert result.exit_code == 0, result.output
301+
assert "is deprecated" not in result.output
302+
303+
result = runner.invoke(cli, ["hello"])
304+
assert result.exit_code == 0, result.output
305+
assert "argument 'MY_ARGUMENT' is deprecated" in result.output
306+
307+
if isinstance(deprecated, str):
308+
assert deprecated in result.output
309+
310+
311+
def test_deprecated_required(runner):
312+
with pytest.raises(ValueError, match="is deprecated and still required"):
313+
click.Argument(["a"], required=True, deprecated=True)
314+
315+
278316
def test_eat_options(runner):
279317
@click.command()
280318
@click.option("-f")

tests/test_commands.py

+13-5
Original file line numberDiff line numberDiff line change
@@ -318,23 +318,31 @@ def cli(verbose, args):
318318

319319

320320
@pytest.mark.parametrize("doc", ["CLI HELP", None])
321-
def test_deprecated_in_help_messages(runner, doc):
322-
@click.command(deprecated=True, help=doc)
321+
@pytest.mark.parametrize("deprecated", [True, "USE OTHER COMMAND INSTEAD"])
322+
def test_deprecated_in_help_messages(runner, doc, deprecated):
323+
@click.command(deprecated=deprecated, help=doc)
323324
def cli():
324325
pass
325326

326327
result = runner.invoke(cli, ["--help"])
327-
assert "(Deprecated)" in result.output
328+
assert "(DEPRECATED" in result.output
328329

330+
if isinstance(deprecated, str):
331+
assert deprecated in result.output
329332

330-
def test_deprecated_in_invocation(runner):
331-
@click.command(deprecated=True)
333+
334+
@pytest.mark.parametrize("deprecated", [True, "USE OTHER COMMAND INSTEAD"])
335+
def test_deprecated_in_invocation(runner, deprecated):
336+
@click.command(deprecated=deprecated)
332337
def deprecated_cmd():
333338
pass
334339

335340
result = runner.invoke(deprecated_cmd)
336341
assert "DeprecationWarning:" in result.output
337342

343+
if isinstance(deprecated, str):
344+
assert deprecated in result.output
345+
338346

339347
def test_command_parse_args_collects_option_prefixes():
340348
@click.command()

tests/test_options.py

+46
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,52 @@ def test_invalid_option(runner):
3333
assert "'--foo'" in message
3434

3535

36+
@pytest.mark.parametrize("deprecated", [True, "USE B INSTEAD"])
37+
def test_deprecated_usage(runner, deprecated):
38+
@click.command()
39+
@click.option("--foo", default="bar", deprecated=deprecated)
40+
def cmd(foo):
41+
click.echo(foo)
42+
43+
result = runner.invoke(cmd, ["--help"])
44+
assert "(DEPRECATED" in result.output
45+
46+
if isinstance(deprecated, str):
47+
assert deprecated in result.output
48+
49+
50+
@pytest.mark.parametrize("deprecated", [True, "USE B INSTEAD"])
51+
def test_deprecated_warning(runner, deprecated):
52+
@click.command()
53+
@click.option(
54+
"--my-option", required=False, deprecated=deprecated, default="default option"
55+
)
56+
def cli(my_option: str):
57+
click.echo(f"{my_option}")
58+
59+
# defaults should not give a deprecated warning
60+
result = runner.invoke(cli, [])
61+
assert result.exit_code == 0, result.output
62+
assert "is deprecated" not in result.output
63+
64+
result = runner.invoke(cli, ["--my-option", "hello"])
65+
assert result.exit_code == 0, result.output
66+
assert "option 'my_option' is deprecated" in result.output
67+
68+
if isinstance(deprecated, str):
69+
assert deprecated in result.output
70+
71+
72+
def test_deprecated_required(runner):
73+
with pytest.raises(ValueError, match="is deprecated and still required"):
74+
click.Option(["--a"], required=True, deprecated=True)
75+
76+
77+
def test_deprecated_prompt(runner):
78+
with pytest.raises(ValueError, match="`deprecated` options cannot use `prompt`"):
79+
click.Option(["--a"], prompt=True, deprecated=True)
80+
81+
3682
def test_invalid_nargs(runner):
3783
with pytest.raises(TypeError, match="nargs=-1"):
3884

0 commit comments

Comments
 (0)