--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Pydantic/PydanticVisitor.py Sat Feb 22 18:04:02 2025 +0100 @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a node visitor to check for pydantic related issues. +""" + +####################################################################### +## PydanticVisitor +## +## adapted from: flake8-pydantic v0.4.0 +## +## Original: Copyright (c) 2023 Victorien +####################################################################### + +import ast + +from collections import deque + +from .PydanticUtils import ( + extractAnnotations, + isDataclass, + isFunction, + isName, + isPydanticModel, +) + + +class PydanticVisitor(ast.NodeVisitor): + """ + Class implementing a node visitor to check for pydantic related issues. + """ + + def __init__(self, errorCallback): + """ + Constructor + + @param errorCallback callback function to register an error + @type func + """ + super().__init__() + + self.__error = errorCallback + + self.__classStack = deque() + + def __enterClass(self, node): + """ + Private method to record class type when entering a class definition. + + @param node reference to the node to be processed + @type ast.ClassDef + """ + if isPydanticModel(node): + self.__classStack.append("pydantic_model") + elif isDataclass(node): + self.__classStack.append("dataclass") + else: + self.__classStack.append("other_class") + + def __leaveClass(self): + """ + Private method to remove the data recorded by the __enterClass method. + """ + self.__classStack.pop() + + @property + def __currentClass(self): + """ + Private method returning the current class type as recorded by the __enterClass + method. + + @return current class type (one of 'pydantic_model', 'dataclass' or + 'other_class') + @rtype str + """ + if not self.__classStack: + return None + + return self.__classStack[-1] + + def __checkForPyd001(self, node: ast.AnnAssign) -> None: + """ + Private method to check positional argument for Field default argument. + + @param node reference to the node to be processed + @type ast.AnnAssign + """ + if ( + self.__currentClass in {"pydantic_model", "dataclass"} + and isinstance(node.value, ast.Call) + and isFunction(node.value, "Field") + and len(node.value.args) >= 1 + ): + self.__error(node.lineno - 1, node.col_offset, "PYD001") + + def __checkForPyd002(self, node): + """ + Private method to check non-annotated attribute inside Pydantic model. + + @param node reference to the node to be processed + @type ast.ClassDef + """ + if self.__currentClass == "pydantic_model": + invalidAssignments = [ + assign + for assign in node.body + if isinstance(assign, ast.Assign) + if isinstance(assign.targets[0], ast.Name) + if not assign.targets[0].id.startswith("_") + if assign.targets[0].id != "model_config" + ] + for assignment in invalidAssignments: + self.__error(assignment.lineno - 1, assignment.col_offset, "PYD002") + + def __checkForPyd003(self, node): + """ + Private method to check unecessary Field call to specify a default value. + + @param node reference to the node to be processed + @type ast.AnnAssign + """ + if ( + self.__currentClass in {"pydantic_model", "dataclass"} + and isinstance(node.value, ast.Call) + and isFunction(node.value, "Field") + and len(node.value.keywords) == 1 + and node.value.keywords[0].arg == "default" + ): + self.__error(node.lineno - 1, node.col_offset, "PYD003") + + def __checkForPyd004(self, node): + """ + Private method to check for a default argument specified in annotated. + + @param node reference to the node to be processed + @type ast.AnnAssign + """ + if ( + self.__currentClass in {"pydantic_model", "dataclass"} + and isinstance(node.annotation, ast.Subscript) + and isName(node.annotation.value, "Annotated") + and isinstance(node.annotation.slice, ast.Tuple) + ): + fieldCall = next( + ( + elt + for elt in node.annotation.slice.elts + if isinstance(elt, ast.Call) + and isFunction(elt, "Field") + and any(k.arg == "default" for k in elt.keywords) + ), + None, + ) + if fieldCall is not None: + self.__error(node.lineno - 1, node.col_offset, "PYD004") + + def __checkForPyd005(self, node): + """ + Private method to check for a field name overriding the annotation. + + @param node reference to the node to be processed + @type ast.ClassDef + """ + if self.__currentClass in {"pydantic_model", "dataclass"}: + previousTargets = set() + + for stmt in node.body: + if isinstance(stmt, ast.AnnAssign) and isinstance( + stmt.target, ast.Name + ): + previousTargets.add(stmt.target.id) + if previousTargets & extractAnnotations(stmt.annotation): + self.__error(stmt.lineno - 1, stmt.col_offset, "PYD005") + + def __checkForPyd006(self, node): + """ + Private method to check for duplicate field names. + + @param node reference to the node to be processed + @type ast.ClassDef + """ + if self.__currentClass in {"pydantic_model", "dataclass"}: + previousTargets = set() + + for stmt in node.body: + if isinstance(stmt, ast.AnnAssign) and isinstance( + stmt.target, ast.Name + ): + if stmt.target.id in previousTargets: + self.__error(stmt.lineno - 1, stmt.col_offset, "PYD006") + + previousTargets.add(stmt.target.id) + + def __checkForPyd010(self, node: ast.ClassDef) -> None: + """ + Private method to check for the use of `__pydantic_config__`. + + @param node reference to the node to be processed + @type ast.ClassDef + """ + if self.__currentClass == "other_class": + for stmt in node.body: + if ( + isinstance(stmt, ast.AnnAssign) + and isinstance(stmt.target, ast.Name) + and stmt.target.id == "__pydantic_config__" + ): + ##~ __pydantic_config__: ... = ... + self.__error(stmt.lineno - 1, stmt.col_offset, "PYD010") + + if isinstance(stmt, ast.Assign) and any( + t.id == "__pydantic_config__" + for t in stmt.targets + if isinstance(t, ast.Name) + ): + ##~ __pydantic_config__ = ... + self.__error(stmt.lineno - 1, stmt.col_offset, "PYD010") + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + """ + Public method to process class definitions. + + @param node reference to the node to be processed. + @type ast.ClassDef + """ + self.__enterClass(node) + + # TODO: implement these methods + self.__checkForPyd002(node) + self.__checkForPyd005(node) + self.__checkForPyd006(node) + self.__checkForPyd010(node) + + self.generic_visit(node) + + self.__leaveClass() + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + """ + Public method to process annotated assignment. + + @param node reference to the node to be processed. + @type ast.AnnAssign + """ + self.__checkForPyd001(node) + self.__checkForPyd003(node) + self.__checkForPyd004(node) + + self.generic_visit(node)