Skip to content

Commit b0afbe0

Browse files
authored
Improve optparse (#87)
* Add `optparse.GENERATE_USAGE` to auto generate usage from options similar to argparse * Add `rich_format_*` methods that can be overriden in subclasses
1 parent 30d6cb6 commit b0afbe0

File tree

4 files changed

+180
-30
lines changed

4 files changed

+180
-30
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
### Features
6+
- Add `optparse.GENERATE_USAGE` to auto generate a usage similar to argparse.
7+
* PR #87
8+
- Add `rich_format_*` methods to optparse formatters. These return a `rich.text.Text` object.
9+
* PR #87
10+
511
### Fixes
612
- Fix ansi escape codes on legacy Windows console
713
* Issue #79, PR #80, PR #85

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,15 @@ parser = optparse.OptionParser(formatter=IndentedRichHelpFormatter())
227227
...
228228
```
229229

230+
You can also generated a more helpful usage message by passing `usage=GENERATE_USAGE` to the
231+
parser. This is similar to the default behavior of `argparse`.
232+
233+
```python
234+
from rich_argparse.optparse import GENERATE_USAGE, IndentedRichHelpFormatter
235+
236+
parser = optparse.OptionParser(usage=GENERATE_USAGE, formatter=IndentedRichHelpFormatter())
237+
```
238+
230239
Similar to `argparse`, you can customize the styles used by the formatter by modifying the
231240
`RichHelpFormatter.styles` dictionary. These are the same styles used by `argparse` but with
232241
the `optparse.` prefix instead:
@@ -235,9 +244,9 @@ the `optparse.` prefix instead:
235244
RichHelpFormatter.styles["optparse.metavar"] = "bold magenta"
236245
```
237246

238-
Syntax highlighting works the same as `argparse`.
247+
Syntax highlighting works the same as with `argparse`.
239248

240-
Colors in the `usage` are not supported yet.
249+
Colors in the `usage` are only supported when using `GENERATE_USAGE`.
241250

242251
Customizing the group name format is not supported. optparse uses Title Case format by default.
243252

rich_argparse/optparse.py

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
"RichHelpFormatter",
1010
"IndentedRichHelpFormatter",
1111
"TitledRichHelpFormatter",
12+
"GENERATE_USAGE",
1213
]
1314

15+
GENERATE_USAGE = "==GENERATE_USAGE=="
16+
1417

1518
class RichHelpFormatter(optparse.HelpFormatter):
1619
"""An optparse HelpFormatter class that renders using rich."""
@@ -22,6 +25,7 @@ class RichHelpFormatter(optparse.HelpFormatter):
2225
"optparse.metavar": "dark_cyan",
2326
"optparse.syntax": "bold",
2427
"optparse.text": "default",
28+
"optparse.prog": "grey50",
2529
}
2630
"""A dict of rich styles to control the formatter styles.
2731
@@ -31,6 +35,7 @@ class RichHelpFormatter(optparse.HelpFormatter):
3135
- ``optparse.groups``: for group names (e.g. "Options")
3236
- ``optparse.help``: for options's help text (e.g. "show this help message and exit")
3337
- ``optparse.metavar``: for meta variables (e.g. "FILE" in "--file=FILE")
38+
- ``argparse.prog``: for %prog in generated usage (e.g. "foo" in "Usage: foo [options]")
3439
- ``optparse.syntax``: for highlights of back-tick quoted text (e.g. "``` `some text` ```"),
3540
- ``optparse.text``: for the descriptions and epilog (e.g. "A foo program")
3641
"""
@@ -75,6 +80,12 @@ def _stringify(self, text: r.RenderableType) -> str:
7580
help = _fix_legacy_win_text(self.console, help)
7681
return help
7782

