Skip to content

cli

ruff_sync.cli

Synchronize Ruff linter configuration across Python projects.

This module provides a CLI tool and library for downloading, parsing, and merging Ruff configuration from upstream sources (like GitHub/GitLab) into local projects.

__all__ module-attribute

__all__ = [
    "Arguments",
    "ColoredFormatter",
    "OutputFormat",
    "get_config",
    "main",
]

__version__ module-attribute

__version__ = version('ruff-sync')

CIProvider module-attribute

CIProvider = Literal[GITHUB, GITLAB]

LOGGER module-attribute

LOGGER = getLogger(__name__)

PARSER module-attribute

PARSER = _get_cli_parser()

OutputFormat

Bases: str, Enum

Output formats for the CLI.

Source code in src/ruff_sync/constants.py
@enum.unique
class OutputFormat(str, enum.Enum):
    """Output formats for the CLI."""

    TEXT = "text"
    JSON = "json"
    GITHUB = "github"
    GITLAB = "gitlab"
    SARIF = "sarif"

    @override
    def __str__(self) -> str:
        """Return the string value for argparse help."""
        return self.value

TEXT class-attribute instance-attribute

TEXT = 'text'

JSON class-attribute instance-attribute

JSON = 'json'

GITHUB class-attribute instance-attribute

GITHUB = 'github'

GITLAB class-attribute instance-attribute

GITLAB = 'gitlab'

SARIF class-attribute instance-attribute

SARIF = 'sarif'

__str__

__str__()

Return the string value for argparse help.

Source code in src/ruff_sync/constants.py
@override
def __str__(self) -> str:
    """Return the string value for argparse help."""
    return self.value

ColoredFormatter

Bases: Formatter

Logging Formatter to add colors.

Source code in src/ruff_sync/cli.py
class ColoredFormatter(logging.Formatter):
    """Logging Formatter to add colors."""

    RESET: ClassVar[str] = "\x1b[0m"
    COLORS: ClassVar[Mapping[int, str]] = {
        logging.DEBUG: "\x1b[36m",  # Cyan
        logging.INFO: "\x1b[32m",  # Green
        logging.WARNING: "\x1b[33m",  # Yellow
        logging.ERROR: "\x1b[31m",  # Red
        logging.CRITICAL: "\x1b[1;31m",  # Bold Red
    }

    def __init__(self, fmt: str = "%(message)s") -> None:
        """Initialize the formatter with a format string."""
        super().__init__(fmt)

    @override
    def format(self, record: logging.LogRecord) -> str:
        """Format the log record with colors if the output is a TTY."""
        if sys.stderr.isatty():
            color = self.COLORS.get(record.levelno, self.RESET)
            return f"{color}{super().format(record)}{self.RESET}"
        return super().format(record)

RESET class-attribute

RESET = '\x1b[0m'

COLORS class-attribute

COLORS = {
    DEBUG: "\x1b[36m",
    INFO: "\x1b[32m",
    WARNING: "\x1b[33m",
    ERROR: "\x1b[31m",
    CRITICAL: "\x1b[1;31m",
}

__init__

__init__(fmt='%(message)s')

Initialize the formatter with a format string.

Source code in src/ruff_sync/cli.py
def __init__(self, fmt: str = "%(message)s") -> None:
    """Initialize the formatter with a format string."""
    super().__init__(fmt)

format

format(record)

Format the log record with colors if the output is a TTY.

Source code in src/ruff_sync/cli.py
@override
def format(self, record: logging.LogRecord) -> str:
    """Format the log record with colors if the output is a TTY."""
    if sys.stderr.isatty():
        color = self.COLORS.get(record.levelno, self.RESET)
        return f"{color}{super().format(record)}{self.RESET}"
    return super().format(record)

Arguments

Bases: NamedTuple

CLI arguments for the ruff-sync tool.

Source code in src/ruff_sync/cli.py
class Arguments(NamedTuple):
    """CLI arguments for the ruff-sync tool."""

    command: str
    upstream: tuple[URL, ...]
    to: pathlib.Path
    exclude: Iterable[str]
    verbose: int
    branch: str = DEFAULT_BRANCH
    path: str | None = None
    semantic: bool = False
    diff: bool = True
    init: bool = False
    pre_commit: bool = False
    save: bool | None = None
    output_format: OutputFormat = OutputFormat.TEXT

    @property
    @deprecated("Use 'to' instead")
    def source(self) -> pathlib.Path:
        """Deprecated: use 'to' instead."""
        return self.to

    @classmethod
    @lru_cache(maxsize=1)
    def fields(cls) -> set[str]:
        """Return the set of all field names, including deprecated ones."""
        return set(cls._fields) | {"source"}

