Skip to content

Create a unified RuleConfig abstraction #569

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
75 changes: 39 additions & 36 deletions src/usethis/_core/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
PytestTool,
RequirementsTxtTool,
RuffTool,
RuleConfig,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -242,11 +243,13 @@ def use_pytest(*, remove: bool = False) -> None:

ensure_pyproject_toml()

rule_config = tool.get_rule_config()

if not remove:
tool.add_test_deps()
tool.add_configs()
if RuffTool().is_used():
RuffTool().select_rules(tool.get_associated_ruff_rules())
RuffTool().select_rules(rule_config.get_all_selected())

# deptry currently can't scan the tests folder for dev deps
# https://github.com/fpgmaas/deptry/issues/302
Expand All @@ -262,7 +265,7 @@ def use_pytest(*, remove: bool = False) -> None:
PytestTool().remove_bitbucket_steps()

if RuffTool().is_used():
RuffTool().deselect_rules(tool.get_associated_ruff_rules())
RuffTool().deselect_rules(rule_config.selected)
tool.remove_configs()
tool.remove_test_deps()
remove_pytest_dir() # Last, since this is a manual step
Expand Down Expand Up @@ -327,49 +330,20 @@ def use_ruff(*, remove: bool = False, minimal: bool = False) -> None:
# Otherwise, we should leave them alone.

if minimal:
add_basic_rules = False
rule_config = RuleConfig()
elif (
all(tool._is_pydocstyle_rule(rule) for rule in tool.get_selected_rules())
or not RuffTool().get_selected_rules()
):
add_basic_rules = True
else:
add_basic_rules = False

if add_basic_rules:
rules = [
"A",
"C4",
"E4",
"E7",
"E9",
"F",
"FLY",
"FURB",
"I",
"PLE",
"PLR",
"RUF",
"SIM",
"UP",
]
for _tool in ALL_TOOLS:
associated_rules = _tool.get_associated_ruff_rules()
if associated_rules and _tool.is_used():
rules += associated_rules
ignored_rules = [
"PLR2004", # https://github.com/nathanjmcdougall/usethis-python/issues/105
"SIM108", # https://github.com/nathanjmcdougall/usethis-python/issues/118
]
rule_config = _get_basic_rule_config()
else:
rules = []
ignored_rules = []
rule_config = RuleConfig()

if not remove:
tool.add_dev_deps()
tool.add_configs()
tool.select_rules(rules)
tool.ignore_rules(ignored_rules)
tool.select_rules(rule_config.get_all_selected())
tool.ignore_rules(rule_config.get_all_ignored())
if PreCommitTool().is_used():
tool.add_pre_commit_repo_configs()
else:
Expand All @@ -382,3 +356,32 @@ def use_ruff(*, remove: bool = False, minimal: bool = False) -> None:
tool.remove_configs()
tool.remove_dev_deps()
tool.remove_managed_files()


def _get_basic_rule_config() -> RuleConfig:
"""Get the basic rule config for Ruff."""
selected = [
"A",
"C4",
"E4",
"E7",
"E9",
"F",
"FLY",
"FURB",
"I",
"PLE",
"PLR",
"RUF",
"SIM",
"UP",
]
for _tool in ALL_TOOLS:
additional_selected = _tool.get_rule_config().get_all_selected()
if additional_selected and _tool.is_used():
selected += additional_selected
ignored = [
"PLR2004", # https://github.com/nathanjmcdougall/usethis-python/issues/105
"SIM108", # https://github.com/nathanjmcdougall/usethis-python/issues/118
]
return RuleConfig(selected=selected, ignored=ignored)
54 changes: 41 additions & 13 deletions src/usethis/_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pathlib import Path
from typing import Any, Literal, Protocol, TypeAlias

from pydantic import BaseModel, InstanceOf
from pydantic import BaseModel, Field, InstanceOf
from typing_extensions import Self, assert_never

