diff --git a/CHANGES.md b/CHANGES.md index 7d6156a43..84649c4a3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -83,6 +83,10 @@ Unreleased {func}`get_pager_file` picked for the output stream, and with `errors="replace"` to match the pipe backend. Any text stdout can encode reaches the pager. +- {meth}`HelpFormatter.write_usage` no longer breaks an argument at a hyphen it + contains, so a long option name or metavar reaching the wrap width moves to + the next line whole instead of being split across two. {func}`wrap_text` + takes a `break_on_hyphens` argument to control this. {issue}`3362` ## Version 8.4.2 diff --git a/src/click/formatting.py b/src/click/formatting.py index c4aa2de57..335dc4cd8 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -34,6 +34,7 @@ def wrap_text( initial_indent: str = "", subsequent_indent: str = "", preserve_paragraphs: bool = False, + break_on_hyphens: bool = True, ) -> str: """A helper function that intelligently wraps text. By default, it assumes that it operates on a single paragraph of text but if the @@ -52,6 +53,13 @@ def wrap_text( each consecutive line. :param preserve_paragraphs: if this flag is set then the wrapping will intelligently handle paragraphs. + :param break_on_hyphens: whether a word may be broken after a hyphen it + contains. Set this to ``False`` for text made of + hyphenated tokens, such as option names, that + should stay on one line. + + .. versionchanged:: 8.5.0 + Added the ``break_on_hyphens`` parameter. .. versionchanged:: 8.4.0 Width is measured in visible characters. ANSI escape sequences in @@ -67,6 +75,7 @@ def wrap_text( initial_indent=initial_indent, subsequent_indent=subsequent_indent, replace_whitespace=False, + break_on_hyphens=break_on_hyphens, ) if not preserve_paragraphs: return wrapper.fill(text) @@ -162,6 +171,10 @@ def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> N :param args: whitespace separated list of arguments. :param prefix: The prefix for the first line. Defaults to ``"Usage: "``. + + .. versionchanged:: 8.5.0 + Wrapping no longer breaks an argument at a hyphen it contains, so + option names and metavars stay on one line. """ if prefix is None: prefix = "{usage} ".format(usage=_("Usage:")) @@ -186,6 +199,7 @@ def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> N text_width, initial_indent=usage_prefix, subsequent_indent=indent, + break_on_hyphens=False, ) ) else: @@ -195,7 +209,11 @@ def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> N indent = " " * (max(self.current_indent, term_len(prefix)) + 4) self.write( wrap_text( - args, text_width, initial_indent=indent, subsequent_indent=indent + args, + text_width, + initial_indent=indent, + subsequent_indent=indent, + break_on_hyphens=False, ) ) diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 272b075fe..f91d92ec3 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -630,3 +630,56 @@ def test_command_write_usage_no_args(runner, command_kwargs, expected_usage_line cli = click.Command("cli", **command_kwargs) result = runner.invoke(cli, ["--help"]) assert result.output.splitlines()[0] == expected_usage_line + + +def test_wrap_text_break_on_hyphens(): + """``wrap_text`` breaks a hyphenated word by default, and keeps it whole + when ``break_on_hyphens`` is disabled. + """ + text = "alpha --max-retry-count" + assert click.formatting.wrap_text(text, width=20) == "alpha --max-retry-\ncount" + assert ( + click.formatting.wrap_text(text, width=20, break_on_hyphens=False) + == "alpha\n--max-retry-count" + ) + + +def test_write_usage_keeps_hyphenated_args_whole(): + """Issue #3362: an argument reaching the wrap width used to be split at + an internal hyphen, so an option name ended up spread over two lines. + """ + options = [ + "--enable-verbose-logging", + "--output-file-path", + "--max-retry-count", + "--disable-cache-mode", + "--config-file-location", + "--user-auth-token", + "--auto-update-interval", + "--force-overwrite-existing", + "--network-timeout-seconds", + "--debug-trace-enabled", + ] + f = click.HelpFormatter(width=65) + f.write_usage("program", " ".join(options)) + lines = f.getvalue().splitlines() + + for option in options: + assert any(option in line for line in lines) + + for line in lines: + assert not line.endswith("-") + + +def test_write_usage_keeps_hyphenated_args_whole_below_prefix(): + """Issue #3362: the branch that puts the arguments on their own line, + used when the prefix is too long to share one, wraps the same way. + """ + f = click.HelpFormatter(width=31) + f.write_usage("a-program-with-a-very-long-name", "[OPTIONS] --max-retry-count") + lines = f.getvalue().splitlines() + + assert any("--max-retry-count" in line for line in lines) + + for line in lines: + assert not line.endswith("-")