command instance-attribute

command

upstream instance-attribute

upstream

to instance-attribute

to

exclude instance-attribute

exclude

verbose instance-attribute

verbose

branch class-attribute instance-attribute

branch = DEFAULT_BRANCH

path class-attribute instance-attribute

path = None

semantic class-attribute instance-attribute

semantic = False

diff class-attribute instance-attribute

diff = True

init class-attribute instance-attribute

init = False

pre_commit class-attribute instance-attribute

pre_commit = False

save class-attribute instance-attribute

save = None

output_format class-attribute instance-attribute

output_format = TEXT

source property

source

Deprecated: use 'to' instead.

fields cached classmethod

fields()

Return the set of all field names, including deprecated ones.

Source code in src/ruff_sync/cli.py
@classmethod
@lru_cache(maxsize=1)
def fields(cls) -> set[str]:
    """Return the set of all field names, including deprecated ones."""
    return set(cls._fields) | {"source"}

ResolvedArgs

Bases: NamedTuple

Internal container for resolved arguments.

Source code in src/ruff_sync/cli.py
class ResolvedArgs(NamedTuple):
    """Internal container for resolved arguments."""

    upstream: tuple[URL, ...]
    to: pathlib.Path
    exclude: Iterable[str]
    branch: str
    path: str | None
    output_format: OutputFormat

upstream instance-attribute

upstream

to instance-attribute

to

exclude instance-attribute

exclude

branch instance-attribute

branch

path instance-attribute

path

output_format instance-attribute

output_format

CLIArguments

Bases: Protocol

Protocol for parsed CLI arguments from ArgumentParser.

Source code in src/ruff_sync/cli.py
class CLIArguments(Protocol):
    """Protocol for parsed CLI arguments from ArgumentParser."""

    command: str | None
    upstream: list[URL]
    to: str | None
    source: str | None
    exclude: list[str] | None
    verbose: int
    branch: str | None
    path: str | None
    pre_commit: bool | None
    output_format: OutputFormat | None
    # Subcommand specific
    init: bool
    save: bool | None
    semantic: bool
    diff: bool

command instance-attribute

command

upstream instance-attribute

upstream

to instance-attribute

to

source instance-attribute

source

exclude instance-attribute

exclude

verbose instance-attribute

verbose

branch instance-attribute

branch

path instance-attribute

path

pre_commit instance-attribute

pre_commit

output_format instance-attribute

output_format

init instance-attribute

init

save instance-attribute

save

semantic instance-attribute

semantic

diff instance-attribute

diff

get_config cached

get_config(source)

Read [tool.ruff-sync] configuration from pyproject.toml.

Examples:

>>> import pathlib
>>> config = get_config(pathlib.Path("."))
>>> if "upstream" in config:
...     print(f"Syncing from {config['upstream']}")
Source code in src/ruff_sync/cli.py
@lru_cache(maxsize=1)
def get_config(
    source: pathlib.Path,
) -> Config:
    """Read [tool.ruff-sync] configuration from pyproject.toml.

    Examples:
        >>> import pathlib
        >>> config = get_config(pathlib.Path("."))
        >>> if "upstream" in config:
        ...     print(f"Syncing from {config['upstream']}")
    """
    local_toml = source / RuffConfigFileName.PYPROJECT_TOML
    # TODO: use pydantic to validate the toml file
    cfg_result: Config = {}
    if local_toml.exists():
        toml = tomlkit.parse(local_toml.read_text())
        config = toml.get("tool", {}).get("ruff-sync")
        if config:
            allowed_keys = set(Config.__annotations__.keys())
            for raw_key, value in config.items():
                # Check for legacy 'source' key to emit deprecation warning
                if raw_key.replace("-", "_") == ConfKey.to_attr(ConfKey.SOURCE):
                    LOGGER.warning(
                        f"DeprecationWarning: [tool.ruff-sync] '{raw_key}' "
                        f"is deprecated. Use '{ConfKey.TO}' instead."
                    )

                # Map legacy names (source, pre_commit_sync) to canonical
                # (to, pre-commit-version-sync)
                canonical_key = ConfKey.get_canonical(raw_key)

                # Normalize TOML key (dashes) to internal Python attribute name (underscores)
                # e.g. "pre-commit-version-sync" -> "pre_commit_version_sync"
                arg_attr = ConfKey.to_attr(canonical_key)

                if arg_attr in allowed_keys:
                    cfg_result[arg_attr] = value  # type: ignore[literal-required]
                else:
                    LOGGER.warning(f"Unknown ruff-sync configuration: {raw_key}")

            # Ensure 'to' is populated if 'source' was used
            to_attr = ConfKey.to_attr(ConfKey.TO)
            source_attr = ConfKey.to_attr(ConfKey.SOURCE)
            if source_attr in cfg_result and to_attr not in cfg_result:
                cfg_result[to_attr] = cfg_result[source_attr]  # type: ignore[literal-required]
    return cfg_result

