Coverage for structured_tutorials / textwrap.py: 100%
51 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-21 19:08 +0100
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-21 19:08 +0100
1# Copyright (c) 2025 Mathias Ertl
2# Licensed under the MIT License. See LICENSE file for details.
4"""Module for wrapping text width."""
6import re
7import textwrap
8from collections.abc import Iterator
9from typing import Any
12class CommandLineTextWrapper(textwrap.TextWrapper):
13 """Subclass of TextWrapper that "unsplits" a short option and its (supposed) value.
15 This makes sure that a command with many options will not break between short options and their value,
16 e.g. for ``docker run -e FOO=foo -e BAR=bar ...``, the text wrapper will never insert a line split between
17 ``-e`` and its respective option value.
19 Note that the class of course does not know the semantics of the command it renders. A short option
20 followed by a value is always considered a reason not to break. For example, for ``docker run ... -d
21 image``, the wrapper will never split between ``-d`` and ``image``, despite the latter being unrelated to
22 the former.
23 """
25 def __init__(self, *args: Any, **kwargs: Any) -> None:
26 super().__init__(*args, **kwargs)
27 self.subsequent_indent = "> "
28 self.break_on_hyphens = False
29 self.break_long_words = False
30 self.replace_whitespace = False
32 def _unsplit_optargs(self, chunks: list[str]) -> Iterator[str]:
33 unsplit: list[str] = []
34 for chunk in chunks:
35 if re.match("-[a-z]$", chunk): # chunk appears to be an option
36 if unsplit: # previous option was also an optarg, so yield what was there
37 yield from unsplit
38 unsplit = [chunk]
39 elif chunk == " ":
40 if unsplit: # this is the whitespace after an option
41 unsplit.append(chunk)
42 else: # a whitespace not preceded by an option
43 yield chunk
45 # The unsplit buffer has two values (short option and space) and this chunk looks like its
46 # value, so yield the buffer and this value as split
47 elif len(unsplit) == 2 and re.match("[a-zA-Z0-9`]", chunk):
48 # unsplit option, whitespace and option value
49 unsplit.append(chunk)
50 yield "".join(unsplit)
51 unsplit = []
53 # There is something in the unsplit buffer, but this chunk does not look like a value (maybe
54 # it's a long option?), so we yield tokens from the buffer and then this chunk.
55 elif unsplit:
56 yield from unsplit
57 unsplit = []
58 yield chunk
59 else:
60 yield chunk
62 # yield any remaining chunks
63 yield from unsplit
65 def _split(self, text: str) -> list[str]:
66 chunks = super()._split(text)
67 chunks = list(self._unsplit_optargs(chunks))
68 return chunks
71def wrap_command_filter(command: str, prompt: str, text_width: int) -> str:
72 """Filter to wrap a command based on the given text width."""
73 lines = []
74 split_command_lines = tuple(enumerate(command.split("\\\n"), start=1))
76 # Split paragraphs based on backslash-newline and wrap them separately
77 for line_no, command_line in split_command_lines:
78 final_line = line_no == len(split_command_lines)
80 # Strip any remaining newline, they are treated as a single space
81 command_line = re.sub(r"\s*\n\s*", " ", command_line).strip()
82 if not command_line:
83 continue
85 wrapper = CommandLineTextWrapper(width=text_width)
86 if line_no == 1:
87 wrapper.initial_indent = prompt
88 else:
89 wrapper.initial_indent = wrapper.subsequent_indent
91 wrapped_command_lines = wrapper.wrap(command_line)
92 lines += [
93 f"{line} \\" if (i != len(wrapped_command_lines) or not final_line) else line
94 for i, line in enumerate(wrapped_command_lines, 1)
95 ]
96 return "\n".join(lines)