]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
handle dunder names in @declared_attr separately
authorMike Bayer <mike_mp@zzzcomputing.com>
Mon, 15 Nov 2021 01:02:10 +0000 (20:02 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 17 Nov 2021 22:48:00 +0000 (17:48 -0500)
Fixed Mypy crash which would occur when using Mypy plugin against code
which made use of :class:`_orm.declared_attr` methods for non-mapped names
like ``__mapper_args__``, ``__table_args__``, or other dunder names, as the
plugin would try to interpret these as mapped attributes which would then
be later mis-handled. As part of this change, the decorated function is
still converted by the plugin into a generic assignment statement (e.g.
``__mapper_args__: Any``) so that the argument signature can continue to be
annotated in the same way one would for any other ``@classmethod`` without
Mypy complaining about the wrong argument type for a method that isn't
explicitly ``@classmethod``.

Fixes: #7321
Change-Id: I55656e867876677c5c55143449db371344be8600

doc/build/changelog/unreleased_14/7321.rst [new file with mode: 0644]
lib/sqlalchemy/ext/mypy/decl_class.py
lib/sqlalchemy/ext/mypy/util.py
test/ext/mypy/files/issue_7321.py [new file with mode: 0644]
test/ext/mypy/files/issue_7321_part2.py [new file with mode: 0644]

diff --git a/doc/build/changelog/unreleased_14/7321.rst b/doc/build/changelog/unreleased_14/7321.rst
new file mode 100644 (file)
index 0000000..08cca43
--- /dev/null
@@ -0,0 +1,16 @@
+.. change::
+    :tags: bug, mypy
+    :tickets: 7321
+
+    Fixed Mypy crash which would occur when using Mypy plugin against code
+    which made use of :class:`_orm.declared_attr` methods for non-mapped names
+    like ``__mapper_args__``, ``__table_args__``, or other dunder names, as the
+    plugin would try to interpret these as mapped attributes which would then
+    be later mis-handled. As part of this change, the decorated function is
+    still converted by the plugin into a generic assignment statement (e.g.
+    ``__mapper_args__: Any``) so that the argument signature can continue to be
+    annotated in the same way one would for any other ``@classmethod`` without
+    Mypy complaining about the wrong argument type for a method that isn't
+    explicitly ``@classmethod``.
+
+
index b85ec0f699e10c6081f6e87f83a45645e2d54c55..0d7462d5bde0dd0b17ca8b93ffee9abda9b4a8ed 100644 (file)
@@ -241,7 +241,20 @@ def _scan_declarative_decorator_stmt(
 
     left_hand_explicit_type: Optional[ProperType] = None
 
-    if isinstance(stmt.func.type, CallableType):
+    if util.name_is_dunder(stmt.name):
+        # for dunder names like __table_args__, __tablename__,
+        # __mapper_args__ etc., rewrite these as simple assignment
+        # statements; otherwise mypy doesn't like if the decorated
+        # function has an annotation like ``cls: Type[Foo]`` because
+        # it isn't @classmethod
+        any_ = AnyType(TypeOfAny.special_form)
+        left_node = NameExpr(stmt.var.name)
+        left_node.node = stmt.var
+        new_stmt = AssignmentStmt([left_node], TempNode(any_))
+        new_stmt.type = left_node.node.type
+        cls.defs.body[dec_index] = new_stmt
+        return
+    elif isinstance(stmt.func.type, CallableType):
         func_type = stmt.func.type.ret_type
         if isinstance(func_type, UnboundType):
             type_id = names.type_id_for_unbound_type(func_type, cls, api)
index a3825f175f65d2bb2391b8f590bbb122e6d1e49b..4d55cb72833fe911de69f3fc78c00a728308fb47 100644 (file)
@@ -1,3 +1,4 @@
+import re
 from typing import Any
 from typing import Iterable
 from typing import Iterator
@@ -82,6 +83,10 @@ class SQLAlchemyAttribute:
         return cls(typ=typ, info=info, **data)
 
 
+def name_is_dunder(name):
+    return bool(re.match(r"^__.+?__$", name))
+
+
 def _set_info_metadata(info: TypeInfo, key: str, data: Any) -> None:
     info.metadata.setdefault("sqlalchemy", {})[key] = data
 
diff --git a/test/ext/mypy/files/issue_7321.py b/test/ext/mypy/files/issue_7321.py
new file mode 100644 (file)
index 0000000..6a40b9d
--- /dev/null
@@ -0,0 +1,21 @@
+from typing import Any
+
+from sqlalchemy.orm import declarative_base
+from sqlalchemy.orm import declared_attr
+
+
+Base = declarative_base()
+
+
+class Foo(Base):
+    @declared_attr
+    def __tablename__(cls) -> str:
+        return "name"
+
+    @declared_attr
+    def __mapper_args__(cls) -> dict[Any, Any]:
+        return {}
+
+    @declared_attr
+    def __table_args__(cls) -> dict[Any, Any]:
+        return {}
diff --git a/test/ext/mypy/files/issue_7321_part2.py b/test/ext/mypy/files/issue_7321_part2.py
new file mode 100644 (file)
index 0000000..f53add1
--- /dev/null
@@ -0,0 +1,27 @@
+from typing import Any
+from typing import Type
+
+from sqlalchemy.orm import declarative_base
+from sqlalchemy.orm import declared_attr
+
+
+Base = declarative_base()
+
+
+class Foo(Base):
+    # no mypy error emitted regarding the
+    # Type[Foo] part
+    @declared_attr
+    def __tablename__(cls: Type["Foo"]) -> str:
+        return "name"
+
+    @declared_attr
+    def __mapper_args__(cls: Type["Foo"]) -> dict[Any, Any]:
+        return {}
+
+    # this was a workaround that works if there's no plugin present, make
+    # sure that doesn't crash anything
+    @classmethod
+    @declared_attr
+    def __table_args__(cls: Type["Foo"]) -> dict[Any, Any]:
+        return {}