From be81328df2c6cef2ad0db69dd755a52cd0b41689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:19:42 +0000 Subject: [PATCH 1/4] Initial plan From c9519d6e82de94a06d93ed9603536d0a7b614089 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:29:48 +0000 Subject: [PATCH 2/4] Add @final to all methods in ToolSpec and Tool implementations Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/ac4205d5-03bc-4d8d-951a-bdf5271a13a7 --- src/usethis/_tool/impl/base/codespell.py | 3 +++ src/usethis/_tool/impl/base/coverage_py.py | 4 +++ src/usethis/_tool/impl/base/deptry.py | 7 ++++- src/usethis/_tool/impl/base/import_linter.py | 4 ++- src/usethis/_tool/impl/base/mkdocs.py | 3 +++ src/usethis/_tool/impl/base/pre_commit.py | 8 +++++- src/usethis/_tool/impl/base/pyproject_toml.py | 4 +++ src/usethis/_tool/impl/base/pytest.py | 8 +++++- .../_tool/impl/base/requirements_txt.py | 3 +++ src/usethis/_tool/impl/base/ruff.py | 26 ++++++++++++++++++- src/usethis/_tool/impl/spec/codespell.py | 8 +++++- src/usethis/_tool/impl/spec/coverage_py.py | 5 +++- src/usethis/_tool/impl/spec/deptry.py | 6 +++++ src/usethis/_tool/impl/spec/import_linter.py | 13 +++++++++- src/usethis/_tool/impl/spec/mkdocs.py | 6 ++++- src/usethis/_tool/impl/spec/pre_commit.py | 5 ++++ src/usethis/_tool/impl/spec/pyproject_fmt.py | 6 +++++ src/usethis/_tool/impl/spec/pyproject_toml.py | 2 ++ src/usethis/_tool/impl/spec/pytest.py | 6 ++++- .../_tool/impl/spec/requirements_txt.py | 3 +++ src/usethis/_tool/impl/spec/ruff.py | 7 ++++- 21 files changed, 126 insertions(+), 11 deletions(-) diff --git a/src/usethis/_tool/impl/base/codespell.py b/src/usethis/_tool/impl/base/codespell.py index 138ec15e..68228a51 100644 --- a/src/usethis/_tool/impl/base/codespell.py +++ b/src/usethis/_tool/impl/base/codespell.py @@ -1,10 +1,13 @@ from __future__ import annotations +from typing import final + from usethis._console import how_print from usethis._tool.base import Tool from usethis._tool.impl.spec.codespell import CodespellToolSpec class CodespellTool(CodespellToolSpec, Tool): + @final def print_how_to_use(self) -> None: how_print(f"Run '{self.how_to_use_cmd()}' to run the {self.name} spellchecker.") diff --git a/src/usethis/_tool/impl/base/coverage_py.py b/src/usethis/_tool/impl/base/coverage_py.py index 865c9639..28d10b99 100644 --- a/src/usethis/_tool/impl/base/coverage_py.py +++ b/src/usethis/_tool/impl/base/coverage_py.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import final + from typing_extensions import assert_never from usethis._backend.dispatch import get_backend @@ -12,6 +14,7 @@ class CoveragePyTool(CoveragePyToolSpec, Tool): + @final def test_deps(self, *, unconditional: bool = False) -> list[Dependency]: from usethis._tool.impl.base.pytest import ( # to avoid circularity; # noqa: PLC0415 PytestTool, @@ -22,6 +25,7 @@ def test_deps(self, *, unconditional: bool = False) -> list[Dependency]: deps += [Dependency(name="pytest-cov")] return deps + @final def print_how_to_use(self) -> None: from usethis._tool.impl.base.pytest import ( # to avoid circularity; # noqa: PLC0415 PytestTool, diff --git a/src/usethis/_tool/impl/base/deptry.py b/src/usethis/_tool/impl/base/deptry.py index fdeb793a..30fe2b72 100644 --- a/src/usethis/_tool/impl/base/deptry.py +++ b/src/usethis/_tool/impl/base/deptry.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from usethis._console import info_print from usethis._file.pyproject_toml.io_ import PyprojectTOMLManager @@ -13,12 +13,14 @@ class DeptryTool(DeptryToolSpec, Tool): + @final def select_rules(self, rules: list[Rule]) -> bool: """Does nothing for deptry - all rules are automatically enabled by default.""" if rules: info_print(f"All {self.name} rules are always implicitly selected.") return False + @final def selected_rules(self) -> list[Rule]: """No notion of selection for deptry. @@ -27,10 +29,12 @@ def selected_rules(self) -> list[Rule]: """ return [] + @final def deselect_rules(self, rules: list[Rule]) -> bool: """Does nothing for deptry - all rules are automatically enabled by default.""" return False + @final def ignored_rules(self) -> list[Rule]: (file_manager,) = self.get_active_config_file_managers() keys = self._get_ignore_keys(file_manager) @@ -41,6 +45,7 @@ def ignored_rules(self) -> list[Rule]: return rules + @final def _get_ignore_keys(self, file_manager: KeyValueFileManager[object]) -> list[str]: """Get the keys for the ignored rules in the given file manager.""" if isinstance(file_manager, PyprojectTOMLManager): diff --git a/src/usethis/_tool/impl/base/import_linter.py b/src/usethis/_tool/impl/base/import_linter.py index 8bd0f7b6..6ed6db4f 100644 --- a/src/usethis/_tool/impl/base/import_linter.py +++ b/src/usethis/_tool/impl/base/import_linter.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from usethis._config import usethis_config from usethis._console import info_print @@ -13,6 +13,7 @@ class ImportLinterTool(ImportLinterToolSpec, Tool): + @final def is_used(self) -> bool: """Check if the Import Linter tool is used in the project.""" # We suppress the warning about assumptions regarding the package name. @@ -20,6 +21,7 @@ def is_used(self) -> bool: with usethis_config.set(quiet=True): return super().is_used() + @final def print_how_to_use(self) -> None: if not _is_inp_rule_selected(): # If Ruff is used, we enable the INP rules instead. diff --git a/src/usethis/_tool/impl/base/mkdocs.py b/src/usethis/_tool/impl/base/mkdocs.py index f65cf549..f3695fa7 100644 --- a/src/usethis/_tool/impl/base/mkdocs.py +++ b/src/usethis/_tool/impl/base/mkdocs.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import final + from typing_extensions import assert_never from usethis._backend.dispatch import get_backend @@ -11,6 +13,7 @@ class MkDocsTool(MkDocsToolSpec, Tool): + @final def print_how_to_use(self) -> None: backend = get_backend() if backend is BackendEnum.uv and is_uv_used(): diff --git a/src/usethis/_tool/impl/base/pre_commit.py b/src/usethis/_tool/impl/base/pre_commit.py index 1e8515f3..0c4876d6 100644 --- a/src/usethis/_tool/impl/base/pre_commit.py +++ b/src/usethis/_tool/impl/base/pre_commit.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from typing_extensions import assert_never @@ -20,12 +20,15 @@ class PreCommitTool(PreCommitToolSpec, Tool): + @final def is_used(self) -> bool: return is_pre_commit_used() + @final def print_how_to_use(self) -> None: how_print(f"Run '{self.how_to_use_cmd()}' to run the hooks manually.") + @final def get_bitbucket_steps( self, *, @@ -63,6 +66,7 @@ def get_bitbucket_steps( else: assert_never(backend) + @final def update_bitbucket_steps(self, *, matrix_python: bool = True) -> None: """Add Bitbucket steps associated with pre-commit, and remove outdated ones. @@ -74,8 +78,10 @@ def update_bitbucket_steps(self, *, matrix_python: bool = True) -> None: """ self._unconditional_update_bitbucket_steps(matrix_python=matrix_python) + @final def migrate_config_to_pre_commit(self) -> None: pass + @final def migrate_config_from_pre_commit(self) -> None: pass diff --git a/src/usethis/_tool/impl/base/pyproject_toml.py b/src/usethis/_tool/impl/base/pyproject_toml.py index 5fb3eea5..d70d0ff9 100644 --- a/src/usethis/_tool/impl/base/pyproject_toml.py +++ b/src/usethis/_tool/impl/base/pyproject_toml.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import final + from usethis._console import how_print, info_print, instruct_print from usethis._file.pyproject_toml.io_ import PyprojectTOMLManager from usethis._tool.base import Tool @@ -30,12 +32,14 @@ class PyprojectTOMLTool(PyprojectTOMLToolSpec, Tool): + @final def print_how_to_use(self) -> None: how_print("Populate 'pyproject.toml' with the project configuration.") info_print( "Learn more at https://packaging.python.org/en/latest/guides/writing-pyproject-toml/" ) + @final def remove_managed_files(self) -> None: # https://github.com/usethis-python/usethis-python/issues/416 # We need to step through the tools and see if pyproject.toml is the active diff --git a/src/usethis/_tool/impl/base/pytest.py b/src/usethis/_tool/impl/base/pytest.py index b2ab7d47..d7868cba 100644 --- a/src/usethis/_tool/impl/base/pytest.py +++ b/src/usethis/_tool/impl/base/pytest.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from typing_extensions import assert_never @@ -29,6 +29,7 @@ class PytestTool(PytestToolSpec, Tool): + @final def test_deps(self, *, unconditional: bool = False) -> list[Dependency]: from usethis._tool.impl.base.coverage_py import ( # to avoid circularity; # noqa: PLC0415 CoveragePyTool, @@ -39,6 +40,7 @@ def test_deps(self, *, unconditional: bool = False) -> list[Dependency]: deps += [Dependency(name="pytest-cov")] return deps + @final def print_how_to_use(self) -> None: how_print( "Add test files to the '/tests' directory with the format 'test_*.py'." @@ -46,6 +48,7 @@ def print_how_to_use(self) -> None: how_print("Add test functions with the format 'test_*()'.") how_print(f"Run '{self.how_to_use_cmd()}' to run the tests.") + @final def get_active_config_file_managers(self) -> set[KeyValueFileManager[object]]: # This is a variant of the "first" method config_spec = self.config_spec() @@ -105,6 +108,7 @@ def get_active_config_file_managers(self) -> set[KeyValueFileManager[object]]: raise NotImplementedError(msg) return {preferred_file_manager} + @final def get_bitbucket_steps( self, *, matrix_python: bool = True ) -> list[bitbucket_schema.Step]: @@ -150,6 +154,7 @@ def get_bitbucket_steps( steps.append(step) return steps + @final def get_managed_bitbucket_step_names(self) -> list[str]: names = set() for step in get_steps_in_default(): @@ -164,6 +169,7 @@ def get_managed_bitbucket_step_names(self) -> list[str]: return sorted(names) + @final def update_bitbucket_steps(self, *, matrix_python: bool = True) -> None: """Update the pytest-related Bitbucket Pipelines steps. diff --git a/src/usethis/_tool/impl/base/requirements_txt.py b/src/usethis/_tool/impl/base/requirements_txt.py index db617c1e..be69be74 100644 --- a/src/usethis/_tool/impl/base/requirements_txt.py +++ b/src/usethis/_tool/impl/base/requirements_txt.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import final + from typing_extensions import assert_never from usethis._backend.dispatch import get_backend @@ -11,6 +13,7 @@ class RequirementsTxtTool(RequirementsTxtToolSpec, Tool): + @final def print_how_to_use(self) -> None: install_method = self.get_install_method() backend = get_backend() diff --git a/src/usethis/_tool/impl/base/ruff.py b/src/usethis/_tool/impl/base/ruff.py index c34cca1e..6fe3b57a 100644 --- a/src/usethis/_tool/impl/base/ruff.py +++ b/src/usethis/_tool/impl/base/ruff.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, final from typing_extensions import assert_never @@ -36,11 +36,13 @@ class RuffTool(RuffToolSpec, Tool): + @final def print_how_to_use(self) -> None: """Print how to use the Ruff tool.""" self.print_how_to_use_linter() self.print_how_to_use_formatter() + @final def print_how_to_use_linter(self) -> None: if not self.is_linter_used(): return @@ -70,6 +72,7 @@ def print_how_to_use_linter(self) -> None: else: assert_never(install_method) + @final def print_how_to_use_formatter(self) -> None: if not self.is_formatter_used(): return @@ -97,6 +100,7 @@ def print_how_to_use_formatter(self) -> None: else: assert_never(install_method) + @final def pre_commit_config(self) -> PreCommitConfig: repo_configs = [] if self.is_linter_used(): @@ -126,6 +130,7 @@ def pre_commit_config(self) -> PreCommitConfig: inform_how_to_use_on_migrate=True, # The pre-commit commands are not simpler than the venv-based commands ) + @final def get_bitbucket_steps( self, *, matrix_python: bool = True ) -> list[bitbucket_schema.Step]: @@ -192,6 +197,7 @@ def get_bitbucket_steps( return steps + @final def selected_rules(self) -> list[Rule]: """Get the Ruff rules selected in the project.""" (file_manager,) = self.get_active_config_file_managers() @@ -204,6 +210,7 @@ def selected_rules(self) -> list[Rule]: return rules + @final def ignored_rules(self) -> list[Rule]: """Get the Ruff rules ignored in the project.""" (file_manager,) = self.get_active_config_file_managers() @@ -215,6 +222,7 @@ def ignored_rules(self) -> list[Rule]: return rules + @final def ignore_rules_in_glob(self, rules: list[Rule], *, glob: str) -> None: """Ignore Ruff rules in the project for a specific glob pattern.""" rules = sorted(set(rules) - set(self.get_ignored_rules_in_glob(glob))) @@ -233,6 +241,7 @@ def ignore_rules_in_glob(self, rules: list[Rule], *, glob: str) -> None: keys = self._get_per_file_ignore_keys(file_manager, glob=glob) file_manager.extend_list(keys=keys, values=rules) + @final def get_ignored_rules_in_glob(self, glob: str) -> list[Rule]: """Get the Ruff rules ignored in the project for a specific glob pattern.""" (file_manager,) = self.get_active_config_file_managers() @@ -244,6 +253,7 @@ def get_ignored_rules_in_glob(self, glob: str) -> list[Rule]: return rules + @final def apply_rule_config(self, rule_config: RuleConfig) -> None: """Apply the Ruff rules associated with a rule config to the project. @@ -271,6 +281,7 @@ def apply_rule_config(self, rule_config: RuleConfig) -> None: rule_config.nontests_unmanaged_ignored, glob="!tests/**/*.py" ) + @final def remove_rule_config(self, rule_config: RuleConfig) -> None: """Remove the Ruff rules associated with a rule config from the project. @@ -279,6 +290,7 @@ def remove_rule_config(self, rule_config: RuleConfig) -> None: self.deselect_rules(rule_config.selected) self.unignore_rules(rule_config.ignored) + @final def set_docstyle(self, style: Literal["numpy", "google", "pep257"]) -> None: (file_manager,) = self.get_active_config_file_managers() @@ -291,6 +303,7 @@ def set_docstyle(self, style: Literal["numpy", "google", "pep257"]) -> None: tick_print(msg) file_manager[self._get_docstyle_keys(file_manager)] = style + @final def get_docstyle(self) -> Literal["numpy", "google", "pep257"] | None: """Get the docstring style set in the project.""" (file_manager,) = self.get_active_config_file_managers() @@ -306,6 +319,7 @@ def get_docstyle(self) -> Literal["numpy", "google", "pep257"] | None: return docstyle + @final def _are_pydocstyle_rules_selected(self) -> bool: """Check if pydocstyle rules are selected in the configuration.""" # If "ALL" is selected, or any rule whose alphabetical part is "D". @@ -317,10 +331,12 @@ def _are_pydocstyle_rules_selected(self) -> bool: return True return False + @final @staticmethod def _is_pydocstyle_rule(rule: Rule) -> bool: return [d for d in rule if d.isalpha()] == ["D"] + @final def _get_select_keys(self, file_manager: KeyValueFileManager[object]) -> list[str]: """Get the keys for the selected rules in the given file manager.""" if isinstance(file_manager, PyprojectTOMLManager): @@ -330,6 +346,7 @@ def _get_select_keys(self, file_manager: KeyValueFileManager[object]) -> list[st else: return super()._get_select_keys(file_manager) + @final def _get_ignore_keys(self, file_manager: KeyValueFileManager[object]) -> list[str]: """Get the keys for the ignored rules in the given file manager.""" if isinstance(file_manager, PyprojectTOMLManager): @@ -339,6 +356,7 @@ def _get_ignore_keys(self, file_manager: KeyValueFileManager[object]) -> list[st else: return super()._get_ignore_keys(file_manager) + @final def _get_per_file_ignore_keys( self, file_manager: KeyValueFileManager[object], *, glob: str ) -> list[str]: @@ -354,6 +372,7 @@ def _get_per_file_ignore_keys( ) raise NotImplementedError(msg) + @final def _get_docstyle_keys( self, file_manager: KeyValueFileManager[object] ) -> list[str]: @@ -369,6 +388,7 @@ def _get_docstyle_keys( ) raise NotImplementedError(msg) + @final def is_linter_used(self) -> bool: """Check if the linter is used in the project. @@ -387,6 +407,7 @@ def is_linter_used(self) -> bool: self.is_auto_detection and self.is_no_subtool_config_present() ) + @final def is_linter_config_present(self) -> bool: return ConfigSpec.from_flat( file_managers=[ @@ -409,6 +430,7 @@ def is_linter_config_present(self) -> bool: ], ).is_present() + @final def is_formatter_used(self) -> bool: """Check if the formatter is used in the project. @@ -427,6 +449,7 @@ def is_formatter_used(self) -> bool: self.is_auto_detection and self.is_no_subtool_config_present() ) + @final def is_formatter_config_present(self) -> bool: return ConfigSpec.from_flat( file_managers=[ @@ -449,6 +472,7 @@ def is_formatter_config_present(self) -> bool: ], ).is_present() + @final def is_no_subtool_config_present(self) -> bool: """Check if no subtool config is present.""" return ( diff --git a/src/usethis/_tool/impl/spec/codespell.py b/src/usethis/_tool/impl/spec/codespell.py index 55a81f99..99c9057a 100644 --- a/src/usethis/_tool/impl/spec/codespell.py +++ b/src/usethis/_tool/impl/spec/codespell.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from usethis._config import usethis_config from usethis._config_file import DotCodespellRCManager @@ -26,6 +26,7 @@ class CodespellToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -34,14 +35,17 @@ def meta(self) -> ToolMeta: managed_files=[Path(".codespellrc")], ) + @final def preferred_file_manager(self) -> KeyValueFileManager[object]: if (usethis_config.cpd() / "pyproject.toml").exists(): return PyprojectTOMLManager() return DotCodespellRCManager() + @final def raw_cmd(self) -> str: return "codespell" + @final def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: deps = [Dependency(name="codespell")] @@ -61,6 +65,7 @@ def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: return deps + @final def pre_commit_config(self) -> PreCommitConfig: return PreCommitConfig.from_single_repo( pre_commit_schema.UriRepo( @@ -75,6 +80,7 @@ def pre_commit_config(self) -> PreCommitConfig: requires_venv=False, ) + @final def config_spec(self) -> ConfigSpec: # https://github.com/codespell-project/codespell?tab=readme-ov-file#using-a-config-file diff --git a/src/usethis/_tool/impl/spec/coverage_py.py b/src/usethis/_tool/impl/spec/coverage_py.py index 7848b604..4f52843b 100644 --- a/src/usethis/_tool/impl/spec/coverage_py.py +++ b/src/usethis/_tool/impl/spec/coverage_py.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from usethis._config import usethis_config from usethis._config_file import ( @@ -20,6 +20,7 @@ class CoveragePyToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -28,11 +29,13 @@ def meta(self) -> ToolMeta: managed_files=[Path(".coveragerc"), Path(".coveragerc.toml")], ) + @final def preferred_file_manager(self) -> KeyValueFileManager[object]: if (usethis_config.cpd() / "pyproject.toml").exists(): return PyprojectTOMLManager() return DotCoverageRCManager() + @final def config_spec(self) -> ConfigSpec: # https://coverage.readthedocs.io/en/latest/config.html#configuration-reference # But the `latest` link doesn't yet include some latest changes regarding diff --git a/src/usethis/_tool/impl/spec/deptry.py b/src/usethis/_tool/impl/spec/deptry.py index 58adaf0b..1e218902 100644 --- a/src/usethis/_tool/impl/spec/deptry.py +++ b/src/usethis/_tool/impl/spec/deptry.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import final from typing_extensions import assert_never @@ -17,6 +18,7 @@ class DeptryToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -24,13 +26,16 @@ def meta(self) -> ToolMeta: url="https://github.com/fpgmaas/deptry", ) + @final def raw_cmd(self) -> str: _dir = get_source_dir_str() return f"deptry {_dir}" + @final def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: return [Dependency(name="deptry")] + @final def pre_commit_config(self) -> PreCommitConfig: backend = get_backend() @@ -74,6 +79,7 @@ def pre_commit_config(self) -> PreCommitConfig: else: assert_never(backend) + @final def config_spec(self) -> ConfigSpec: # https://deptry.com/usage/#configuration return ConfigSpec.from_flat( diff --git a/src/usethis/_tool/impl/spec/import_linter.py b/src/usethis/_tool/impl/spec/import_linter.py index 849f05c0..4675a7bc 100644 --- a/src/usethis/_tool/impl/spec/import_linter.py +++ b/src/usethis/_tool/impl/spec/import_linter.py @@ -3,7 +3,7 @@ import functools import re from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from typing_extensions import assert_never @@ -38,6 +38,7 @@ class ImportLinterToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -49,17 +50,21 @@ def meta(self) -> ToolMeta: ), ) + @final def raw_cmd(self) -> str: return "lint-imports" + @final def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: return [Dependency(name="import-linter")] + @final def preferred_file_manager(self) -> KeyValueFileManager[object]: if (usethis_config.cpd() / "pyproject.toml").exists(): return PyprojectTOMLManager() return DotImportLinterManager() + @final def config_spec(self) -> ConfigSpec: # https://import-linter.readthedocs.io/en/stable/usage.html @@ -220,6 +225,7 @@ def get_root_packages() -> list[str] | NoConfigValue: ], ) + @final def _are_active_ini_contracts(self) -> bool: # Consider active config manager, and see if there's a matching regex # for the contract in the INI file. @@ -231,6 +237,7 @@ def _are_active_ini_contracts(self) -> bool: return False return [re.compile("importlinter:contract:.*")] in file_manager + @final def _is_root_package_singular(self) -> bool: (file_manager,) = self._get_active_config_file_managers_from_resolution( self._get_resolution(), @@ -244,6 +251,7 @@ def _is_root_package_singular(self) -> bool: msg = f"Unsupported file manager: '{file_manager}'." raise NotImplementedError(msg) + @final def _get_layered_architecture_by_module_by_root_package( self, ) -> dict[str, dict[str, LayeredArchitecture]]: @@ -280,9 +288,11 @@ def _get_layered_architecture_by_module_by_root_package( return layered_architecture_by_module_by_root_package + @final def _get_resolution(self) -> ResolutionT: return "first" + @final def _get_file_manager_by_relative_path( self, ) -> dict[Path, KeyValueFileManager[object]]: @@ -292,6 +302,7 @@ def _get_file_manager_by_relative_path( Path("pyproject.toml"): PyprojectTOMLManager(), } + @final def pre_commit_config(self) -> PreCommitConfig: backend = get_backend() diff --git a/src/usethis/_tool/impl/spec/mkdocs.py b/src/usethis/_tool/impl/spec/mkdocs.py index 26412dce..a426528e 100644 --- a/src/usethis/_tool/impl/spec/mkdocs.py +++ b/src/usethis/_tool/impl/spec/mkdocs.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from usethis._config_file import MkDocsYMLManager from usethis._integrations.project.name import get_project_name @@ -14,6 +14,7 @@ class MkDocsToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -22,6 +23,7 @@ def meta(self) -> ToolMeta: managed_files=[Path("mkdocs.yml")], ) + @final def doc_deps(self, *, unconditional: bool = False) -> list[Dependency]: deps = [Dependency(name="mkdocs")] @@ -30,11 +32,13 @@ def doc_deps(self, *, unconditional: bool = False) -> list[Dependency]: return deps + @final def preferred_file_manager(self) -> KeyValueFileManager[object]: """If there is no currently active config file, this is the preferred one.""" # Should set the mkdocs.yml file manager as the preferred one return MkDocsYMLManager() + @final def config_spec(self) -> ConfigSpec: """Get the configuration specification for this tool.""" return ConfigSpec.from_flat( diff --git a/src/usethis/_tool/impl/spec/pre_commit.py b/src/usethis/_tool/impl/spec/pre_commit.py index 2038c77c..b9328ce3 100644 --- a/src/usethis/_tool/impl/spec/pre_commit.py +++ b/src/usethis/_tool/impl/spec/pre_commit.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import final from typing_extensions import assert_never @@ -16,6 +17,7 @@ class PreCommitToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -24,12 +26,15 @@ def meta(self) -> ToolMeta: managed_files=[Path(".pre-commit-config.yaml")], ) + @final def raw_cmd(self) -> str: return pre_commit_raw_cmd + @final def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: return [Dependency(name="pre-commit")] + @final def pre_commit_config(self) -> PreCommitConfig: """Get the pre-commit configurations for the tool.""" backend = get_backend() diff --git a/src/usethis/_tool/impl/spec/pyproject_fmt.py b/src/usethis/_tool/impl/spec/pyproject_fmt.py index 15e3c83d..af55da89 100644 --- a/src/usethis/_tool/impl/spec/pyproject_fmt.py +++ b/src/usethis/_tool/impl/spec/pyproject_fmt.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import final from usethis._file.pyproject_toml.io_ import PyprojectTOMLManager from usethis._integrations.pre_commit import schema as pre_commit_schema @@ -13,6 +14,7 @@ class PyprojectFmtToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -21,12 +23,15 @@ def meta(self) -> ToolMeta: url="https://github.com/tox-dev/toml-fmt/tree/main/pyproject-fmt", ) + @final def raw_cmd(self) -> str: return "pyproject-fmt pyproject.toml" + @final def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: return [Dependency(name="pyproject-fmt")] + @final def pre_commit_config(self) -> PreCommitConfig: return PreCommitConfig.from_single_repo( pre_commit_schema.UriRepo( @@ -37,6 +42,7 @@ def pre_commit_config(self) -> PreCommitConfig: requires_venv=False, ) + @final def config_spec(self) -> ConfigSpec: # https://pyproject-fmt.readthedocs.io/en/latest/#configuration-via-file return ConfigSpec.from_flat( diff --git a/src/usethis/_tool/impl/spec/pyproject_toml.py b/src/usethis/_tool/impl/spec/pyproject_toml.py index fbd2ac3f..01484734 100644 --- a/src/usethis/_tool/impl/spec/pyproject_toml.py +++ b/src/usethis/_tool/impl/spec/pyproject_toml.py @@ -1,11 +1,13 @@ from __future__ import annotations from pathlib import Path +from typing import final from usethis._tool.base import ToolMeta, ToolSpec class PyprojectTOMLToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( diff --git a/src/usethis/_tool/impl/spec/pytest.py b/src/usethis/_tool/impl/spec/pytest.py index 18056fb7..e7c44d85 100644 --- a/src/usethis/_tool/impl/spec/pytest.py +++ b/src/usethis/_tool/impl/spec/pytest.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from typing_extensions import assert_never @@ -21,6 +21,7 @@ class PytestToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -34,14 +35,17 @@ def meta(self) -> ToolMeta: rule_config=RuleConfig(selected=["PT"], nontests_unmanaged_ignored=["PT"]), ) + @final def raw_cmd(self) -> str: return "pytest" + @final def preferred_file_manager(self) -> KeyValueFileManager[object]: if (usethis_config.cpd() / "pyproject.toml").exists(): return PyprojectTOMLManager() return PytestINIManager() + @final def config_spec(self) -> ConfigSpec: # https://docs.pytest.org/en/stable/reference/customize.html#configuration-file-formats # "Options from multiple configfiles candidates are never merged - the first match wins." diff --git a/src/usethis/_tool/impl/spec/requirements_txt.py b/src/usethis/_tool/impl/spec/requirements_txt.py index 3f1fafc6..7d68152f 100644 --- a/src/usethis/_tool/impl/spec/requirements_txt.py +++ b/src/usethis/_tool/impl/spec/requirements_txt.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import final from typing_extensions import assert_never @@ -13,6 +14,7 @@ class RequirementsTxtToolSpec(ToolSpec): + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -21,6 +23,7 @@ def meta(self) -> ToolMeta: managed_files=[Path("requirements.txt")], ) + @final def pre_commit_config(self) -> PreCommitConfig: backend = get_backend() diff --git a/src/usethis/_tool/impl/spec/ruff.py b/src/usethis/_tool/impl/spec/ruff.py index bdc7acb1..55254d5c 100644 --- a/src/usethis/_tool/impl/spec/ruff.py +++ b/src/usethis/_tool/impl/spec/ruff.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, final from usethis._config import usethis_config from usethis._config_file import DotRuffTOMLManager, RuffTOMLManager @@ -15,6 +15,7 @@ class RuffToolSpec(ToolSpec): + @final def __init__( self, linter_detection: Literal["auto", "always", "never"] = "auto", @@ -38,6 +39,7 @@ def __init__( formatter_detection == "auto" ) + @final @property def meta(self) -> ToolMeta: return ToolMeta( @@ -46,14 +48,17 @@ def meta(self) -> ToolMeta: managed_files=[Path(".ruff.toml"), Path("ruff.toml")], ) + @final def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: return [Dependency(name="ruff")] + @final def preferred_file_manager(self) -> KeyValueFileManager[object]: if (usethis_config.cpd() / "pyproject.toml").exists(): return PyprojectTOMLManager() return RuffTOMLManager() + @final def config_spec(self) -> ConfigSpec: # https://docs.astral.sh/ruff/configuration/#config-file-discovery From a2e3c8ec1a283d556d6fecded71c49d180069737 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:13:07 +0000 Subject: [PATCH 3/4] Document @final requirement for Tool/ToolSpec methods in CONTRIBUTING.md Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/a307b5d3-0c33-4642-b872-8712d1c47ffc --- CONTRIBUTING.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41b9d05d..58c39cbf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,24 @@ Tool implementations are defined in classes in the `usethis._tool.impl` module. - Declare this new submodule in the `.importlinter` configuration to architecturally describe its dependency relationships with other tools' submodules. For example, does your tool integrate with pre-commit? It should be in a higher layer module than the `pre-commit` submodule. - Define a `usethis._tool.base.ToolSpec` subclass, e.g. for a tool named Xyz, define a class `XyzToolSpec(ToolSpec)`. - Start by implementing its `name` property method, then work through the other methods. Most method have default implementations, but even in those cases you will need to consider them individually and determine an appropriate implementation. For example, methods which specify the tool's dependencies default to empty dependencies, but you shouldn't rely on this. +- Mark all methods in your `ToolSpec` subclass with the `@typing.final` decorator. This prevents the methods from being accidentally overridden in the `Tool` subclass, ensuring a clean separation between static tool specification and runtime behavior. For properties, use `@final` above `@property`. For example: + + ```python + from typing import final + + class XyzToolSpec(ToolSpec): + @final + @property + def meta(self) -> ToolMeta: + ... + + @final + def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: + ... + ``` + - Then, define a subclass of the `ToolSpec` subclass you just created, which also subclasses `usethis._tool.base.Tool`, e.g. for a tool named Xyz, define a class `XyzTool(XyzToolSpec, Tool)`. The only method this usually requires a non-default implementation for is `config_spec` to specify which configuration sections should be set up for the tool (and which sections the tool manages). However, you may find it helpful to provide custom implementations for other methods as well, e.g. `print_how_to_use`. +- Mark all methods in your `Tool` subclass with `@final` as well, to prevent further subclassing from overriding them. - Include a comment with a URL linking to the tool's source repo for reference. #### Register your `Tool` subclass From 6ef3702c570688d3ba6f43955fc6c47b7db6162b Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Sat, 21 Mar 2026 09:19:36 +1300 Subject: [PATCH 4/4] Remove example code for @typing.final usage Removed example code for marking methods with `@typing.final` in `ToolSpec` subclass. Also remove outdated advice regarding URL (now in `ToolMeta` --- CONTRIBUTING.md | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58c39cbf..8a75047c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,25 +140,9 @@ Tool implementations are defined in classes in the `usethis._tool.impl` module. - Declare this new submodule in the `.importlinter` configuration to architecturally describe its dependency relationships with other tools' submodules. For example, does your tool integrate with pre-commit? It should be in a higher layer module than the `pre-commit` submodule. - Define a `usethis._tool.base.ToolSpec` subclass, e.g. for a tool named Xyz, define a class `XyzToolSpec(ToolSpec)`. - Start by implementing its `name` property method, then work through the other methods. Most method have default implementations, but even in those cases you will need to consider them individually and determine an appropriate implementation. For example, methods which specify the tool's dependencies default to empty dependencies, but you shouldn't rely on this. -- Mark all methods in your `ToolSpec` subclass with the `@typing.final` decorator. This prevents the methods from being accidentally overridden in the `Tool` subclass, ensuring a clean separation between static tool specification and runtime behavior. For properties, use `@final` above `@property`. For example: - - ```python - from typing import final - - class XyzToolSpec(ToolSpec): - @final - @property - def meta(self) -> ToolMeta: - ... - - @final - def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: - ... - ``` - +- Mark all methods in your `ToolSpec` subclass with the `@typing.final` decorator. This prevents the methods from being accidentally overridden in the `Tool` subclass. - Then, define a subclass of the `ToolSpec` subclass you just created, which also subclasses `usethis._tool.base.Tool`, e.g. for a tool named Xyz, define a class `XyzTool(XyzToolSpec, Tool)`. The only method this usually requires a non-default implementation for is `config_spec` to specify which configuration sections should be set up for the tool (and which sections the tool manages). However, you may find it helpful to provide custom implementations for other methods as well, e.g. `print_how_to_use`. - Mark all methods in your `Tool` subclass with `@final` as well, to prevent further subclassing from overriding them. -- Include a comment with a URL linking to the tool's source repo for reference. #### Register your `Tool` subclass