83+
def rich_format_usage(self, usage: str) -> r.Text:
84+
raise NotImplementedError("subclasses must implement")
85+
86+
def rich_format_heading(self, heading: str) -> r.Text:
87+
raise NotImplementedError("subclasses must implement")
88+
7889
def _rich_format_text(self, text: str) -> r.Text:
7990
# HelpFormatter._format_text() equivalent that produces rich.text.Text
8091
text_width = max(self.width - 2 * self.current_indent, 11)
@@ -84,15 +95,31 @@ def _rich_format_text(self, text: str) -> r.Text:
8495
rich_text.highlight_regex(highlight, style_prefix="optparse.")
8596
return _rich_fill(self.console, rich_text, text_width, indent)
8697

87-
def format_description(self, description: str) -> str:
98+
def rich_format_description(self, description: str) -> r.Text:
8899
if not description:
89-
return ""
90-
return self._stringify(self._rich_format_text(description)) + "\n"
100+
return r.Text()
101+
return self._rich_format_text(description) + r.Text("\n")
91102

92-
def format_epilog(self, epilog: str) -> str:
103+
def rich_format_epilog(self, epilog: str) -> r.Text:
93104
if not epilog:
94-
return ""
95-
return "\n" + self._stringify(self._rich_format_text(epilog)) + "\n"
105+
return r.Text()
106+
return r.Text("\n") + self._rich_format_text(epilog) + r.Text("\n")
107+
108+
def format_usage(self, usage: str) -> str:
109+
if usage is GENERATE_USAGE:
110+
rich_usage = self._generate_usage()
111+
else:
112+
rich_usage = self.rich_format_usage(usage)
113+
return self._stringify(rich_usage)
114+
115+
def format_heading(self, heading: str) -> str:
116+
return self._stringify(self.rich_format_heading(heading))
117+
118+
def format_description(self, description: str) -> str:
119+
return self._stringify(self.rich_format_description(description))
120+
121+
def format_epilog(self, epilog: str) -> str:
122+
return self._stringify(self.rich_format_epilog(epilog))
96123

97124
def rich_expand_default(self, option: optparse.Option) -> r.Text:
98125
assert option.help is not None
@@ -108,7 +135,7 @@ def rich_expand_default(self, option: optparse.Option) -> r.Text:
108135
rich_help.highlight_regex(highlight, style_prefix="optparse.")
109136
return rich_help
110137

111-
def format_option(self, option: optparse.Option) -> str:
138+
def rich_format_option(self, option: optparse.Option) -> r.Text:
112139
result: list[r.Text] = []
113140
opts = self.rich_option_strings[option]
114141
opt_width = self.help_position - self.current_indent - 2
@@ -131,7 +158,10 @@ def format_option(self, option: optparse.Option) -> str:
131158
result.append(r.Text("\n"))
132159
else:
133160
pass # pragma: no cover
134-
return self._stringify(r.Text().join(result))
161+
return r.Text().join(result)
162+
163+
def format_option(self, option: optparse.Option) -> str:
164+
return self._stringify(self.rich_format_option(option))
135165

136166
def store_option_strings(self, parser: optparse.OptionParser) -> None:
137167
self.indent()
@@ -185,8 +215,34 @@ def rich_format_option_strings(self, option: optparse.Option) -> r.Text:
185215

186216
return r.Text(", ").join(opts)
187217

218+
def _generate_usage(self) -> r.Text:
219+
"""Generate usage string from the parser's actions."""
220+
if self.parser is None:
221+
raise TypeError("Cannot generate usage if parser is not set")
222+
mark = "==GENERATED_USAGE_MARKER=="
223+
usage_lines: list[r.Text] = []
224+
prefix = self.rich_format_usage(mark).split(mark)[0]
225+
usage_lines.extend(prefix.split("\n"))
226+
usage_lines[-1].append(self.parser.get_prog_name(), "optparse.prog")
227+
indent = len(usage_lines[-1]) + 1
228+
for option in self.parser.option_list:
229+
if option.help == optparse.SUPPRESS_HELP:
230+
continue
231+
opt_str = option._short_opts[0] if option._short_opts else option.get_opt_string()
232+
option_usage = r.Text("[").append(opt_str, "optparse.args")
233+
if option.takes_value():
234+
metavar = option.metavar or option.dest.upper() # type: ignore[union-attr]
235+
option_usage.append(" ").append(metavar, "optparse.metavar")
236+
option_usage.append("]")
237+
if len(usage_lines[-1]) + len(option_usage) + 1 > self.width:
238+
usage_lines.append(r.Text(" " * indent) + option_usage)
239+
else:
240+
usage_lines[-1].append(" ").append(option_usage)
241+
usage_lines.append(r.Text())
242+
return r.Text("\n").join(usage_lines)
243+
188244

