-
Notifications
You must be signed in to change notification settings - Fork 237
Expand file tree
/
Copy path_formatter.py
More file actions
376 lines (314 loc) · 10.9 KB
/
_formatter.py
File metadata and controls
376 lines (314 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
"""Custom help formatter for tmuxp CLI with colorized examples.
This module provides a custom argparse formatter that colorizes example
sections in help output, similar to vcspull's formatter.
Examples
--------
>>> from tmuxp.cli._formatter import TmuxpHelpFormatter
>>> TmuxpHelpFormatter # doctest: +ELLIPSIS
<class '...TmuxpHelpFormatter'>
"""
from __future__ import annotations
import argparse
import logging
import re
import typing as t
logger = logging.getLogger(__name__)
# Options that expect a value (set externally or via --option=value)
OPTIONS_EXPECTING_VALUE = frozenset(
{
"-f",
"--file",
"-s",
"--socket-name",
"-S",
"--socket-path",
"-L",
"--log-level",
"-c",
"--command",
"-t",
"--target",
"-o",
"--output",
# Note: -d is --detached (flag-only), not a value option
"--color",
"-w",
"--workspace",
}
)
# Standalone flag options (no value)
OPTIONS_FLAG_ONLY = frozenset(
{
"-h",
"--help",
"-V",
"--version",
"-y",
"--yes",
"-n",
"--no",
"-d",
"--detached",
"-2",
"-8",
"-a",
"--append",
"--json",
"--raw",
}
)
class TmuxpHelpFormatter(argparse.RawDescriptionHelpFormatter):
"""Help formatter with colorized examples for tmuxp CLI.
This formatter extends RawDescriptionHelpFormatter to preserve formatting
of description text while adding syntax highlighting to example sections.
The formatter uses a `_theme` attribute (set externally) to apply colors.
If no theme is set, the formatter falls back to plain text output.
Examples
--------
>>> formatter = TmuxpHelpFormatter("tmuxp")
>>> formatter # doctest: +ELLIPSIS
<...TmuxpHelpFormatter object at ...>
"""
# Theme for colorization, set by create_themed_formatter() or externally
_theme: HelpTheme | None = None
def _fill_text(self, text: str, width: int, indent: str) -> str:
"""Fill text, colorizing examples sections if theme is available.
Parameters
----------
text : str
Text to format.
width : int
Maximum line width.
indent : str
Indentation prefix.
Returns
-------
str
Formatted text, with colorized examples if theme is set.
Examples
--------
Without theme, returns text via parent formatter:
>>> formatter = TmuxpHelpFormatter("test")
>>> formatter._fill_text("hello", 80, "")
'hello'
"""
theme = getattr(self, "_theme", None)
if not text or theme is None:
return super()._fill_text(text, width, indent)
lines = text.splitlines(keepends=True)
formatted_lines: list[str] = []
in_examples_block = False
expect_value = False
for line in lines:
if line.strip() == "":
in_examples_block = False
expect_value = False
formatted_lines.append(f"{indent}{line}")
continue
has_newline = line.endswith("\n")
stripped_line = line.rstrip("\n")
leading_length = len(stripped_line) - len(stripped_line.lstrip(" "))
leading = stripped_line[:leading_length]
content = stripped_line[leading_length:]
content_lower = content.lower()
# Recognize example section headings:
# - "examples:" starts the examples block
# - "X examples:" or "X:" are sub-section headings within examples
is_examples_start = content_lower == "examples:"
is_category_in_block = (
in_examples_block and content.endswith(":") and not content[0].isspace()
)
is_section_heading = (
content_lower.endswith("examples:") or is_category_in_block
) and not is_examples_start
if is_section_heading or is_examples_start:
formatted_content = f"{theme.heading}{content}{theme.reset}"
in_examples_block = True
expect_value = False
elif in_examples_block:
colored_content = self._colorize_example_line(
content,
theme=theme,
expect_value=expect_value,
)
expect_value = colored_content.expect_value
formatted_content = colored_content.text
else:
formatted_content = stripped_line
newline = "\n" if has_newline else ""
formatted_lines.append(f"{indent}{leading}{formatted_content}{newline}")
return "".join(formatted_lines)
class _ColorizedLine(t.NamedTuple):
"""Result of colorizing an example line."""
text: str
expect_value: bool
def _colorize_example_line(
self,
content: str,
*,
theme: t.Any,
expect_value: bool,
) -> _ColorizedLine:
"""Colorize a single example command line.
Parameters
----------
content : str
The line content to colorize.
theme : Any
Theme object with color attributes (prog, action, etc.).
expect_value : bool
Whether the previous token expects a value.
Returns
-------
_ColorizedLine
Named tuple with colorized text and updated expect_value state.
Examples
--------
With an empty theme (no colors), returns text unchanged:
>>> formatter = TmuxpHelpFormatter("test")
>>> theme = HelpTheme.from_colors(None)
>>> result = formatter._colorize_example_line(
... "tmuxp load", theme=theme, expect_value=False
... )
>>> result.text
'tmuxp load'
>>> result.expect_value
False
"""
parts: list[str] = []
expecting_value = expect_value
first_token = True
colored_subcommand = False
for match in re.finditer(r"\s+|\S+", content):
token = match.group()
if token.isspace():
parts.append(token)
continue
if expecting_value:
color = theme.label
expecting_value = False
elif token.startswith("--"):
color = theme.long_option
expecting_value = (
token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE
)
elif token.startswith("-"):
color = theme.short_option
expecting_value = (
token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE
)
elif first_token:
color = theme.prog
elif not colored_subcommand:
color = theme.action
colored_subcommand = True
else:
color = None
first_token = False
if color:
parts.append(f"{color}{token}{theme.reset}")
else:
parts.append(token)
return self._ColorizedLine(text="".join(parts), expect_value=expecting_value)
class HelpTheme(t.NamedTuple):
"""Theme colors for help output.
Examples
--------
>>> from tmuxp.cli._formatter import HelpTheme
>>> theme = HelpTheme.from_colors(None)
>>> theme.reset
''
"""
prog: str
action: str
long_option: str
short_option: str
label: str
heading: str
reset: str
@classmethod
def from_colors(cls, colors: t.Any) -> HelpTheme:
"""Create theme from Colors instance.
Parameters
----------
colors : Colors | None
Colors instance, or None for no colors.
Returns
-------
HelpTheme
Theme with ANSI codes if colors enabled, empty strings otherwise.
Examples
--------
>>> from tmuxp.cli._colors import Colors, ColorMode
>>> from tmuxp.cli._formatter import HelpTheme
>>> colors = Colors(ColorMode.NEVER)
>>> theme = HelpTheme.from_colors(colors)
>>> theme.reset
''
"""
if colors is None or not colors._enabled:
return cls(
prog="",
action="",
long_option="",
short_option="",
label="",
heading="",
reset="",
)
# Import style here to avoid circular import
from tmuxp.cli._colors import style
return cls(
prog=style("", fg="magenta", bold=True).removesuffix("\033[0m"),
action=style("", fg="cyan").removesuffix("\033[0m"),
long_option=style("", fg="green").removesuffix("\033[0m"),
short_option=style("", fg="green").removesuffix("\033[0m"),
label=style("", fg="yellow").removesuffix("\033[0m"),
heading=style("", fg="blue").removesuffix("\033[0m"),
reset="\033[0m",
)
def create_themed_formatter(
colors: t.Any | None = None,
) -> type[TmuxpHelpFormatter]:
"""Create a help formatter class with theme bound.
This factory creates a formatter subclass with the theme injected,
allowing colorized help output without modifying argparse internals.
When no colors argument is provided, uses AUTO mode which respects
NO_COLOR, FORCE_COLOR environment variables and TTY detection.
Parameters
----------
colors : Colors | None
Colors instance for styling. If None, uses ColorMode.AUTO.
Returns
-------
type[TmuxpHelpFormatter]
Formatter class with theme bound.
Examples
--------
>>> from tmuxp.cli._colors import ColorMode, Colors
>>> from tmuxp.cli._formatter import create_themed_formatter, HelpTheme
With explicit colors enabled:
>>> colors = Colors(ColorMode.ALWAYS)
>>> formatter_cls = create_themed_formatter(colors)
>>> formatter = formatter_cls("test")
>>> formatter._theme is not None
True
With colors disabled:
>>> colors = Colors(ColorMode.NEVER)
>>> formatter_cls = create_themed_formatter(colors)
>>> formatter = formatter_cls("test")
>>> formatter._theme is None
True
"""
# Import here to avoid circular import at module load
from tmuxp.cli._colors import ColorMode, Colors
if colors is None:
colors = Colors(ColorMode.AUTO)
# Create theme if colors are enabled, None otherwise
theme = HelpTheme.from_colors(colors) if colors._enabled else None
class ThemedTmuxpHelpFormatter(TmuxpHelpFormatter):
"""TmuxpHelpFormatter with theme pre-configured."""
def __init__(self, prog: str, **kwargs: t.Any) -> None:
super().__init__(prog, **kwargs)
self._theme = theme
return ThemedTmuxpHelpFormatter