From 8fa7e63c38f373f2bbee39a30f663f800463b5c1 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 30 Sep 2025 15:20:45 -0500 Subject: [PATCH 1/5] Add no-redundant-dataclasses pre-commit hook --- .pre-commit-hooks.yaml | 6 ++ pyproject.toml | 2 + strawberry/hooks/__init__.py | 0 strawberry/hooks/no_redundant_dataclasses.py | 76 ++++++++++++++++++++ tests/hooks/__init__.py | 0 tests/hooks/test_no_redundant_dataclasses.py | 46 ++++++++++++ 6 files changed, 130 insertions(+) create mode 100644 .pre-commit-hooks.yaml create mode 100644 strawberry/hooks/__init__.py create mode 100644 strawberry/hooks/no_redundant_dataclasses.py create mode 100644 tests/hooks/__init__.py create mode 100644 tests/hooks/test_no_redundant_dataclasses.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000000..694978fe81 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: no-redundant-dataclasses + name: No redundant dataclasses + description: Prevents using raw dataclasses on Strawberry GraphQL types + entry: pre-commit-no-redundant-dataclasses + language: python + types: [python] diff --git a/pyproject.toml b/pyproject.toml index 63a144a07d..50e35da2f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ documentation = "https://strawberry.rocks/" [project.scripts] strawberry = "strawberry.cli:run" +pre-commit-no-redundant-dataclasses = "strawberry.hooks.no_redundant_dataclasses:main" [project.optional-dependencies] aiohttp = ["aiohttp>=3.7.4.post0,<4"] @@ -342,6 +343,7 @@ ignore = [ "strawberry/extensions/tracing/__init__.py" = ["TCH004"] "strawberry/fastapi/*" = ["B008"] "strawberry/annotation.py" = ["RET505"] +"strawberry/hooks/*" = ["T201"] "tests/*" = [ "ANN001", "ANN201", diff --git a/strawberry/hooks/__init__.py b/strawberry/hooks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/strawberry/hooks/no_redundant_dataclasses.py b/strawberry/hooks/no_redundant_dataclasses.py new file mode 100644 index 0000000000..d535ba4899 --- /dev/null +++ b/strawberry/hooks/no_redundant_dataclasses.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import argparse +import ast +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class Visitor(ast.NodeVisitor): + """A linter that finds issues and includes the source code.""" + + def __init__(self) -> None: + self.errors: list[str] = [] + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + is_strawberry_class = False + is_raw_dataclass = False + + for decorator in node.decorator_list: + if ( + isinstance(decorator, ast.Attribute) + and isinstance(decorator.value, ast.Name) + and decorator.value.id == "strawberry" + and decorator.attr == "type" + ) or ( + isinstance(decorator, ast.Call) + and isinstance(decorator.func, ast.Attribute) + and isinstance(decorator.func.value, ast.Name) + and decorator.func.value.id == "strawberry" + and decorator.func.attr == "type" + ): + is_strawberry_class = True + + if (isinstance(decorator, ast.Name) and decorator.id == "dataclass") or ( + isinstance(decorator, ast.Attribute) + and isinstance(decorator.value, ast.Name) + and decorator.value.id == "dataclasses" + and decorator.attr == "dataclass" + ): + is_raw_dataclass = True + + if is_strawberry_class and is_raw_dataclass: + self.errors.append(f":{node.lineno}: {node.name}") + + self.generic_visit(node) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("filenames", nargs="*") + args = parser.parse_args(argv) + violations: list[str] = [] + + for filename in args.filenames: + with Path(filename).open("rb") as f: + tree = ast.parse(f.read(), filename=filename) + visitor = Visitor() + visitor.visit(tree) + violations.extend(f"- {filename}{error}" for error in visitor.errors) + + if len(violations) == 0: + return 0 + + msg = "\n".join( + ( + "Decorating strawberry types with dataclasses.dataclass is redundant.", + "Remove the dataclass decorator from the following classes:", + *violations, + ) + ) + + print(msg) + return 1 diff --git a/tests/hooks/__init__.py b/tests/hooks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/hooks/test_no_redundant_dataclasses.py b/tests/hooks/test_no_redundant_dataclasses.py new file mode 100644 index 0000000000..62091fd0d6 --- /dev/null +++ b/tests/hooks/test_no_redundant_dataclasses.py @@ -0,0 +1,46 @@ +from textwrap import dedent + +from strawberry.hooks.no_redundant_dataclasses import main + + +def test_check_namespaced_decorator(tmp_path): + code = """ + import strawberry + import dataclasses + + @dataclasses.dataclass + @strawberry.type + class Foo: ... + """ + file = tmp_path / "foo.py" + file.write_text(dedent(code), encoding="utf-8") + exit_code = main([str(file)]) + assert exit_code == 1 + + +def test_check_imported_decorator(tmp_path): + code = """ + import strawberry + from dataclasses import dataclass + + @dataclass + @strawberry.type + class Foo: ... + """ + file = tmp_path / "foo.py" + file.write_text(dedent(code), encoding="utf-8") + exit_code = main([str(file)]) + assert exit_code == 1 + + +def test_check_passing_file(tmp_path): + code = """ + import strawberry + + @strawberry.type + class Foo: ... + """ + file = tmp_path / "foo.py" + file.write_text(dedent(code), encoding="utf-8") + exit_code = main([str(file)]) + assert exit_code == 0 From c0f4d585c847b5e4a65c0ae356fa7315e9faece0 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Sat, 29 Nov 2025 10:31:11 -0600 Subject: [PATCH 2/5] Update strawberry/hooks/no_redundant_dataclasses.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- strawberry/hooks/no_redundant_dataclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strawberry/hooks/no_redundant_dataclasses.py b/strawberry/hooks/no_redundant_dataclasses.py index d535ba4899..9679fc9bb7 100644 --- a/strawberry/hooks/no_redundant_dataclasses.py +++ b/strawberry/hooks/no_redundant_dataclasses.py @@ -61,7 +61,7 @@ def main(argv: Sequence[str] | None = None) -> int: visitor.visit(tree) violations.extend(f"- {filename}{error}" for error in visitor.errors) - if len(violations) == 0: + if not violations: return 0 msg = "\n".join( From fb2a0e443b0dbca70a32c3c10c21b47585a22317 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Sat, 29 Nov 2025 10:31:42 -0600 Subject: [PATCH 3/5] Update strawberry/hooks/no_redundant_dataclasses.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- strawberry/hooks/no_redundant_dataclasses.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/strawberry/hooks/no_redundant_dataclasses.py b/strawberry/hooks/no_redundant_dataclasses.py index 9679fc9bb7..0a6429a881 100644 --- a/strawberry/hooks/no_redundant_dataclasses.py +++ b/strawberry/hooks/no_redundant_dataclasses.py @@ -20,17 +20,19 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: is_raw_dataclass = False for decorator in node.decorator_list: + for decorator in node.decorator_list: + # Check for strawberry decorators: type, input, interface if ( isinstance(decorator, ast.Attribute) and isinstance(decorator.value, ast.Name) and decorator.value.id == "strawberry" - and decorator.attr == "type" + and decorator.attr in ("type", "input", "interface") ) or ( isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute) and isinstance(decorator.func.value, ast.Name) and decorator.func.value.id == "strawberry" - and decorator.func.attr == "type" + and decorator.func.attr in ("type", "input", "interface") ): is_strawberry_class = True From 178ed51cbdccc12aae8c9c81a1d44e79ecbcd9e9 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Fri, 5 Dec 2025 16:12:01 -0600 Subject: [PATCH 4/5] Update no_redundant_dataclasses.py --- strawberry/hooks/no_redundant_dataclasses.py | 1 - 1 file changed, 1 deletion(-) diff --git a/strawberry/hooks/no_redundant_dataclasses.py b/strawberry/hooks/no_redundant_dataclasses.py index 0a6429a881..4b0da85cb7 100644 --- a/strawberry/hooks/no_redundant_dataclasses.py +++ b/strawberry/hooks/no_redundant_dataclasses.py @@ -19,7 +19,6 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: is_strawberry_class = False is_raw_dataclass = False - for decorator in node.decorator_list: for decorator in node.decorator_list: # Check for strawberry decorators: type, input, interface if ( From 72e307dbb5d8f6df11b9ae9a5c271de515fc8ebb Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Fri, 5 Dec 2025 16:39:12 -0600 Subject: [PATCH 5/5] add RELEASE.md --- RELEASE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 RELEASE.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..dec62cb0b0 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,15 @@ +Release type: minor + +Add a [pre-commit](https://pre-commit.com/) hook to prevent redundant `@dataclass` decorators on Strawberry types. + +Since Strawberry types (`@strawberry.type`, `@strawberry.input`, `@strawberry.interface`) already provide dataclass functionality, decorating them with `@dataclass` is redundant. This hook detects and prevents this pattern, helping maintain cleaner code. + +To use the hook, add it to your `.pre-commit-config.yaml`: + +```yaml +repos: + - repo: https://github.com/strawberry-graphql/strawberry + rev: + hooks: + - id: no-redundant-dataclasses +```