]>
Commit | Line | Data |
---|---|---|
53e6db90 DC |
1 | import typing as t |
2 | from contextlib import contextmanager | |
3 | from gettext import gettext as _ | |
4 | ||
5 | from ._compat import term_len | |
6 | from .parser import split_opt | |
7 | ||
8 | # Can force a width. This is used by the test system | |
9 | FORCED_WIDTH: t.Optional[int] = None | |
10 | ||
11 | ||
12 | def measure_table(rows: t.Iterable[t.Tuple[str, str]]) -> t.Tuple[int, ...]: | |
13 | widths: t.Dict[int, int] = {} | |
14 | ||
15 | for row in rows: | |
16 | for idx, col in enumerate(row): | |
17 | widths[idx] = max(widths.get(idx, 0), term_len(col)) | |
18 | ||
19 | return tuple(y for x, y in sorted(widths.items())) | |
20 | ||
21 | ||
22 | def iter_rows( | |
23 | rows: t.Iterable[t.Tuple[str, str]], col_count: int | |
24 | ) -> t.Iterator[t.Tuple[str, ...]]: | |
25 | for row in rows: | |
26 | yield row + ("",) * (col_count - len(row)) | |
27 | ||
28 | ||
29 | def wrap_text( | |
30 | text: str, | |
31 | width: int = 78, | |
32 | initial_indent: str = "", | |
33 | subsequent_indent: str = "", | |
34 | preserve_paragraphs: bool = False, | |
35 | ) -> str: | |
36 | """A helper function that intelligently wraps text. By default, it | |
37 | assumes that it operates on a single paragraph of text but if the | |
38 | `preserve_paragraphs` parameter is provided it will intelligently | |
39 | handle paragraphs (defined by two empty lines). | |
40 | ||
41 | If paragraphs are handled, a paragraph can be prefixed with an empty | |
42 | line containing the ``\\b`` character (``\\x08``) to indicate that | |
43 | no rewrapping should happen in that block. | |
44 | ||
45 | :param text: the text that should be rewrapped. | |
46 | :param width: the maximum width for the text. | |
47 | :param initial_indent: the initial indent that should be placed on the | |
48 | first line as a string. | |
49 | :param subsequent_indent: the indent string that should be placed on | |
50 | each consecutive line. | |
51 | :param preserve_paragraphs: if this flag is set then the wrapping will | |
52 | intelligently handle paragraphs. | |
53 | """ | |
54 | from ._textwrap import TextWrapper | |
55 | ||
56 | text = text.expandtabs() | |
57 | wrapper = TextWrapper( | |
58 | width, | |
59 | initial_indent=initial_indent, | |
60 | subsequent_indent=subsequent_indent, | |
61 | replace_whitespace=False, | |
62 | ) | |
63 | if not preserve_paragraphs: | |
64 | return wrapper.fill(text) | |
65 | ||
66 | p: t.List[t.Tuple[int, bool, str]] = [] | |
67 | buf: t.List[str] = [] | |
68 | indent = None | |
69 | ||
70 | def _flush_par() -> None: | |
71 | if not buf: | |
72 | return | |
73 | if buf[0].strip() == "\b": | |
74 | p.append((indent or 0, True, "\n".join(buf[1:]))) | |
75 | else: | |
76 | p.append((indent or 0, False, " ".join(buf))) | |
77 | del buf[:] | |
78 | ||
79 | for line in text.splitlines(): | |
80 | if not line: | |
81 | _flush_par() | |
82 | indent = None | |
83 | else: | |
84 | if indent is None: | |
85 | orig_len = term_len(line) | |
86 | line = line.lstrip() | |
87 | indent = orig_len - term_len(line) | |
88 | buf.append(line) | |
89 | _flush_par() | |
90 | ||
91 | rv = [] | |
92 | for indent, raw, text in p: | |
93 | with wrapper.extra_indent(" " * indent): | |
94 | if raw: | |
95 | rv.append(wrapper.indent_only(text)) | |
96 | else: | |
97 | rv.append(wrapper.fill(text)) | |
98 | ||
99 | return "\n\n".join(rv) | |
100 | ||
101 | ||
102 | class HelpFormatter: | |
103 | """This class helps with formatting text-based help pages. It's | |
104 | usually just needed for very special internal cases, but it's also | |
105 | exposed so that developers can write their own fancy outputs. | |
106 | ||
107 | At present, it always writes into memory. | |
108 | ||
109 | :param indent_increment: the additional increment for each level. | |
110 | :param width: the width for the text. This defaults to the terminal | |
111 | width clamped to a maximum of 78. | |
112 | """ | |
113 | ||
114 | def __init__( | |
115 | self, | |
116 | indent_increment: int = 2, | |
117 | width: t.Optional[int] = None, | |
118 | max_width: t.Optional[int] = None, | |
119 | ) -> None: | |
120 | import shutil | |
121 | ||
122 | self.indent_increment = indent_increment | |
123 | if max_width is None: | |
124 | max_width = 80 | |
125 | if width is None: | |
126 | width = FORCED_WIDTH | |
127 | if width is None: | |
128 | width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50) | |
129 | self.width = width | |
130 | self.current_indent = 0 | |
131 | self.buffer: t.List[str] = [] | |
132 | ||
133 | def write(self, string: str) -> None: | |
134 | """Writes a unicode string into the internal buffer.""" | |
135 | self.buffer.append(string) | |
136 | ||
137 | def indent(self) -> None: | |
138 | """Increases the indentation.""" | |
139 | self.current_indent += self.indent_increment | |
140 | ||
141 | def dedent(self) -> None: | |
142 | """Decreases the indentation.""" | |
143 | self.current_indent -= self.indent_increment | |
144 | ||
145 | def write_usage( | |
146 | self, prog: str, args: str = "", prefix: t.Optional[str] = None | |
147 | ) -> None: | |
148 | """Writes a usage line into the buffer. | |
149 | ||
150 | :param prog: the program name. | |
151 | :param args: whitespace separated list of arguments. | |
152 | :param prefix: The prefix for the first line. Defaults to | |
153 | ``"Usage: "``. | |
154 | """ | |
155 | if prefix is None: | |
156 | prefix = f"{_('Usage:')} " | |
157 | ||
158 | usage_prefix = f"{prefix:>{self.current_indent}}{prog} " | |
159 | text_width = self.width - self.current_indent | |
160 | ||
161 | if text_width >= (term_len(usage_prefix) + 20): | |
162 | # The arguments will fit to the right of the prefix. | |
163 | indent = " " * term_len(usage_prefix) | |
164 | self.write( | |
165 | wrap_text( | |
166 | args, | |
167 | text_width, | |
168 | initial_indent=usage_prefix, | |
169 | subsequent_indent=indent, | |
170 | ) | |
171 | ) | |
172 | else: | |
173 | # The prefix is too long, put the arguments on the next line. | |
174 | self.write(usage_prefix) | |
175 | self.write("\n") | |
176 | indent = " " * (max(self.current_indent, term_len(prefix)) + 4) | |
177 | self.write( | |
178 | wrap_text( | |
179 | args, text_width, initial_indent=indent, subsequent_indent=indent | |
180 | ) | |
181 | ) | |
182 | ||
183 | self.write("\n") | |
184 | ||
185 | def write_heading(self, heading: str) -> None: | |
186 | """Writes a heading into the buffer.""" | |
187 | self.write(f"{'':>{self.current_indent}}{heading}:\n") | |
188 | ||
189 | def write_paragraph(self) -> None: | |
190 | """Writes a paragraph into the buffer.""" | |
191 | if self.buffer: | |
192 | self.write("\n") | |
193 | ||
194 | def write_text(self, text: str) -> None: | |
195 | """Writes re-indented text into the buffer. This rewraps and | |
196 | preserves paragraphs. | |
197 | """ | |
198 | indent = " " * self.current_indent | |
199 | self.write( | |
200 | wrap_text( | |
201 | text, | |
202 | self.width, | |
203 | initial_indent=indent, | |
204 | subsequent_indent=indent, | |
205 | preserve_paragraphs=True, | |
206 | ) | |
207 | ) | |
208 | self.write("\n") | |
209 | ||
210 | def write_dl( | |
211 | self, | |
212 | rows: t.Sequence[t.Tuple[str, str]], | |
213 | col_max: int = 30, | |
214 | col_spacing: int = 2, | |
215 | ) -> None: | |
216 | """Writes a definition list into the buffer. This is how options | |
217 | and commands are usually formatted. | |
218 | ||
219 | :param rows: a list of two item tuples for the terms and values. | |
220 | :param col_max: the maximum width of the first column. | |
221 | :param col_spacing: the number of spaces between the first and | |
222 | second column. | |
223 | """ | |
224 | rows = list(rows) | |
225 | widths = measure_table(rows) | |
226 | if len(widths) != 2: | |
227 | raise TypeError("Expected two columns for definition list") | |
228 | ||
229 | first_col = min(widths[0], col_max) + col_spacing | |
230 | ||
231 | for first, second in iter_rows(rows, len(widths)): | |
232 | self.write(f"{'':>{self.current_indent}}{first}") | |
233 | if not second: | |
234 | self.write("\n") | |
235 | continue | |
236 | if term_len(first) <= first_col - col_spacing: | |
237 | self.write(" " * (first_col - term_len(first))) | |
238 | else: | |
239 | self.write("\n") | |
240 | self.write(" " * (first_col + self.current_indent)) | |
241 | ||
242 | text_width = max(self.width - first_col - 2, 10) | |
243 | wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True) | |
244 | lines = wrapped_text.splitlines() | |
245 | ||
246 | if lines: | |
247 | self.write(f"{lines[0]}\n") | |
248 | ||
249 | for line in lines[1:]: | |
250 | self.write(f"{'':>{first_col + self.current_indent}}{line}\n") | |
251 | else: | |
252 | self.write("\n") | |
253 | ||
254 | @contextmanager | |
255 | def section(self, name: str) -> t.Iterator[None]: | |
256 | """Helpful context manager that writes a paragraph, a heading, | |
257 | and the indents. | |
258 | ||
259 | :param name: the section name that is written as heading. | |
260 | """ | |
261 | self.write_paragraph() | |
262 | self.write_heading(name) | |
263 | self.indent() | |
264 | try: | |
265 | yield | |
266 | finally: | |
267 | self.dedent() | |
268 | ||
269 | @contextmanager | |
270 | def indentation(self) -> t.Iterator[None]: | |
271 | """A context manager that increases the indentation.""" | |
272 | self.indent() | |
273 | try: | |
274 | yield | |
275 | finally: | |
276 | self.dedent() | |
277 | ||
278 | def getvalue(self) -> str: | |
279 | """Returns the buffer contents.""" | |
280 | return "".join(self.buffer) | |
281 | ||
282 | ||
283 | def join_options(options: t.Sequence[str]) -> t.Tuple[str, bool]: | |
284 | """Given a list of option strings this joins them in the most appropriate | |
285 | way and returns them in the form ``(formatted_string, | |
286 | any_prefix_is_slash)`` where the second item in the tuple is a flag that | |
287 | indicates if any of the option prefixes was a slash. | |
288 | """ | |
289 | rv = [] | |
290 | any_prefix_is_slash = False | |
291 | ||
292 | for opt in options: | |
293 | prefix = split_opt(opt)[0] | |
294 | ||
295 | if prefix == "/": | |
296 | any_prefix_is_slash = True | |
297 | ||
298 | rv.append((len(prefix), opt)) | |
299 | ||
300 | rv.sort(key=lambda x: x[0]) | |
301 | return ", ".join(x[1] for x in rv), any_prefix_is_slash |