Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
152 changes: 132 additions & 20 deletions src/vcspull/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,18 +25,133 @@

log = logging.getLogger(__name__)

SYNC_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_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 = build_description(
"""
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"
),
],
),
),
)

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.
""",
(
(
None,
[
"vcspull fmt",
"vcspull fmt -c ./myrepos.yaml",
"vcspull fmt --write",
"vcspull fmt --all",
],
),
),
)


@overload
Expand All @@ -54,8 +170,8 @@ def create_parser(
"""Create CLI argument parser for vcspull."""
parser = argparse.ArgumentParser(
prog="vcspull",
formatter_class=argparse.RawDescriptionHelpFormatter,
description=SYNC_DESCRIPTION,
formatter_class=VcspullHelpFormatter,
description=CLI_DESCRIPTION,
)
parser.add_argument(
"--version",
Expand All @@ -75,28 +191,24 @@ 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)

import_parser = subparsers.add_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.",
formatter_class=VcspullHelpFormatter,
description=IMPORT_DESCRIPTION,
)
create_import_subparser(import_parser)

fmt_parser = subparsers.add_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.",
formatter_class=VcspullHelpFormatter,
description=FMT_DESCRIPTION,
)
create_fmt_subparser(fmt_parser)

Expand Down
131 changes: 131 additions & 0 deletions src/vcspull/cli/_formatter.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions src/vcspull/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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)
Expand Down