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)

    def format(self, record: logging.LogRecord) -> str:  # type: ignore[explicit-override]
        """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
def format(self, record: logging.LogRecord) -> str:  # type: ignore[explicit-override]
    """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] | MissingType
    verbose: int
    branch: str | MissingType = MISSING
    path: str | MissingType = MISSING
    semantic: bool = False
    diff: bool = True
    init: bool = False
    pre_commit: bool | MissingType = MISSING
    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 = MISSING

path class-attribute instance-attribute

path = MISSING

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 = MISSING

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] | MissingType
    branch: str | MissingType
    path: str | MissingType

upstream instance-attribute

upstream

to instance-attribute

to

exclude instance-attribute

exclude

branch instance-attribute

branch

path instance-attribute

path

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: dict[str, Any] = {}
    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 arg, value in config.items():
                arg_norm = arg.replace("-", "_")

                # Handle aliases for pre-commit
                if arg_norm in ("pre_commit_sync", "pre_commit"):
                    arg_norm = "pre_commit_version_sync"

                if arg_norm in allowed_keys:
                    if arg_norm == "source":
                        LOGGER.warning(
                            "DeprecationWarning: [tool.ruff-sync] 'source' is deprecated. "
                            "Use 'to' instead."
                        )
                    cfg_result[arg_norm] = value
                else:
                    LOGGER.warning(f"Unknown ruff-sync configuration: {arg}")
            # Ensure 'to' is populated if 'source' was used
            if "source" in cfg_result and "to" not in cfg_result:
                cfg_result["to"] = cfg_result["source"]
    return cast("Config", 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",
        "-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 = _resolve_args(args, config, initial_to)

    pre_commit_arg = getattr(args, "pre_commit", None)
    if pre_commit_arg is not None:
        pre_commit_val: bool | MissingType = pre_commit_arg
    elif "pre-commit-version-sync" in config:
        pre_commit_val = cast("Any", config).get("pre-commit-version-sync")
    elif "pre_commit_version_sync" in config:
        pre_commit_val = config.get("pre_commit_version_sync", MISSING)
    else:
        pre_commit_val = MISSING

    # Build Arguments first so _resolve_defaults can centralize MISSING → default
    # resolution for branch and path (keeps cli.main and core._merge_multiple_upstreams
    # in sync with a single source of truth).
    exec_args = Arguments(
        command=args.command,
        upstream=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=getattr(args, "output_format", OutputFormat.TEXT),
    )

    # Use the shared helper from constants so the MISSING→default logic for
    # branch/path cannot diverge between cli.main and core._merge_multiple_upstreams.
    res_branch, res_path, _exclude = resolve_defaults(branch, path, exclude)
    resolved_upstream = tuple(
        resolve_raw_url(u, branch=res_branch, path=res_path) for u in upstream
    )
    exec_args = exec_args._replace(upstream=resolved_upstream)

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

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