diff --git a/README.md b/README.md index 9702e4b..4746595 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A tool for finding Ruff rules that are not yet configured, and can be added to y - Rules that can be automatically fixed by Ruff šŸŖ„ - Rules violated in the repository, sorted by ascending violation count šŸ”Ž +**✨ New:** When all rules in a category (e.g., RUF, ASYNC) belong to the same situation, adopt-ruff highlights these categories with ready-to-use configuration snippets! + The output is a markdown report, easy to check as a Github action summary and CSV files with relevant Rule information per category. _See example at the bottom of this page_ @@ -98,15 +100,28 @@ Run `adopt-ruff --help` for more information.\ ## Respected Ruff rules 374 Ruff rules are already respected in the repo - they can be added right away šŸš€ + +### āœ… Categories with ALL rules respected: +- **flake8-async (ASYNC)**: All 12 rules šŸŽ‰ +- **flake8-builtins (A)**: All 3 rules šŸŽ‰ + +šŸ’” **Quick add to your config:** +```toml +[tool.ruff.lint] +select = ["A", "ASYNC"] +``` +
Details | Code | Name | Fixable | Preview | Linter | |----------|----------------------------------------------|-----------|-----------|----------------------------| +| A001 | builtin-variable-shadowing | No | False | flake8-builtins | +| A002 | builtin-argument-shadowing | No | False | flake8-builtins | | A003 | builtin-attribute-shadowing | No | False | flake8-builtins | | AIR001 | airflow-variable-name-task-id-mismatch | No | False | Airflow | | ASYNC100 | blocking-http-call-in-async-function | No | False | flake8-async | -| ASYNC101 | open-sleep-or-subprocess-in-async-function | No | False | Perflint | +| ASYNC101 | open-sleep-or-subprocess-in-async-function | No | False | flake8-async | | PERF403 | manual-dict-comprehension | No | True | Perflint | (table truncated for example purposes) @@ -116,6 +131,17 @@ Run `adopt-ruff --help` for more information.\ ## Autofixable Ruff rules 65 Ruff rules are violated in the repo, but can be auto-fixed šŸŖ„ + +### šŸŽÆ Categories with ALL rules autofixable: +- **Ruff-specific rules (RUF)**: All 5 rules ✨ +- **flake8-comprehensions (C4)**: All 8 rules ✨ + +šŸ’” **Quick add to your config:** +```toml +[tool.ruff.lint] +select = ["C4", "RUF"] +``` +
Details @@ -124,6 +150,10 @@ Run `adopt-ruff --help` for more information.\ | B010 | set-attr-with-constant | Always | False | flake8-bugbear | | B011 | assert-false | Always | False | flake8-bugbear | | C401 | unnecessary-generator-set | Always | False | flake8-comprehensions | +| C402 | unnecessary-generator-dict | Always | False | flake8-comprehensions | +| RUF001 | ambiguous-unicode-character-string | Always | False | Ruff-specific rules | +| RUF002 | ambiguous-unicode-character-docstring | Always | False | Ruff-specific rules | +| RUF003 | ambiguous-unicode-character-comment | Always | False | Ruff-specific rules | (table truncated for example purposes) @@ -132,6 +162,13 @@ Run `adopt-ruff --help` for more information.\ ## Applicable Rules 194 other Ruff rules are not yet configured in the repository + +### šŸ“‹ Categories with ALL rules violated: +- **pydocstyle (D)**: All 58 rules šŸ” +- **flake8-use-pathlib (PTH)**: All 27 rules šŸ” + +šŸ’” **Tip:** These categories need attention across the entire codebase +
Details diff --git a/adopt_ruff/main.py b/adopt_ruff/main.py index 65c9410..25ed9c1 100644 --- a/adopt_ruff/main.py +++ b/adopt_ruff/main.py @@ -15,7 +15,14 @@ from adopt_ruff.models.ruff_config import RuffConfig from adopt_ruff.models.ruff_output import Violation from adopt_ruff.models.rule import FixAvailability, Rule -from adopt_ruff.utils import ARTIFACTS_PATH, logger, output_table, search_config_file +from adopt_ruff.utils import ( + ARTIFACTS_PATH, + find_complete_categories, + generate_pyproject_suggestion, + logger, + output_table, + search_config_file, +) def run_ruff(path: Path) -> tuple[set[Rule], tuple[Violation, ...], Version]: @@ -99,6 +106,24 @@ def run( f"{len(respected)} Ruff rules are already respected in the repo - " "they can be added right away šŸš€" ) + + # Check for complete categories + complete_cats = find_complete_categories(respected, rules) + if complete_cats: + md.new_header(3, "āœ… Categories with ALL rules respected:") + for prefix in sorted(complete_cats.keys()): + linter_name, count = complete_cats[prefix] + md.new_line(f"- **{linter_name} ({prefix})**: All {count} rules šŸŽ‰") + + # Add configuration suggestion + md.new_line( + "\nšŸ’” **Quick add to your config:**" + ) + md.new_line( + generate_pyproject_suggestion(list(complete_cats.keys()), "select") + ) + md.new_line("") # Add spacing + output_table( items=([r.as_dict() for r in respected]), path=ARTIFACTS_PATH / "respected.csv", @@ -120,6 +145,24 @@ def run( md.new_line( f"{len(autofixable)} Ruff rules are violated in the repo, but can{always_status} be auto-fixed šŸŖ„" ) + + # Check for complete categories + complete_cats = find_complete_categories(autofixable, rules) + if complete_cats: + md.new_header(3, "šŸŽÆ Categories with ALL rules autofixable:") + for prefix in sorted(complete_cats.keys()): + linter_name, count = complete_cats[prefix] + md.new_line(f"- **{linter_name} ({prefix})**: All {count} rules ✨") + + # Add configuration suggestion + md.new_line( + "\nšŸ’” **Quick add to your config:**" + ) + md.new_line( + generate_pyproject_suggestion(list(complete_cats.keys()), "select") + ) + md.new_line("") # Add spacing + output_table( items=([r.as_dict() for r in autofixable]), path=ARTIFACTS_PATH / "autofixable.csv", @@ -153,6 +196,20 @@ def run( md.new_line( f"{len(applicable_rules)} other Ruff rules are not yet configured in the repository" ) + + # Check for complete categories + complete_cats = find_complete_categories(applicable_rules, rules) + if complete_cats: + md.new_header(3, "šŸ“‹ Categories with ALL rules violated:") + for prefix in sorted(complete_cats.keys()): + linter_name, count = complete_cats[prefix] + md.new_line(f"- **{linter_name} ({prefix})**: All {count} rules šŸ”") + + md.new_line( + "\nšŸ’” **Tip:** These categories need attention across the entire codebase" + ) + md.new_line("") # Add spacing + output_table( items=( [ diff --git a/adopt_ruff/utils.py b/adopt_ruff/utils.py index db241f5..7d41d6b 100644 --- a/adopt_ruff/utils.py +++ b/adopt_ruff/utils.py @@ -1,4 +1,6 @@ import csv +import re +from collections import defaultdict from collections.abc import Iterable from pathlib import Path @@ -80,3 +82,72 @@ def search_config_file(path: Path) -> Path | None: logger.info(f"found config file at {file_path.resolve()!s}") return file_path return None + + +def extract_category_prefix(code: str) -> str: + """ + Extracts the category prefix from a rule code. + Examples: RUF001 -> RUF, B010 -> B, ASYNC100 -> ASYNC + """ + match = re.match(r"^([A-Z]+)", code) + return match.group(1) if match else code + + +def find_complete_categories(rules: Iterable, all_rules: set) -> dict[str, tuple[str, int]]: + """ + Finds categories where ALL rules from that category are in the given set. + + Args: + rules: The rules in the current situation (e.g., autofixable, respected) + all_rules: All available rules + + Returns: + Dict mapping category prefix to (linter_name, count) + """ + from adopt_ruff.models.rule import Rule + + rules_list = list(rules) if not isinstance(rules, list) else rules + + # Group all rules by category + all_by_category: dict[str, list[Rule]] = defaultdict(list) + for rule in all_rules: + prefix = extract_category_prefix(rule.code) + all_by_category[prefix].append(rule) + + # Group current situation rules by category + current_by_category: dict[str, list[Rule]] = defaultdict(list) + for rule in rules_list: + prefix = extract_category_prefix(rule.code) + current_by_category[prefix].append(rule) + + # Find complete categories (all rules from category are in current situation) + complete_categories = {} + for prefix, current_rules in current_by_category.items(): + all_in_category = all_by_category[prefix] + if len(current_rules) == len(all_in_category) and len(current_rules) > 0: + # All rules from this category are in the current situation + linter_name = current_rules[0].linter + complete_categories[prefix] = (linter_name, len(current_rules)) + + return complete_categories + + +def generate_pyproject_suggestion(category_prefixes: list[str], section: str) -> str: + """ + Generates a pyproject.toml configuration suggestion. + + Args: + category_prefixes: List of category prefixes (e.g., ["RUF", "ASYNC"]) + section: Either "select" for respected/autofixable or "ignore" for applicable + + Returns: + Formatted configuration suggestion + """ + if not category_prefixes: + return "" + + codes = ", ".join(f'"{prefix}"' for prefix in sorted(category_prefixes)) + return f"""```toml +[tool.ruff.lint] +{section} = [{codes}] +```""" diff --git a/pyproject.toml b/pyproject.toml index 11b8ac5..04362e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ build-backend = "hatchling.build" dev = [ "mypy>=1.14.0", "pre-commit>=4.0.1", + "pytest>=8.3.4", "ruff>=0.8.4", ] typing = [ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b3073d --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package for adopt-ruff diff --git a/tests/test_category_grouping.py b/tests/test_category_grouping.py new file mode 100644 index 0000000..3317b44 --- /dev/null +++ b/tests/test_category_grouping.py @@ -0,0 +1,175 @@ +"""Tests for category-level grouping functionality.""" + +import pytest + +from adopt_ruff.models.rule import FixAvailability, Rule +from adopt_ruff.utils import ( + extract_category_prefix, + find_complete_categories, + generate_pyproject_suggestion, +) + + +class TestExtractCategoryPrefix: + """Tests for extract_category_prefix function.""" + + def test_simple_prefix(self): + """Test extracting simple letter prefixes.""" + assert extract_category_prefix("B010") == "B" + assert extract_category_prefix("C401") == "C" + assert extract_category_prefix("E501") == "E" + + def test_multi_letter_prefix(self): + """Test extracting multi-letter prefixes.""" + assert extract_category_prefix("RUF001") == "RUF" + assert extract_category_prefix("ASYNC100") == "ASYNC" + assert extract_category_prefix("PLW0127") == "PLW" + + def test_prefix_only(self): + """Test when code is just a prefix.""" + assert extract_category_prefix("RUF") == "RUF" + assert extract_category_prefix("B") == "B" + + +class TestFindCompleteCategories: + """Tests for find_complete_categories function.""" + + def create_rule(self, code: str, linter: str) -> Rule: + """Helper to create a test rule.""" + return Rule( + name=f"test-{code}", + code=code, + linter=linter, + summary="Test rule", + message_formats=(), + fix=FixAvailability.ALWAYS, + explanation="Test explanation", + preview=False, + ) + + def test_complete_category(self): + """Test detecting a complete category.""" + # Create all rules + all_rules = { + self.create_rule("RUF001", "Ruff-specific rules"), + self.create_rule("RUF002", "Ruff-specific rules"), + self.create_rule("RUF003", "Ruff-specific rules"), + self.create_rule("B010", "flake8-bugbear"), + self.create_rule("B011", "flake8-bugbear"), + } + + # All RUF rules are in the situation + situation_rules = [ + self.create_rule("RUF001", "Ruff-specific rules"), + self.create_rule("RUF002", "Ruff-specific rules"), + self.create_rule("RUF003", "Ruff-specific rules"), + ] + + complete = find_complete_categories(situation_rules, all_rules) + + assert "RUF" in complete + assert complete["RUF"] == ("Ruff-specific rules", 3) + assert "B" not in complete # Not all B rules are in situation + + def test_incomplete_category(self): + """Test when category is not complete.""" + all_rules = { + self.create_rule("RUF001", "Ruff-specific rules"), + self.create_rule("RUF002", "Ruff-specific rules"), + self.create_rule("RUF003", "Ruff-specific rules"), + } + + # Only some RUF rules are in the situation + situation_rules = [ + self.create_rule("RUF001", "Ruff-specific rules"), + self.create_rule("RUF002", "Ruff-specific rules"), + ] + + complete = find_complete_categories(situation_rules, all_rules) + + assert "RUF" not in complete # Not all RUF rules are in situation + + def test_multiple_complete_categories(self): + """Test detecting multiple complete categories.""" + all_rules = { + self.create_rule("RUF001", "Ruff-specific rules"), + self.create_rule("RUF002", "Ruff-specific rules"), + self.create_rule("ASYNC100", "flake8-async"), + self.create_rule("ASYNC101", "flake8-async"), + self.create_rule("B010", "flake8-bugbear"), + } + + situation_rules = [ + self.create_rule("RUF001", "Ruff-specific rules"), + self.create_rule("RUF002", "Ruff-specific rules"), + self.create_rule("ASYNC100", "flake8-async"), + self.create_rule("ASYNC101", "flake8-async"), + ] + + complete = find_complete_categories(situation_rules, all_rules) + + assert "RUF" in complete + assert complete["RUF"] == ("Ruff-specific rules", 2) + assert "ASYNC" in complete + assert complete["ASYNC"] == ("flake8-async", 2) + assert "B" not in complete + + def test_empty_situation(self): + """Test with no rules in situation.""" + all_rules = { + self.create_rule("RUF001", "Ruff-specific rules"), + self.create_rule("B010", "flake8-bugbear"), + } + + complete = find_complete_categories([], all_rules) + + assert len(complete) == 0 + + def test_single_rule_category(self): + """Test category with just one rule.""" + all_rules = { + self.create_rule("X001", "single-rule-linter"), + self.create_rule("B010", "flake8-bugbear"), + } + + situation_rules = [ + self.create_rule("X001", "single-rule-linter"), + ] + + complete = find_complete_categories(situation_rules, all_rules) + + assert "X" in complete + assert complete["X"] == ("single-rule-linter", 1) + + +class TestGeneratePyprojectSuggestion: + """Tests for generate_pyproject_suggestion function.""" + + def test_single_category_select(self): + """Test generating suggestion for a single category with select.""" + suggestion = generate_pyproject_suggestion(["RUF"], "select") + assert "[tool.ruff.lint]" in suggestion + assert 'select = ["RUF"]' in suggestion + assert "```toml" in suggestion + + def test_multiple_categories_select(self): + """Test generating suggestion for multiple categories.""" + suggestion = generate_pyproject_suggestion(["RUF", "ASYNC", "B"], "select") + assert "[tool.ruff.lint]" in suggestion + assert 'select = ["ASYNC", "B", "RUF"]' in suggestion # Should be sorted + + def test_ignore_section(self): + """Test generating suggestion with ignore section.""" + suggestion = generate_pyproject_suggestion(["RUF"], "ignore") + assert "[tool.ruff.lint]" in suggestion + assert 'ignore = ["RUF"]' in suggestion + + def test_empty_categories(self): + """Test with empty category list.""" + suggestion = generate_pyproject_suggestion([], "select") + assert suggestion == "" + + def test_categories_are_sorted(self): + """Test that categories are sorted in output.""" + suggestion = generate_pyproject_suggestion(["Z", "A", "M"], "select") + assert 'select = ["A", "M", "Z"]' in suggestion