From cca081720fb63a57d83985e2f86948f11f7e8fbf Mon Sep 17 00:00:00 2001 From: Bhuvansh855 Date: Mon, 13 Apr 2026 13:19:30 +0530 Subject: [PATCH 1/4] Add failing test for __qualname__ type checking (fixes #20821) --- test-data/unit/check-classes.test | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index 5a66eff2bd3b7..134215eea5489 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -9412,3 +9412,9 @@ from typ import NT def f() -> NT: return NT(x='') [builtins fixtures/tuple.pyi] + +[case test_qualname_must_be_str] +class X: + __qualname__ = 5 +[out] +error: "__qualname__" must be str From cc69f3fb556d1b319da168e8b8884de9afe667c4 Mon Sep 17 00:00:00 2001 From: Bhuvansh855 Date: Mon, 13 Apr 2026 13:27:45 +0530 Subject: [PATCH 2/4] Fix #20821: enforce __qualname__ must be str in class body --- mypy/semanal.py | 6 +++++- test-data/unit/check-classes.test | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 8f2005fdefcdf..42ad961925fe2 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -3325,7 +3325,11 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None: # This should be safe as generally semantic analyzer is idempotent. with self.allow_unbound_tvars_set(): s.rvalue.accept(self) - + if self.is_class_scope(): + for lvalue in s.lvalues: + if isinstance(lvalue, NameExpr) and lvalue.name == "__qualname__": + if not isinstance(s.rvalue, StrExpr): + self.fail('"__qualname__" must be str', s) # The r.h.s. is now ready to be classified, first check if it is a special form: special_form = False # * type alias diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index 134215eea5489..fbc3d8d47d62b 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -9417,4 +9417,4 @@ def f() -> NT: class X: __qualname__ = 5 [out] -error: "__qualname__" must be str +main:2: error: "__qualname__" must be str From 53fcb408c85ad7875139c2c5ee6f169b18762604 Mon Sep 17 00:00:00 2001 From: Bhuvansh Date: Mon, 13 Apr 2026 13:51:01 +0530 Subject: [PATCH 3/4] Update semanal.py --- mypy/semanal.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 42ad961925fe2..c61f7ddbd036a 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -3325,7 +3325,11 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None: # This should be safe as generally semantic analyzer is idempotent. with self.allow_unbound_tvars_set(): s.rvalue.accept(self) - if self.is_class_scope(): + if ( + self.is_class_scope() + and not self.is_stub_file + and not self.is_typeshed_stub_file + ): for lvalue in s.lvalues: if isinstance(lvalue, NameExpr) and lvalue.name == "__qualname__": if not isinstance(s.rvalue, StrExpr): From dd5693ffb9de06abb871ab7f40f035710c81fadd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 08:22:50 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/semanal.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index c61f7ddbd036a..c7a6626a8bd83 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -3325,11 +3325,7 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None: # This should be safe as generally semantic analyzer is idempotent. with self.allow_unbound_tvars_set(): s.rvalue.accept(self) - if ( - self.is_class_scope() - and not self.is_stub_file - and not self.is_typeshed_stub_file - ): + if self.is_class_scope() and not self.is_stub_file and not self.is_typeshed_stub_file: for lvalue in s.lvalues: if isinstance(lvalue, NameExpr) and lvalue.name == "__qualname__": if not isinstance(s.rvalue, StrExpr):