from usethis._config_file import (
Expand Down Expand Up @@ -180,6 +180,41 @@ def paths(self) -> set[Path]:
return {(Path.cwd() / path).resolve() for path in self.root}


class RuleConfig(BaseModel):
"""Configuration for linter rules associated with a tool.

There is a distinction between selected and ignored rules. Selected rules are those
which are enabled and will be run by the tool unless ignored. Ignored rules are
those which are not run by the tool, even if they are selected. This follows the
Ruff paradigm.

There is also a distinction between managed and unmanaged rule config. Managed
selections (and ignores) are those which are managed exclusively by the one tool,
and so can be safely removed if the tool is removed. Unmanaged selections
(and ignores) are those which are shared with other tools, and so they should only
be added, never removed.

Attributes:
selected: Managed selected rules.
ignored: Managed ignored rules.
unmanaged_selected: Unmanaged selected rules.
unmanaged_ignored: Unmanaged ignored rules.
"""

selected: list[Rule] = Field(default_factory=list)
ignored: list[Rule] = Field(default_factory=list)
unmanaged_selected: list[Rule] = Field(default_factory=list)
unmanaged_ignored: list[Rule] = Field(default_factory=list)

def get_all_selected(self) -> list[Rule]:
"""Get all selected rules."""
return self.selected + self.unmanaged_selected

def get_all_ignored(self) -> list[Rule]:
"""Get all ignored rules."""
return self.ignored + self.unmanaged_ignored


class Tool(Protocol):
@property
@abstractmethod
Expand Down Expand Up @@ -547,16 +582,9 @@ def update_bitbucket_steps(self) -> None:
):
remove_bitbucket_step_from_default(step)

def get_associated_ruff_rules(self) -> list[Rule]:
"""Get the Ruff rule codes associated with the tool.

These are managed rules and it is assumed that they can be removed if the tool
is removed. It only makes sense to include rules which are tightly bound
with the tool.
"""
# For other rules which are not tightly bound to the tool, see
# https://github.com/nathanjmcdougall/usethis-python/issues/499
return []
def get_rule_config(self) -> RuleConfig:
"""Get the linter rule configuration associated with this tool."""
return RuleConfig()

def is_managed_rule(self, rule: Rule) -> bool:
"""Determine if a rule is managed by this tool."""
Expand Down Expand Up @@ -1560,8 +1588,8 @@ def get_config_spec(self) -> ConfigSpec:
def get_managed_files(self) -> list[Path]:
return [Path(".pytest.ini"), Path("pytest.ini"), Path("tests/conftest.py")]

def get_associated_ruff_rules(self) -> list[Rule]:
return ["PT"]
def get_rule_config(self) -> RuleConfig:
return RuleConfig(selected=["PT"])

def get_active_config_file_managers(self) -> set[KeyValueFileManager]:
# This is a variant of the "first" method
Expand Down
10 changes: 5 additions & 5 deletions tests/usethis/test_usethis_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
PyprojectTOMLTool,
RequirementsTxtTool,
RuffTool,
Rule,
RuleConfig,
Tool,
)

Expand Down Expand Up @@ -92,8 +92,8 @@ def get_config_spec(self) -> ConfigSpec:
],
)

def get_associated_ruff_rules(self) -> list[Rule]:
return ["MYRULE"]
def get_rule_config(self) -> RuleConfig:
return RuleConfig(selected=["MYRULE"])

def get_managed_files(self) -> list[Path]:
return [Path("mytool-config.yaml")]
Expand Down Expand Up @@ -187,11 +187,11 @@ def test_default(self):
class TestGetAssociatedRuffRules:
def test_default(self):
tool = DefaultTool()
assert tool.get_associated_ruff_rules() == []
assert tool.get_rule_config() == RuleConfig()

def test_specific(self):
tool = MyTool()
assert tool.get_associated_ruff_rules() == ["MYRULE"]
assert tool.get_rule_config() == RuleConfig(selected=["MYRULE"])

class TestGetManagedFiles:
def test_default(self):
Expand Down