main

main()

Run the ruff-sync CLI.

Source code in src/ruff_sync/cli.py
def main() -> int:
    """Run the ruff-sync CLI."""
    # Handle backward compatibility: default to 'pull' if no command provided
    if len(sys.argv) > 1 and sys.argv[1] not in (
        "pull",
        "check",
        "inspect",
        "-h",
        "--help",
        "--version",
    ):
        sys.argv.insert(1, "pull")
    elif len(sys.argv) == 1:
        sys.argv.append("pull")

    args = PARSER.parse_args()

    # Configure logging
    log_level = {
        0: logging.WARNING,
        1: logging.INFO,
    }.get(args.verbose, logging.DEBUG)

    # Configure logging for the entire ruff_sync package
    root_logger = logging.getLogger("ruff_sync")
    root_logger.setLevel(log_level)

    # Avoid adding multiple handlers if main() is called multiple times (e.g. in tests)
    if not root_logger.handlers:
        handler = logging.StreamHandler()
        handler.setFormatter(ColoredFormatter())
        root_logger.addHandler(handler)

    root_logger.propagate = "PYTEST_CURRENT_TEST" in os.environ

    # Determine target 'to' from CLI or use default '.'
    # Defer Path conversion to avoid pyfakefs issues with captured Path class
    arg_to = args.to or args.source
    initial_to = pathlib.Path(arg_to) if arg_to else pathlib.Path()
    config: Config = get_config(initial_to)

    upstream, to_val, exclude, branch, path, output_format = _resolve_args(args, config, initial_to)
    pre_commit_val = _resolve_pre_commit(args, config)

    resolved_upstream = tuple(resolve_raw_url(u, branch=branch, path=path) for u in upstream)

    exec_args = Arguments(
        command=args.command,
        upstream=resolved_upstream,
        to=to_val,
        exclude=exclude,
        verbose=args.verbose,
        branch=branch,
        path=path,
        semantic=getattr(args, "semantic", False),
        diff=getattr(args, "diff", True),
        init=getattr(args, "init", False),
        pre_commit=pre_commit_val,
        save=getattr(args, "save", None),
        output_format=output_format,
    )

    # Warn if the specified output format doesn't match the current CI environment
    _validate_ci_output_format(exec_args)

    try:
        if exec_args.command == "inspect":
            from ruff_sync.tui import get_tui_app  # noqa: PLC0415

            app_class = get_tui_app()
            return app_class(exec_args).run() or 0

        if exec_args.command == "check":
            return asyncio.run(check(exec_args))
        return asyncio.run(pull(exec_args))
    except DependencyError as e:
        LOGGER.error(f"❌ {e}")  # noqa: TRY400
        return 1
    except UpstreamError as e:
        for url, err in e.errors:
            LOGGER.error(f"❌ Failed to fetch {url}: {err}")  # noqa: TRY400
        return 4

inspect

inspect()

Entry point for the ruff-inspect console script.

Source code in src/ruff_sync/cli.py
def inspect() -> int:
    """Entry point for the ruff-inspect console script."""
    # Handle optional subcommands/args if user passed any to ruff-inspect
    # but primarily ensure "inspect" is the command.
    if len(sys.argv) > 1 and sys.argv[1] not in ("-h", "--help", "--version"):
        # If they passed args but no command, insert 'inspect'
        if sys.argv[1] not in ("pull", "check", "inspect"):
            sys.argv.insert(1, "inspect")
    else:
        # Default to 'inspect' if no args or just flags
        sys.argv.insert(1, "inspect")

    return main()