From 6ea342e548a1d4993db73bea741cf8122a15652a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 31 Dec 2025 13:35:52 -0500 Subject: [PATCH] Special-case type return --- .../resources/mdtest/metaclass.md | 34 +++++++++++++++++++ crates/ty_python_semantic/src/types.rs | 18 ++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/metaclass.md b/crates/ty_python_semantic/resources/mdtest/metaclass.md index 363657276a..6e7fff4e39 100644 --- a/crates/ty_python_semantic/resources/mdtest/metaclass.md +++ b/crates/ty_python_semantic/resources/mdtest/metaclass.md @@ -115,6 +115,40 @@ Foo() # error: [missing-argument] reveal_type(Foo(1)) # revealed: Foo ``` +### Metaclass `__call__` returning bare `type` + +When the metaclass `__call__` is annotated as returning `type`, this is typically a mistake in +singleton patterns where the programmer intended to return an instance. Both mypy and pyright handle +this specially by ignoring the `type` return annotation and using the instance type instead. + +```py +from typing import Any + +class Singleton(type): + _instances: dict["Singleton", object] = {} + + def __call__(cls, *args: Any, **kwargs: Any) -> type: + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + # error: [invalid-return-type] + return cls._instances[cls] + +class MyConfig(metaclass=Singleton): + def __init__(self, x: int) -> None: + pass + + def get(self, key: str) -> str: + return key + +# Despite the `-> type` annotation, we treat this as returning an instance. +# This matches mypy and pyright behavior for this common pattern. +MyConfig() # error: [missing-argument] +reveal_type(MyConfig(1)) # revealed: MyConfig + +# Instance methods work correctly. +MyConfig(1).get("key") +``` + ## Default ```py diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 2f97c128a7..4516fcb4d2 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -7349,11 +7349,23 @@ impl<'db> Type<'db> { .to_instance(db) .expect("type should be convertible to instance type"); + // Special case: if the return type is exactly `type`, treat it as returning the + // instance type. This is a common pattern in singleton metaclasses where `__call__` + // is annotated as `-> type` but actually returns an instance. Both mypy and pyright + // handle this specially to avoid false positives. + // Note: `-> type` in annotations becomes `NominalInstance(type)`. + let returns_bare_type = matches!( + metaclass_return_type, + Type::NominalInstance(instance) if instance.class(db).is_known(db, KnownClass::Type) + ); + // Check if we should skip `__new__`/`__init__` evaluation. // Skip if: return type is not assignable to instance, is Never, or contains Any. - let skip_new_init = !metaclass_return_type.is_assignable_to(db, instance_ty) - || metaclass_return_type.is_never() - || matches!(metaclass_return_type, Type::Dynamic(DynamicType::Any)); + // But don't skip if the return type is exactly `type` (common singleton pattern). + let skip_new_init = !returns_bare_type + && (!metaclass_return_type.is_assignable_to(db, instance_ty) + || metaclass_return_type.is_never() + || matches!(metaclass_return_type, Type::Dynamic(DynamicType::Any))); // If there are argument errors or we should skip `__new__`/`__init__`, return metaclass result. if call_result.is_err() || skip_new_init {