Skip to content

Commit b75d354

Browse files
committed
cli/formatter(feat[help]): colorize example sections
1 parent a04032e commit b75d354

2 files changed

Lines changed: 239 additions & 54 deletions

File tree

‎src/vcspull/cli/__init__.py‎

Lines changed: 108 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from vcspull.__about__ import __version__
1515
from vcspull.log import setup_logger
1616

17+
from ._formatter import VcspullHelpFormatter
1718
from ._import import (
1819
create_import_subparser,
1920
import_from_filesystem,
@@ -24,73 +25,126 @@
2425

2526
log = logging.getLogger(__name__)
2627

27-
CLI_DESCRIPTION = textwrap.dedent(
28+
ExampleBlocks = t.Sequence[tuple[str | None, list[str]]]
29+
30+
31+
def build_description(intro: str, example_blocks: ExampleBlocks) -> str:
32+
"""Assemble help text with optional example sections."""
33+
sections: list[str] = []
34+
intro_text = textwrap.dedent(intro).strip()
35+
if intro_text:
36+
sections.append(intro_text)
37+
38+
for heading, commands in example_blocks:
39+
if not commands:
40+
continue
41+
title = "examples:" if heading is None else f"{heading} examples:"
42+
lines = [title]
43+
lines.extend(f" {command}" for command in commands)
44+
sections.append("\n".join(lines))
45+
46+
return "\n\n".join(sections)
47+
48+
49+
CLI_DESCRIPTION = build_description(
2850
"""
2951
Manage multiple VCS repositories from a single configuration file.
52+
""",
53+
(
54+
(
55+
"sync",
56+
[
57+
'vcspull sync "*"',
58+
'vcspull sync "django-*"',
59+
'vcspull sync "django-*" flask',
60+
'vcspull sync -c ./myrepos.yaml "*"',
61+
'vcspull sync -c ./myrepos.yaml myproject',
62+
],
63+
),
64+
(
65+
"import",
66+
[
67+
"vcspull import mylib https://github.com/example/mylib.git",
68+
"vcspull import -c ./myrepos.yaml mylib git@github.com:example/mylib.git",
69+
"vcspull import --scan ~/code",
70+
(
71+
"vcspull import --scan ~/code --recursive "
72+
"--workspace-root ~/code --yes"
73+
),
74+
],
75+
),
76+
(
77+
"fmt",
78+
[
79+
"vcspull fmt",
80+
"vcspull fmt -c ./myrepos.yaml",
81+
"vcspull fmt --write",
82+
"vcspull fmt --all",
83+
],
84+
),
85+
),
86+
)
3087

31-
sync examples:
32-
vcspull sync "*"
33-
vcspull sync "django-*"
34-
vcspull sync "django-*" flask
35-
vcspull sync -c ./myrepos.yaml "*"
36-
vcspull sync -c ./myrepos.yaml myproject
37-
38-
import examples:
39-
vcspull import mylib https://github.com/example/mylib.git
40-
vcspull import -c ./myrepos.yaml mylib git@github.com:example/mylib.git
41-
vcspull import --scan ~/code
42-
vcspull import --scan ~/code --recursive --workspace-root ~/code --yes
43-
44-
fmt examples:
45-
vcspull fmt
46-
vcspull fmt -c ./myrepos.yaml
47-
vcspull fmt --write
48-
vcspull fmt --all
49-
""",
50-
).strip()
51-
52-
SYNC_DESCRIPTION = textwrap.dedent(
88+
SYNC_DESCRIPTION = build_description(
5389
"""
5490
sync vcs repos
91+
""",
92+
(
93+
(
94+
None,
95+
[
96+
'vcspull sync "*"',
97+
'vcspull sync "django-*"',
98+
'vcspull sync "django-*" flask',
99+
'vcspull sync -c ./myrepos.yaml "*"',
100+
'vcspull sync -c ./myrepos.yaml myproject',
101+
],
102+
),
103+
),
104+
)
55105

56-
examples:
57-
vcspull sync "*"
58-
vcspull sync "django-*"
59-
vcspull sync "django-*" flask
60-
vcspull sync -c ./myrepos.yaml "*"
61-
vcspull sync -c ./myrepos.yaml myproject
62-
""",
63-
).strip()
64-
65-
IMPORT_DESCRIPTION = textwrap.dedent(
106+
IMPORT_DESCRIPTION = build_description(
66107
"""
67108
Import repositories into a vcspull configuration file.
68109
69110
Provide NAME and URL to add a single repository, or use --scan to
70111
discover existing git repositories within a directory.
112+
""",
113+
(
114+
(
115+
None,
116+
[
117+
"vcspull import mylib https://github.com/example/mylib.git",
118+
"vcspull import -c ./myrepos.yaml mylib git@github.com:example/mylib.git",
119+
"vcspull import --scan ~/code",
120+
(
121+
"vcspull import --scan ~/code --recursive "
122+
"--workspace-root ~/code --yes"
123+
),
124+
],
125+
),
126+
),
127+
)
71128

72-
examples:
73-
vcspull import mylib https://github.com/example/mylib.git
74-
vcspull import -c ./myrepos.yaml mylib git@github.com:example/mylib.git
75-
vcspull import --scan ~/code
76-
vcspull import --scan ~/code --recursive --workspace-root ~/code --yes
77-
""",
78-
).strip()
79-
80-
FMT_DESCRIPTION = textwrap.dedent(
129+
FMT_DESCRIPTION = build_description(
81130
"""
82131
Format vcspull configuration files for consistency.
83132
84133
Normalizes repository entries, sorts sections, and can write changes
85134
back to disk or format all discovered configuration files.
86-
87-
examples:
88-
vcspull fmt
89-
vcspull fmt -c ./myrepos.yaml
90-
vcspull fmt --write
91-
vcspull fmt --all
92-
""",
93-
).strip()
135+
""",
136+
(
137+
(
138+
None,
139+
[
140+
"vcspull fmt",
141+
"vcspull fmt -c ./myrepos.yaml",
142+
"vcspull fmt --write",
143+
"vcspull fmt --all",
144+
],
145+
),
146+
),
147+
)
94148

95149

96150
@overload
@@ -109,7 +163,7 @@ def create_parser(
109163
"""Create CLI argument parser for vcspull."""
110164
parser = argparse.ArgumentParser(
111165
prog="vcspull",
112-
formatter_class=argparse.RawDescriptionHelpFormatter,
166+
formatter_class=VcspullHelpFormatter,
113167
description=CLI_DESCRIPTION,
114168
)
115169
parser.add_argument(
@@ -130,23 +184,23 @@ def create_parser(
130184
sync_parser = subparsers.add_parser(
131185
"sync",
132186
help="synchronize repos",
133-
formatter_class=argparse.RawDescriptionHelpFormatter,
187+
formatter_class=VcspullHelpFormatter,
134188
description=SYNC_DESCRIPTION,
135189
)
136190
create_sync_subparser(sync_parser)
137191

138192
import_parser = subparsers.add_parser(
139193
"import",
140194
help="import repository or scan filesystem for repositories",
141-
formatter_class=argparse.RawDescriptionHelpFormatter,
195+
formatter_class=VcspullHelpFormatter,
142196
description=IMPORT_DESCRIPTION,
143197
)
144198
create_import_subparser(import_parser)
145199

146200
fmt_parser = subparsers.add_parser(
147201
"fmt",
148202
help="format vcspull configuration files",
149-
formatter_class=argparse.RawDescriptionHelpFormatter,
203+
formatter_class=VcspullHelpFormatter,
150204
description=FMT_DESCRIPTION,
151205
)
152206
create_fmt_subparser(fmt_parser)

‎src/vcspull/cli/_formatter.py‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Custom help formatter used by vcspull CLI."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import re
7+
import typing as t
8+
9+
OPTIONS_EXPECTING_VALUE = {
10+
"-c",
11+
"--config",
12+
"--log-level",
13+
"--path",
14+
"--workspace-root",
15+
"--scan",
16+
}
17+
18+
OPTIONS_FLAG_ONLY = {
19+
"-h",
20+
"--help",
21+
"-w",
22+
"--write",
23+
"--all",
24+
"--recursive",
25+
"-r",
26+
"--yes",
27+
"-y",
28+
}
29+
30+
31+
class VcspullHelpFormatter(argparse.RawDescriptionHelpFormatter):
32+
"""Render description blocks while colorizing example sections when possible."""
33+
34+
def _fill_text(self, text: str, width: int, indent: str) -> str:
35+
theme = getattr(self, "_theme", None)
36+
if not text or theme is None:
37+
return super()._fill_text(text, width, indent)
38+
39+
lines = text.splitlines(keepends=True)
40+
formatted_lines: list[str] = []
41+
in_examples_block = False
42+
expect_value = False
43+
44+
for line in lines:
45+
if line.strip() == "":
46+
in_examples_block = False
47+
expect_value = False
48+
formatted_lines.append(f"{indent}{line}")
49+
continue
50+
51+
has_newline = line.endswith("\n")
52+
stripped_line = line.rstrip("\n")
53+
leading_length = len(stripped_line) - len(stripped_line.lstrip(" "))
54+
leading = stripped_line[:leading_length]
55+
content = stripped_line[leading_length:]
56+
content_lower = content.lower()
57+
58+
if content_lower.endswith("examples:") and content_lower != "examples:":
59+
formatted_content = f"{theme.heading}{content}{theme.reset}"
60+
in_examples_block = True
61+
expect_value = False
62+
elif content_lower == "examples:":
63+
formatted_content = f"{theme.heading}{content}{theme.reset}"
64+
in_examples_block = True
65+
expect_value = False
66+
elif in_examples_block:
67+
colored_content = self._colorize_example_line(
68+
content,
69+
theme=theme,
70+
expect_value=expect_value,
71+
)
72+
expect_value = colored_content.expect_value
73+
formatted_content = colored_content.text
74+
else:
75+
formatted_content = stripped_line
76+
77+
newline = "\n" if has_newline else ""
78+
formatted_lines.append(f"{indent}{leading}{formatted_content}{newline}")
79+
80+
return "".join(formatted_lines)
81+
82+
class _ColorizedLine(t.NamedTuple):
83+
text: str
84+
expect_value: bool
85+
86+
def _colorize_example_line(
87+
self,
88+
content: str,
89+
*,
90+
theme: t.Any,
91+
expect_value: bool,
92+
) -> _ColorizedLine:
93+
parts: list[str] = []
94+
expecting_value = expect_value
95+
first_token = True
96+
97+
for match in re.finditer(r"\s+|\S+", content):
98+
token = match.group()
99+
if token.isspace():
100+
parts.append(token)
101+
continue
102+
103+
color = None
104+
token_for_matching = token
105+
106+
if expecting_value:
107+
color = theme.label
108+
expecting_value = False
109+
elif token_for_matching.startswith("--"):
110+
color = theme.long_option
111+
expecting_value = token_for_matching not in OPTIONS_FLAG_ONLY and (
112+
token_for_matching in OPTIONS_EXPECTING_VALUE
113+
)
114+
elif token_for_matching.startswith("-"):
115+
color = theme.short_option
116+
expecting_value = token_for_matching not in OPTIONS_FLAG_ONLY and (
117+
token_for_matching in OPTIONS_EXPECTING_VALUE
118+
)
119+
elif first_token:
120+
color = theme.heading
121+
else:
122+
color = None
123+
124+
first_token = False
125+
126+
if color:
127+
parts.append(f"{color}{token}{theme.reset}")
128+
else:
129+
parts.append(token)
130+
131+
return self._ColorizedLine(text="".join(parts), expect_value=expecting_value)

0 commit comments

Comments
 (0)