189-
class IndentedRichHelpFormatter(RichHelpFormatter, optparse.IndentedHelpFormatter):
245+
class IndentedRichHelpFormatter(RichHelpFormatter):
190246
"""Format help with indented section bodies."""
191247

192248
def __init__(
@@ -198,18 +254,19 @@ def __init__(
198254
) -> None:
199255
super().__init__(indent_increment, max_help_position, width, short_first)
200256

201-
def format_usage(self, usage: str) -> str:
202-
usage = super().format_usage(usage)
203-
prefix = super().format_usage("").rstrip()
257+
def rich_format_usage(self, usage: str) -> r.Text:
258+
usage_template = optparse._("Usage: %s\n") # type: ignore[attr-defined]
259+
usage = usage_template % usage
260+
prefix = (usage_template % "").rstrip()
204261
spans = [r.Span(0, len(prefix), "optparse.groups")]
205-
return self._stringify(r.Text(usage, spans=spans))
262+
return r.Text(usage, spans=spans)
206263

207-
def format_heading(self, heading: str) -> str:
264+
def rich_format_heading(self, heading: str) -> r.Text:
208265
text = r.Text(" " * self.current_indent).append(f"{heading}:", "optparse.groups")
209-
return self._stringify(text) + "\n"
266+
return text + r.Text("\n")
210267

211268

212-
class TitledRichHelpFormatter(RichHelpFormatter, optparse.TitledHelpFormatter):
269+
class TitledRichHelpFormatter(RichHelpFormatter):
213270
"""Format help with underlined section headers."""
214271

215272
def __init__(
@@ -221,20 +278,15 @@ def __init__(
221278
) -> None:
222279
super().__init__(indent_increment, max_help_position, width, short_first)
223280

224-
def format_usage(self, usage: str) -> str:
225-
usage_heading = super().format_usage("").rstrip("\n")
226-
return f"{usage_heading}{usage}\n"
281+
def rich_format_usage(self, usage: str) -> r.Text:
282+
heading = self.rich_format_heading(optparse._("Usage")) # type: ignore[attr-defined]
283+
return r.Text.assemble(heading, " ", usage, "\n")
227284

228-
def format_heading(self, heading: str) -> str:
229-
text = r.Text().append_tokens(
230-
[
231-
(heading, "optparse.groups"),
232-
("\n", None),
233-
("=-"[self.level] * len(heading), "optparse.groups"),
234-
("\n", None),
235-
]
285+
def rich_format_heading(self, heading: str) -> r.Text:
286+
underline = "=-"[self.level] * len(heading)
287+
return r.Text.assemble(
288+
(heading, "optparse.groups"), "\n", (underline, "optparse.groups"), "\n"
236289
)
237-
return self._stringify(text)
238290

239291

240292
if __name__ == "__main__":
@@ -244,6 +296,7 @@ def format_heading(self, heading: str) -> str:
244296
formatter=IndentedRichHelpFormatter(),
245297
prog="python -m rich_arparse.optparse",
246298
epilog=":link: https://github.com/hamdanal/rich-argparse#optparse-support.",
299+
usage=GENERATE_USAGE,
247300
)
248301
parser.add_option("--formatter", metavar="rich", help="A piece of :cake: isn't it? :wink:")
249302
parser.add_option(

tests/test_optparse.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import rich_argparse._lazy_rich as r
1919
from rich_argparse.optparse import (
20+
GENERATE_USAGE,
2021
IndentedRichHelpFormatter,
2122
RichHelpFormatter,
2223
TitledRichHelpFormatter,
@@ -376,3 +377,84 @@ def test_legacy_windows(legacy_console, old_windows, colors): # pragma: win32 c
376377
init_win_colors.assert_called_with()
377378
else:
378379
init_win_colors.assert_not_called()
380+
381+
382+
@pytest.mark.parametrize(
383+
("formatter", "description", "nb_o", "expected"),
384+
(
385+
pytest.param(
386+
IndentedRichHelpFormatter(),
387+
None,
388+
2,
389+
"""\
390+
\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] [\x1b[36m--foo\x1b[0m \x1b[38;5;36mFOO\x1b[0m]
391+
392+
\x1b[38;5;208mOptions:\x1b[0m
393+
\x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m
394+
\x1b[36m--foo\x1b[0m=\x1b[38;5;36mFOO\x1b[0m \x1b[39mfoo help\x1b[0m
395+
""",
396+
id="indented",
397+
),
398+
pytest.param(
399+
IndentedRichHelpFormatter(),
400+
"A description.",
401+
2,
402+
"""\
403+
\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] [\x1b[36m--foo\x1b[0m \x1b[38;5;36mFOO\x1b[0m]
404+
405+
\x1b[39mA description.\x1b[0m
406+
407+
\x1b[38;5;208mOptions:\x1b[0m
408+
\x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m
409+
\x1b[36m--foo\x1b[0m=\x1b[38;5;36mFOO\x1b[0m \x1b[39mfoo help\x1b[0m
410+
""",
411+
id="indented-desc",
412+
),
413+
pytest.param(
414+
IndentedRichHelpFormatter(),
415+
None,
416+
30,
417+
"""\
418+
\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m]
419+
[\x1b[36m--foooooooooooooooooooooooooooooo\x1b[0m \x1b[38;5;36mFOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO\x1b[0m]
420+
421+
\x1b[38;5;208mOptions:\x1b[0m
422+
\x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m
423+
\x1b[36m--foooooooooooooooooooooooooooooo\x1b[0m=\x1b[38;5;36mFOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO\x1b[0m
424+
\x1b[39mfoo help\x1b[0m
425+
""",
426+
id="indented-long",
427+
),
428+
pytest.param(
429+
TitledRichHelpFormatter(),
430+
None,
431+
2,
432+
"""\
433+
\x1b[38;5;208mUsage\x1b[0m
434+
\x1b[38;5;208m=====\x1b[0m
435+
\x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] [\x1b[36m--foo\x1b[0m \x1b[38;5;36mFOO\x1b[0m]
436+
437+
\x1b[38;5;208mOptions\x1b[0m
438+
\x1b[38;5;208m=======\x1b[0m
439+
\x1b[36m--help\x1b[0m, \x1b[36m-h\x1b[0m \x1b[39mshow this help message and exit\x1b[0m
440+
\x1b[36m--foo\x1b[0m=\x1b[38;5;36mFOO\x1b[0m \x1b[39mfoo help\x1b[0m
441+
""",
442+
id="titled",
443+
),
444+
),
445+
)
446+
@pytest.mark.usefixtures("force_color")
447+
def test_generated_usage(formatter, description, nb_o, expected):
448+
parser = OptionParser(
449+
prog="PROG", formatter=formatter, usage=GENERATE_USAGE, description=description
450+
)
451+
parser.add_option("--f" + "o" * nb_o, help="foo help")
452+
parser.add_option("--bar", help=SUPPRESS_HELP)
453+
assert parser.format_help() == dedent(expected)
454+
455+
456+
def test_generated_usage_no_parser():
457+
formatter = IndentedRichHelpFormatter()
458+
with pytest.raises(TypeError) as exc_info:
459+
formatter.format_usage(GENERATE_USAGE)
460+
assert str(exc_info.value) == "Cannot generate usage if parser is not set"

0 commit comments

Comments
 (0)