introspect your Pylint configuration and figure out what Ruff rules to enable or disable
if __name__ == "__main__":
    from pylint_to_ruff.cli import cli

    cli()

pylint_to_ruff/__main__.py
akx/pylint-to-ruff
import argparse
import io

from pylint_to_ruff.pylint import get_pylint_message_control_output
from pylint_to_ruff.ruff import get_ruffverse


def cli():
    ap = argparse.ArgumentParser()
    ap.add_argument(
        "wd",
        type=str,
        help="Working directory",
        default=".",
        nargs="?",
    )
    ap.add_argument(
        "--no-explain",
        action="store_true",
        default=False,
        help="Don't print explanation comments",
    )
    ap.add_argument(
        "--no-unsupported",
        action="store_true",
        default=False,
        help="Don't print commented-out unsupported rules",
    )
    args = ap.parse_args()
    wd = args.wd
    pmc = get_pylint_message_control_output(wd)
    ruffverse = get_ruffverse()
    config = get_ruff_config_segment(
        pmc,
        ruffverse,
        explain=not args.no_explain,
        print_unsupported=not args.no_unsupported,
    )
    print(config)


def _format_rule(code: str, explanation: str, *, commented_out: bool = False, explain: bool) -> str:
    if not explain:
        explanation = ""
    line = f'"{code}",'
    if explanation:
        line += f"  # {explanation}"
    if commented_out:
        line = f"# {line}"
    return f"  {line}"


def get_ruff_config_segment(pmc, ruffverse, *, explain: bool, print_unsupported: bool) -> str:
    sio = io.StringIO()
    for name, m_set in [
        ("extend-select", pmc.enabled),
        ("ignore", pmc.disabled),
    ]:
        if not m_set:
            continue
        print(f"{name} = [", file=sio)
        for m in sorted(m_set, key=lambda m: m.code):
            pl_code = f"PL{m.code}"
            ruff_codes = ruffverse.get_ruff_equivalent_codes(m)
            if ruff_codes:
                for ruff_code in sorted(ruff_codes):
                    print(_format_rule(ruff_code, m.name, explain=explain), file=sio)
            else:
                if not print_unsupported:
                    continue
                print(_format_rule(pl_code, m.name, commented_out=True, explain=explain), file=sio)
        print("]\n", file=sio)
    return sio.getvalue()

pylint_to_ruff/cli.py
akx/pylint-to-ruff
import dataclasses
import os.path
import subprocess
import sys
from collections import namedtuple


class PylintMessage(namedtuple("_PylintMessage", ["name", "code"])):
    pass


@dataclasses.dataclass(frozen=True)
class PylintMessageConfig:
    enabled: set[PylintMessage] = dataclasses.field(default_factory=set)
    disabled: set[PylintMessage] = dataclasses.field(default_factory=set)
    non_emittable: set[PylintMessage] = dataclasses.field(default_factory=set)


def parse_pylint_msg_spec(spec: str) -> PylintMessage:
    name, _, code = spec.strip(")").partition(" (")
    return PylintMessage(name, code)


def get_pylint_message_control_output(working_directory: str) -> PylintMessageConfig:
    pmc = PylintMessageConfig()
    current = None
    command = ["pylint", "--list-msgs-enabled"]
    pylintrc_file = os.path.join(working_directory, ".pylintrc")
    if os.path.isfile(pylintrc_file):
        command += ["--rcfile", pylintrc_file]
    for line in subprocess.check_output(
        command,
        cwd=working_directory,
        encoding="utf-8",
    ).splitlines():
        line = line.rstrip()  # noqa: PLW2901
        if not line:
            continue
        if line.startswith("Enabled messages"):
            current = pmc.enabled
            continue
        if line.startswith("Disabled messages"):
            current = pmc.disabled
            continue
        if line.startswith("Non-emittable messages"):
            current = pmc.non_emittable
            continue
        if line.startswith("  "):
            current.add(parse_pylint_msg_spec(line[2:]))
            continue
        else:
            print(f"Unknown line in pylint output: {line!r}", file=sys.stderr)

    return pmc

pylint_to_ruff/pylint.py
akx/pylint-to-ruff
import dataclasses
import json
import subprocess
from functools import cached_property

from pylint_to_ruff.pylint import PylintMessage
from pylint_to_ruff.ruff_aliases import RUFF_ALIASES


@dataclasses.dataclass(frozen=True)
class Ruffverse:
    rules: dict[str, dict] = dataclasses.field(default_factory=dict)

    @cached_property
    def known_pylint_rules_by_code(self) -> dict[str, dict]:
        return {code[2:]: rule for code, rule in self.rules.items() if code.startswith("PL")}

    @cached_property
    def name_to_code(self) -> dict[str, str]:
        return {rule["name"]: code for code, rule in self.rules.items()}

    def get_ruff_equivalent_codes(self, m: PylintMessage) -> set[str]:
        pl_code = f"PL{m.code}"
        if pl_code in self.known_pylint_rules_by_code:
            return {pl_code}
        if m.name in self.name_to_code:
            return {self.name_to_code[m.name]}
        if pl_code in RUFF_ALIASES:
            return RUFF_ALIASES[pl_code]
        return set()


def get_ruffverse() -> Ruffverse:
    rules_output = subprocess.check_output(["ruff", "rule", "--format=json", "--all"], encoding="utf-8")
    rules = {rule["code"]: rule for rule in json.loads(rules_output)}
    return Ruffverse(rules=rules)

