diff --git a/src/usethis/_core/tool.py b/src/usethis/_core/tool.py index 05ccaed..0f1d413 100644 --- a/src/usethis/_core/tool.py +++ b/src/usethis/_core/tool.py @@ -31,6 +31,7 @@ PytestTool, RequirementsTxtTool, RuffTool, + RuleConfig, ) if TYPE_CHECKING: @@ -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 @@ -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 @@ -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: @@ -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) diff --git a/src/usethis/_tool.py b/src/usethis/_tool.py index d3b3163..75ea0ab 100644 --- a/src/usethis/_tool.py +++ b/src/usethis/_tool.py @@ -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 ( @@ -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 @@ -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.""" @@ -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 diff --git a/tests/usethis/test_usethis_tool.py b/tests/usethis/test_usethis_tool.py index 20ef2f1..36b538e 100644 --- a/tests/usethis/test_usethis_tool.py +++ b/tests/usethis/test_usethis_tool.py @@ -25,7 +25,7 @@ PyprojectTOMLTool, RequirementsTxtTool, RuffTool, - Rule, + RuleConfig, Tool, ) @@ -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")] @@ -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):