Repository: pallets/click (python)

Issue:
Mark parameter as deprecated
Thanks for this great project!
Perhaps it's just me who feels that it is rather cumbersome mark a single parameter as deprecated (the closet I found was https://stackoverflow.com/a/50402799). Is there perhaps a more official method to mark a single parameter as deprecated?
That would somewhat be similar to https://github.com/pallets/click/issues/1507.

Possible scenarios:
- the parameter was renamed (e.g. a previous version was using `--name` as option, but now a dev want's to make sure it's the `--sur-name`.
- the parameter is not needed anymore

Perhaps as an example consider:
```
import click

@click.command()
@click.option('--count', default=1, help='Number of greetings.')
@click.option('--name', prompt='Your name',
              help='The person to greet.')
def hello(count, name):
    """Simple program that greets NAME for a total of COUNT times."""
    for x in range(count):
        click.echo(f"Hello {name}!")

if __name__ == '__main__':
    hello()
```
and I want to rename `--count` to `--greetings-count`.
However I don't want to remove  `--count`  instantly, instead when users use `--count` prompt a message that they should use the new `--greetings-count`. The usage of `--count` and `--greetings-count` should be forbidden.

Thanks.

Pull request:
Introduce Parameter.deprecated + Command.deprecated message customization
Implement of #2263, Showing deprecation message with `--help` for `@click.option`

- fixes #2263 

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [x] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index aedea88..a90ff58 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -79,6 +79,16 @@ Unreleased
         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.
+
+
 Version 8.1.8
 -------------
 
diff --git a/src/click/core.py b/src/click/core.py
index abe9fa9..1c1a467 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -856,12 +856,15 @@ class Command:
                             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.
+    :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,
@@ -905,7 +908,7 @@ class Command:
         add_help_option: bool = True,
         no_args_is_help: bool = False,
         hidden: bool = False,
-        deprecated: 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
@@ -1059,7 +1062,14 @@ class Command:
             text = ""
 
         if self.deprecated:
-            text = _("(Deprecated) {text}").format(text=text)
+            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()
 
@@ -1089,7 +1099,14 @@ class Command:
             text = ""
 
         if self.deprecated:
-            text = _("(Deprecated) {text}").format(text=text)
+            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()
@@ -1183,9 +1200,13 @@ class Command:
         in the right way.
         """
         if self.deprecated:
+            extra_message = (
+                f" {self.deprecated}" if isinstance(self.deprecated, str) else ""
+            )
             message = _(
                 "DeprecationWarning: The command {name!r} is deprecated."
-            ).format(name=self.name)
+                "{extra_message}"
+            ).format(name=self.name, extra_message=extra_message)
             echo(style(message, fg="red"), err=True)
 
         if self.callback is not None:
@@ -1988,6 +2009,18 @@ 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
@@ -2044,6 +2077,7 @@ class Parameter:
             [Context, Parameter, str], list[CompletionItem] | list[str]
         ]
         | None = None,
+        deprecated: bool | str = False,
     ) -> None:
         self.name: str | None
         self.opts: list[str]
@@ -2071,6 +2105,7 @@ class Parameter:
         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:
@@ -2113,6 +2148,13 @@ class Parameter:
                             f"'default' {subject} must match nargs={nargs}."
                         )
 
+            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.
@@ -2332,6 +2374,29 @@ class Parameter:
     ) -> 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:
@@ -2402,7 +2467,8 @@ class Option(Parameter):
         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.
@@ -2469,13 +2535,16 @@ class Option(Parameter):
         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:
@@ -2487,6 +2556,14 @@ class Option(Parameter):
         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
@@ -2548,6 +2625,9 @@ class Option(Parameter):
         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.")
 
@@ -2983,6 +3063,8 @@ class Argument(Parameter):
         var = self.type.get_metavar(self)
         if not var:
             var = self.name.upper()  # type: ignore
+        if self.deprecated:
+            var += "!"
         if not self.required:
             var = f"[{var}]"
         if self.nargs != 1:


Test patch:
diff --git a/tests/test_arguments.py b/tests/test_arguments.py
index 5dc56a4..8c1ff00 100644
--- a/tests/test_arguments.py
+++ b/tests/test_arguments.py
@@ -275,6 +275,44 @@ def test_implicit_non_required(runner):
     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")
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 02c4a30..eb8df85 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -318,23 +318,31 @@ def test_unprocessed_options(runner):
 
 
 @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_options.py b/tests/test_options.py
index 2a3b692..b7267c1 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"):