diff --git a/.cspell.json b/.cspell.json index 3216fc9b..56112fe4 100644 --- a/.cspell.json +++ b/.cspell.json @@ -59,6 +59,7 @@ "Zenodo" ], "ignoreWords": [ + "FURB", "MAINT", "PyPI", "addopts", diff --git a/pyproject.toml b/pyproject.toml index e3fb1fca..f0477c14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -193,6 +193,7 @@ addopts = [ filterwarnings = [ "error", "ignore: Importing ErrorTree directly from the jsonschema package is deprecated.*", + "ignore: The `hash` argument is deprecated in favor of `unsafe_hash` and will be removed in or after August 2025.:DeprecationWarning", ] markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] testpaths = [ @@ -215,40 +216,11 @@ docstring-code-format = true line-ending = "lf" [tool.ruff.lint] -extend-select = [ - "A", - "B", - "BLE", - "C4", - "C90", - "D", - "EM", - "ERA", - "FA", - "I", - "ICN", - "INP", - "ISC", - "N", - "NPY", - "PGH", - "PIE", - "PL", - "Q", - "RET", - "RSE", - "RUF", - "S", - "SIM", - "T20", - "TCH", - "TID", - "TRY", - "UP", - "YTT", -] ignore = [ + "ANN401", "C408", + "COM812", + "CPY001", "D101", "D102", "D103", @@ -258,15 +230,24 @@ ignore = [ "D213", "D407", "D416", + "DOC", "E501", + "FBT", + "FURB101", + "FURB103", + "FURB140", + "G004", "ISC001", "PLR0913", "PLW1514", "PLW2901", + "PT001", + "PTH", "S301", "SIM108", "UP036", ] +select = ["ALL"] task-tags = ["cspell"] [tool.ruff.lint.flake8-tidy-imports] @@ -287,14 +268,15 @@ split-on-trailing-comma = false "docs/conf.py" = ["D100"] "setup.py" = ["D100"] "tests/*" = [ + "ANN", "D", "INP001", "PGH001", "PLC2701", - "PLR0913", "PLR2004", "PLR6301", "S101", + "SLF001", "T20", ] diff --git a/src/compwa_policy/check_dev_files/__init__.py b/src/compwa_policy/check_dev_files/__init__.py index 2e9d05e5..9186db8b 100644 --- a/src/compwa_policy/check_dev_files/__init__.py +++ b/src/compwa_policy/check_dev_files/__init__.py @@ -92,7 +92,6 @@ def main(argv: Sequence[str] | None = None) -> int: release_drafter.main, args.repo_name, args.repo_title, - github_pages=args.github_pages, organization=args.repo_organization, ) do(mypy.main) diff --git a/src/compwa_policy/check_dev_files/github_workflows.py b/src/compwa_policy/check_dev_files/github_workflows.py index a8026617..ba3aab29 100644 --- a/src/compwa_policy/check_dev_files/github_workflows.py +++ b/src/compwa_policy/check_dev_files/github_workflows.py @@ -250,8 +250,8 @@ def __get_package_name() -> str: if not s.endswith(".egg-info") ] if candidate_dirs: - return sorted(candidate_dirs)[0] - return sorted(src_dirs)[0] + return min(candidate_dirs) + return min(src_dirs) def _copy_workflow_file(filename: str) -> None: diff --git a/src/compwa_policy/check_dev_files/release_drafter.py b/src/compwa_policy/check_dev_files/release_drafter.py index 4a44a285..222e370b 100644 --- a/src/compwa_policy/check_dev_files/release_drafter.py +++ b/src/compwa_policy/check_dev_files/release_drafter.py @@ -10,18 +10,14 @@ from compwa_policy.utilities.yaml import create_prettier_round_trip_yaml -def main( - repo_name: str, repo_title: str, github_pages: bool, organization: str -) -> None: +def main(repo_name: str, repo_title: str, organization: str) -> None: update_file(CONFIG_PATH.release_drafter_workflow) - _update_draft(repo_name, repo_title, github_pages, organization) + _update_draft(repo_name, repo_title, organization) -def _update_draft( - repo_name: str, repo_title: str, github_pages: bool, organization: str -) -> None: +def _update_draft(repo_name: str, repo_title: str, organization: str) -> None: yaml = create_prettier_round_trip_yaml() - expected = _get_expected_config(repo_name, repo_title, github_pages, organization) + expected = _get_expected_config(repo_name, repo_title, organization) output_path = CONFIG_PATH.release_drafter_config if not os.path.exists(output_path): yaml.dump(expected, output_path) @@ -35,7 +31,7 @@ def _update_draft( def _get_expected_config( - repo_name: str, repo_title: str, github_pages: bool, organization: str + repo_name: str, repo_title: str, organization: str ) -> dict[str, Any]: yaml = create_prettier_round_trip_yaml() config = yaml.load(COMPWA_POLICY_DIR / CONFIG_PATH.release_drafter_config) diff --git a/src/compwa_policy/check_dev_files/ruff.py b/src/compwa_policy/check_dev_files/ruff.py index 863d2b63..cd9c3ba3 100644 --- a/src/compwa_policy/check_dev_files/ruff.py +++ b/src/compwa_policy/check_dev_files/ruff.py @@ -114,7 +114,7 @@ def __remove_nbqa_option(pyproject: ModifiablePyproject, option: str) -> None: def __remove_tool_table(pyproject: ModifiablePyproject, tool_table: str) -> None: - tools = pyproject._document.get("tool") + tools = pyproject._document.get("tool") # noqa: SLF001 if isinstance(tools, dict) and tool_table in tools: tools.pop(tool_table) msg = f"Removed [tool.{tool_table}] table" @@ -210,16 +210,22 @@ def __update_global_settings( } if has_notebooks: key = "extend-include" - default_includes = ["*.ipynb"] - minimal_settings[key] = ___merge(default_includes, settings.get(key, [])) + default_includes = sorted({ + "*.ipynb", + *settings.get(key, []), + }) + minimal_settings[key] = to_toml_array(default_includes) src_directories = ___get_src_directories() if src_directories: minimal_settings["src"] = src_directories typings_dir = "typings" if filter_files([typings_dir]): key = "extend-exclude" - default_excludes = [typings_dir] - minimal_settings[key] = ___merge(default_excludes, settings.get(key, [])) + default_includes = sorted({ + typings_dir, + *settings.get(key, []), + }) + minimal_settings[key] = to_toml_array(default_includes) if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) msg = "Updated Ruff configuration" @@ -242,13 +248,6 @@ def ___get_target_version(pyproject: Pyproject) -> str: return lowest_version -def ___merge(*listings: Iterable[str], enforce_multiline: bool = False) -> Array: - merged = set() - for lst in listings: - merged |= set(lst) - return to_toml_array(sorted(merged), enforce_multiline) - - def ___get_src_directories() -> list[str]: expected_directories = ( "src", @@ -277,7 +276,10 @@ def __update_ruff_format_settings(pyproject: ModifiablePyproject) -> None: def __update_ruff_lint_settings(pyproject: ModifiablePyproject) -> None: settings = pyproject.get_table("tool.ruff.lint", create=True) - ignored_rules = [ + ignored_rules = { + "ANN401", # allow typing.Any + "COM812", # missing trailing comma + "CPY001", # don't add copyright "D101", # class docstring "D102", # method docstring "D103", # function docstring @@ -287,60 +289,34 @@ def __update_ruff_lint_settings(pyproject: ModifiablePyproject) -> None: "D213", # multi-line docstring should start at the second line "D407", # missing dashed underline after section "D416", # section name does not have to end with a colon + "DOC", # do not check undocumented exceptions "E501", # line-width already handled by black + "FURB101", # do not enforce Path.read_text() + "FURB103", # do not enforce Path.write_text() + "FURB140", # do not enforce itertools.starmap + "G004", # allow f-string in logging "ISC001", # conflicts with ruff formatter "PLW1514", # allow missing encoding in open() + "PT001", # allow pytest.fixture without parentheses + "PTH", # do not enforce Path "SIM108", # allow if-else blocks - ] + } if "3.6" in pyproject.get_supported_python_versions(): - ignored_rules.append("UP036") - ignored_rules = sorted({*settings.get("ignore", []), *ignored_rules}) + ignored_rules.add("UP036") + ignored_rules = ___merge_rules(settings.get("ignore", []), ignored_rules) minimal_settings = { - "extend-select": ___get_selected_ruff_rules(pyproject), - "ignore": to_toml_array(ignored_rules), + "select": to_toml_array(["ALL"]), + "ignore": to_toml_array(sorted(ignored_rules), enforce_multiline=True), "task-tags": ___get_task_tags(settings), } if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) msg = "Updated Ruff linting configuration" pyproject.append_to_changelog(msg) - - -def ___get_selected_ruff_rules(pyproject: Pyproject) -> Array: - rules = { - "A", - "B", - "BLE", - "C4", - "C90", - "D", - "EM", - "ERA", - "I", - "ICN", - "INP", - "ISC", - "N", - "NPY", - "PGH", - "PIE", - "PL", - "Q", - "RET", - "RSE", - "RUF", - "S", - "SIM", - "T20", - "TCH", - "TID", - "TRY", - "UP", - "YTT", - } - if "3.6" not in pyproject.get_supported_python_versions(): - rules.add("FA") - return to_toml_array(sorted(rules)) + if "extend-select" in settings: + del settings["extend-select"] + msg = "Removed [tool.ruff.lint.extend-select] configuration" + pyproject.append_to_changelog(msg) def ___get_task_tags(ruff_settings: Mapping[str, Any]) -> Array: @@ -354,86 +330,137 @@ def ___get_task_tags(ruff_settings: Mapping[str, Any]) -> Array: def __update_per_file_ignores( pyproject: ModifiablePyproject, has_notebooks: bool ) -> None: - settings = pyproject.get_table("tool.ruff.lint.per-file-ignores", create=True) - minimal_settings = {} + minimal_settings: dict[str, Array] = {} if has_notebooks: key = "*.ipynb" - default_ignores = { - "B018", # useless-expression - "C90", # complex-structure - "D", # pydocstyle - "E703", # useless-semicolon - "N806", # non-lowercase-variable-in-function - "N816", # mixed-case-variable-in-global-scope - "PLR09", # complicated logic - "PLR2004", # magic-value-comparison - "PLW0602", # global-variable-not-assigned - "PLW0603", # global-statement - "S101", # `assert` detected - "T20", # print found - "TCH00", # type-checking block - } - expected_rules = ___merge_rules( - default_ignores, - ___get_existing_nbqa_ignores(pyproject), - settings.get(key, []), + minimal_settings[key] = ___get_per_file_ignores( + pyproject, + key=key, + expected_ignores={ + "ANN", # global-statement + "B018", # useless-expression + "C90", # complex-structure + "D", # pydocstyle + "E703", # useless-semicolon + "N806", # non-lowercase-variable-in-function + "N816", # mixed-case-variable-in-global-scope + "PLR09", # complicated logic + "PLR2004", # magic-value-comparison + "PLW0602", # global-variable-not-assigned + "PLW0603", # global-statement + "S101", # `assert` detected + "T20", # print found + "TCH00", # type-checking block + *___get_existing_nbqa_ignores(pyproject), + }, + banned_ignores={ + "F821", # identify variables that are not defined + "ISC003", # explicit-string-concatenation + }, ) - banned_rules = { - "F821", # identify variables that are not defined - "ISC003", # explicit-string-concatenation - } - minimal_settings[key] = ___ban(expected_rules, banned_rules) docs_dir = "docs" if os.path.exists(docs_dir) and os.path.isdir(docs_dir): key = f"{docs_dir}/*" - default_ignores = { - "INP001", # implicit namespace package - "S101", # `assert` detected - "S113", # requests call without timeout - } - minimal_settings[key] = ___merge_rules(default_ignores, settings.get(key, [])) + minimal_settings[key] = ___get_per_file_ignores( + pyproject, + key=key, + expected_ignores={ + "INP001", # implicit namespace package + "S101", # `assert` detected + "S113", # requests call without timeout + }, + ) conf_path = f"{docs_dir}/conf.py" if os.path.exists(conf_path): - key = f"{conf_path}" - default_ignores = { - "D100", # no module docstring - } - minimal_settings[key] = ___merge_rules(default_ignores, settings.get(key, [])) + key = conf_path + minimal_settings[key] = ___get_per_file_ignores( + pyproject, + key=key, + expected_ignores={ + "D100", # no module docstring + }, + ) if os.path.exists("setup.py"): minimal_settings["setup.py"] = to_toml_array(["D100"]) - tests_dir = "tests" - if os.path.exists(tests_dir) and os.path.isdir(tests_dir): + for tests_dir in ["benchmarks", "tests"]: + if not os.path.exists(tests_dir): + continue + if not os.path.isdir(tests_dir): + continue key = f"{tests_dir}/*" - default_ignores = { - "D", # no need for pydocstyle - "INP001", # allow implicit-namespace-package - "PGH001", # allow eval - "PLC2701", # private module imports - "PLR2004", # magic-value-comparison - "PLR6301", # allow non-static method - "S101", # allow assert - "T20", # allow print and pprint - } - minimal_settings[key] = ___merge_rules(default_ignores, settings.get(key, [])) - if not complies_with_subset(settings, minimal_settings): - settings.update(minimal_settings) + minimal_settings[key] = ___get_per_file_ignores( + pyproject, + key=key, + expected_ignores={ + "ANN", # don't check missing types + "D", # no need for pydocstyle + "FBT001", # don't force booleans as keyword arguments + "INP001", # allow implicit-namespace-package + "PGH001", # allow eval + "PLC2701", # private module imports + "PLR2004", # magic-value-comparison + "PLR6301", # allow non-static method + "S101", # allow assert + "SLF001", # allow access to private members + "T20", # allow print and pprint + }, + ) + per_file_ignores = pyproject.get_table( + "tool.ruff.lint.per-file-ignores", create=True + ) + if not complies_with_subset(per_file_ignores, minimal_settings): + per_file_ignores.update(minimal_settings) msg = "Updated Ruff configuration" pyproject.append_to_changelog(msg) -def ___merge_rules(*rule_sets: Iterable[str], enforce_multiline: bool = False) -> Array: +def ___get_per_file_ignores( + pyproject: Pyproject, + key: str, + expected_ignores: set[str], + banned_ignores: set[str] | None = None, +) -> Array: + per_file_ignores = pyproject.get_table( + "tool.ruff.lint.per-file-ignores", create=True + ) + existing_ignores = per_file_ignores.get(key, []) + expected_ignores = ___merge_rules(expected_ignores, existing_ignores) + if banned_ignores is not None: + expected_ignores = ___ban_rules(expected_ignores, banned_ignores) + global_settings = pyproject.get_table("tool.ruff.lint", create=True) + global_ignores = global_settings.get("ignore", []) + expected_ignores = ___ban_rules(expected_ignores, global_ignores) + return to_toml_array(sorted(expected_ignores)) + + +def ___ban_rules(rules: Iterable[str], banned_rules: Iterable[str]) -> set[str]: + """Extend Ruff rules with new rules and filter out redundant ones. + + >>> result = ___ban_rules( + ... ["C90", "B018", "D", "E402"], + ... banned_rules=["D10", "C", "E"], + ... ) + >>> sorted(result) + ['B018', 'D'] + """ + banned_set = tuple(banned_rules) + return {rule for rule in rules if not any(rule.startswith(r) for r in banned_set)} + + +def ___merge_rules(*rule_sets: Iterable[str]) -> set[str]: """Extend Ruff rules with new rules and filter out redundant ones. - >>> ___merge_rules(["C90", "B018"], ["D10", "C"]) + >>> sorted(___merge_rules(["C90", "B018"], ["D10", "C"])) ['B018', 'C', 'D10'] """ - merged = ___merge(*rule_sets) - filtered = { + merged_rules: set[str] = set() + for rule_set in rule_sets: + merged_rules |= set(rule_set) + return { rule - for rule in merged - if not any(rule != r and rule.startswith(r) for r in merged) + for rule in merged_rules + if not any(rule != r and rule.startswith(r) for r in merged_rules) } - return to_toml_array(sorted(filtered), enforce_multiline) def ___get_existing_nbqa_ignores(pyproject: Pyproject) -> set[str]: @@ -448,21 +475,6 @@ def ___get_existing_nbqa_ignores(pyproject: Pyproject) -> set[str]: } -def ___ban( - rules: Iterable[str], banned_rules: Iterable[str], enforce_multiline: bool = False -) -> Array: - """Extend Ruff rules with new rules and filter out redundant ones. - - >>> ___ban(["C90", "B018"], banned_rules=["D10", "C"]) - ['B018'] - """ - banned_set = tuple(banned_rules) - filtered = { - rule for rule in rules if not any(rule.startswith(r) for r in banned_set) - } - return to_toml_array(sorted(filtered), enforce_multiline) - - def __update_isort_settings(pyproject: ModifiablePyproject) -> None: settings = pyproject.get_table("tool.ruff.lint.isort", create=True) minimal_settings = {"split-on-trailing-comma": False} diff --git a/src/compwa_policy/utilities/executor.py b/src/compwa_policy/utilities/executor.py index 6337d41f..fe16e8a6 100644 --- a/src/compwa_policy/utilities/executor.py +++ b/src/compwa_policy/utilities/executor.py @@ -8,6 +8,7 @@ from __future__ import annotations import inspect +import operator import os import sys import time @@ -23,6 +24,10 @@ from typing import ParamSpec else: from typing_extensions import ParamSpec +if sys.version_info < (3, 11): + from typing_extensions import Self +else: + from typing import Self T = TypeVar("T") P = ParamSpec("P") @@ -96,7 +101,7 @@ def __call__( else: return result - def __enter__(self) -> Executor: + def __enter__(self) -> Self: self.__is_in_context = True return self @@ -128,7 +133,7 @@ def print_execution_times(self) -> None: if total_time > 0.08: # noqa: PLR2004 print(f"\nTotal sub-hook time: {total_time:.2f} s") # noqa: T201 sorted_times = sorted( - self.__execution_times.items(), key=lambda x: x[1], reverse=True + self.__execution_times.items(), key=operator.itemgetter(1), reverse=True ) for function_name, sub_time in sorted_times: if sub_time < 0.03: # noqa: PLR2004 diff --git a/src/compwa_policy/utilities/precommit/__init__.py b/src/compwa_policy/utilities/precommit/__init__.py index e4e4e651..5380c099 100644 --- a/src/compwa_policy/utilities/precommit/__init__.py +++ b/src/compwa_policy/utilities/precommit/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import sys from contextlib import AbstractContextManager from pathlib import Path from textwrap import indent @@ -18,6 +19,11 @@ ) from compwa_policy.utilities.yaml import create_prettier_round_trip_yaml +if sys.version_info < (3, 11): + from typing_extensions import Self +else: + from typing import Self + if TYPE_CHECKING: from types import TracebackType @@ -80,7 +86,7 @@ def __init__( self.__is_in_context = False self.__changelog: list[str] = [] - def __enter__(self) -> ModifiablePrecommit: + def __enter__(self) -> Self: self.__is_in_context = True return self diff --git a/src/compwa_policy/utilities/pyproject/__init__.py b/src/compwa_policy/utilities/pyproject/__init__.py index 1ad203b1..4d696558 100644 --- a/src/compwa_policy/utilities/pyproject/__init__.py +++ b/src/compwa_policy/utilities/pyproject/__init__.py @@ -44,6 +44,10 @@ from typing_extensions import Literal, final else: from typing import Literal, final +if sys.version_info < (3, 11): + from typing_extensions import Self +else: + from typing import Self if sys.version_info < (3, 12): from typing_extensions import override else: @@ -150,7 +154,7 @@ def dumps(self) -> str: src = tomlkit.dumps(self._document, sort_keys=True) return f"{src.strip()}\n" - def __enter__(self) -> ModifiablePyproject: + def __enter__(self) -> Self: object.__setattr__(self, "_is_in_context", True) return self diff --git a/src/compwa_policy/utilities/vscode.py b/src/compwa_policy/utilities/vscode.py index 31c90d85..59ecac08 100644 --- a/src/compwa_policy/utilities/vscode.py +++ b/src/compwa_policy/utilities/vscode.py @@ -76,7 +76,7 @@ def _update_dict_recursively(old: dict, new: dict, sort: bool = False) -> dict: merged = dict(old) for key, value in new.items(): if key in merged: - merged[key] = _determine_new_value(merged[key], value, key, sort) + merged[key] = _determine_new_value(merged[key], value, sort) else: merged[key] = value if sort: @@ -84,7 +84,7 @@ def _update_dict_recursively(old: dict, new: dict, sort: bool = False) -> dict: return merged -def _determine_new_value(old: V, new: V, key: str | None, sort: bool = False) -> V: +def _determine_new_value(old: V, new: V, sort: bool = False) -> V: if isinstance(old, dict) and isinstance(new, dict): return _update_dict_recursively(old, new, sort) # type: ignore[return-value] if isinstance(old, list) and isinstance(new, list): diff --git a/src/compwa_policy/utilities/yaml.py b/src/compwa_policy/utilities/yaml.py index 0ffec72f..27bccee8 100644 --- a/src/compwa_policy/utilities/yaml.py +++ b/src/compwa_policy/utilities/yaml.py @@ -12,7 +12,7 @@ class _IncreasedYamlIndent(yaml.Dumper): - def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: + def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: # noqa: ARG002 return super().increase_indent(flow, indentless=False) def write_line_break(self, data: str | None = None) -> None: diff --git a/tests/utilities/precommit/test_class.py b/tests/utilities/precommit/test_class.py index ccaae0f3..cfec28ed 100644 --- a/tests/utilities/precommit/test_class.py +++ b/tests/utilities/precommit/test_class.py @@ -20,15 +20,15 @@ def example_config(this_dir: Path) -> str: class TestModifiablePrecommit: def test_no_context_manager(self, example_config: str): + precommit = ModifiablePrecommit.load(example_config) + precommit.document["fail_fast"] = True with pytest.raises( expected_exception=RuntimeError, match=r"^Modifications can only be made within a context$", ): - precommit = ModifiablePrecommit.load(example_config) - precommit.document["fail_fast"] = True precommit.append_to_changelog("Fake modification") - def test_context_manager_path(self, this_dir: Path, example_config: str): + def test_context_manager_path(self, example_config: str): input_stream = io.StringIO(example_config) with pytest.raises( PrecommitError, diff --git a/tests/utilities/pyproject/test_setters.py b/tests/utilities/pyproject/test_setters.py index 623fd1eb..8ae68e4d 100644 --- a/tests/utilities/pyproject/test_setters.py +++ b/tests/utilities/pyproject/test_setters.py @@ -85,7 +85,7 @@ def test_add_dependency_optional(): assert new_content == expected -@pytest.fixture(scope="function") +@pytest.fixture def pyproject_example() -> PyprojectTOML: src = dedent(""" [project]