Skip to content

Commit c0ade62

Browse files
authored
vcspull --help: Improve examples, colorize (#471)
- add a formatter that colorizes CLI help example sections and apply it to the root parser plus all subcommands - expand the bundled `vcspull --help` examples so each subcommand offers copy-pastable usage snippets - keep CLI logger discovery output stable after the formatter refactor and document the improvements in `CHANGES`
2 parents ee296a2 + 2b0cf99 commit c0ade62

4 files changed

Lines changed: 276 additions & 20 deletions

File tree

CHANGES

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force
3333

3434
_Notes on upcoming releases will be added here_
3535

36+
### Improvements
37+
38+
- Align CLI help output with CPython’s argparse theming by adding a dedicated
39+
formatter that colorizes example sections for top-level and subcommand help
40+
screens (#471).
41+
- Expand `vcspull --help` to include additional example commands for the `sync`,
42+
`import`, and `fmt` subcommands, giving users clearer quick-start guidance (#471).
43+
- Keep CLI logger discovery stable while refactoring the formatter into its own
44+
module, preventing additional loggers from surfacing in downstream tools (#471).
45+
3646
## vcspull v1.37.0 (2025-10-18)
3747

3848
### Breaking changes

src/vcspull/cli/__init__.py

Lines changed: 132 additions & 20 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,18 +25,133 @@
2425

2526
log = logging.getLogger(__name__)
2627

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

31-
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-
).strip()
110+
IMPORT_DESCRIPTION = build_description(
111+
"""
112+
Import a repository to the vcspull configuration file.
113+
114+
Provide NAME and URL to add a single repository, or use --scan to
115+
discover existing git repositories within a directory.
116+
""",
117+
(
118+
(
119+
None,
120+
[
121+
"vcspull import mylib https://github.com/example/mylib.git",
122+
(
123+
"vcspull import -c ./myrepos.yaml mylib "
124+
"git@github.com:example/mylib.git"
125+
),
126+
"vcspull import --scan ~/code",
127+
(
128+
"vcspull import --scan ~/code --recursive "
129+
"--workspace-root ~/code --yes"
130+
),
131+
],
132+
),
133+
),
134+
)
135+
136+
FMT_DESCRIPTION = build_description(
137+
"""
138+
Format vcspull configuration files for consistency.
139+
140+
Normalizes repository entries, sorts sections, and can write changes
141+
back to disk or format all discovered configuration files.
142+
""",
143+
(
144+
(
145+
None,
146+
[
147+
"vcspull fmt",
148+
"vcspull fmt -c ./myrepos.yaml",
149+
"vcspull fmt --write",
150+
"vcspull fmt --all",
151+
],
152+
),
153+
),
154+
)
39155

40156

41157
@overload
@@ -54,8 +170,8 @@ def create_parser(
54170
"""Create CLI argument parser for vcspull."""
55171
parser = argparse.ArgumentParser(
56172
prog="vcspull",
57-
formatter_class=argparse.RawDescriptionHelpFormatter,
58-
description=SYNC_DESCRIPTION,
173+
formatter_class=VcspullHelpFormatter,
174+
description=CLI_DESCRIPTION,
59175
)
60176
parser.add_argument(
61177
"--version",
@@ -75,28 +191,24 @@ def create_parser(
75191
sync_parser = subparsers.add_parser(
76192
"sync",
77193
help="synchronize repos",
78-
formatter_class=argparse.RawDescriptionHelpFormatter,
194+
formatter_class=VcspullHelpFormatter,
79195
description=SYNC_DESCRIPTION,
80196
)
81197
create_sync_subparser(sync_parser)
82198

83199
import_parser = subparsers.add_parser(
84200
"import",
85201
help="import repository or scan filesystem for repositories",
86-
formatter_class=argparse.RawDescriptionHelpFormatter,
87-
description="Import a repository to the vcspull configuration file. "
88-
"Can import a single repository by name and URL, or scan a directory "
89-
"to discover and import multiple repositories.",
202+
formatter_class=VcspullHelpFormatter,
203+
description=IMPORT_DESCRIPTION,
90204
)
91205
create_import_subparser(import_parser)
92206

93207
fmt_parser = subparsers.add_parser(
94208
"fmt",
95209
help="format vcspull configuration files",
96-
formatter_class=argparse.RawDescriptionHelpFormatter,
97-
description="Format vcspull configuration files for consistency. "
98-
"Normalizes compact format to verbose format, standardizes on 'repo' key, "
99-
"and sorts directories and repositories alphabetically.",
210+
formatter_class=VcspullHelpFormatter,
211+
description=FMT_DESCRIPTION,
100212
)
101213
create_fmt_subparser(fmt_parser)
102214

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+
is_section_heading = (
58+
content_lower.endswith("examples:") and content_lower != "examples:"
59+
)
60+
61+
if is_section_heading or content_lower == "examples:":
62+
formatted_content = f"{theme.heading}{content}{theme.reset}"
63+
in_examples_block = True
64+
expect_value = False
65+
elif in_examples_block:
66+
colored_content = self._colorize_example_line(
67+
content,
68+
theme=theme,
69+
expect_value=expect_value,
70+
)
71+
expect_value = colored_content.expect_value
72+
formatted_content = colored_content.text
73+
else:
74+
formatted_content = stripped_line
75+
76+
newline = "\n" if has_newline else ""
77+
formatted_lines.append(f"{indent}{leading}{formatted_content}{newline}")
78+
79+
return "".join(formatted_lines)
80+
81+
class _ColorizedLine(t.NamedTuple):
82+
text: str
83+
expect_value: bool
84+
85+
def _colorize_example_line(
86+
self,
87+
content: str,
88+
*,
89+
theme: t.Any,
90+
expect_value: bool,
91+
) -> _ColorizedLine:
92+
parts: list[str] = []
93+
expecting_value = expect_value
94+
first_token = True
95+
colored_subcommand = False
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+
if expecting_value:
104+
color = theme.label
105+
expecting_value = False
106+
elif token.startswith("--"):
107+
color = theme.long_option
108+
expecting_value = (
109+
token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE
110+
)
111+
elif token.startswith("-"):
112+
color = theme.short_option
113+
expecting_value = (
114+
token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE
115+
)
116+
elif first_token:
117+
color = theme.prog
118+
elif not colored_subcommand:
119+
color = theme.action
120+
colored_subcommand = True
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)

src/vcspull/log.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
def get_cli_logger_names(include_self: bool = True) -> list[str]:
3434
"""Return logger names under ``vcspull.cli``."""
3535
names: set[str] = set()
36+
exclude = {"vcspull.cli._formatter"}
3637
cli_module = importlib.import_module("vcspull.cli")
3738
if include_self:
3839
names.add(cli_module.__name__)
@@ -42,6 +43,8 @@ def get_cli_logger_names(include_self: bool = True) -> list[str]:
4243
cli_module.__path__,
4344
prefix="vcspull.cli.",
4445
):
46+
if module_info.name in exclude:
47+
continue
4548
names.add(module_info.name)
4649

4750
return sorted(names)

0 commit comments

Comments
 (0)