pylint_to_ruff/ruff.py
akx/pylint-to-ruff
# This file is auto-generated by update_ruff_aliases.py
RUFF_ALIASES = {
    "C0103": {"N815"},
    "C0112": {"D419"},
    "C0113": {"SIM208"},
    "C0114": {"D100"},
    "C0115": {"D101"},
    "C0116": {"D103"},
    "C0121": {"E712", "E711"},
    "C0123": {"E721"},
    "C0198": {"Q002"},
    "C0199": {"D210"},
    "C0201": {"SIM118"},
    "C0202": {"N804"},
    "C0301": {"E501"},
    "C0304": {"W292"},
    "C0321": {"E702", "E701"},
    "C0325": {"UP034"},
    "C0410": {"E401"},
    "C0411": {"I001"},
    "C0412": {"I001"},
    "C0413": {"E402"},
    "C0501": {"SIM110", "SIM111"},
    "C2201": {"SIM300"},
    "E0001": {"E999"},
    "E0102": {"F811"},
    "E0103": {"F701", "F702"},
    "E0104": {"F706"},
    "E0105": {"F704"},
    "E0107": {"B002"},
    "E0112": {"F622"},
    "E0211": {"N805"},
    "E0213": {"N805"},
    "E0602": {"F821"},
    "E0603": {"F822"},
    "E0711": {"F901"},
    "E1301": {"F501"},
    "E1302": {"F506"},
    "E1303": {"F502"},
    "E1304": {"F524"},
    "E1305": {"F522"},
    "E1306": {"F524"},
    "R0123": {"F632"},
    "R0205": {"UP004"},
    "R1260": {"C901"},
    "R1705": {"RET505"},
    "R1706": {"SIM108"},
    "R1707": {"COM818"},
    "R1710": {"RET502", "RET501"},
    "R1715": {"SIM401"},
    "R1717": {"C402"},
    "R1718": {"C401"},
    "R1720": {"RET506"},
    "R1721": {"C416"},
    "R1723": {"RET508"},
    "R1724": {"RET507"},
    "R1725": {"UP008"},
    "R1728": {"C417"},
    "R1729": {"C417"},
    "R1734": {"C405"},
    "R1735": {"C406"},
    "R6002": {"UP006"},
    "R6003": {"UP007"},
    "W0102": {"B006"},
    "W0104": {"B018"},
    "W0106": {"B018"},
    "W0107": {"PIE790"},
    "W0109": {"F601"},
    "W0122": {"S102"},
    "W0123": {"PGH001"},
    "W0130": {"B033"},
    "W0150": {"B012"},
    "W0160": {"SIM108"},
    "W0199": {"F631"},
    "W0301": {"E703"},
    "W0401": {"F403"},
    "W0410": {"F404"},
    "W0511": {"FIX003", "FIX002", "FIX001", "FIX004"},
    "W0611": {"F401"},
    "W0612": {"F841"},
    "W0613": {"ARG001"},
    "W0622": {"A001"},
    "W0640": {"B023"},
    "W0702": {"E722"},
    "W0705": {"B014"},
    "W0706": {"TRY302"},
    "W0707": {"TRY200"},
    "W1113": {"B026"},
    "W1201": {"G"},
    "W1202": {"G"},
    "W1203": {"G"},
    "W1301": {"F504"},
    "W1303": {"F524"},
    "W1304": {"F507"},
    "W1305": {"F525"},
    "W1309": {"F541"},
    "W1310": {"F541"},
    "W1401": {"W605"},
    "W1404": {"ISC001"},
    "W1405": {"Q000"},
    "W1515": {"T100"},
}

pylint_to_ruff/ruff_aliases.py
akx/pylint-to-ruff
import json
import os
import re
import subprocess
from collections.abc import Iterable

implemented_rule_re = re.compile(
    # fmt: off
    r"^- \[x\] "
    r"`(?P<name>.+?)` / "
    r"`(?P<code>.+?)`"
    r"\s*"
    r"(?:\((?P<aliases>.+)\))?",
    # fmt: on
)


def get_ruff_issue_970_body():
    if not os.path.isfile("issue-970.json"):
        subprocess.check_call(
            [
                "curl",
                "-fL",
                "https://api.github.com/repos/astral-sh/ruff/issues/970",
                "-o",
                "issue-970.json",
            ],
        )

    with open("issue-970.json") as f:
        content = json.load(f)["body"]
    return content


def parse_issue_body(content) -> Iterable[str, set[str]]:
    for line in content.splitlines():
        if m := implemented_rule_re.match(line):
            code = m.group("code")
            aliases = {c.strip() for c in (m.group("aliases") or "").replace("`", "").split(",") if c}
            if aliases == {f"PL{code}"}:  # not an alias really
                continue
            if not aliases:
                continue
            yield (code, aliases)


def main():
    content = get_ruff_issue_970_body()
    with open("pylint_to_ruff/ruff_aliases.py", "w") as f:
        print("# This file is auto-generated by update_ruff_aliases.py", file=f)
        print("RUFF_ALIASES = {", file=f)

        for code, aliases in sorted(parse_issue_body(content)):
            print(f"    {code!r}: {aliases!r},", file=f)
        print("}", file=f)
    print(f"Updated {f.name}")


if __name__ == "__main__":
    main()

update_ruff_aliases.py
akx/pylint-to-ruff