Coverage for structured_tutorials / textwrap.py: 100%

51 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-22 22:55 +0100

1# Copyright (c) 2025 Mathias Ertl 

2# Licensed under the MIT License. See LICENSE file for details. 

3 

4"""Module for wrapping text width.""" 

5 

6import re 

7import textwrap 

8from collections.abc import Iterator 

9from typing import Any 

10 

11 

12class CommandLineTextWrapper(textwrap.TextWrapper): 

13 """Subclass of TextWrapper that "unsplits" a short option and its (supposed) value. 

14 

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. 

18 

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 """ 

24 

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 

31 

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 

44 

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 = [] 

52 

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 

61 

62 # yield any remaining chunks 

63 yield from unsplit 

64 

65 def _split(self, text: str) -> list[str]: 

66 chunks = super()._split(text) 

67 chunks = list(self._unsplit_optargs(chunks)) 

68 return chunks 

69 

70 

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)) 

75 

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) 

79 

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 

84 

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 

90 

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)