diff --git a/crates/ty_python_semantic/resources/mdtest/call/type.md b/crates/ty_python_semantic/resources/mdtest/call/type.md index 8769f15b8cf93..01a4cb4503f6e 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/type.md +++ b/crates/ty_python_semantic/resources/mdtest/call/type.md @@ -115,17 +115,116 @@ reveal_type(bar.base_attr) # revealed: int reveal_type(bar.mixin_attr) # revealed: str ``` -Attributes from the namespace dict (third argument) are not tracked. Like Pyright, we error when -attempting to access them: +Attributes from the namespace dict (third argument) are tracked: ```py class Base: ... Foo = type("Foo", (Base,), {"custom_attr": 42}) + +# Class attribute access +reveal_type(Foo.custom_attr) # revealed: Literal[42] + +# Instance attribute access foo = Foo() +reveal_type(foo.custom_attr) # revealed: Literal[42] +``` + +When the namespace dict is not a literal (e.g., passed as a parameter), attribute access returns +`Unknown` since we can't know what attributes might be defined: + +```py +from typing import Any + +class DynamicBase: ... + +def f(attributes: dict[str, Any]): + X = type("X", (DynamicBase,), attributes) + + reveal_type(X) # revealed: + + # Attribute access returns Unknown since the namespace is dynamic + reveal_type(X.foo) # revealed: Unknown + + x = X() + reveal_type(x.bar) # revealed: Unknown +``` + +When a namespace dictionary is partially dynamic (e.g., a dict literal with spread or non-literal +keys), static attributes have precise types while unknown attributes return `Unknown`: + +```py +from typing import Any + +def f(extra_attrs: dict[str, Any], y: str): + X = type("X", (), {"a": 42, **extra_attrs}) + + # Static attributes in the namespace dictionary have precise types, + # but the dictionary was not entirely static, so other attributes + # are still available and resolve to `Unknown`: + reveal_type(X().a) # revealed: Literal[42] + reveal_type(X().whatever) # revealed: Unknown + + Y = type("Y", (), {"a": 56, y: 72}) + reveal_type(Y().a) # revealed: Literal[56] + reveal_type(Y().whatever) # revealed: Unknown +``` + +When a `TypedDict` is passed as the namespace argument, we synthesize a class type with the known +keys from the `TypedDict` as attributes. Since `TypedDict` instances are "open" (they can have +arbitrary additional string keys), unknown attributes return `Unknown`: + +```py +from typing import TypedDict -# error: [unresolved-attribute] "Object of type `Foo` has no attribute `custom_attr`" -reveal_type(foo.custom_attr) # revealed: Unknown +class Namespace(TypedDict): + z: int + +def g(attributes: Namespace): + Y = type("Y", (), attributes) + + reveal_type(Y) # revealed: + + # Known keys from the TypedDict are tracked as attributes + reveal_type(Y.z) # revealed: int + + y = Y() + reveal_type(y.z) # revealed: int + + # Unknown attributes return Unknown since TypedDicts are open + reveal_type(Y.unknown) # revealed: Unknown + reveal_type(y.unknown) # revealed: Unknown +``` + +## Closed TypedDicts (PEP-728) + +TODO: We don't support the PEP-728 `closed=True` keyword argument to `TypedDict` yet. When we do, a +closed TypedDict namespace should NOT be marked as dynamic, and accessing unknown attributes should +emit an error instead of returning `Unknown`. + +```py +from typing import TypedDict + +class ClosedNamespace(TypedDict, closed=True): + x: int + y: str + +def h(ns: ClosedNamespace): + X = type("X", (), ns) + + reveal_type(X) # revealed: + + # Known keys from the TypedDict are tracked as attributes + reveal_type(X.x) # revealed: int + reveal_type(X.y) # revealed: str + + x = X() + reveal_type(x.x) # revealed: int + reveal_type(x.y) # revealed: str + + # TODO: Once we support `closed=True`, these should emit errors instead of returning Unknown + reveal_type(X.unknown) # revealed: Unknown + reveal_type(x.unknown) # revealed: Unknown ``` ## Inheritance from dynamic classes @@ -513,7 +612,129 @@ class B(metaclass=Meta2): ... Bad = type("Bad", (A, B), {}) ``` -## Cyclic dynamic class definitions +## `__slots__` in namespace dictionary + +Dynamic classes can define `__slots__` in the namespace dictionary. Non-empty `__slots__` makes the +class a "disjoint base", which prevents it from being used alongside other disjoint bases in a class +hierarchy: + +```py +# Dynamic class with non-empty __slots__ +Slotted = type("Slotted", (), {"__slots__": ("x", "y")}) +slotted = Slotted() +reveal_type(slotted) # revealed: Slotted + +# Classes with empty __slots__ are not disjoint bases +EmptySlots = type("EmptySlots", (), {"__slots__": ()}) + +# Classes with no __slots__ are not disjoint bases +NoSlots = type("NoSlots", (), {}) + +# String __slots__ are treated as a single slot (non-empty) +StringSlots = type("StringSlots", (), {"__slots__": "x"}) +``` + +Dynamic classes with non-empty `__slots__` cannot coexist with other disjoint bases: + +```py +class RegularSlotted: + __slots__ = ("a",) + +DynSlotted = type("DynSlotted", (), {"__slots__": ("b",)}) + +# error: [instance-layout-conflict] +class Conflict( + RegularSlotted, + DynSlotted, +): ... +``` + +Two dynamic classes with non-empty `__slots__` also conflict: + +```py +A = type("A", (), {"__slots__": ("x",)}) +B = type("B", (), {"__slots__": ("y",)}) + +# error: [instance-layout-conflict] +class Conflict( + A, + B, +): ... +``` + +`instance-layout-conflict` errors are also emitted for classes that inherit from dynamic classes +with disjoint bases: + +```py +from typing import Any + +class DisjointBase1: + __slots__ = ("a",) + +class DisjointBase2: + __slots__ = ("b",) + +def f(ns: dict[str, Any]): + cls1 = type("cls1", (DisjointBase1,), ns) + cls2 = type("cls2", (DisjointBase2,), ns) + + # error: [instance-layout-conflict] + cls3 = type("cls3", (cls1, cls2), {}) + + # error: [instance-layout-conflict] + class Cls4(cls1, cls2): ... +``` + +When the namespace dictionary is dynamic (not a literal), we can't determine if `__slots__` is +defined, so no diagnostic is emitted: + +```py +from typing import Any + +class SlottedBase: + __slots__ = ("a",) + +def f(ns: dict[str, Any]): + # The namespace might or might not contain __slots__, so no error is emitted + Dynamic = type("Dynamic", (), ns) + + # No error: we can't prove there's a conflict since ns might not have __slots__ + class MaybeConflict(SlottedBase, Dynamic): ... +``` + +## `instance-layout-conflict` diagnostic snapshots + + + +When the bases are a tuple literal, the diagnostic includes annotations for each conflicting base: + +```py +class A: + __slots__ = ("x",) + +class B: + __slots__ = ("y",) + +# error: [instance-layout-conflict] +X = type("X", (A, B), {}) +``` + +When the bases are not a tuple literal (e.g., a variable), the diagnostic is emitted without +per-base annotations: + +```py +class C: + __slots__ = ("x",) + +class D: + __slots__ = ("y",) + +bases: tuple[type[C], type[D]] = (C, D) +# error: [instance-layout-conflict] +Y = type("Y", bases, {}) +``` + +## Cyclic functional class definitions Self-referential class definitions using `type()` are detected. The name being defined is referenced in the bases tuple before it's available: diff --git a/crates/ty_python_semantic/resources/mdtest/decorators/total_ordering.md b/crates/ty_python_semantic/resources/mdtest/decorators/total_ordering.md index 08dba7069cf0b..108c4b15ea3ad 100644 --- a/crates/ty_python_semantic/resources/mdtest/decorators/total_ordering.md +++ b/crates/ty_python_semantic/resources/mdtest/decorators/total_ordering.md @@ -504,3 +504,94 @@ class HasOrderingMethod: ValidOrderedClass = total_ordering(HasOrderingMethod) reveal_type(ValidOrderedClass) # revealed: type[HasOrderingMethod] ``` + +## Function call form with `type()` + +When `total_ordering` is called on a class created with `type()`, the same validation is performed: + +```py +from functools import total_ordering + +def lt_impl(self, other) -> bool: + return True + +# No error: the functional class defines `__lt__` in its namespace +ValidFunctional = total_ordering(type("ValidFunctional", (), {"__lt__": lt_impl})) + +InvalidFunctionalBase = type("InvalidFunctionalBase", (), {}) +# error: [invalid-total-ordering] +InvalidFunctional = total_ordering(InvalidFunctionalBase) +``` + +## Inherited from functional class + +When a class inherits from a functional class that defines an ordering method, `@total_ordering` +correctly detects it: + +```py +from functools import total_ordering + +def lt_impl(self, other) -> bool: + return True + +def eq_impl(self, other) -> bool: + return True + +# Functional class with __lt__ method +OrderedBase = type("OrderedBase", (), {"__lt__": lt_impl}) + +# A class inheriting from OrderedBase gets the ordering method +@total_ordering +class Ordered(OrderedBase): + def __eq__(self, other: object) -> bool: + return True + +o1 = Ordered() +o2 = Ordered() + +# Inherited __lt__ is available +reveal_type(o1 < o2) # revealed: bool + +# @total_ordering synthesizes the other methods +reveal_type(o1 <= o2) # revealed: bool +reveal_type(o1 > o2) # revealed: bool +reveal_type(o1 >= o2) # revealed: bool +``` + +When the dynamic base class does not define any ordering method, `@total_ordering` emits an error: + +```py +from functools import total_ordering + +# Dynamic class without ordering methods (invalid for @total_ordering) +NoOrderBase = type("NoOrderBase", (), {}) + +@total_ordering # error: [invalid-total-ordering] +class NoOrder(NoOrderBase): + def __eq__(self, other: object) -> bool: + return True +``` + +## Dynamic namespace + +When a `type()`-constructed class has a dynamic namespace, we assume it might provide an ordering +method (since we can't know what's in the namespace). No error is emitted when such a class is +passed to `@total_ordering`: + +```py +from functools import total_ordering +from typing import Any + +def f(ns: dict[str, Any]): + # Dynamic class with dynamic namespace - might have ordering methods + DynamicBase = type("DynamicBase", (), ns) + + # No error: the dynamic namespace might contain __lt__ or another ordering method + @total_ordering + class Ordered(DynamicBase): + def __eq__(self, other: object) -> bool: + return True + + # Also works when calling total_ordering as a function + OrderedDirect = total_ordering(type("OrderedDirect", (), ns)) +``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/type.md_-_Calls_to_`type()`_-_`instance-layout-con\342\200\246_(d3fedd90588465f3).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/type.md_-_Calls_to_`type()`_-_`instance-layout-con\342\200\246_(d3fedd90588465f3).snap" new file mode 100644 index 0000000000000..c4a2b49c85ca6 --- /dev/null +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/type.md_-_Calls_to_`type()`_-_`instance-layout-con\342\200\246_(d3fedd90588465f3).snap" @@ -0,0 +1,74 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- + +--- +mdtest name: type.md - Calls to `type()` - `instance-layout-conflict` diagnostic snapshots +mdtest path: crates/ty_python_semantic/resources/mdtest/call/type.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | class A: + 2 | __slots__ = ("x",) + 3 | + 4 | class B: + 5 | __slots__ = ("y",) + 6 | + 7 | # error: [instance-layout-conflict] + 8 | X = type("X", (A, B), {}) + 9 | class C: +10 | __slots__ = ("x",) +11 | +12 | class D: +13 | __slots__ = ("y",) +14 | +15 | bases: tuple[type[C], type[D]] = (C, D) +16 | # error: [instance-layout-conflict] +17 | Y = type("Y", bases, {}) +``` + +# Diagnostics + +``` +error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases + --> src/mdtest_snippet.py:8:5 + | + 7 | # error: [instance-layout-conflict] + 8 | X = type("X", (A, B), {}) + | ^^^^^^^^^^^^^^^^^^^^^ Bases `A` and `B` cannot be combined in multiple inheritance + 9 | class C: +10 | __slots__ = ("x",) + | +info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts + --> src/mdtest_snippet.py:8:16 + | + 7 | # error: [instance-layout-conflict] + 8 | X = type("X", (A, B), {}) + | - - `B` instances have a distinct memory layout because `B` defines non-empty `__slots__` + | | + | `A` instances have a distinct memory layout because `A` defines non-empty `__slots__` + 9 | class C: +10 | __slots__ = ("x",) + | +info: rule `instance-layout-conflict` is enabled by default + +``` + +``` +error[instance-layout-conflict]: Class will raise `TypeError` at runtime due to incompatible bases + --> src/mdtest_snippet.py:17:5 + | +15 | bases: tuple[type[C], type[D]] = (C, D) +16 | # error: [instance-layout-conflict] +17 | Y = type("Y", bases, {}) + | ^^^^^^^^^^^^^^^^^^^^ Bases `C` and `D` cannot be combined in multiple inheritance + | +info: Two classes cannot coexist in a class's MRO if their instances have incompatible memory layouts +info: rule `instance-layout-conflict` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index ba4f3b61fc43a..c58d27b54a12d 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -643,13 +643,17 @@ impl<'db> ClassLiteral<'db> { /// Returns `true` if this class defines any ordering method (`__lt__`, `__le__`, `__gt__`, /// `__ge__`) in its own body (not inherited). Used by `@total_ordering` to determine if /// synthesis is valid. - // TODO: A dynamic class could provide ordering methods in the namespace dictionary: - // ```python - // >>> X = type("X", (), {"__lt__": lambda self, other: True}) - // ``` + /// + /// For dynamic classes, this checks if any ordering methods are provided in the namespace + /// dictionary: + /// ```python + /// X = type("X", (), {"__lt__": lambda self, other: True}) + /// ``` pub(crate) fn has_own_ordering_method(self, db: &'db dyn Db) -> bool { - self.as_static() - .is_some_and(|class| class.has_own_ordering_method(db)) + match self { + Self::Static(class) => class.has_own_ordering_method(db), + Self::Dynamic(class) => class.has_own_ordering_method(db), + } } /// Returns the static class definition if this is one. @@ -704,20 +708,27 @@ impl<'db> ClassLiteral<'db> { } /// Returns whether this class is a disjoint base. - // TODO: A dynamic class could provide __slots__ in the namespace dictionary, which would make - // it a disjoint base: - // ```python - // >>> X = type("X", (), {"__slots__": ("a",)}) - // >>> class Foo(int, X): ... - // ... - // Traceback (most recent call last): - // File "", line 1, in - // class Foo(int, X): ... - // TypeError: multiple bases have instance lay-out conflict - // ``` + /// + /// A class is considered a disjoint base if: + /// - It has the `@disjoint_base` decorator (static classes only), or + /// - It defines non-empty `__slots__` + /// + /// For dynamic classes created via `type()`, we check if `__slots__` is provided + /// in the namespace dictionary: + /// ```python + /// >>> X = type("X", (), {"__slots__": ("a",)}) + /// >>> class Foo(int, X): ... + /// ... + /// Traceback (most recent call last): + /// File "", line 1, in + /// class Foo(int, X): ... + /// TypeError: multiple bases have instance lay-out conflict + /// ``` pub(super) fn as_disjoint_base(self, db: &'db dyn Db) -> Option> { - self.as_static() - .and_then(|class| class.as_disjoint_base(db)) + match self { + Self::Static(class) => class.as_disjoint_base(db), + Self::Dynamic(class) => class.as_disjoint_base(db), + } } /// Returns a non-generic instance of this class. @@ -1323,8 +1334,12 @@ impl<'db> ClassType<'db> { Signature::new(parameters, return_annotation) } - let Some((class_literal, specialization)) = self.static_class_literal(db) else { - return Member::unbound(); + let (class_literal, specialization) = match self { + Self::NonGeneric(ClassLiteral::Dynamic(dynamic)) => { + return dynamic.own_class_member(db, name); + } + Self::NonGeneric(ClassLiteral::Static(class)) => (class, None), + Self::Generic(generic) => (generic.origin(db), Some(generic.specialization(db))), }; let fallback_member_lookup = || { @@ -1637,12 +1652,21 @@ impl<'db> ClassType<'db> { /// A helper function for `instance_member` that looks up the `name` attribute only on /// this class, not on its superclasses. pub(super) fn own_instance_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { - let Some((class_literal, specialization)) = self.static_class_literal(db) else { - return Member::unbound(); - }; - class_literal - .own_instance_member(db, name) - .map_type(|ty| ty.apply_optional_specialization(db, specialization)) + match self { + Self::NonGeneric(ClassLiteral::Dynamic(dynamic)) => { + dynamic.own_instance_member(db, name) + } + Self::NonGeneric(ClassLiteral::Static(class_literal)) => { + class_literal.own_instance_member(db, name) + } + Self::Generic(generic) => { + let specialization = generic.specialization(db); + generic + .origin(db) + .own_instance_member(db, name) + .map_type(|ty| ty.apply_optional_specialization(db, Some(specialization))) + } + } } /// Return a callable type (or union of callable types) that represents the callable @@ -2111,16 +2135,27 @@ impl<'db> StaticClassLiteral<'db> { let Some(base_class) = base.into_class() else { continue; }; - let Some((base_literal, base_specialization)) = base_class.static_class_literal(db) - else { - continue; - }; - if base_literal.is_known(db, KnownClass::Object) { - continue; - } - let member = class_member(db, base_literal.body_scope(db), name); - if let Some(ty) = member.ignore_possibly_undefined() { - return Some(ty.apply_optional_specialization(db, base_specialization)); + match base_class.class_literal(db) { + ClassLiteral::Static(base_literal) => { + if base_literal.is_known(db, KnownClass::Object) { + continue; + } + let member = class_member(db, base_literal.body_scope(db), name); + if let Some(ty) = member.ignore_possibly_undefined() { + let base_specialization = base_class + .static_class_literal(db) + .and_then(|(_, spec)| spec); + return Some(ty.apply_optional_specialization(db, base_specialization)); + } + } + ClassLiteral::Dynamic(dynamic) => { + // Dynamic classes (created with `type()`) can also define ordering methods + // in their namespace dict. + let member = dynamic.own_class_member(db, name); + if let Some(ty) = member.ignore_possibly_undefined() { + return Some(ty); + } + } } } } @@ -2376,7 +2411,9 @@ impl<'db> StaticClassLiteral<'db> { { Some(DisjointBase::due_to_decorator(self)) } else if SlotsKind::from(db, self) == SlotsKind::NotEmpty { - Some(DisjointBase::due_to_dunder_slots(self)) + Some(DisjointBase::due_to_dunder_slots(ClassLiteral::Static( + self, + ))) } else { None } @@ -4665,18 +4702,8 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> { /// - name: "Foo" /// - bases: [Base] /// -/// # Limitations -/// -/// TODO: Attributes from the namespace dict (third argument to `type()`) are not tracked. -/// This matches Pyright's behavior. For example: -/// -/// ```python -/// Foo = type("Foo", (), {"attr": 42}) -/// Foo().attr # Error: no attribute 'attr' -/// ``` -/// -/// Supporting namespace dict attributes would require parsing dict literals and tracking -/// the attribute types, similar to how TypedDict handles its fields. +/// This is called "dynamic" because the class is created dynamically at runtime +/// via a function call rather than a class statement. /// /// # Salsa interning /// @@ -4709,6 +4736,16 @@ pub struct DynamicClassLiteral<'db> { /// The definition where this class is created. pub definition: Definition<'db>, + /// The class members from the namespace dict (third argument to `type()`). + /// Each entry is a (name, type) pair extracted from the dict literal. + #[returns(deref)] + pub members: Box<[(Name, Type<'db>)]>, + + /// Whether the namespace dict (third argument) is dynamic (not a literal dict, + /// or contains non-string-literal keys). When true, attribute lookups on this + /// class and its instances return `Unknown` instead of failing. + pub has_dynamic_namespace: bool, + /// Dataclass parameters if this class has been wrapped with `@dataclass` decorator /// or passed to `dataclass()` as a function. pub dataclass_params: Option>, @@ -4900,6 +4937,29 @@ impl<'db> DynamicClassLiteral<'db> { } } + /// Look up a class member defined directly on this class (not inherited). + /// + /// Returns [`Member::unbound`] if the member is not found in the namespace dict, + /// unless the namespace is dynamic, in which case returns `Unknown`. + pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { + // If the namespace is dynamic (not a literal dict) and the name isn't in `self.members`, + // return Unknown since we can't know what attributes might be defined. + self.members(db) + .iter() + .find_map(|(member_name, ty)| (name == member_name).then_some(*ty)) + .or_else(|| self.has_dynamic_namespace(db).then(Type::unknown)) + .map(Member::definitely_declared) + .unwrap_or_default() + } + + /// Look up an instance member defined directly on this class (not inherited). + /// + /// For dynamic classes, instance members are the same as class members + /// since they come from the namespace dict. + pub(super) fn own_instance_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { + self.own_class_member(db, name) + } + /// Try to compute the MRO for this dynamic class. /// /// Returns `Ok(Mro)` if successful, or `Err(DynamicMroError)` if there's @@ -4909,6 +4969,50 @@ impl<'db> DynamicClassLiteral<'db> { Mro::of_dynamic_class(db, self) } + /// Return `Some()` if this dynamic class is known to be a [`DisjointBase`]. + /// + /// A dynamic class is a disjoint base if `__slots__` is defined in the namespace + /// dictionary and is non-empty. Example: + /// ```python + /// X = type("X", (), {"__slots__": ("a",)}) + /// ``` + pub(super) fn as_disjoint_base(self, db: &'db dyn Db) -> Option> { + // Check if __slots__ is in the members + for (name, ty) in self.members(db) { + if name.as_str() == "__slots__" { + // Check if the slots are non-empty + let is_non_empty = match ty { + // __slots__ = ("a", "b") + Type::NominalInstance(nominal) => nominal.tuple_spec(db).is_some_and(|spec| { + spec.len().into_fixed_length().is_some_and(|len| len > 0) + }), + // __slots__ = "abc" # Same as ("abc",) + Type::StringLiteral(_) => true, + // Other types are considered dynamic/unknown + _ => false, + }; + if is_non_empty { + return Some(DisjointBase::due_to_dunder_slots(ClassLiteral::Dynamic( + self, + ))); + } + } + } + None + } + + /// Returns `true` if this dynamic class defines any ordering method (`__lt__`, `__le__`, + /// `__gt__`, `__ge__`) in its namespace dictionary. Used by `@total_ordering` to determine + /// if synthesis is valid. + /// + /// If the namespace is dynamic, returns `true` since we can't know if ordering methods exist. + pub(crate) fn has_own_ordering_method(self, db: &'db dyn Db) -> bool { + const ORDERING_METHODS: &[&str] = &["__lt__", "__le__", "__gt__", "__ge__"]; + ORDERING_METHODS + .iter() + .any(|name| !self.own_class_member(db, name).is_undefined()) + } + /// Returns a new [`DynamicClassLiteral`] with the given dataclass params, preserving all other fields. pub(crate) fn with_dataclass_params( self, @@ -4920,6 +5024,8 @@ impl<'db> DynamicClassLiteral<'db> { self.name(db).clone(), self.bases(db), self.definition(db), + self.members(db), + self.has_dynamic_namespace(db), dataclass_params, ) } @@ -5296,7 +5402,7 @@ impl InheritanceCycle { /// [PEP 800]: https://peps.python.org/pep-0800/ #[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)] pub(super) struct DisjointBase<'db> { - pub(super) class: StaticClassLiteral<'db>, + pub(super) class: ClassLiteral<'db>, pub(super) kind: DisjointBaseKind, } @@ -5305,14 +5411,14 @@ impl<'db> DisjointBase<'db> { /// because it has the `@disjoint_base` decorator on its definition fn due_to_decorator(class: StaticClassLiteral<'db>) -> Self { Self { - class, + class: ClassLiteral::Static(class), kind: DisjointBaseKind::DisjointBaseDecorator, } } /// Creates a [`DisjointBase`] instance where we know the class is a disjoint base /// because of its `__slots__` definition. - fn due_to_dunder_slots(class: StaticClassLiteral<'db>) -> Self { + fn due_to_dunder_slots(class: ClassLiteral<'db>) -> Self { Self { class, kind: DisjointBaseKind::DefinesSlots, @@ -5324,10 +5430,12 @@ impl<'db> DisjointBase<'db> { self == other || self .class - .is_subclass_of(db, None, other.class.default_specialization(db)) + .default_specialization(db) + .is_subclass_of(db, other.class.default_specialization(db)) || other .class - .is_subclass_of(db, None, self.class.default_specialization(db)) + .default_specialization(db) + .is_subclass_of(db, self.class.default_specialization(db)) } } diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index fac705cabf44b..36302200bfb01 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -3009,16 +3009,15 @@ pub(crate) fn report_invalid_exception_cause(context: &InferContext, node: &ast: pub(crate) fn report_instance_layout_conflict( context: &InferContext, - class: StaticClassLiteral, - node: &ast::StmtClassDef, + header_range: TextRange, + base_nodes: Option<&[ast::Expr]>, disjoint_bases: &IncompatibleBases, ) { debug_assert!(disjoint_bases.len() > 1); let db = context.db(); - let Some(builder) = context.report_lint(&INSTANCE_LAYOUT_CONFLICT, class.header_range(db)) - else { + let Some(builder) = context.report_lint(&INSTANCE_LAYOUT_CONFLICT, header_range) else { return; }; @@ -3042,9 +3041,14 @@ pub(crate) fn report_instance_layout_conflict( originating_base, } = disjoint_base_info; - let span = context.span(&node.bases()[*node_index]); + // Get the span for this base from the AST (if available) + let Some(base_node) = base_nodes.and_then(|nodes| nodes.get(*node_index)) else { + continue; + }; + + let span = context.span(base_node); let mut annotation = Annotation::secondary(span.clone()); - if originating_base.as_static() == Some(disjoint_base.class) { + if *originating_base == disjoint_base.class { match disjoint_base.kind { DisjointBaseKind::DefinesSlots => { annotation = annotation.message(format_args!( @@ -3168,11 +3172,10 @@ impl<'db> IncompatibleBases<'db> { .keys() .filter(|other_base| other_base != disjoint_base) .all(|other_base| { - !disjoint_base.class.is_subclass_of( - db, - None, - other_base.class.default_specialization(db), - ) + !disjoint_base + .class + .default_specialization(db) + .is_subclass_of(db, other_base.class.default_specialization(db)) }) }) .map(|(base, info)| (*base, *info)) diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 4332542b81960..3700d778bb924 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -857,8 +857,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if disjoint_bases.len() > 1 { report_instance_layout_conflict( &self.context, - class, - class_node, + class.header_range(self.db()), + Some(class_node.bases()), &disjoint_bases, ); } @@ -6095,15 +6095,59 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Infer the argument types. let name_type = self.infer_expression(name_arg, TypeContext::default()); let bases_type = self.infer_expression(bases_arg, TypeContext::default()); + + // Extract members from the namespace dict (third argument). + // Infer the whole dict first to avoid double-inferring individual values. let namespace_type = self.infer_expression(namespace_arg, TypeContext::default()); + let (members, has_dynamic_namespace): (Box<[(ast::name::Name, Type<'db>)]>, bool) = + if let ast::Expr::Dict(dict) = namespace_arg { + // Check if all keys are string literal types. If any key is not a string literal + // type or is missing (spread), the namespace is considered dynamic. + let all_keys_are_string_literals = dict.items.iter().all(|item| { + item.key + .as_ref() + .is_some_and(|k| matches!(self.expression_type(k), Type::StringLiteral(_))) + }); + let members = dict + .items + .iter() + .filter_map(|item| { + // Only extract items with string literal keys. + let key_expr = item.key.as_ref()?; + let key_name = match self.expression_type(key_expr) { + Type::StringLiteral(s) => ast::name::Name::new(s.value(db)), + _ => return None, + }; + // Get the already-inferred type from when we inferred the dict above. + let value_ty = self.expression_type(&item.value); + Some((key_name, value_ty)) + }) + .collect(); + (members, !all_keys_are_string_literals) + } else if let Type::TypedDict(typed_dict) = namespace_type { + // Namespace is a TypedDict instance. Extract known keys as members. + // TypedDicts are "open" (can have additional string keys), so this + // is still a dynamic namespace for unknown attributes. + let members: Box<[(ast::name::Name, Type<'db>)]> = typed_dict + .items(db) + .iter() + .map(|(name, field)| (name.clone(), field.declared_ty)) + .collect(); + (members, true) + } else { + // Namespace is not a dict literal, so it's dynamic. + (Box::new([]), true) + }; - if !namespace_type.is_assignable_to( - db, - KnownClass::Dict - .to_specialized_instance(db, &[KnownClass::Str.to_instance(db), Type::any()]), - ) && let Some(builder) = self - .context - .report_lint(&INVALID_ARGUMENT_TYPE, namespace_arg) + if !matches!(namespace_type, Type::TypedDict(_)) + && !namespace_type.is_assignable_to( + db, + KnownClass::Dict + .to_specialized_instance(db, &[KnownClass::Str.to_instance(db), Type::any()]), + ) + && let Some(builder) = self + .context + .report_lint(&INVALID_ARGUMENT_TYPE, namespace_arg) { let mut diagnostic = builder .into_diagnostic("Invalid argument to parameter 3 (`namespace`) of `type()`"); @@ -6130,13 +6174,22 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ast::name::Name::new_static("") }; - let bases = self.extract_dynamic_type_bases(bases_arg, bases_type, &name); + let (bases, mut disjoint_bases) = + self.extract_dynamic_type_bases(bases_arg, bases_type, &name); - let dynamic_class = DynamicClassLiteral::new(db, name, bases, definition, None); + let dynamic_class = DynamicClassLiteral::new( + db, + name, + bases, + definition, + members, + has_dynamic_namespace, + None, + ); // Check for MRO errors. - if let Err(error) = dynamic_class.try_mro(db) { - match error.reason() { + match dynamic_class.try_mro(db) { + Err(error) => match error.reason() { DynamicMroErrorKind::DuplicateBases(duplicates) => { if let Some(builder) = self.context.report_lint(&DUPLICATE_BASE, call_expr) { builder.into_diagnostic(format_args!( @@ -6164,6 +6217,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { )); } } + }, + Ok(_) => { + // MRO succeeded, check for instance-layout-conflict. + disjoint_bases.remove_redundant_entries(db); + if disjoint_bases.len() > 1 { + report_instance_layout_conflict( + &self.context, + dynamic_class.header_range(db), + bases_arg.as_tuple_expr().map(|tuple| tuple.elts.as_slice()), + &disjoint_bases, + ); + } } } @@ -6191,14 +6256,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// Extract base classes from the second argument of a `type()` call. /// - /// If any bases were invalid, diagnostics are emitted and the dynamic - /// class is inferred as inheriting from `Unknown`. + /// Returns the extracted bases and any disjoint bases found (for instance-layout-conflict + /// checking). If any bases were invalid, diagnostics are emitted and the dynamic class is + /// inferred as inheriting from `Unknown`. fn extract_dynamic_type_bases( &mut self, bases_node: &ast::Expr, bases_type: Type<'db>, name: &ast::name::Name, - ) -> Box<[ClassBase<'db>]> { + ) -> (Box<[ClassBase<'db>]>, IncompatibleBases<'db>) { let db = self.db(); // Get AST nodes for base expressions (for diagnostics). @@ -6209,7 +6275,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let placeholder_class: ClassLiteral<'db> = KnownClass::Object.try_to_class_literal(db).unwrap().into(); - bases_type + let mut disjoint_bases = IncompatibleBases::default(); + + let bases = bases_type .tuple_instance_spec(db) .as_deref() .and_then(|spec| spec.as_fixed_length()) @@ -6224,6 +6292,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(class_base) = ClassBase::try_from_type(db, *base, placeholder_class) { + // Collect disjoint bases for instance-layout-conflict checking. + if let ClassBase::Class(base_class) = class_base { + if let Some(disjoint_base) = base_class.nearest_disjoint_base(db) { + disjoint_bases.insert( + disjoint_base, + idx, + base_class.class_literal(db), + ); + } + } return class_base; } @@ -6256,20 +6334,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { "Only class objects or `Any` are supported as class bases", ); } - } else { - if let Some(builder) = - self.context.report_lint(&INVALID_BASE, diagnostic_node) - { - let mut diagnostic = builder.into_diagnostic(format_args!( - "Invalid class base with type `{}`", - base.display(db) + } else if let Some(builder) = + self.context.report_lint(&INVALID_BASE, diagnostic_node) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid class base with type `{}`", + base.display(db) + )); + if bases_tuple_elts.is_none() { + diagnostic.info(format_args!( + "Element {} of the tuple is invalid", + idx + 1 )); - if bases_tuple_elts.is_none() { - diagnostic.info(format_args!( - "Element {} of the tuple is invalid", - idx + 1 - )); - } } } @@ -6292,7 +6368,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { )); } Box::from([ClassBase::unknown()]) - }) + }); + + (bases, disjoint_bases) } fn infer_annotated_assignment_statement(&mut self, assignment: &ast::StmtAnnAssign) {