diff --git a/CHANGES.md b/CHANGES.md index 8991e82576..7b91bee1e8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,12 @@ +## Version 8.5.0 + +Unreleased + +- Supported versions of Windows enable ANSI terminal styles by default. + Colorama is no longer a dependency and is not used. {issue}`2986` {pr}`3505` +- {class}`Argument` accepts a `help` parameter, and help output includes + a `Positional arguments` section when argument help is available. {issue}`2983` {pr}`3473` + ## Version 8.4.2 Unreleased diff --git a/docs/arguments.md b/docs/arguments.md index 90d37c1569..81b331bd5d 100644 --- a/docs/arguments.md +++ b/docs/arguments.md @@ -10,10 +10,13 @@ Arguments are: * Are positional in nature. * Similar to a limited version of {ref}`options ` that can take an arbitrary number of inputs -* {ref}`Documented manually `. +* Can take an optional `help` string shown in the ``Positional arguments`` + section of the help page, or be {ref}`documented in the command docstring + `. Useful and often used kwargs are: +* `help`: Help text for the argument. * `default`: Passes a default. * `nargs`: Sets the number of arguments. Set to -1 to take an arbitrary number. diff --git a/docs/changes.md b/docs/changes.md index 58b5a195c9..5f522c2220 100644 --- a/docs/changes.md +++ b/docs/changes.md @@ -1,5 +1,4 @@ # Changes - ```{include} ../CHANGES.md ``` diff --git a/docs/documentation.md b/docs/documentation.md index 73a0ea75f1..1215c85e50 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -15,7 +15,7 @@ Simple example: .. click:example:: @click.command() - @click.argument('name') + @click.argument('name', help='The name to print') @click.option('--count', default=1, help='number of greetings') def hello(name: str, count: int): """This script prints hello and a name one or more times.""" @@ -113,8 +113,10 @@ The help epilog is printed at the end of the help and is useful for showing exam ## Documenting Arguments -{class}`click.argument` does not take a `help` parameter. This follows the Unix Command Line Tools convention of using arguments only for necessary things and documenting them in the command help text -by name. This should then be done via the docstring. +{class}`click.argument` accepts an optional `help` parameter that is shown in +the ``Positional arguments`` section of the help page. You can still document +arguments in the command docstring, especially when you want to describe them +in the main help text by name. A brief example: @@ -122,7 +124,7 @@ A brief example: .. click:example:: @click.command() - @click.argument('filename') + @click.argument('filename', help='The file to print.') def touch(filename): """Print FILENAME.""" click.echo(filename) @@ -131,7 +133,7 @@ A brief example: invoke(touch, args=['--help']) ``` -Or more explicitly: +Or more explicitly in the docstring: ```{eval-rst} .. click:example:: diff --git a/docs/quickstart.md b/docs/quickstart.md index e7b4b54f9a..c48260cfa5 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -24,8 +24,7 @@ Some standalone examples of Click applications are packaged with Click. They are performs custom validation of parameters in different ways. - [naval](https://github.com/pallets/click/tree/main/examples/naval) : Port of the [docopt](http://docopt.org/) naval example. -- [colors](https://github.com/pallets/click/tree/main/examples/colors) : A simple example that colorizes text. Uses - colorama on Windows. +- [colors](https://github.com/pallets/click/tree/main/examples/colors) : A simple example that colorizes text. - [aliases](https://github.com/pallets/click/tree/main/examples/aliases) : An advanced example that implements {ref}`aliases`. - [imagepipe](https://github.com/pallets/click/tree/main/examples/imagepipe) : A complex example that implements some @@ -81,7 +80,7 @@ What this means is that the {func}`echo` function applies some error correction instead of dying with a {exc}`UnicodeError`. The echo function also supports color and other styles in output. It will automatically remove styles if the output -stream is a file. On Windows, colorama is automatically installed and used. See {ref}`ansi-colors`. +stream is a file. See {ref}`ansi-colors`. If you don't need this, you can also use the `print()` construct / function. diff --git a/docs/utils.md b/docs/utils.md index 8afe390792..18a8a4a1c3 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -52,7 +52,7 @@ click.echo('Hello World!', err=True) ```{versionadded} 2.0 ``` -The {func}`echo` function supports ANSI colors and styles. On Windows this uses [colorama](https://pypi.org/project/colorama/). +The {func}`echo` function supports ANSI colors and styles. Primarily this means that: @@ -60,8 +60,13 @@ Primarily this means that: - the {func}`echo` function will transparently connect to the terminal on Windows and translate ANSI codes to terminal API calls. This means that colors will work on Windows the same way they do on other operating systems. -On Windows, Click uses colorama without calling `colorama.init()`. You can still call that in your code, but it's not -required for Click. +:::{admonition} Older Windows Support +:class: note + +Recent Windows 11 supports ANSI styling by default, in both Terminal and cmd.exe. +If you need to support color output on older versions of Windows, install +[colorama](https://pypi.org/project/colorama/) and call `colorama.init()`. +::: For styling a string, the {func}`style` function can be used: diff --git a/examples/colors/README b/examples/colors/README index 7aec8efb5b..c6b2e15569 100644 --- a/examples/colors/README +++ b/examples/colors/README @@ -3,8 +3,6 @@ $ colors_ colors is a simple example that shows how you can colorize text. - Uses colorama on Windows. - Usage: $ pip install --editable . diff --git a/pyproject.toml b/pyproject.toml index ed91e5d741..0156d5e8ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "click" -version = "8.4.2.dev" +version = "8.5.0.dev" description = "Composable command line interface toolkit" readme = "README.md" license = "BSD-3-Clause" @@ -14,9 +14,6 @@ classifiers = [ "Typing :: Typed", ] requires-python = ">=3.10" -dependencies = [ - "colorama; platform_system == 'Windows'", -] [project.urls] Donate = "https://palletsprojects.com/donate" @@ -114,12 +111,6 @@ show_error_codes = true pretty = true strict = true -[[tool.mypy.overrides]] -module = [ - "colorama.*", -] -ignore_missing_imports = true - [tool.pyright] pythonVersion = "3.10" include = ["src", "tests/typing"] diff --git a/src/click/_compat.py b/src/click/_compat.py index 134c4f3893..90e328bbd9 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -12,7 +12,6 @@ CYGWIN = sys.platform.startswith("cygwin") WIN = sys.platform.startswith("win") -auto_wrap_for_ansi: t.Callable[[t.TextIO], t.TextIO] | None = None _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") @@ -510,9 +509,7 @@ def should_strip_ansi( return not color -# On Windows, wrap the output streams with colorama to support ANSI -# color codes. -# NOTE: double check is needed so mypy does not analyze this on Linux +# double check is needed so mypy does not analyze this on Linux if sys.platform.startswith("win") and WIN: from ._winconsole import _get_windows_console_stream @@ -521,43 +518,6 @@ def _get_argv_encoding() -> str: return locale.getpreferredencoding() - _ansi_stream_wrappers: cabc.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() - - def auto_wrap_for_ansi(stream: t.TextIO, color: bool | None = None) -> t.TextIO: - """Support ANSI color and style codes on Windows by wrapping a - stream with colorama. - """ - try: - cached = _ansi_stream_wrappers.get(stream) - except Exception: - cached = None - - if cached is not None: - return cached - - import colorama - - strip = should_strip_ansi(stream, color) - ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) - rv = t.cast(t.TextIO, ansi_wrapper.stream) - _write = rv.write - - def _safe_write(s: str) -> int: - try: - return _write(s) - except BaseException: - ansi_wrapper.reset_all() - raise - - rv.write = _safe_write # type: ignore[method-assign] - - try: - _ansi_stream_wrappers[stream] = rv - except Exception: - pass - - return rv - else: def _get_argv_encoding() -> str: diff --git a/src/click/core.py b/src/click/core.py index d2db291d08..a0c0c407e7 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1216,11 +1216,13 @@ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: - :meth:`format_usage` - :meth:`format_help_text` + - :meth:`format_arguments` - :meth:`format_options` - :meth:`format_epilog` """ self.format_usage(ctx, formatter) self.format_help_text(ctx, formatter) + self.format_arguments(ctx, formatter) self.format_options(ctx, formatter) self.format_epilog(ctx, formatter) @@ -1247,13 +1249,25 @@ def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: opts = [] for param in self.get_params(ctx): rv = param.get_help_record(ctx) - if rv is not None: + if rv is not None and not isinstance(param, Argument): opts.append(rv) if opts: with formatter.section(_("Options")): formatter.write_dl(opts) + def format_arguments(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the arguments that have a help record into the formatter.""" + args = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None and isinstance(param, Argument): + args.append(rv) + + if args: + with formatter.section(_("Positional arguments")): + formatter.write_dl(args) + def format_epilog(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the epilog into the formatter if it exists.""" if self.epilog: @@ -3534,6 +3548,11 @@ class Argument(Parameter): and are required by default. All parameters are passed onwards to the constructor of :class:`Parameter`. + + :param help: the help string. + + .. versionchanged:: 8.5 + Added the ``help`` parameter. """ param_type_name = "argument" @@ -3542,6 +3561,7 @@ def __init__( self, param_decls: cabc.Sequence[str], required: bool | None = None, + help: str | None = None, **attrs: t.Any, ) -> None: # Auto-detect the requirement status of the argument if not explicitly set. @@ -3557,8 +3577,24 @@ def __init__( if "multiple" in attrs: raise TypeError("__init__() got an unexpected keyword argument 'multiple'.") + deprecated = attrs.get("deprecated", False) + + if help: + help = inspect.cleandoc(help) + + if deprecated: + label = _format_deprecated_label(deprecated) + help = f"{help} {label}" if help else label + + self.help = help + super().__init__(param_decls, required=required, **attrs) + def to_info_dict(self) -> dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update(help=self.help) + return info_dict + @property def human_readable_name(self) -> str: if self.metavar is not None: @@ -3601,6 +3637,12 @@ def _parse_decls( def get_usage_pieces(self, ctx: Context) -> list[str]: return [self.make_metavar(ctx)] + def get_help_record(self, ctx: Context) -> tuple[str, str] | None: + if self.help is None: + return None + + return self.make_metavar(ctx), self.help + def get_error_hint(self, ctx: Context | None) -> str: if ctx is not None: return f"'{self.make_metavar(ctx)}'" diff --git a/src/click/termui.py b/src/click/termui.py index 9bc88db14d..e5e9678c38 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -13,7 +13,6 @@ from ._compat import isatty from ._compat import strip_ansi -from ._compat import WIN from .exceptions import Abort from .exceptions import UsageError from .globals import resolve_color_default @@ -84,17 +83,7 @@ def hidden_prompt_func(prompt: str) -> str: def _readline_prompt(func: t.Callable[[str], str], text: str, err: bool) -> str: """Call a prompt function, passing the full prompt on non-Windows so readline can handle line editing and cursor positioning correctly. - - On Windows the prompt is written separately via :func:`echo` for - colorama support, with only the last character passed to *func*. """ - if WIN: - # Write the prompt separately so that we get nice coloring - # through colorama on Windows. - echo(text[:-1], nl=False, err=err) - # Echo the last character to stdout to work around an issue - # where readline causes backspace to clear the whole line. - return func(text[-1:]) if err: with redirect_stdout(sys.stderr): return func(text) diff --git a/src/click/utils.py b/src/click/utils.py index c0cb22d683..8a5506c558 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -13,7 +13,6 @@ from ._compat import _default_text_stderr from ._compat import _default_text_stdout from ._compat import _find_binary_writer -from ._compat import auto_wrap_for_ansi from ._compat import binary_streams from ._compat import open_stream from ._compat import should_strip_ansi @@ -274,6 +273,9 @@ def echo( default Click will remove color if the output does not look like an interactive terminal. + .. versionchanged:: 8.5 + Colorama is no longer used for color on Windows. + .. versionchanged:: 6.0 Support Unicode output on the Windows console. Click does not modify ``sys.stdout``, so ``sys.stdout.write()`` and ``print()`` @@ -331,16 +333,8 @@ def echo( # ANSI style code support. For no message or bytes, nothing happens. # When outputting to a file instead of a terminal, strip codes. - else: - color = resolve_color_default(color) - - if should_strip_ansi(file, color): - out = strip_ansi(out) - elif WIN: - if auto_wrap_for_ansi is not None: - file = auto_wrap_for_ansi(file, color) # type: ignore - elif not color: - out = strip_ansi(out) + elif should_strip_ansi(file, resolve_color_default(color)): + out = strip_ansi(out) file.write(out) # type: ignore file.flush() diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 60152820d9..f4b452336a 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -295,6 +295,50 @@ def cli(f): assert result.output == "test\n" +def test_argument_help(runner): + @click.command() + @click.argument("name", help="The name to print") + @click.option("--count", default=1, help="number of greetings") + def cli(name, count): + pass + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + assert "Positional arguments:" in result.output + assert "NAME" in result.output + assert "The name to print" in result.output + assert "Options:" in result.output + assert "number of greetings" in result.output + assert result.output.index("Positional arguments:") < result.output.index( + "Options:" + ) + + +def test_argument_help_options_only_no_arguments_section(runner): + @click.command() + @click.option("--count", default=1, help="number of greetings") + def cli(count): + pass + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + assert "Positional arguments:" not in result.output + assert "Options:" in result.output + assert "number of greetings" in result.output + + +def test_argument_help_optional_metavar(runner): + @click.command() + @click.argument("name", required=False, default="", help="The name to print") + def cli(name): + pass + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + assert "[NAME]" in result.output + assert "The name to print" in result.output + + def test_deprecated_usage(runner): @click.command() @click.argument("f", required=False, deprecated=True) @@ -332,6 +376,50 @@ def cli(foo): assert result.output.splitlines()[0] == f"Usage: cli [OPTIONS] {expected}" +@pytest.mark.parametrize( + ("deprecated", "expected_label"), + [(True, "(DEPRECATED)"), ("use g instead", "(DEPRECATED: use g instead)")], +) +def test_deprecated_usage_help_record(runner, deprecated, expected_label): + @click.command() + @click.argument("f", required=False, deprecated=deprecated, help="path to the file") + def cli(f): + click.echo(f) + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + assert "Positional arguments:" in result.output + assert "[F!]" in result.output + assert f"path to the file {expected_label}" in result.output + + +def test_deprecated_usage_help_record_without_help(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 + # Deprecation alone produces a help row with just the deprecation label. + assert "Positional arguments:" in result.output + assert "(DEPRECATED)" in result.output + + +@pytest.mark.parametrize( + ("deprecated", "expected"), + [(True, "(DEPRECATED)"), ("USE B INSTEAD", "(DEPRECATED: USE B INSTEAD)")], +) +@pytest.mark.parametrize("help_text", ["", None]) +def test_deprecated_empty_help_no_leading_space(help_text, deprecated, expected): + """An argument with empty or missing help text must not gain a stray leading + space before the deprecation label. + """ + arg = click.Argument(["foo"], required=False, help=help_text, deprecated=deprecated) + ctx = click.Context(click.Command("cli")) + assert arg.get_help_record(ctx)[1] == expected + + @pytest.mark.parametrize("deprecated", [True, "USE B INSTEAD"]) def test_deprecated_warning(runner, deprecated): @click.command() diff --git a/tests/test_basic.py b/tests/test_basic.py index 3f7352879f..ebbe162e68 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -258,9 +258,11 @@ def cli(f): @pytest.mark.parametrize( ("value", "expect"), - chain( - ((x, "True") for x in ("1", "true", "t", "yes", "y", "on")), - ((x, "False") for x in ("0", "false", "f", "no", "n", "off")), + tuple( + chain( + ((x, "True") for x in ("1", "true", "t", "yes", "y", "on")), + ((x, "False") for x in ("0", "false", "f", "no", "n", "off")), + ) ), ) def test_boolean_conversion(runner, value, expect): diff --git a/tests/test_info_dict.py b/tests/test_info_dict.py index 20fe68cc13..b434c3fc58 100644 --- a/tests/test_info_dict.py +++ b/tests/test_info_dict.py @@ -40,6 +40,7 @@ "multiple": False, "default": None, "envvar": None, + "help": None, }, ) NUMBER_OPTION = ( @@ -273,3 +274,42 @@ class TestType(click.ParamType): pass assert TestType().to_info_dict()["name"] == "TestType" + + +@pytest.mark.parametrize( + ("help_in", "help_out"), + [ + pytest.param(None, None, id="None"), + pytest.param("", "", id="empty"), + pytest.param("single line", "single line", id="single-line"), + pytest.param( + "\n first line\n second line\n ", + "first line\nsecond line", + id="multi-line", + ), + ], +) +def test_argument_to_info_dict_help(help_in, help_out): + arg = click.Argument(["name"], help=help_in) + assert arg.to_info_dict()["help"] == help_out + + +def test_argument_to_info_dict_nargs(): + arg = click.Argument(["files"], nargs=-1, help="files to process") + info = arg.to_info_dict() + assert info["nargs"] == -1 + assert info["help"] == "files to process" + + +def test_command_to_info_dict_multiple_arguments(): + @click.command() + @click.argument("src", help="source path") + @click.argument("dst", help="destination path") + def cli(src, dst): + pass + + ctx = click.Context(cli) + params = cli.to_info_dict(ctx)["params"] + args = [p for p in params if p["param_type_name"] == "argument"] + assert [p["name"] for p in args] == ["src", "dst"] + assert [p["help"] for p in args] == ["source path", "destination path"] diff --git a/tests/test_utils.py b/tests/test_utils.py index 1aebf3cc5a..b7a4259e94 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -541,22 +541,18 @@ def test_echo_color_flag(monkeypatch, capfd): out, err = capfd.readouterr() assert out == f"{styled_text}\n" - isatty = True click.echo(styled_text) out, err = capfd.readouterr() assert out == f"{styled_text}\n" isatty = False - # Faking isatty() is not enough on Windows; - # the implementation caches the colorama wrapped stream - # so we have to use a new stream for each test - stream = StringIO() - click.echo(styled_text, file=stream) - assert stream.getvalue() == f"{text}\n" - - stream = StringIO() - click.echo(styled_text, file=stream, color=True) - assert stream.getvalue() == f"{styled_text}\n" + click.echo(styled_text) + out, err = capfd.readouterr() + assert out == f"{text}\n" + + click.echo(styled_text, color=True) + out, err = capfd.readouterr() + assert out == f"{styled_text}\n" def test_prompt_cast_default(capfd, monkeypatch): diff --git a/uv.lock b/uv.lock index 72a4012485..46ae78248a 100644 --- a/uv.lock +++ b/uv.lock @@ -173,11 +173,8 @@ wheels = [ [[package]] name = "click" -version = "8.4.2.dev0" +version = "8.5.0.dev0" source = { editable = "." } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] [package.dev-dependencies] dev = [ @@ -221,7 +218,6 @@ typing = [ ] [package.metadata] -requires-dist = [{ name = "colorama", marker = "sys_platform == 'win32'" }] [package.metadata.requires-dev] dev = [