Skip to content

Commit 8e5f419

Browse files
committed
fix: Preserve multiline help text formatting.
1 parent cdb1ef4 commit 8e5f419

6 files changed

Lines changed: 112 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 0.32
44

55
### 0.32.2
6+
- fix: Preserve multiline help text formatting.
67
- fix: Dedent attribute docstrings' content.
78

89
### 0.32.1

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cappa"
3-
version = "0.32.1"
3+
version = "0.32.2"
44
description = "Declarative CLI argument parser."
55

66
urls = { repository = "https://github.com/dancardin/cappa" }

src/cappa/help.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -271,29 +271,45 @@ def format_args(
271271

272272

273273
def _markdown_to_text(console: Console, renderables: Sequence[TextComponent]) -> Text:
274+
# Render the text without line-wrapping because we're not yet printing it and it should reflow
275+
# until the point at which it's being printed to the user.
276+
render_console = Console(soft_wrap=True)
277+
render_options = render_console.options.update(no_wrap=True, overflow="ignore")
278+
274279
result = Text()
275280
for renderable in renderables:
276281
if isinstance(renderable, Markdown):
277-
for segment in console.render(renderable):
278-
text = segment.text.strip("\n")
282+
renderable_result = Text()
283+
for segment in render_console.render(renderable, render_options):
284+
text = segment.text
285+
if not text:
286+
continue
279287
if text.startswith(" "): # dedup leading spaces
280288
text = " " + text.lstrip()
281289
if text.endswith(" "): # dedup trailing spaces
282290
text = text.rstrip() + " "
283-
if text:
284-
result.append(Text(text, style=segment.style or "", end=""))
291+
renderable_result.append(Text(text, style=segment.style or "", end=""))
292+
renderable_result.rstrip()
293+
if renderable_result:
294+
_append_to_text(result, renderable_result)
285295
else:
286-
if result:
287-
result.append(" ")
288-
289296
if isinstance(renderable, str):
290297
renderable = Text.from_markup(renderable)
291298

292-
result.append(renderable)
299+
_append_to_text(result, renderable)
293300

294301
return result
295302

296303

304+
def _append_to_text(text: Text, component: Text) -> None:
305+
# When a component is multiline (like long, structured {help}), it looks weird when components
306+
# like {choices} or {default} are appended to the end of the last line. In such cases, add a newline.
307+
# Sort of a weird heuristic, we'll see if this holds well in practice.
308+
if text:
309+
text.append("\n" if "\n" in text.plain else " ")
310+
text.append(component)
311+
312+
297313
def _get_text_component_text(c: TextComponent) -> str:
298314
if isinstance(c, Text):
299315
return c.plain

tests/help/test_markdown.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@
44

55
import pytest
66
from rich.console import Console
7+
from typing_extensions import Annotated
78

89
import cappa
9-
from tests.utils import Backend, backends, parse
10+
from tests.utils import (
11+
Backend,
12+
backends,
13+
parse,
14+
strip_trailing_whitespace,
15+
terminal_width,
16+
)
1017

1118

1219
@pytest.mark.help
@@ -38,3 +45,57 @@ class Args:
3845
assert "This\x1b[0m" in result.out
3946
assert " \x1b[1mis\x1b[0m" in result.out # typos: ignore
4047
assert " \x1b[3mneat\x1b[0m" in result.out
48+
49+
50+
@pytest.mark.help
51+
@backends
52+
def test_multi_paragraph_arg_help_preserves_paragraph_breaks(
53+
backend: Backend, capsys: Any
54+
):
55+
@dataclass
56+
class Args:
57+
foo: Annotated[
58+
str,
59+
cappa.Arg(
60+
help="First paragraph summary.\n\nSecond paragraph body.",
61+
),
62+
]
63+
64+
with terminal_width(80), pytest.raises(cappa.Exit):
65+
parse(Args, "--help", backend=backend)
66+
67+
out = strip_trailing_whitespace(capsys.readouterr().out)
68+
69+
assert "First paragraph summary." in out
70+
assert "Second paragraph body." in out
71+
72+
summary_pos = out.index("First paragraph summary.")
73+
body_pos = out.index("Second paragraph body.")
74+
between = out[summary_pos + len("First paragraph summary.") : body_pos]
75+
assert "\n\n" in between
76+
77+
78+
@pytest.mark.help
79+
@backends
80+
def test_soft_line_breaks_fold_into_spaces(backend: Backend, capsys: Any):
81+
@dataclass
82+
class SoftBreakArgs:
83+
foo: str
84+
"""
85+
This is a long sentence that goes on and on.
86+
This second soft-break line continues the same paragraph.
87+
"""
88+
89+
with terminal_width(80), pytest.raises(cappa.Exit):
90+
parse(SoftBreakArgs, "--help", backend=backend, completion=False)
91+
92+
out = strip_trailing_whitespace(capsys.readouterr().out)
93+
94+
# Words from adjacent soft-break lines must be separated by a space
95+
assert "on.This" not in out
96+
assert "on. This" in out
97+
98+
# And must not have a blank line between them (same paragraph)
99+
first = out.index("goes on and on.")
100+
second = out.index("same paragraph.")
101+
assert "\n\n" not in out[first:second]

tests/test_docstring.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,57 @@
77

88
def test_attribute_docstring_single_line():
99
@dataclass
10-
class Args:
10+
class SingleLineArgs:
1111
foo: int
1212
"""A simple description."""
1313

14-
result = get_attribute_docstrings(Args)
14+
result = get_attribute_docstrings(SingleLineArgs)
1515
assert result == {"foo": "A simple description."}
1616

1717

1818
def test_attribute_docstring_indented_multiline():
1919
@dataclass
20-
class Args:
20+
class IndentedMultilineArgs:
2121
foo: int
2222
"""First paragraph.
2323
2424
Second paragraph.
2525
"""
2626

27-
result = get_attribute_docstrings(Args)
27+
result = get_attribute_docstrings(IndentedMultilineArgs)
2828
assert result == {"foo": "First paragraph.\n\nSecond paragraph."}
2929

3030

3131
def test_attribute_docstring_indented_multiline_dedented():
3232
"""Indented attribute docstrings must not be treated as Markdown code blocks."""
3333

3434
@dataclass
35-
class Args:
35+
class IndentedDedentedArgs:
3636
foo: int
3737
"""Summary line.
3838
3939
Body paragraph that would have 8 spaces of indent before cleandoc,
4040
with a long enough body to wrap.
4141
"""
4242

43-
result = get_attribute_docstrings(Args)
43+
result = get_attribute_docstrings(IndentedDedentedArgs)
4444

4545
# No leading spaces on continuation lines
4646
assert not any(line.startswith(" ") for line in result["foo"].splitlines())
47+
48+
49+
def test_attribute_docstring_multiple_fields():
50+
@dataclass
51+
class MultipleFieldArgs:
52+
foo: int
53+
"""Foo help.
54+
55+
Foo body.
56+
"""
57+
58+
bar: str
59+
"""Bar help."""
60+
61+
result = get_attribute_docstrings(MultipleFieldArgs)
62+
assert result["foo"] == "Foo help.\n\nFoo body."
63+
assert result["bar"] == "Bar help."

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)