├── tests ├── __init__.py ├── rules │ ├── __init__.py │ ├── test_pyd006.py │ ├── test_pyd003.py │ ├── test_pyd004.py │ ├── test_pyd001.py │ ├── test_pyd010.py │ ├── test_pyd002.py │ └── test_pyd005.py ├── test_is_dataclass.py └── test_is_pydantic_model.py ├── src └── flake8_pydantic │ ├── py.typed │ ├── __init__.py │ ├── _compat.py │ ├── plugin.py │ ├── errors.py │ ├── visitor.py │ └── _utils.py ├── requirements ├── requirements.in ├── requirements-dev.in ├── requirements-test.in ├── requirements-dev.txt ├── requirements.txt └── requirements-test.txt ├── tox.ini ├── .github └── workflows │ ├── tests.yml │ └── lint.yml ├── CHANGELOG.md ├── LICENSE ├── pyproject.toml ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/rules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/flake8_pydantic/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/requirements.in: -------------------------------------------------------------------------------- 1 | flake8 2 | pydantic 3 | -------------------------------------------------------------------------------- /requirements/requirements-dev.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | mypy 3 | ruff 4 | -------------------------------------------------------------------------------- /src/flake8_pydantic/__init__.py: -------------------------------------------------------------------------------- 1 | from .plugin import Plugin 2 | 3 | __all__ = ("Plugin",) 4 | -------------------------------------------------------------------------------- /requirements/requirements-test.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | pytest 3 | tox 4 | tox-gh-actions 5 | -------------------------------------------------------------------------------- /src/flake8_pydantic/_compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info >= (3, 11): 4 | from typing import Self, TypeAlias 5 | else: 6 | from typing_extensions import Self, TypeAlias 7 | 8 | __all__ = ("Self", "TypeAlias") 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>4 4 | envlist = py3{9,10,11,12,13} 5 | 6 | [gh-actions] 7 | python = 8 | 3.9: py39 9 | 3.10: py310 10 | 3.11: py311 11 | 3.12: py312 12 | 3.13: py313 13 | 14 | [testenv] 15 | deps = 16 | -r requirements/requirements.txt 17 | -r requirements/requirements-test.txt 18 | commands = pytest --basetemp={envtmpdir} {posargs} 19 | -------------------------------------------------------------------------------- /requirements/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile requirements/requirements-dev.in 6 | # 7 | mypy==1.14.0 8 | # via -r requirements/requirements-dev.in 9 | mypy-extensions==1.0.0 10 | # via mypy 11 | ruff==0.8.4 12 | # via -r requirements/requirements-dev.in 13 | typing-extensions==4.12.2 14 | # via 15 | # -c /home/victorien/ws/flake8-pydantic/requirements/requirements.txt 16 | # mypy 17 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile requirements/requirements.in 6 | # 7 | annotated-types==0.7.0 8 | # via pydantic 9 | flake8==7.1.1 10 | # via -r requirements/requirements.in 11 | mccabe==0.7.0 12 | # via flake8 13 | pycodestyle==2.12.1 14 | # via flake8 15 | pydantic==2.10.4 16 | # via -r requirements/requirements.in 17 | pydantic-core==2.27.2 18 | # via pydantic 19 | pyflakes==3.2.0 20 | # via flake8 21 | typing-extensions==4.12.2 22 | # via 23 | # pydantic 24 | # pydantic-core 25 | -------------------------------------------------------------------------------- /src/flake8_pydantic/plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | from collections.abc import Iterator 5 | from importlib.metadata import version 6 | from typing import Any 7 | 8 | from .visitor import Visitor 9 | 10 | 11 | class Plugin: 12 | name = "flake8-pydantic" 13 | version = version(name) 14 | 15 | def __init__(self, tree: ast.AST) -> None: 16 | self._tree = tree 17 | 18 | def run(self) -> Iterator[tuple[int, int, str, type[Any]]]: 19 | visitor = Visitor() 20 | visitor.visit(self._tree) 21 | for error in visitor.errors: 22 | yield *error.as_flake8_error(), type(self) 23 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Run tests with pytest 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | pip install pip-tools 22 | pip-sync requirements/requirements.txt requirements/requirements-test.txt 23 | - name: Test with tox 24 | run: tox 25 | -------------------------------------------------------------------------------- /tests/rules/test_pyd006.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | 5 | import pytest 6 | 7 | from flake8_pydantic.errors import PYD006, Error 8 | from flake8_pydantic.visitor import Visitor 9 | 10 | PYD006_1 = """ 11 | class Model(BaseModel): 12 | x: int 13 | x: str = "1" 14 | """ 15 | 16 | PYD006_2 = """ 17 | class Model(BaseModel): 18 | x: int 19 | y: int 20 | """ 21 | 22 | 23 | @pytest.mark.parametrize( 24 | ["source", "expected"], 25 | [ 26 | (PYD006_1, [PYD006(4, 4)]), 27 | (PYD006_2, []), 28 | ], 29 | ) 30 | def test_pyd006(source: str, expected: list[Error]) -> None: 31 | module = ast.parse(source) 32 | visitor = Visitor() 33 | visitor.visit(module) 34 | 35 | assert visitor.errors == expected 36 | -------------------------------------------------------------------------------- /tests/rules/test_pyd003.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | 5 | import pytest 6 | 7 | from flake8_pydantic.errors import PYD003, Error 8 | from flake8_pydantic.visitor import Visitor 9 | 10 | PYD003_NOT_OK = """ 11 | class Model(BaseModel): 12 | a: int = Field(default=1) 13 | """ 14 | 15 | PYD003_OK = """ 16 | class Model(BaseModel): 17 | a: int = Field(default=1, description="") 18 | """ 19 | 20 | 21 | @pytest.mark.parametrize( 22 | ["source", "expected"], 23 | [ 24 | (PYD003_NOT_OK, [PYD003(3, 4)]), 25 | (PYD003_OK, []), 26 | ], 27 | ) 28 | def test_pyd003(source: str, expected: list[Error]) -> None: 29 | module = ast.parse(source) 30 | visitor = Visitor() 31 | visitor.visit(module) 32 | 33 | assert visitor.errors == expected 34 | -------------------------------------------------------------------------------- /tests/rules/test_pyd004.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | 5 | import pytest 6 | 7 | from flake8_pydantic.errors import PYD004, Error 8 | from flake8_pydantic.visitor import Visitor 9 | 10 | PYD004_1 = """ 11 | class Model(BaseModel): 12 | a: Annotated[int, Field(default=1, description="")] 13 | """ 14 | 15 | PYD004_2 = """ 16 | class Model(BaseModel): 17 | a: Annotated[int, Unrelated(), Field(default=1)] 18 | """ 19 | 20 | 21 | @pytest.mark.parametrize( 22 | ["source", "expected"], 23 | [ 24 | (PYD004_1, [PYD004(3, 4)]), 25 | (PYD004_2, [PYD004(3, 4)]), 26 | ], 27 | ) 28 | def test_pyd004(source: str, expected: list[Error]) -> None: 29 | module = ast.parse(source) 30 | visitor = Visitor() 31 | visitor.visit(module) 32 | 33 | assert visitor.errors == expected 34 | -------------------------------------------------------------------------------- /tests/rules/test_pyd001.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | 5 | import pytest 6 | 7 | from flake8_pydantic.errors import PYD001, Error 8 | from flake8_pydantic.visitor import Visitor 9 | 10 | PYD001_MODEL = """ 11 | class Model(BaseModel): 12 | a: int = Field(1) 13 | """ 14 | 15 | PYD001_DATACLASS = """ 16 | @dataclass 17 | class Model: 18 | a: int = Field(1) 19 | """ 20 | 21 | PYD001_OK = """ 22 | class Model(BaseModel): 23 | a: int = Field(default=1, description="") 24 | """ 25 | 26 | 27 | @pytest.mark.parametrize( 28 | ["source", "expected"], 29 | [ 30 | (PYD001_MODEL, [PYD001(3, 4)]), 31 | (PYD001_DATACLASS, [PYD001(4, 4)]), 32 | (PYD001_OK, []), 33 | ], 34 | ) 35 | def test_pyd001(source: str, expected: list[Error]) -> None: 36 | module = ast.parse(source) 37 | visitor = Visitor() 38 | visitor.visit(module) 39 | 40 | assert visitor.errors == expected 41 | -------------------------------------------------------------------------------- /tests/rules/test_pyd010.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | 5 | import pytest 6 | 7 | from flake8_pydantic.errors import PYD010, Error 8 | from flake8_pydantic.visitor import Visitor 9 | 10 | PYD010_1 = """ 11 | class Model(TypedDict): 12 | __pydantic_config__ = {} 13 | """ 14 | 15 | PYD010_2 = """ 16 | class Model(TypedDict): 17 | __pydantic_config__: dict = {} 18 | """ 19 | 20 | # Works with any class, as we can't accurately determine if in a `TypedDict` subclass 21 | PYD010_3 = """ 22 | class Model: 23 | __pydantic_config__: dict = {} 24 | """ 25 | 26 | 27 | @pytest.mark.parametrize( 28 | ["source", "expected"], 29 | [ 30 | (PYD010_1, [PYD010(3, 4)]), 31 | (PYD010_2, [PYD010(3, 4)]), 32 | (PYD010_3, [PYD010(3, 4)]), 33 | ], 34 | ) 35 | def test_pyd010(source: str, expected: list[Error]) -> None: 36 | module = ast.parse(source) 37 | visitor = Visitor() 38 | visitor.visit(module) 39 | 40 | assert visitor.errors == expected 41 | -------------------------------------------------------------------------------- /tests/test_is_dataclass.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | from typing import cast 5 | 6 | import pytest 7 | 8 | from flake8_pydantic._utils import is_dataclass 9 | 10 | # Positive cases: 11 | DATACLASS_1 = """ 12 | @dataclass 13 | class Model: 14 | pass 15 | """ 16 | 17 | DATACLASS_2 = """ 18 | @pydantic_dataclass 19 | class Model: 20 | pass 21 | """ 22 | 23 | DATACLASS_3 = """ 24 | @dataclasses.dataclass 25 | class Model: 26 | pass 27 | """ 28 | 29 | DATACLASS_4 = """ 30 | @dataclasses.dataclass() 31 | @otherdec(arg=1) 32 | class Model: 33 | pass 34 | """ 35 | 36 | 37 | @pytest.mark.parametrize( 38 | ["source", "expected"], 39 | [ 40 | (DATACLASS_1, True), 41 | (DATACLASS_2, True), 42 | (DATACLASS_3, True), 43 | (DATACLASS_4, True), 44 | ], 45 | ) 46 | def test_is_dataclass(source: str, expected: bool) -> None: 47 | class_def = cast(ast.ClassDef, ast.parse(source).body[0]) 48 | assert is_dataclass(class_def) == expected 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.4.0 (2024-12-26) 4 | 5 | - Update dependencies (#18) 6 | Apply new Ruff and mymy changes 7 | - Add support for Python 3.13 (#17) 8 | - Ignore non-annotated `model_config` attributes (#16) 9 | 10 | ## 0.3.1 (2024-05-06) 11 | 12 | - Improve Pydantic model detection robustness (#11) 13 | - Fix crash in the visitor implementation (#10) 14 | 15 | ## 0.3.0 (2024-03-14) 16 | 17 | Add a new rule: 18 | - `PYD006` - *Duplicate field name* 19 | 20 | Will raise an error with the following: 21 | 22 | ```python 23 | class Model(BaseModel): 24 | x: int 25 | x: int = 1 26 | ``` 27 | 28 | ## 0.2.0 (2024-02-24) 29 | 30 | Add three new rules: 31 | - `PYD003` - *Unecessary Field call to specify a default value* 32 | - `PYD004` - *Default argument specified in annotated* 33 | - `PYD005` - *Field name overrides annotation* 34 | 35 | - Drop support for Python 3.8 36 | 37 | ## 0.1.0.post0 (2024-02-23) 38 | 39 | - Add missing `readme` metadata entry 40 | 41 | ## 0.1.0 (2024-02-23) 42 | 43 | - Initial release 44 | -------------------------------------------------------------------------------- /tests/rules/test_pyd002.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | 5 | import pytest 6 | 7 | from flake8_pydantic.errors import PYD002, Error 8 | from flake8_pydantic.visitor import Visitor 9 | 10 | PYD002_MODEL = """ 11 | class Model(BaseModel): 12 | a = 1 13 | """ 14 | 15 | PYD002_MODEL_PRIVATE_FIELD = """ 16 | class Model(BaseModel): 17 | _a = 1 18 | """ 19 | 20 | PYD002_MODEL_MODEL_CONFIG = """ 21 | class Model(BaseModel): 22 | model_config = {} 23 | """ 24 | 25 | PYD002_DATACLASS = """ 26 | @dataclass 27 | class Model: 28 | a = 1 29 | """ 30 | 31 | 32 | @pytest.mark.parametrize( 33 | ["source", "expected"], 34 | [ 35 | (PYD002_MODEL, [PYD002(3, 4)]), 36 | (PYD002_MODEL_PRIVATE_FIELD, []), 37 | (PYD002_MODEL_MODEL_CONFIG, []), 38 | (PYD002_DATACLASS, []), 39 | ], 40 | ) 41 | def test_pyd002(source: str, expected: list[Error]) -> None: 42 | module = ast.parse(source) 43 | visitor = Visitor() 44 | visitor.visit(module) 45 | 46 | assert visitor.errors == expected 47 | -------------------------------------------------------------------------------- /requirements/requirements-test.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile requirements/requirements-test.in 6 | # 7 | cachetools==5.5.0 8 | # via tox 9 | chardet==5.2.0 10 | # via tox 11 | colorama==0.4.6 12 | # via tox 13 | distlib==0.3.9 14 | # via virtualenv 15 | filelock==3.16.1 16 | # via 17 | # tox 18 | # virtualenv 19 | iniconfig==2.0.0 20 | # via pytest 21 | packaging==24.2 22 | # via 23 | # pyproject-api 24 | # pytest 25 | # tox 26 | platformdirs==4.3.6 27 | # via 28 | # tox 29 | # virtualenv 30 | pluggy==1.5.0 31 | # via 32 | # pytest 33 | # tox 34 | pyproject-api==1.8.0 35 | # via tox 36 | pytest==8.3.4 37 | # via -r requirements/requirements-test.in 38 | tox==4.23.2 39 | # via 40 | # -r requirements/requirements-test.in 41 | # tox-gh-actions 42 | tox-gh-actions==3.2.0 43 | # via -r requirements/requirements-test.in 44 | virtualenv==20.28.0 45 | # via tox 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Victorien 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/rules/test_pyd005.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | 5 | import pytest 6 | 7 | from flake8_pydantic.errors import PYD005, Error 8 | from flake8_pydantic.visitor import Visitor 9 | 10 | PYD005_1 = """ 11 | class Model(BaseModel): 12 | date: date 13 | """ 14 | 15 | PYD005_2 = """ 16 | class Model(BaseModel): 17 | date: dict[str, date] 18 | """ 19 | 20 | PYD005_3 = """ 21 | class Model(BaseModel): 22 | date: Annotated[list[date], ...] 23 | """ 24 | 25 | PYD005_4 = """ 26 | class Model(BaseModel): 27 | date: int = 1 28 | foo: date 29 | """ 30 | 31 | PYD005_5 = """ 32 | class Model(BaseModel): 33 | date: Union[date, None] = None 34 | """ 35 | 36 | PYD005_6 = """ 37 | class Model(BaseModel): 38 | date: int | date | None = None 39 | """ 40 | 41 | # OK: 42 | 43 | PYD005_7 = """ 44 | class Model(BaseModel): 45 | foo: date | None = None 46 | date: int 47 | """ 48 | 49 | 50 | @pytest.mark.parametrize( 51 | ["source", "expected"], 52 | [ 53 | (PYD005_1, [PYD005(3, 4)]), 54 | (PYD005_2, [PYD005(3, 4)]), 55 | (PYD005_3, [PYD005(3, 4)]), 56 | (PYD005_4, [PYD005(4, 4)]), 57 | (PYD005_5, [PYD005(3, 4)]), 58 | (PYD005_6, [PYD005(3, 4)]), 59 | (PYD005_7, []), 60 | ], 61 | ) 62 | def test_pyd005(source: str, expected: list[Error]) -> None: 63 | module = ast.parse(source) 64 | visitor = Visitor() 65 | visitor.visit(module) 66 | 67 | assert visitor.errors == expected 68 | -------------------------------------------------------------------------------- /src/flake8_pydantic/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | from abc import ABC 5 | from dataclasses import dataclass 6 | from typing import ClassVar 7 | 8 | from ._compat import Self 9 | 10 | 11 | @dataclass 12 | class Error(ABC): 13 | error_code: ClassVar[str] 14 | message: ClassVar[str] 15 | lineno: int 16 | col_offset: int 17 | 18 | @classmethod 19 | def from_node(cls, node: ast.stmt) -> Self: 20 | return cls(lineno=node.lineno, col_offset=node.col_offset) 21 | 22 | def as_flake8_error(self) -> tuple[int, int, str]: 23 | return (self.lineno, self.col_offset, f"{self.error_code} {self.message}") 24 | 25 | 26 | class PYD001(Error): 27 | error_code = "PYD001" 28 | message = "Positional argument for Field default argument" 29 | 30 | 31 | class PYD002(Error): 32 | error_code = "PYD002" 33 | message = "Non-annotated attribute inside Pydantic model" 34 | 35 | 36 | class PYD003(Error): 37 | error_code = "PYD003" 38 | message = "Unecessary Field call to specify a default value" 39 | 40 | 41 | class PYD004(Error): 42 | error_code = "PYD004" 43 | message = "Default argument specified in annotated" 44 | 45 | 46 | class PYD005(Error): 47 | error_code = "PYD005" 48 | message = "Field name overrides annotation" 49 | 50 | 51 | class PYD006(Error): 52 | error_code = "PYD006" 53 | message = "Duplicate field name" 54 | 55 | 56 | class PYD010(Error): 57 | error_code = "PYD010" 58 | message = "Usage of __pydantic_config__" 59 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | ruff-format: 7 | name: Check code formatting with Ruff 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set up Python 3.11 12 | uses: actions/setup-python@v5 13 | with: 14 | python-version: "3.11" 15 | - name: Install dependencies 16 | run: | 17 | pip install pip-tools 18 | pip-sync requirements/requirements.txt requirements/requirements-dev.txt 19 | - name: Run Ruff formatter 20 | run: ruff format --diff 21 | 22 | ruff-check: 23 | name: Check code linting with Ruff 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Python 3.11 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: "3.11" 31 | - name: Install dependencies 32 | run: | 33 | pip install pip-tools 34 | pip-sync requirements/requirements.txt requirements/requirements-dev.txt 35 | - name: Run Ruff formatter 36 | run: ruff check --output-format=github 37 | mypy: 38 | name: Check type hints with mypy 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: actions/setup-python@v5 43 | with: 44 | python-version: '3.11' 45 | - name: Install dependencies 46 | run: | 47 | pip install pip-tools 48 | pip-sync requirements/requirements.txt requirements/requirements-dev.txt 49 | - name: Run mypy 50 | run: | 51 | mypy src/ 52 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "flake8-pydantic" 7 | version = "0.4.0" 8 | description = "A flake8 plugin to check Pydantic related code." 9 | readme = "README.md" 10 | authors = [ 11 | {name = "Victorien", email = "contact@vctrn.dev"} 12 | ] 13 | requires-python = ">=3.9" 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3 :: Only", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Typing :: Typed", 25 | "License :: OSI Approved :: MIT License", 26 | "Intended Audience :: Developers", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | "Topic :: Software Development :: Quality Assurance", 29 | "Framework :: Flake8", 30 | "Framework :: Pydantic", 31 | "Framework :: Pydantic :: 2", 32 | ] 33 | dependencies = [ 34 | "flake8", 35 | "typing-extensions>=4.4.0; python_version < '3.11'", 36 | ] 37 | license = {file = "LICENSE"} 38 | 39 | [project.urls] 40 | Homepage = "https://github.com/Viicos/flake8-pydantic" 41 | Source = "https://github.com/Viicos/flake8-pydantic" 42 | Changelog = "https://github.com/Viicos/flake8-pydantic/blob/main/CHANGELOG.md" 43 | 44 | [project.entry-points."flake8.extension"] 45 | PYD = "flake8_pydantic:Plugin" 46 | 47 | [tool.setuptools] 48 | package-dir = {"" = "src"} 49 | 50 | [tool.setuptools.packages.find] 51 | where = ["src"] 52 | 53 | [tool.setuptools.package-data] 54 | "flake8_pydantic" = ["py.typed"] 55 | 56 | [tool.ruff] 57 | line-length = 120 58 | src = ["src"] 59 | target-version = "py39" 60 | 61 | [tool.ruff.lint] 62 | preview = true 63 | explicit-preview-rules = true 64 | select = [ 65 | "E", # pycodestyle (E) 66 | "W", # pycodestyle (W) 67 | "F", # Pyflakes 68 | "UP", # pyupgrade 69 | "I", # isort 70 | "PL", # Pylint 71 | "RUF", # Ruff 72 | "RUF022", # Ruff-preview 73 | ] 74 | 75 | [tool.ruff.lint.isort] 76 | known-first-party = ["flake8_pydantic"] 77 | 78 | [tool.mypy] 79 | strict = true 80 | 81 | [tool.pytest.ini_options] 82 | pythonpath = "src" 83 | -------------------------------------------------------------------------------- /tests/test_is_pydantic_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | from typing import cast 5 | 6 | import pytest 7 | 8 | from flake8_pydantic._utils import is_pydantic_model 9 | 10 | # Positive cases: 11 | SUBCLASSES_BASE_MODEL_1 = """ 12 | class Model(BaseModel): 13 | pass 14 | """ 15 | 16 | SUBCLASSES_BASE_MODEL_2 = """ 17 | class Model(pydantic.BaseModel): 18 | pass 19 | """ 20 | 21 | SUBCLASSES_ROOT_MODEL = """ 22 | class Model(RootModel): 23 | root: int 24 | """ 25 | 26 | HAS_ANNOTATED_MODEL_CONFIG = """ 27 | class SubModel(ParentModel): 28 | model_config: ModelConfig = {} 29 | """ 30 | 31 | HAS_MODEL_CONFIG = """ 32 | class SubModel(ParentModel): 33 | model_config = {} 34 | """ 35 | 36 | HAS_FIELD_FUNCTION_1 = """ 37 | class SubModel(ParentModel): 38 | a = Field(title="A") 39 | """ 40 | 41 | HAS_FIELD_FUNCTION_2 = """ 42 | class SubModel(ParentModel): 43 | a: int = Field(gt=1) 44 | """ 45 | 46 | HAS_FIELD_FUNCTION_3 = """ 47 | class SubModel(ParentModel): 48 | a = pydantic.Field(alias="b") 49 | """ 50 | 51 | HAS_FIELD_FUNCTION_4 = """ 52 | class SubModel(ParentModel): 53 | a: int = pydantic.Field(repr=True) 54 | """ 55 | 56 | HAS_FIELD_FUNCTION_5 = """ 57 | class SubModel(ParentModel): 58 | a: int = pydantic.Field() 59 | """ 60 | 61 | HAS_FIELD_FUNCTION_6 = """ 62 | class SubModel(ParentModel): 63 | a: int = pydantic.Field(1) 64 | """ 65 | 66 | USES_ANNOTATED_1 = """ 67 | class SubModel(ParentModel): 68 | a: Annotated[int, ""] 69 | """ 70 | 71 | USES_ANNOTATED_2 = """ 72 | class SubModel(ParentModel): 73 | a: typing.Annotated[int, ""] 74 | """ 75 | 76 | HAS_PYDANTIC_DECORATOR_1 = """ 77 | class SubModel(ParentModel): 78 | @computed_field 79 | @unrelated 80 | def func(): pass 81 | """ 82 | 83 | HAS_PYDANTIC_DECORATOR_2 = """ 84 | class SubModel(ParentModel): 85 | @pydantic.computed_field 86 | def func(): pass 87 | """ 88 | 89 | HAS_PYDANTIC_METHOD_1 = """ 90 | class SubModel(ParentModel): 91 | def model_dump(self): pass 92 | """ 93 | 94 | HAS_PYDANTIC_METHOD_2 = """ 95 | class SubModel(ParentModel): 96 | def __pydantic_some_method__(self): pass 97 | """ 98 | 99 | HAS_PYDANTIC_METHOD_3 = """ 100 | class SubModel(ParentModel): 101 | def __get_pydantic_core_schema__(self): pass 102 | """ 103 | 104 | # Negative cases: 105 | NO_BASES = """ 106 | class Model: 107 | a = Field() 108 | """ 109 | 110 | UNRELATED_FIELD_ARG = """ 111 | class SubModel(ParentModel): 112 | a: int = Field(some_arg=1) 113 | """ 114 | 115 | UNRELATED_MODEL_METHOD = """ 116 | class SubModel(ParentModel): 117 | def model_unrelated(): pass 118 | """ 119 | 120 | 121 | @pytest.mark.parametrize( 122 | ["source", "expected"], 123 | [ 124 | (SUBCLASSES_BASE_MODEL_1, True), 125 | (SUBCLASSES_BASE_MODEL_2, True), 126 | (SUBCLASSES_ROOT_MODEL, True), 127 | (HAS_ANNOTATED_MODEL_CONFIG, True), 128 | (HAS_MODEL_CONFIG, True), 129 | (HAS_FIELD_FUNCTION_1, True), 130 | (HAS_FIELD_FUNCTION_2, True), 131 | (HAS_FIELD_FUNCTION_3, True), 132 | (HAS_FIELD_FUNCTION_4, True), 133 | (HAS_FIELD_FUNCTION_5, True), 134 | (HAS_FIELD_FUNCTION_6, True), 135 | (USES_ANNOTATED_1, True), 136 | (USES_ANNOTATED_2, True), 137 | (HAS_PYDANTIC_DECORATOR_1, True), 138 | (HAS_PYDANTIC_DECORATOR_2, True), 139 | (HAS_PYDANTIC_METHOD_1, True), 140 | (HAS_PYDANTIC_METHOD_2, True), 141 | (HAS_PYDANTIC_METHOD_3, True), 142 | (NO_BASES, False), 143 | (UNRELATED_FIELD_ARG, False), 144 | (UNRELATED_MODEL_METHOD, False), 145 | ], 146 | ) 147 | def test_is_pydantic_model(source: str, expected: bool) -> None: 148 | class_def = cast(ast.ClassDef, ast.parse(source).body[0]) 149 | assert is_pydantic_model(class_def) == expected 150 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Ruff linter 153 | .ruff_cache/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # VSCode 159 | .vscode/ 160 | 161 | # PyCharm 162 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 163 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 164 | # and can be added to the global gitignore or merged into this file. For a more nuclear 165 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 166 | #.idea/ 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flake8 Pydantic 2 | 3 | [![Python versions](https://img.shields.io/pypi/pyversions/flake8-pydantic.svg)](https://www.python.org/downloads/) 4 | [![PyPI version](https://img.shields.io/pypi/v/flake8-pydantic.svg)](https://pypi.org/project/flake8-pydantic/) 5 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 6 | 7 | A `flake8` plugin to check Pydantic related code. 8 | 9 | ## Class detection 10 | 11 | `flake8_pydantic` parses the [AST](https://docs.python.org/3/library/ast.html) to emit linting errors. As such, 12 | it cannot accurately determine if a class is defined as a Pydantic model. However, it tries its best, using the following heuristics: 13 | - The class inherits from `BaseModel` or `RootModel`. 14 | - The class has a `model_config` attribute set. 15 | - The class has a field defined with the `Field` function. 16 | - The class has a field making use of `Annotated`. 17 | - The class makes use of Pydantic decorators, such as `computed_field` or `model_validator`. 18 | - The class overrides any of the Pydantic methods, such as `model_dump`. 19 | 20 | ## Error codes 21 | 22 | ### `PYD001` - *Positional argument for Field default argument* 23 | 24 | Raise an error if the `default` argument of the [`Field`](https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field) function is positional. 25 | 26 | ```python 27 | class Model(BaseModel): 28 | foo: int = Field(1) 29 | ``` 30 | 31 | Although allowed at runtime by Pydantic, it does not comply with the [typing specification (PEP 681)](https://typing.readthedocs.io/en/latest/spec/dataclasses.html#field-specifier-parameters) and type checkers will not be able to synthesize a correct `__init__` method. 32 | 33 | Instead, consider using an explicit keyword argument: 34 | 35 | ```python 36 | class Model(BaseModel): 37 | foo: int = Field(default=1) 38 | ``` 39 | 40 | ### `PYD002` - *Non-annotated attribute inside Pydantic model* 41 | 42 | Raise an error if a non-annotated attribute is defined inside a Pydantic model class. 43 | 44 | ```python 45 | class Model(BaseModel): 46 | foo = 1 # Will error at runtime 47 | ``` 48 | 49 | ### `PYD003` - *Unecessary Field call to specify a default value* 50 | 51 | Raise an error if the [`Field`](https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field) function 52 | is used only to specify a default value. 53 | 54 | ```python 55 | class Model(BaseModel): 56 | foo: int = Field(default=1) 57 | ``` 58 | 59 | Instead, consider specifying the default value directly: 60 | 61 | ```python 62 | class Model(BaseModel): 63 | foo: int = 1 64 | ``` 65 | 66 | ### `PYD004` - *Default argument specified in annotated* 67 | 68 | Raise an error if the `default` argument of the [`Field`](https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field) function is used together with [`Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated). 69 | 70 | ```python 71 | class Model(BaseModel): 72 | foo: Annotated[int, Field(default=1, description="desc")] 73 | ``` 74 | 75 | To make type checkers aware of the default value, consider specifying the default value directly: 76 | 77 | ```python 78 | class Model(BaseModel): 79 | foo: Annotated[int, Field(description="desc")] = 1 80 | ``` 81 | 82 | ### `PYD005` - *Field name overrides annotation* 83 | 84 | Raise an error if the field name clashes with the annotation. 85 | 86 | ```python 87 | from datetime import date 88 | 89 | class Model(BaseModel): 90 | date: date | None = None 91 | ``` 92 | 93 | Because of how Python [evaluates](https://docs.python.org/3/reference/simple_stmts.html#annassign) 94 | annotated assignments, unexpected issues can happen when using an annotation name that clashes with a field 95 | name. Pydantic will try its best to warn you about such issues, but can fail in complex scenarios (and the 96 | issue may even be silently ignored). 97 | 98 | Instead, consider, using an [alias](https://docs.pydantic.dev/latest/concepts/fields/#field-aliases) or referencing your type under a different name: 99 | 100 | ```python 101 | from datetime import date 102 | 103 | date_ = date 104 | 105 | class Model(BaseModel): 106 | date_aliased: date | None = Field(default=None, alias="date") 107 | # or 108 | date: date_ | None = None 109 | ``` 110 | 111 | ### `PYD010` - *Usage of `__pydantic_config__`* 112 | 113 | Raise an error if a Pydantic configuration is set with [`__pydantic_config__`](https://docs.pydantic.dev/dev/concepts/config/#configuration-with-dataclass-from-the-standard-library-or-typeddict). 114 | 115 | ```python 116 | class Model(TypedDict): 117 | __pydantic_config__ = {} # Type checkers will emit an error 118 | ``` 119 | 120 | Although allowed at runtime by Python, type checkers will emit an error as it is not allowed to assign values when defining a [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict). 121 | 122 | Instead, consider using the [`with_config`](https://docs.pydantic.dev/dev/api/config/#pydantic.config.with_config) decorator: 123 | 124 | ```python 125 | @with_config({"str_to_lower": True}) 126 | class Model(TypedDict): 127 | pass 128 | ``` 129 | 130 | And many more to come. 131 | 132 | ## Roadmap 133 | 134 | Once the rules of the plugin gets stable, the goal will be to implement them in [Ruff](https://github.com/astral-sh/ruff), with autofixes when possible. 135 | -------------------------------------------------------------------------------- /src/flake8_pydantic/visitor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | from collections import deque 5 | from typing import Literal 6 | 7 | from ._compat import TypeAlias 8 | from ._utils import extract_annotations, is_dataclass, is_function, is_name, is_pydantic_model 9 | from .errors import PYD001, PYD002, PYD003, PYD004, PYD005, PYD006, PYD010, Error 10 | 11 | ClassType: TypeAlias = Literal["pydantic_model", "dataclass", "other_class"] 12 | 13 | 14 | class Visitor(ast.NodeVisitor): 15 | def __init__(self) -> None: 16 | self.errors: list[Error] = [] 17 | self.class_stack: deque[ClassType] = deque() 18 | 19 | def enter_class(self, node: ast.ClassDef) -> None: 20 | if is_pydantic_model(node): 21 | self.class_stack.append("pydantic_model") 22 | elif is_dataclass(node): 23 | self.class_stack.append("dataclass") 24 | else: 25 | self.class_stack.append("other_class") 26 | 27 | def leave_class(self) -> None: 28 | self.class_stack.pop() 29 | 30 | @property 31 | def current_class(self) -> ClassType | None: 32 | if not self.class_stack: 33 | return None 34 | return self.class_stack[-1] 35 | 36 | def _check_pyd_001(self, node: ast.AnnAssign) -> None: 37 | if ( 38 | self.current_class in {"pydantic_model", "dataclass"} 39 | and isinstance(node.value, ast.Call) 40 | and is_function(node.value, "Field") 41 | and len(node.value.args) >= 1 42 | ): 43 | self.errors.append(PYD001.from_node(node)) 44 | 45 | def _check_pyd_002(self, node: ast.ClassDef) -> None: 46 | if self.current_class == "pydantic_model": 47 | invalid_assignments = [ 48 | assign 49 | for assign in node.body 50 | if isinstance(assign, ast.Assign) 51 | if isinstance(assign.targets[0], ast.Name) 52 | if not assign.targets[0].id.startswith("_") 53 | if not assign.targets[0].id == "model_config" 54 | ] 55 | for assignment in invalid_assignments: 56 | self.errors.append(PYD002.from_node(assignment)) 57 | 58 | def _check_pyd_003(self, node: ast.AnnAssign) -> None: 59 | if ( 60 | self.current_class in {"pydantic_model", "dataclass"} 61 | and isinstance(node.value, ast.Call) 62 | and is_function(node.value, "Field") 63 | and len(node.value.keywords) == 1 64 | and node.value.keywords[0].arg == "default" 65 | ): 66 | self.errors.append(PYD003.from_node(node)) 67 | 68 | def _check_pyd_004(self, node: ast.AnnAssign) -> None: 69 | if ( 70 | self.current_class in {"pydantic_model", "dataclass"} 71 | and isinstance(node.annotation, ast.Subscript) 72 | and is_name(node.annotation.value, "Annotated") 73 | and isinstance(node.annotation.slice, ast.Tuple) 74 | ): 75 | field_call = next( 76 | ( 77 | elt 78 | for elt in node.annotation.slice.elts 79 | if isinstance(elt, ast.Call) 80 | and is_function(elt, "Field") 81 | and any(k.arg == "default" for k in elt.keywords) 82 | ), 83 | None, 84 | ) 85 | if field_call is not None: 86 | self.errors.append(PYD004.from_node(node)) 87 | 88 | def _check_pyd_005(self, node: ast.ClassDef) -> None: 89 | if self.current_class in {"pydantic_model", "dataclass"}: 90 | previous_targets: set[str] = set() 91 | 92 | for stmt in node.body: 93 | if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): 94 | # TODO only add before if AnnAssign? 95 | # the following seems to work: 96 | # date: date 97 | previous_targets.add(stmt.target.id) 98 | if previous_targets & extract_annotations(stmt.annotation): 99 | self.errors.append(PYD005.from_node(stmt)) 100 | 101 | def _check_pyd_006(self, node: ast.ClassDef) -> None: 102 | if self.current_class in {"pydantic_model", "dataclass"}: 103 | previous_targets: set[str] = set() 104 | 105 | for stmt in node.body: 106 | if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): 107 | if stmt.target.id in previous_targets: 108 | self.errors.append(PYD006.from_node(stmt)) 109 | 110 | previous_targets.add(stmt.target.id) 111 | 112 | def _check_pyd_010(self, node: ast.ClassDef) -> None: 113 | if self.current_class == "other_class": 114 | for stmt in node.body: 115 | if ( 116 | isinstance(stmt, ast.AnnAssign) 117 | and isinstance(stmt.target, ast.Name) 118 | and stmt.target.id == "__pydantic_config__" 119 | ): 120 | # __pydantic_config__: ... = ... 121 | self.errors.append(PYD010.from_node(stmt)) 122 | if isinstance(stmt, ast.Assign) and any( 123 | t.id == "__pydantic_config__" for t in stmt.targets if isinstance(t, ast.Name) 124 | ): 125 | # __pydantic_config__ = ... 126 | self.errors.append(PYD010.from_node(stmt)) 127 | 128 | def visit_ClassDef(self, node: ast.ClassDef) -> None: 129 | self.enter_class(node) 130 | self._check_pyd_002(node) 131 | self._check_pyd_005(node) 132 | self._check_pyd_006(node) 133 | self._check_pyd_010(node) 134 | self.generic_visit(node) 135 | self.leave_class() 136 | 137 | def visit_AnnAssign(self, node: ast.AnnAssign) -> None: 138 | self._check_pyd_001(node) 139 | self._check_pyd_003(node) 140 | self._check_pyd_004(node) 141 | self.generic_visit(node) 142 | -------------------------------------------------------------------------------- /src/flake8_pydantic/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | 5 | 6 | def get_decorator_names(decorator_list: list[ast.expr]) -> set[str]: 7 | names: set[str] = set() 8 | for dec in decorator_list: 9 | if isinstance(dec, ast.Call): 10 | names.add(dec.func.attr if isinstance(dec.func, ast.Attribute) else dec.func.id) # type: ignore 11 | elif isinstance(dec, ast.Name): 12 | names.add(dec.id) 13 | elif isinstance(dec, ast.Attribute): 14 | names.add(dec.attr) 15 | 16 | return names 17 | 18 | 19 | def _has_pydantic_model_base(node: ast.ClassDef, *, include_root_model: bool) -> bool: 20 | model_class_names = {"BaseModel"} 21 | if include_root_model: 22 | model_class_names.add("RootModel") 23 | 24 | for base in node.bases: 25 | if isinstance(base, ast.Name) and base.id in model_class_names: 26 | return True 27 | if isinstance(base, ast.Attribute) and base.attr in model_class_names: 28 | return True 29 | return False 30 | 31 | 32 | def _has_model_config(node: ast.ClassDef) -> bool: 33 | for stmt in node.body: 34 | if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) and stmt.target.id == "model_config": 35 | # model_config: ... = ... 36 | return True 37 | if isinstance(stmt, ast.Assign) and any( 38 | t.id == "model_config" for t in stmt.targets if isinstance(t, ast.Name) 39 | ): 40 | # model_config = ... 41 | return True 42 | return False 43 | 44 | 45 | PYDANTIC_FIELD_ARGUMENTS = { 46 | "default", 47 | "default_factory", 48 | "alias", 49 | "alias_priority", 50 | "validation_alias", 51 | "title", 52 | "description", 53 | "examples", 54 | "exclude", 55 | "discriminator", 56 | "json_schema_extra", 57 | "frozen", 58 | "validate_default", 59 | "repr", 60 | "init", 61 | "init_var", 62 | "kw_only", 63 | "pattern", 64 | "strict", 65 | "gt", 66 | "ge", 67 | "lt", 68 | "le", 69 | "multiple_of", 70 | "allow_inf_nan", 71 | "max_digits", 72 | "decimal_places", 73 | "min_length", 74 | "max_length", 75 | "union_mode", 76 | } 77 | 78 | 79 | def _has_field_function(node: ast.ClassDef) -> bool: 80 | for stmt in node.body: 81 | if ( 82 | isinstance(stmt, (ast.Assign, ast.AnnAssign)) 83 | and isinstance(stmt.value, ast.Call) 84 | and ( 85 | (isinstance(stmt.value.func, ast.Name) and stmt.value.func.id == "Field") # f = Field(...) 86 | or ( 87 | isinstance(stmt.value.func, ast.Attribute) and stmt.value.func.attr == "Field" 88 | ) # f = pydantic.Field(...) 89 | ) 90 | and all(kw.arg in PYDANTIC_FIELD_ARGUMENTS for kw in stmt.value.keywords if kw.arg is not None) 91 | ): 92 | return True 93 | 94 | return False 95 | 96 | 97 | def _has_annotated_field(node: ast.ClassDef) -> bool: 98 | for stmt in node.body: 99 | if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.annotation, ast.Subscript): 100 | if isinstance(stmt.annotation.value, ast.Name) and stmt.annotation.value.id == "Annotated": 101 | # f: Annotated[...] 102 | return True 103 | if isinstance(stmt.annotation.value, ast.Attribute) and stmt.annotation.value.attr == "Annotated": 104 | # f: typing.Annotated[...] 105 | return True 106 | return False 107 | 108 | 109 | PYDANTIC_DECORATORS = { 110 | "computed_field", 111 | "field_serializer", 112 | "model_serializer", 113 | "field_validator", 114 | "model_validator", 115 | } 116 | 117 | 118 | def _has_pydantic_decorator(node: ast.ClassDef) -> bool: 119 | for stmt in node.body: 120 | if isinstance(stmt, ast.FunctionDef): 121 | decorator_names = get_decorator_names(stmt.decorator_list) 122 | if PYDANTIC_DECORATORS & decorator_names: 123 | return True 124 | return False 125 | 126 | 127 | PYDANTIC_METHODS = { 128 | "model_construct", 129 | "model_copy", 130 | "model_dump", 131 | "model_dump_json", 132 | "model_json_schema", 133 | "model_parametrized_name", 134 | "model_rebuild", 135 | "model_validate", 136 | "model_validate_json", 137 | "model_validate_strings", 138 | } 139 | 140 | 141 | def _has_pydantic_method(node: ast.ClassDef) -> bool: 142 | for stmt in node.body: 143 | if isinstance(stmt, ast.FunctionDef) and ( 144 | stmt.name.startswith(("__pydantic_", "__get_pydantic_")) or stmt.name in PYDANTIC_METHODS 145 | ): 146 | return True 147 | return False 148 | 149 | 150 | def is_pydantic_model(node: ast.ClassDef, *, include_root_model: bool = True) -> bool: 151 | """Determine if a class definition is a Pydantic model. 152 | 153 | Multiple heuristics are use to determine if this is the case: 154 | - The class inherits from `BaseModel` (or `RootModel` if `include_root_model` is `True`). 155 | - The class has a `model_config` attribute set. 156 | - The class has a field defined with the `Field` function. 157 | - The class has a field making use of `Annotated`. 158 | - The class makes use of Pydantic decorators, such as `computed_field` or `model_validator`. 159 | - The class overrides any of the Pydantic methods, such as `model_dump`. 160 | """ 161 | if not node.bases: 162 | return False 163 | 164 | return ( 165 | _has_pydantic_model_base(node, include_root_model=include_root_model) 166 | or _has_model_config(node) 167 | or _has_field_function(node) 168 | or _has_annotated_field(node) 169 | or _has_pydantic_decorator(node) 170 | or _has_pydantic_method(node) 171 | ) 172 | 173 | 174 | def is_dataclass(node: ast.ClassDef) -> bool: 175 | """Determine if a class is a dataclass.""" 176 | 177 | return bool({"dataclass", "pydantic_dataclass"} & get_decorator_names(node.decorator_list)) 178 | 179 | 180 | def is_function(node: ast.Call, function_name: str) -> bool: 181 | return (isinstance(node.func, ast.Name) and node.func.id == function_name) or ( 182 | isinstance(node.func, ast.Attribute) and node.func.attr == function_name 183 | ) 184 | 185 | 186 | def is_name(node: ast.expr, name: str) -> bool: 187 | return (isinstance(node, ast.Name) and node.id == name) or (isinstance(node, ast.Attribute) and node.attr == name) 188 | 189 | 190 | def extract_annotations(node: ast.expr) -> set[str]: 191 | annotations: set[str] = set() 192 | 193 | if isinstance(node, ast.Name): 194 | # foo: date = ... 195 | annotations.add(node.id) 196 | if isinstance(node, ast.BinOp): 197 | # foo: date | None = ... 198 | annotations |= extract_annotations(node.left) 199 | annotations |= extract_annotations(node.right) 200 | if isinstance(node, ast.Subscript): 201 | # foo: dict[str, date] 202 | # foo: Annotated[list[date], ...] 203 | if isinstance(node.slice, ast.Tuple): 204 | for elt in node.slice.elts: 205 | annotations |= extract_annotations(elt) 206 | if isinstance(node.slice, ast.Name): 207 | annotations.add(node.slice.id) 208 | 209 | return annotations 210 | --------------------------------------------------------------------------------