From cd7a2b902b22dc7b6b8c574a8bb0c9215ff71167 Mon Sep 17 00:00:00 2001 From: Naman Mishra Date: Thu, 27 Aug 2026 00:21:14 +0530 Subject: [PATCH] Don't break usage-line options at hyphens `HelpFormatter.write_usage` wrapped arguments with `textwrap`'s default `break_on_hyphens=True`, so long options like `--max-retry-count` could be split across lines at a hyphen. Thread a `break_on_hyphens` parameter through `wrap_text` (default `True`, preserving existing behaviour) and pass `False` from `write_usage` so option tokens stay intact. fixes #3362 Co-Authored-By: Claude Opus 4.8 --- CHANGES.md | 10 ++++++++++ src/click/formatting.py | 15 ++++++++++++++- tests/test_formatting.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 3c9a94e6c..7be180bc5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,13 @@ +## Version 8.6.0 + +Unreleased + +- The usage line no longer breaks hyphenated tokens such as + `--option-name` at a hyphen when they reach the wrap boundary. + `wrap_text()` gained a `break_on_hyphens` parameter to control this. + {issue}`3362` + + ## Version 8.5.0 Released 2026-08-24 diff --git a/src/click/formatting.py b/src/click/formatting.py index c4aa2de57..0ecc7bc95 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,12 +53,18 @@ 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 wrapping may break lines at hyphens + within a word. Disable this to keep hyphenated + tokens such as ``--option-name`` intact. .. versionchanged:: 8.4.0 Width is measured in visible characters. ANSI escape sequences in ``text``, ``initial_indent``, or ``subsequent_indent`` no longer count toward the width budget, so styled input wraps based on what the user sees instead of raw byte length. + + .. versionchanged:: 8.6.0 + Added the ``break_on_hyphens`` parameter. """ from ._textwrap import TextWrapper @@ -67,6 +74,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) @@ -186,6 +194,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 +204,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..b3a2d8a3f 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -517,6 +517,37 @@ def test_write_usage_styled_prefix_keeps_options_on_one_line(): assert visible == "Usage: cli [OPTIONS]\n" +def test_write_usage_does_not_break_options_at_hyphens(): + """Issue #3362: hyphenated tokens (e.g. ``--option-name``) on the usage + line must not be broken at a hyphen when they hit the wrap boundary. + """ + 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", + ] + + formatter = click.HelpFormatter(width=65) + formatter.write_usage("program", " ".join(options)) + rendered = formatter.getvalue() + + # No wrapped line may end on a mid-token hyphen. + for line in rendered.splitlines(): + assert not line.rstrip().endswith("-") + + # Every option token survives intact across the wrap. + tokens = rendered.replace("Usage:", "").split() + assert tokens[0] == "program" + assert tokens[1:] == options + + @pytest.mark.parametrize( ("formatter_kwargs", "current_indent", "prog", "args", "prefix", "expected"), [