From 7e7420ecffa9b0a384dd183040ddfc287c512f29 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 18 Oct 2025 16:54:48 -0500 Subject: [PATCH 1/3] cli/__init__.py(docs[help]): add import and fmt examples --- src/vcspull/cli/__init__.py | 65 +++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/src/vcspull/cli/__init__.py b/src/vcspull/cli/__init__.py index 32048141..5a9dcdd0 100644 --- a/src/vcspull/cli/__init__.py +++ b/src/vcspull/cli/__init__.py @@ -24,6 +24,31 @@ log = logging.getLogger(__name__) +CLI_DESCRIPTION = textwrap.dedent( + """ + Manage multiple VCS repositories from a single configuration file. + + sync examples: + vcspull sync "*" + vcspull sync "django-*" + vcspull sync "django-*" flask + vcspull sync -c ./myrepos.yaml "*" + vcspull sync -c ./myrepos.yaml myproject + + import examples: + vcspull import mylib https://github.com/example/mylib.git + vcspull import -c ./myrepos.yaml mylib git@github.com:example/mylib.git + vcspull import --scan ~/code + vcspull import --scan ~/code --recursive --workspace-root ~/code --yes + + fmt examples: + vcspull fmt + vcspull fmt -c ./myrepos.yaml + vcspull fmt --write + vcspull fmt --all +""", +).strip() + SYNC_DESCRIPTION = textwrap.dedent( """ sync vcs repos @@ -37,6 +62,36 @@ """, ).strip() +IMPORT_DESCRIPTION = textwrap.dedent( + """ + Import repositories into a vcspull configuration file. + + Provide NAME and URL to add a single repository, or use --scan to + discover existing git repositories within a directory. + + examples: + vcspull import mylib https://github.com/example/mylib.git + vcspull import -c ./myrepos.yaml mylib git@github.com:example/mylib.git + vcspull import --scan ~/code + vcspull import --scan ~/code --recursive --workspace-root ~/code --yes +""", +).strip() + +FMT_DESCRIPTION = textwrap.dedent( + """ + Format vcspull configuration files for consistency. + + Normalizes repository entries, sorts sections, and can write changes + back to disk or format all discovered configuration files. + + examples: + vcspull fmt + vcspull fmt -c ./myrepos.yaml + vcspull fmt --write + vcspull fmt --all +""", +).strip() + @overload def create_parser( @@ -55,7 +110,7 @@ def create_parser( parser = argparse.ArgumentParser( prog="vcspull", formatter_class=argparse.RawDescriptionHelpFormatter, - description=SYNC_DESCRIPTION, + description=CLI_DESCRIPTION, ) parser.add_argument( "--version", @@ -84,9 +139,7 @@ def create_parser( "import", help="import repository or scan filesystem for repositories", formatter_class=argparse.RawDescriptionHelpFormatter, - description="Import a repository to the vcspull configuration file. " - "Can import a single repository by name and URL, or scan a directory " - "to discover and import multiple repositories.", + description=IMPORT_DESCRIPTION, ) create_import_subparser(import_parser) @@ -94,9 +147,7 @@ def create_parser( "fmt", help="format vcspull configuration files", formatter_class=argparse.RawDescriptionHelpFormatter, - description="Format vcspull configuration files for consistency. " - "Normalizes compact format to verbose format, standardizes on 'repo' key, " - "and sorts directories and repositories alphabetically.", + description=FMT_DESCRIPTION, ) create_fmt_subparser(fmt_parser) From 6f60780ebbf9867a1eefbc70b4c10d758437f9e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 18 Oct 2025 17:07:12 -0500 Subject: [PATCH 2/3] cli/formatter(feat[help]): colorize example sections --- src/vcspull/cli/__init__.py | 171 +++++++++++++++++++++++----------- src/vcspull/cli/_formatter.py | 131 ++++++++++++++++++++++++++ src/vcspull/log.py | 3 + 3 files changed, 250 insertions(+), 55 deletions(-) create mode 100644 src/vcspull/cli/_formatter.py diff --git a/src/vcspull/cli/__init__.py b/src/vcspull/cli/__init__.py index 5a9dcdd0..381f0daa 100644 --- a/src/vcspull/cli/__init__.py +++ b/src/vcspull/cli/__init__.py @@ -14,6 +14,7 @@ from vcspull.__about__ import __version__ from vcspull.log import setup_logger +from ._formatter import VcspullHelpFormatter from ._import import ( create_import_subparser, import_from_filesystem, @@ -24,73 +25,133 @@ log = logging.getLogger(__name__) -CLI_DESCRIPTION = textwrap.dedent( + +def build_description( + intro: str, + example_blocks: t.Sequence[tuple[str | None, t.Sequence[str]]], +) -> str: + """Assemble help text with optional example sections.""" + sections: list[str] = [] + intro_text = textwrap.dedent(intro).strip() + if intro_text: + sections.append(intro_text) + + for heading, commands in example_blocks: + if not commands: + continue + title = "examples:" if heading is None else f"{heading} examples:" + lines = [title] + lines.extend(f" {command}" for command in commands) + sections.append("\n".join(lines)) + + return "\n\n".join(sections) + + +CLI_DESCRIPTION = build_description( """ Manage multiple VCS repositories from a single configuration file. + """, + ( + ( + "sync", + [ + 'vcspull sync "*"', + 'vcspull sync "django-*"', + 'vcspull sync "django-*" flask', + 'vcspull sync -c ./myrepos.yaml "*"', + "vcspull sync -c ./myrepos.yaml myproject", + ], + ), + ( + "import", + [ + "vcspull import mylib https://github.com/example/mylib.git", + ( + "vcspull import -c ./myrepos.yaml mylib " + "git@github.com:example/mylib.git" + ), + "vcspull import --scan ~/code", + ( + "vcspull import --scan ~/code --recursive " + "--workspace-root ~/code --yes" + ), + ], + ), + ( + "fmt", + [ + "vcspull fmt", + "vcspull fmt -c ./myrepos.yaml", + "vcspull fmt --write", + "vcspull fmt --all", + ], + ), + ), +) - sync examples: - vcspull sync "*" - vcspull sync "django-*" - vcspull sync "django-*" flask - vcspull sync -c ./myrepos.yaml "*" - vcspull sync -c ./myrepos.yaml myproject - - import examples: - vcspull import mylib https://github.com/example/mylib.git - vcspull import -c ./myrepos.yaml mylib git@github.com:example/mylib.git - vcspull import --scan ~/code - vcspull import --scan ~/code --recursive --workspace-root ~/code --yes - - fmt examples: - vcspull fmt - vcspull fmt -c ./myrepos.yaml - vcspull fmt --write - vcspull fmt --all -""", -).strip() - -SYNC_DESCRIPTION = textwrap.dedent( +SYNC_DESCRIPTION = build_description( """ sync vcs repos + """, + ( + ( + None, + [ + 'vcspull sync "*"', + 'vcspull sync "django-*"', + 'vcspull sync "django-*" flask', + 'vcspull sync -c ./myrepos.yaml "*"', + "vcspull sync -c ./myrepos.yaml myproject", + ], + ), + ), +) - examples: - vcspull sync "*" - vcspull sync "django-*" - vcspull sync "django-*" flask - vcspull sync -c ./myrepos.yaml "*" - vcspull sync -c ./myrepos.yaml myproject -""", -).strip() - -IMPORT_DESCRIPTION = textwrap.dedent( +IMPORT_DESCRIPTION = build_description( """ - Import repositories into a vcspull configuration file. + Import a repository to the vcspull configuration file. Provide NAME and URL to add a single repository, or use --scan to discover existing git repositories within a directory. + """, + ( + ( + None, + [ + "vcspull import mylib https://github.com/example/mylib.git", + ( + "vcspull import -c ./myrepos.yaml mylib " + "git@github.com:example/mylib.git" + ), + "vcspull import --scan ~/code", + ( + "vcspull import --scan ~/code --recursive " + "--workspace-root ~/code --yes" + ), + ], + ), + ), +) - examples: - vcspull import mylib https://github.com/example/mylib.git - vcspull import -c ./myrepos.yaml mylib git@github.com:example/mylib.git - vcspull import --scan ~/code - vcspull import --scan ~/code --recursive --workspace-root ~/code --yes -""", -).strip() - -FMT_DESCRIPTION = textwrap.dedent( +FMT_DESCRIPTION = build_description( """ Format vcspull configuration files for consistency. Normalizes repository entries, sorts sections, and can write changes back to disk or format all discovered configuration files. - - examples: - vcspull fmt - vcspull fmt -c ./myrepos.yaml - vcspull fmt --write - vcspull fmt --all -""", -).strip() + """, + ( + ( + None, + [ + "vcspull fmt", + "vcspull fmt -c ./myrepos.yaml", + "vcspull fmt --write", + "vcspull fmt --all", + ], + ), + ), +) @overload @@ -109,7 +170,7 @@ def create_parser( """Create CLI argument parser for vcspull.""" parser = argparse.ArgumentParser( prog="vcspull", - formatter_class=argparse.RawDescriptionHelpFormatter, + formatter_class=VcspullHelpFormatter, description=CLI_DESCRIPTION, ) parser.add_argument( @@ -130,7 +191,7 @@ def create_parser( sync_parser = subparsers.add_parser( "sync", help="synchronize repos", - formatter_class=argparse.RawDescriptionHelpFormatter, + formatter_class=VcspullHelpFormatter, description=SYNC_DESCRIPTION, ) create_sync_subparser(sync_parser) @@ -138,7 +199,7 @@ def create_parser( import_parser = subparsers.add_parser( "import", help="import repository or scan filesystem for repositories", - formatter_class=argparse.RawDescriptionHelpFormatter, + formatter_class=VcspullHelpFormatter, description=IMPORT_DESCRIPTION, ) create_import_subparser(import_parser) @@ -146,7 +207,7 @@ def create_parser( fmt_parser = subparsers.add_parser( "fmt", help="format vcspull configuration files", - formatter_class=argparse.RawDescriptionHelpFormatter, + formatter_class=VcspullHelpFormatter, description=FMT_DESCRIPTION, ) create_fmt_subparser(fmt_parser) diff --git a/src/vcspull/cli/_formatter.py b/src/vcspull/cli/_formatter.py new file mode 100644 index 00000000..809f7f93 --- /dev/null +++ b/src/vcspull/cli/_formatter.py @@ -0,0 +1,131 @@ +"""Custom help formatter used by vcspull CLI.""" + +from __future__ import annotations + +import argparse +import re +import typing as t + +OPTIONS_EXPECTING_VALUE = { + "-c", + "--config", + "--log-level", + "--path", + "--workspace-root", + "--scan", +} + +OPTIONS_FLAG_ONLY = { + "-h", + "--help", + "-w", + "--write", + "--all", + "--recursive", + "-r", + "--yes", + "-y", +} + + +class VcspullHelpFormatter(argparse.RawDescriptionHelpFormatter): + """Render description blocks while colorizing example sections when possible.""" + + def _fill_text(self, text: str, width: int, indent: str) -> str: + 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() + is_section_heading = ( + content_lower.endswith("examples:") and content_lower != "examples:" + ) + + if is_section_heading or content_lower == "examples:": + 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): + text: str + expect_value: bool + + def _colorize_example_line( + self, + content: str, + *, + theme: t.Any, + expect_value: bool, + ) -> _ColorizedLine: + 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) diff --git a/src/vcspull/log.py b/src/vcspull/log.py index 6dbb1037..7acdc802 100644 --- a/src/vcspull/log.py +++ b/src/vcspull/log.py @@ -33,6 +33,7 @@ def get_cli_logger_names(include_self: bool = True) -> list[str]: """Return logger names under ``vcspull.cli``.""" names: set[str] = set() + exclude = {"vcspull.cli._formatter"} cli_module = importlib.import_module("vcspull.cli") if include_self: names.add(cli_module.__name__) @@ -42,6 +43,8 @@ def get_cli_logger_names(include_self: bool = True) -> list[str]: cli_module.__path__, prefix="vcspull.cli.", ): + if module_info.name in exclude: + continue names.add(module_info.name) return sorted(names) From 2b0cf99a4ba05cc7af8c1fa3854b7b2c9f0f769e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 18 Oct 2025 17:14:22 -0500 Subject: [PATCH 3/3] docs(changelog): record CLI formatter, example enhancements --- CHANGES | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGES b/CHANGES index a9305815..9993bf35 100644 --- a/CHANGES +++ b/CHANGES @@ -33,6 +33,16 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force _Notes on upcoming releases will be added here_ +### Improvements + +- Align CLI help output with CPython’s argparse theming by adding a dedicated + formatter that colorizes example sections for top-level and subcommand help + screens (#471). +- Expand `vcspull --help` to include additional example commands for the `sync`, + `import`, and `fmt` subcommands, giving users clearer quick-start guidance (#471). +- Keep CLI logger discovery stable while refactoring the formatter into its own + module, preventing additional loggers from surfacing in downstream tools (#471). + ## vcspull v1.37.0 (2025-10-18) ### Breaking changes