diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 1f71887712f1f..73b3032ed86bf 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -3069,11 +3069,11 @@ Added in 0.0.12 +reveal_mro(Point2) # revealed: (, ) +``` + +## Fields with defaults + +The third element of a 3-tuple specifies a default value: + +```py +from dataclasses import make_dataclass + +PointWithDefault = make_dataclass("PointWithDefault", [("x", int), ("y", int, 0)]) + +# error: [missing-argument] "No argument provided for required parameter `x`" +PointWithDefault() + +# Good - y has a default +p1 = PointWithDefault(1) +p2 = PointWithDefault(1, 2) + +reveal_type(PointWithDefault.__init__) # revealed: (self: PointWithDefault, x: int, y: int = 0) -> None +``` + +## Fields with `field()` defaults + +Using `dataclasses.field()` as the third element of a 3-tuple: + +```py +from dataclasses import make_dataclass, field + +PointWithField = make_dataclass( + "PointWithField", + [ + ("x", int), + ("y", int, field(default=0)), + ("z", list, field(default_factory=list)), + ], +) + +# error: [missing-argument] "No argument provided for required parameter `x`" +PointWithField() + +# Good - y and z have defaults +p1 = PointWithField(1) +p2 = PointWithField(1, 2) +p3 = PointWithField(1, 2, [3]) + +reveal_type(p1.x) # revealed: int +reveal_type(p1.y) # revealed: int +reveal_type(p1.z) # revealed: list[Unknown] +``` + +## Fields with `init=False` via `field()` + +```py +from dataclasses import make_dataclass, field + +PointPartialInit = make_dataclass( + "PointPartialInit", + [ + ("x", int), + ("y", int, field(init=False, default=0)), + ], +) + +# Only x is in __init__ +p = PointPartialInit(1) +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int + +# error: [unknown-argument] "Argument `y` does not match any known parameter" +PointPartialInit(1, y=2) +``` + +## Fields with `kw_only=True` via `field()` + +```py +from dataclasses import make_dataclass, field + +PointKwOnlyField = make_dataclass( + "PointKwOnlyField", + [ + ("x", int), + ("y", int, field(kw_only=True)), + ], +) + +# x is positional, y is keyword-only +p1 = PointKwOnlyField(1, y=2) +reveal_type(p1.x) # revealed: int +reveal_type(p1.y) # revealed: int + +# error: [missing-argument] "No argument provided for required parameter `y`" +# error: [too-many-positional-arguments] "Too many positional arguments: expected 1, got 2" +PointKwOnlyField(1, 2) + +reveal_type(PointKwOnlyField.__init__) # revealed: (self: PointKwOnlyField, x: int, *, y: int) -> None +``` + +## Fields with `kw_only=False` overriding class-level `kw_only=True` + +Per-field `kw_only=False` overrides the class-level default: + +```py +from dataclasses import make_dataclass, field + +MixedKwOnly = make_dataclass( + "MixedKwOnly", + [ + ("x", int, field(kw_only=False)), # Override: positional + ("y", int), # Uses class default: keyword-only + ], + kw_only=True, # Default all fields to keyword-only +) + +# x is positional (overridden), y is keyword-only (class default) +p1 = MixedKwOnly(1, y=2) +reveal_type(p1.x) # revealed: int +reveal_type(p1.y) # revealed: int + +reveal_type(MixedKwOnly.__init__) # revealed: (self: MixedKwOnly, x: int, *, y: int) -> None +``` + +## Fields with combined `field()` options + +```py +from dataclasses import make_dataclass, field + +ComplexFields = make_dataclass( + "ComplexFields", + [ + ("required", int), + ("with_default", int, field(default=10)), + ("with_factory", list, field(default_factory=list)), + ("kw_with_default", str, field(kw_only=True, default="hello")), + ], +) + +# Only 'required' is required; others have defaults +c1 = ComplexFields(1) +c2 = ComplexFields(1, 20) +c3 = ComplexFields(1, 20, [1, 2, 3]) +c4 = ComplexFields(1, kw_with_default="world") + +reveal_type(c1.required) # revealed: int +reveal_type(c1.with_default) # revealed: int +reveal_type(c1.with_factory) # revealed: list[Unknown] +reveal_type(c1.kw_with_default) # revealed: str + +# fmt: off +reveal_type(ComplexFields.__init__) # revealed: (self: ComplexFields, required: int, with_default: int = 10, with_factory: list[Unknown] = ..., *, kw_with_default: str = "hello") -> None +``` + +## Dataclass methods + +### `__init__` + +```py +from dataclasses import make_dataclass + +Point3 = make_dataclass("Point3", [("x", int), ("y", int)]) + +# Good +p1 = Point3(1, 2) +p2 = Point3(x=1, y=2) + +# error: [missing-argument] +p3 = Point3(1) + +# error: [missing-argument] +p4 = Point3() +``` + +## Dataclass parameters + +### `init=False` + +```py +from dataclasses import make_dataclass + +PointNoInit = make_dataclass("PointNoInit", [("x", int), ("y", int)], init=False) + +# error: [too-many-positional-arguments] +p = PointNoInit(1, 2) +``` + +### `repr=False` + +```py +from dataclasses import make_dataclass + +PointNoRepr = make_dataclass("PointNoRepr", [("x", int), ("y", int)], repr=False) + +p = PointNoRepr(1, 2) +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int + +# The class is still created and usable, repr=False just affects __repr__ +reveal_type(p) # revealed: PointNoRepr +``` + +### `eq=False` + +```py +from dataclasses import make_dataclass + +PointNoEq = make_dataclass("PointNoEq", [("x", int), ("y", int)], eq=False) + +p1 = PointNoEq(1, 2) +p2 = PointNoEq(1, 2) + +# Falls back to object.__eq__ +reveal_type(p1 == p2) # revealed: bool +``` + +### `order=True` + +```py +from dataclasses import make_dataclass + +PointOrder = make_dataclass("PointOrder", [("x", int), ("y", int)], order=True) + +p1 = PointOrder(1, 2) +p2 = PointOrder(3, 4) + +reveal_type(p1 < p2) # revealed: bool +reveal_type(p1 <= p2) # revealed: bool +reveal_type(p1 > p2) # revealed: bool +reveal_type(p1 >= p2) # revealed: bool +``` + +### `total_ordering` with `order=True` + +Using `total_ordering` on a dataclass with `order=True` is redundant since the comparison methods +are already synthesized. However, this doesn't cause an error: + +```py +from dataclasses import make_dataclass +from functools import total_ordering + +# No error - but this is redundant since order=True already provides comparison methods +PointOrdered = total_ordering(make_dataclass("PointOrdered", [("x", int)], order=True)) + +p1 = PointOrdered(1) +p2 = PointOrdered(2) +reveal_type(p1 < p2) # revealed: bool +``` + +### `total_ordering` without `order=True` + +Using `total_ordering` on a dataclass without `order=True` requires at least one ordering method to +be defined. Since `make_dataclass` with `order=False` doesn't synthesize any comparison methods, +this results in an error: + +```py +from dataclasses import make_dataclass +from functools import total_ordering + +# error: [invalid-total-ordering] "`@functools.total_ordering` requires at least one ordering method (`__lt__`, `__le__`, `__gt__`, or `__ge__`) to be defined: `PointNoOrder` does not define `__lt__`, `__le__`, `__gt__`, or `__ge__`" +PointNoOrder = total_ordering(make_dataclass("PointNoOrder", [("x", int)], order=False)) +``` + +### `frozen=True` + +```py +from dataclasses import make_dataclass + +PointFrozen = make_dataclass("PointFrozen", [("x", int), ("y", int)], frozen=True) + +p = PointFrozen(1, 2) + +# frozen dataclasses generate __hash__ +reveal_type(hash(p)) # revealed: int + +# frozen dataclasses are immutable +p.x = 42 # error: [invalid-assignment] +p.y = 56 # error: [invalid-assignment] +``` + +### `unsafe_hash=True` + +```py +from dataclasses import make_dataclass + +PointUnsafeHash = make_dataclass("PointUnsafeHash", [("x", int), ("y", int)], unsafe_hash=True) + +p = PointUnsafeHash(1, 2) + +# unsafe_hash=True generates __hash__ even without frozen=True +reveal_type(hash(p)) # revealed: int +``` + +### `eq=True` without `frozen=True` sets `__hash__` to `None` + +```py +from dataclasses import make_dataclass + +# By default, eq=True and frozen=False, which sets __hash__ to None +PointDefaultHash = make_dataclass("PointDefaultHash", [("x", int)]) + +p = PointDefaultHash(1) + +# __hash__ is None, so hash() fails at runtime +reveal_type(PointDefaultHash.__hash__) # revealed: None +``` + +### `kw_only=True` + +```py +from dataclasses import make_dataclass + +PointKwOnly = make_dataclass("PointKwOnly", [("x", int), ("y", int)], kw_only=True) + +# Good +p1 = PointKwOnly(x=1, y=2) + +# error: [missing-argument] "No arguments provided for required parameters `x`, `y`" +# error: [too-many-positional-arguments] "Too many positional arguments: expected 0, got 2" +p2 = PointKwOnly(1, 2) +``` + +### `match_args=True` (default) + +```py +from dataclasses import make_dataclass + +Point5 = make_dataclass("Point5", [("x", int), ("y", int)]) + +reveal_type(Point5.__match_args__) # revealed: tuple[Literal["x"], Literal["y"]] +``` + +### `match_args=False` + +```py +from dataclasses import make_dataclass + +PointNoMatchArgs = make_dataclass("PointNoMatchArgs", [("x", int), ("y", int)], match_args=False) + +# error: [unresolved-attribute] "Class `PointNoMatchArgs` has no attribute `__match_args__`" +reveal_type(PointNoMatchArgs.__match_args__) # revealed: Unknown +``` + +### `slots=True` + +Functional dataclasses with `slots=True` and non-empty fields are understood as disjoint bases, +causing an `instance-layout-conflict` error when combined with other slotted classes: + +```py +from dataclasses import make_dataclass + +PointSlots = make_dataclass("PointSlots", [("x", int), ("y", int)], slots=True) + +p = PointSlots(1, 2) +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int + +# Combining two slotted classes with non-empty __slots__ causes a layout conflict +OtherSlots = make_dataclass("OtherSlots", [("z", int)], slots=True) + +class Combined(PointSlots, OtherSlots): ... # error: [instance-layout-conflict] + +# Empty slots are fine +EmptySlots = make_dataclass("EmptySlots", [], slots=True) + +class CombinedWithEmpty(PointSlots, EmptySlots): ... # No error +``` + +### `weakref_slot=True` + +The `weakref_slot` parameter (Python 3.11+) adds a `__weakref__` slot when combined with +`slots=True`: + +```toml +[environment] +python-version = "3.11" +``` + +```py +from dataclasses import make_dataclass +import weakref + +PointWeakref = make_dataclass("PointWeakref", [("x", int)], slots=True, weakref_slot=True) + +p = PointWeakref(1) +reveal_type(p.x) # revealed: int + +# __weakref__ attribute is available +reveal_type(p.__weakref__) # revealed: Any | None +``` + +### Combining multiple flags + +Multiple flags can be combined: + +```py +from dataclasses import make_dataclass + +# frozen=True enables hashing and order=True enables comparisons +PointFrozenOrdered = make_dataclass( + "PointFrozenOrdered", + [("x", int), ("y", int)], + frozen=True, + order=True, +) + +p1 = PointFrozenOrdered(1, 2) +p2 = PointFrozenOrdered(3, 4) + +# frozen dataclasses are hashable +reveal_type(hash(p1)) # revealed: int + +# order=True enables comparisons +reveal_type(p1 < p2) # revealed: bool +reveal_type(p1 <= p2) # revealed: bool +reveal_type(p1 > p2) # revealed: bool +reveal_type(p1 >= p2) # revealed: bool + +# frozen dataclasses are immutable +p1.x = 42 # error: [invalid-assignment] +``` + +### `slots=True` with `frozen=True` + +```py +from dataclasses import make_dataclass + +SlottedFrozen = make_dataclass( + "SlottedFrozen", + [("x", int)], + slots=True, + frozen=True, +) + +p = SlottedFrozen(1) +reveal_type(hash(p)) # revealed: int + +# Frozen, so immutable +p.x = 42 # error: [invalid-assignment] +``` + +### `kw_only=True` with `frozen=True` + +```py +from dataclasses import make_dataclass + +KwOnlyFrozen = make_dataclass( + "KwOnlyFrozen", + [("x", int), ("y", int)], + kw_only=True, + frozen=True, +) + +# All arguments must be keyword-only +p = KwOnlyFrozen(x=1, y=2) +reveal_type(hash(p)) # revealed: int + +# error: [missing-argument] "No arguments provided for required parameters `x`, `y`" +# error: [too-many-positional-arguments] +KwOnlyFrozen(1, 2) +``` + +## `__dataclass_fields__` + +```py +from dataclasses import make_dataclass, Field + +Point6 = make_dataclass("Point6", [("x", int), ("y", int)]) + +reveal_type(Point6.__dataclass_fields__) # revealed: dict[str, Field[Any]] +``` + +## Base classes + +The `bases` keyword argument specifies base classes: + +```py +from dataclasses import make_dataclass +from ty_extensions import reveal_mro + +class Base: + def greet(self) -> str: + return "Hello" + +Derived = make_dataclass("Derived", [("value", int)], bases=(Base,)) +reveal_mro(Derived) # revealed: (, , ) + +d = Derived(42) +reveal_type(d) # revealed: Derived +reveal_type(d.value) # revealed: int +reveal_type(d.greet()) # revealed: str +``` + +## Dynamic fields (unknown fields) + +When the fields argument is dynamic (not a literal), we fall back to gradual typing. + +```py +from dataclasses import make_dataclass +from ty_extensions import reveal_mro + +def get_fields(): + return [("x", int)] + +fields = get_fields() +PointDynamic = make_dataclass("PointDynamic", fields) + +p = PointDynamic(1) # No error - accepts any arguments +reveal_type(p.x) # revealed: Any + +# The class is still inferred as inheriting directly from `object` +# (`Unknown` is not inserted into the MRO) +reveal_mro(PointDynamic) # revealed: (, ) + +# ...but nonetheless, we assume that all attributes are available, +# similar to attribute access on `Unknown` +reveal_type(p.unknown) # revealed: Any +``` + +## Starred arguments + +When `*args` or `**kwargs` are used, we can't statically determine the arguments, so we fall back to +gradual typing. + +```py +from dataclasses import make_dataclass + +args = ("Point", [("x", int)]) +PointStarred = make_dataclass(*args) + +p = PointStarred(1) # No error - accepts any arguments +reveal_type(p.x) # revealed: Unknown + +kwargs = {"cls_name": "Point2", "fields": [("y", str)]} +Point2 = make_dataclass(**kwargs) + +p2 = Point2("hello") # No error - accepts any arguments +reveal_type(p2.y) # revealed: Unknown +``` + +## Argument validation + +### Too few positional arguments + +Both `cls_name` and `fields` are required: + +```py +from dataclasses import make_dataclass + +# error: [missing-argument] "No argument provided for required parameter `fields` of function `make_dataclass`" +Point = make_dataclass("Point") +``` + +### Too many positional arguments + +Only `cls_name` and `fields` are positional arguments: + +```py +from dataclasses import make_dataclass + +# error: [too-many-positional-arguments] "Too many positional arguments to function `make_dataclass`: expected 2, got 3" +Point = make_dataclass("Point", [("x", int)], (object,)) +``` + +### Unknown keyword argument + +```py +from dataclasses import make_dataclass + +# error: [unknown-argument] "Argument `unknown` does not match any known parameter of function `make_dataclass`" +Point = make_dataclass("Point", [("x", int)], unknown=True) +``` + +### Invalid type for `cls_name` + +```py +from dataclasses import make_dataclass + +# error: [invalid-argument-type] "Invalid argument to parameter `cls_name` of `make_dataclass()`" +Point = make_dataclass(123, [("x", int)]) +``` + +### Invalid type for boolean parameters + +```py +from dataclasses import make_dataclass + +# error: [invalid-argument-type] "Invalid argument to parameter `init` of `make_dataclass()`" +C1 = make_dataclass("C1", [("x", int)], init="yes") + +# error: [invalid-argument-type] "Invalid argument to parameter `repr` of `make_dataclass()`" +C2 = make_dataclass("C2", [("x", int)], repr="no") + +# error: [invalid-argument-type] "Invalid argument to parameter `eq` of `make_dataclass()`" +C3 = make_dataclass("C3", [("x", int)], eq=None) + +# error: [invalid-argument-type] "Invalid argument to parameter `order` of `make_dataclass()`" +C4 = make_dataclass("C4", [("x", int)], order=1) + +# error: [invalid-argument-type] "Invalid argument to parameter `frozen` of `make_dataclass()`" +C5 = make_dataclass("C5", [("x", int)], frozen="true") + +# error: [invalid-argument-type] "Invalid argument to parameter `kw_only` of `make_dataclass()`" +C6 = make_dataclass("C6", [("x", int)], kw_only=[]) + +# error: [invalid-argument-type] "Invalid argument to parameter `unsafe_hash` of `make_dataclass()`" +C7 = make_dataclass("C7", [("x", int)], unsafe_hash="yes") + +# error: [invalid-argument-type] "Invalid argument to parameter `match_args` of `make_dataclass()`" +C8 = make_dataclass("C8", [("x", int)], match_args=1) + +# error: [invalid-argument-type] "Invalid argument to parameter `slots` of `make_dataclass()`" +C9 = make_dataclass("C9", [("x", int)], slots="yes") + +# error: [invalid-argument-type] "Invalid argument to parameter `weakref_slot` of `make_dataclass()`" +C10 = make_dataclass("C10", [("x", int)], weakref_slot=None) +``` + +### Invalid type for `namespace` + +```py +from dataclasses import make_dataclass + +# error: [invalid-argument-type] "Invalid argument to parameter `namespace` of `make_dataclass()`" +Point = make_dataclass("Point", [("x", int)], namespace="invalid") +``` + +### Invalid type for `module` + +```py +from dataclasses import make_dataclass + +# error: [invalid-argument-type] "Invalid argument to parameter `module` of `make_dataclass()`" +Point = make_dataclass("Point", [("x", int)], module=123) +``` + +### Invalid type for `bases` + +At runtime, `make_dataclass` requires `bases` to be a tuple (not a list or other iterable). + +```py +from dataclasses import make_dataclass + +# error: [invalid-argument-type] "Invalid argument to parameter `bases` of `make_dataclass()`: Expected `tuple`, found `Literal[12345]`" +Point1 = make_dataclass("Point1", [("x", int)], bases=12345) + +# error: [invalid-argument-type] "Invalid argument to parameter `bases` of `make_dataclass()`: Expected `tuple`, found `list[Unknown | ]`" +Point2 = make_dataclass("Point2", [("x", int)], bases=[object]) +``` + +### Valid `namespace` and `module` + +```py +from dataclasses import make_dataclass + +# These are all valid +Point1 = make_dataclass("Point1", [("x", int)], namespace=None) +Point2 = make_dataclass("Point2", [("x", int)], namespace={"custom_attr": 42}) +Point3 = make_dataclass("Point3", [("x", int)], module=None) +Point4 = make_dataclass("Point4", [("x", int)], module="my_module") +``` + +## Invalid bases + +### TypedDict and Generic bases + +These special forms are not allowed as bases for classes created via `make_dataclass()`. + +```py +from dataclasses import make_dataclass +from typing import TypedDict, Generic + +# error: [invalid-base] "Invalid base for class created via `make_dataclass()`" +A = make_dataclass("A", [("x", int)], bases=(TypedDict,)) + +# error: [invalid-base] "Invalid base for class created via `make_dataclass()`" +B = make_dataclass("B", [("x", int)], bases=(Generic,)) +``` + +### Protocol base + +Protocol bases use a different lint (`unsupported-dynamic-base`) because they're technically valid +Python but not supported by ty for dynamic classes. + +```py +from dataclasses import make_dataclass +from typing import Protocol + +# error: [unsupported-dynamic-base] "Unsupported base for class created via `make_dataclass()`" +C = make_dataclass("C", [("x", int)], bases=(Protocol,)) +``` + +### Final class base + +Cannot inherit from a `@final` class. + +```py +from dataclasses import make_dataclass +from typing import final + +@final +class FinalClass: + pass + +# error: [subclass-of-final-class] "Class `D` cannot inherit from final class `FinalClass`" +D = make_dataclass("D", [("x", int)], bases=(FinalClass,)) +``` + +### Enum base + +Creating an enum class via `make_dataclass()` is not supported. + +```py +from dataclasses import make_dataclass +from enum import Enum + +# error: [invalid-base] "Invalid base for class created via `make_dataclass()`" +E = make_dataclass("E", [("x", int)], bases=(Enum,)) +``` + +## Deferred evaluation + +### String annotations (forward references) + +String annotations (forward references) are properly evaluated to types: + +```py +from dataclasses import make_dataclass + +Point = make_dataclass("Point", [("x", "int"), ("y", "int")]) +p = Point(1, 2) + +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int +``` + +### Recursive references + +Recursive references in functional syntax are supported: + +```py +from dataclasses import make_dataclass + +Node = make_dataclass("Node", [("value", int), ("next", "Node | None")]) +n = Node(1, None) + +reveal_type(n.value) # revealed: int +reveal_type(n.next) # revealed: Node | None +``` + +### Mutually recursive types + +Mutually recursive types work correctly: + +```py +from dataclasses import make_dataclass + +A = make_dataclass("A", [("x", "B | None")]) +B = make_dataclass("B", [("x", "C")]) +C = make_dataclass("C", [("x", A)]) + +a = A(x=B(x=C(x=A(x=None)))) + +reveal_type(a.x) # revealed: B | None + +if a.x is not None: + reveal_type(a.x) # revealed: B + reveal_type(a.x.x) # revealed: C + reveal_type(a.x.x.x) # revealed: A + reveal_type(a.x.x.x.x) # revealed: B | None + +A(x=42) # error: [invalid-argument-type] + +# error: [invalid-argument-type] +# error: [missing-argument] +A(x=C()) + +# error: [invalid-argument-type] +A(x=C(x=A(x=None))) +``` + +### Complex recursive type with generics + +String annotations work with generic types: + +```py +from dataclasses import make_dataclass + +TreeNode = make_dataclass("TreeNode", [("value", int), ("children", "list[TreeNode]")]) + +t = TreeNode(1, []) +reveal_type(t.value) # revealed: int +reveal_type(t.children) # revealed: list[TreeNode] +``` + +### make_dataclass as base class with forward references + +When `make_dataclass` is used as a base class for a static class, forward references to the outer +class are resolved: + +```py +from dataclasses import make_dataclass + +class X(make_dataclass("XBase", [("next", "X | None")])): + pass + +x = X(next=None) +reveal_type(x.next) # revealed: X | None + +# Recursive construction works +x2 = X(next=X(next=None)) +reveal_type(x2.next) # revealed: X | None +``` + +## Edge cases + +### Empty fields list + +A dataclass with no fields is valid: + +```py +from dataclasses import make_dataclass + +Empty = make_dataclass("Empty", []) + +e = Empty() +reveal_type(e) # revealed: Empty + +# No fields, so __init__ takes no arguments +# error: [too-many-positional-arguments] +Empty(1) +``` + +### Equality methods with `eq=True` (default) + +```py +from dataclasses import make_dataclass + +PointEq = make_dataclass("PointEq", [("x", int), ("y", int)]) + +p1 = PointEq(1, 2) +p2 = PointEq(1, 2) +p3 = PointEq(3, 4) + +# __eq__ is synthesized +reveal_type(p1 == p2) # revealed: bool +reveal_type(p1 == p3) # revealed: bool + +# __ne__ is also available (from object, but works correctly) +reveal_type(p1 != p2) # revealed: bool +``` + +### `namespace` parameter + +The `namespace` parameter allows adding custom attributes/methods to the class. + +```py +from dataclasses import make_dataclass + +def custom_method(self) -> str: + return f"Point({self.x}, {self.y})" + +PointWithMethod = make_dataclass( + "PointWithMethod", + [("x", int), ("y", int)], + namespace={"describe": custom_method, "version": 1}, +) + +p = PointWithMethod(1, 2) +reveal_type(p.x) # revealed: int + +# TODO: We don't currently track namespace additions, so these are unresolved +# error: [unresolved-attribute] +p.describe() + +# error: [unresolved-attribute] +reveal_type(p.version) # revealed: Unknown +``` + +### Single field + +```py +from dataclasses import make_dataclass + +Single = make_dataclass("Single", [("value", int)]) + +s = Single(42) +reveal_type(s.value) # revealed: int +reveal_type(Single.__init__) # revealed: (self: Single, value: int) -> None +``` + +### Many fields + +```py +from dataclasses import make_dataclass + +ManyFields = make_dataclass( + "ManyFields", + [ + ("a", int), + ("b", str), + ("c", float), + ("d", bool), + ("e", list), + ], +) + +m = ManyFields(1, "hello", 3.14, True, []) +reveal_type(m.a) # revealed: int +reveal_type(m.b) # revealed: str +reveal_type(m.c) # revealed: int | float +reveal_type(m.d) # revealed: bool +reveal_type(m.e) # revealed: list[Unknown] +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index ac325eb3680b1..e80b5f22e9203 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -49,7 +49,7 @@ use crate::suppression::check_suppressions; use crate::types::bound_super::BoundSuperType; use crate::types::builder::RecursivelyDefined; use crate::types::call::{Binding, Bindings, CallArguments, CallableBinding}; -use crate::types::class::NamedTupleSpec; +use crate::types::class::{DataclassSpec, NamedTupleSpec}; pub(crate) use crate::types::class_base::ClassBase; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; use crate::types::context::{LintDiagnosticGuard, LintDiagnosticGuardBuilder}; @@ -5601,6 +5601,10 @@ impl<'db> Type<'db> { invalid_expressions: smallvec_inline![InvalidTypeExpression::NamedTupleSpec], fallback_type: Type::unknown(), }), + KnownInstanceType::DataclassSpec(_) => Err(InvalidTypeExpressionError { + invalid_expressions: smallvec_inline![InvalidTypeExpression::DataclassSpec], + fallback_type: Type::unknown(), + }), KnownInstanceType::UnionType(instance) => { // Cloning here is cheap if the result is a `Type` (which is `Copy`). It's more // expensive if there are errors. @@ -6024,6 +6028,7 @@ impl<'db> Type<'db> { KnownInstanceType::Literal(_) | KnownInstanceType::LiteralStringAlias(_) | KnownInstanceType::NamedTupleSpec(_) | + KnownInstanceType::DataclassSpec(_) | KnownInstanceType::NewType(_) => { // TODO: For some of these, we may need to apply the type mapping to inner types. self @@ -6432,6 +6437,7 @@ impl<'db> Type<'db> { | KnownInstanceType::Literal(_) | KnownInstanceType::LiteralStringAlias(_) | KnownInstanceType::NamedTupleSpec(_) + | KnownInstanceType::DataclassSpec(_) | KnownInstanceType::NewType(_) => { // TODO: For some of these, we may need to try to find legacy typevars in inner types. } @@ -7102,6 +7108,9 @@ pub enum KnownInstanceType<'db> { /// The inferred spec for a functional `NamedTuple` class. NamedTupleSpec(NamedTupleSpec<'db>), + + /// The inferred spec for a functional `make_dataclass` class. + DataclassSpec(DataclassSpec<'db>), } fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( @@ -7153,6 +7162,14 @@ fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( visitor.visit_type(db, field.ty); } } + KnownInstanceType::DataclassSpec(spec) => { + for field in spec.fields(db) { + visitor.visit_type(db, field.ty); + if let Some(default) = field.default_ty { + visitor.visit_type(db, default); + } + } + } } } @@ -7194,6 +7211,7 @@ impl<'db> KnownInstanceType<'db> { .map_base_class_type(db, |class_type| class_type.normalized_impl(db, visitor)), ), Self::NamedTupleSpec(spec) => Self::NamedTupleSpec(spec.normalized_impl(db, visitor)), + Self::DataclassSpec(spec) => Self::DataclassSpec(spec.normalized_impl(db, visitor)), Self::Deprecated(_) | Self::ConstraintSet(_) | Self::GenericContext(_) @@ -7253,6 +7271,9 @@ impl<'db> KnownInstanceType<'db> { Self::NamedTupleSpec(spec) => spec .recursive_type_normalized_impl(db, div, true) .map(Self::NamedTupleSpec), + Self::DataclassSpec(spec) => spec + .recursive_type_normalized_impl(db, div, true) + .map(Self::DataclassSpec), } } @@ -7279,7 +7300,7 @@ impl<'db> KnownInstanceType<'db> { | Self::Callable(_) => KnownClass::GenericAlias, Self::LiteralStringAlias(_) => KnownClass::Str, Self::NewType(_) => KnownClass::NewType, - Self::NamedTupleSpec(_) => KnownClass::Sequence, + Self::NamedTupleSpec(_) | Self::DataclassSpec(_) => KnownClass::Sequence, } } @@ -7553,6 +7574,8 @@ enum InvalidTypeExpression<'db> { Specialization, /// Same for `NamedTupleSpec` NamedTupleSpec, + /// Same for `DataclassSpec` + DataclassSpec, /// Same for `typing.TypedDict` TypedDict, /// Same for `typing.TypeAlias`, anywhere except for as the sole annotation on an annotated @@ -7614,6 +7637,9 @@ impl<'db> InvalidTypeExpression<'db> { InvalidTypeExpression::NamedTupleSpec => { f.write_str("`NamedTupleSpec` is not allowed in type expressions") } + InvalidTypeExpression::DataclassSpec => { + f.write_str("`DataclassSpec` is not allowed in type expressions") + } InvalidTypeExpression::TypedDict => f.write_str( "The special form `typing.TypedDict` \ is not allowed in type expressions", diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 6266187d4c19d..8b305f2117c3b 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -197,6 +197,7 @@ impl<'db> CodeGeneratorKind<'db> { } ClassLiteral::Dynamic(dynamic_class) => Self::from_dynamic_class(db, dynamic_class), ClassLiteral::DynamicNamedTuple(_) => Some(Self::NamedTuple), + ClassLiteral::DynamicDataclass(_) => Some(Self::DataclassLike(None)), } } @@ -468,6 +469,8 @@ pub enum ClassLiteral<'db> { Dynamic(DynamicClassLiteral<'db>), /// A class created via `collections.namedtuple()` or `typing.NamedTuple()`. DynamicNamedTuple(DynamicNamedTupleLiteral<'db>), + /// A class created via `dataclasses.make_dataclass()`. + DynamicDataclass(DynamicDataclassLiteral<'db>), } impl<'db> ClassLiteral<'db> { @@ -477,6 +480,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.name(db), Self::Dynamic(class) => class.name(db), Self::DynamicNamedTuple(namedtuple) => namedtuple.name(db), + Self::DynamicDataclass(dataclass) => dataclass.name(db), } } @@ -502,6 +506,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.metaclass(db), Self::Dynamic(class) => class.metaclass(db), Self::DynamicNamedTuple(namedtuple) => namedtuple.metaclass(db), + Self::DynamicDataclass(dataclass) => dataclass.metaclass(db), } } @@ -516,6 +521,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.class_member(db, name, policy), Self::Dynamic(class) => class.class_member(db, name, policy), Self::DynamicNamedTuple(namedtuple) => namedtuple.class_member(db, name, policy), + Self::DynamicDataclass(dataclass) => dataclass.class_member(db, name, policy), } } @@ -531,7 +537,7 @@ impl<'db> ClassLiteral<'db> { ) -> PlaceAndQualifiers<'db> { match self { Self::Static(class) => class.class_member_from_mro(db, name, policy, mro_iter), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => { + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => { // Dynamic classes don't have inherited generic context and are never `object`. let result = MroLookup::new(db, mro_iter).class_member(name, policy, None, false); match result { @@ -557,7 +563,9 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn default_specialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.default_specialization(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => { + ClassType::NonGeneric(self) + } } } @@ -565,7 +573,9 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn identity_specialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.identity_specialization(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => { + ClassType::NonGeneric(self) + } } } @@ -583,7 +593,7 @@ impl<'db> ClassLiteral<'db> { pub fn is_typed_dict(self, db: &'db dyn Db) -> bool { match self { Self::Static(class) => class.is_typed_dict(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => false, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => false, } } @@ -591,7 +601,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn is_tuple(self, db: &'db dyn Db) -> bool { match self { Self::Static(class) => class.is_tuple(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => false, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => false, } } @@ -614,6 +624,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.file(db), Self::Dynamic(class) => class.scope(db).file(db), Self::DynamicNamedTuple(class) => class.scope(db).file(db), + Self::DynamicDataclass(class) => class.scope(db).file(db), } } @@ -626,6 +637,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.header_range(db), Self::Dynamic(class) => class.header_range(db), Self::DynamicNamedTuple(class) => class.header_range(db), + Self::DynamicDataclass(class) => class.header_range(db), } } @@ -640,8 +652,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.is_final(db), // Dynamic classes created via `type()`, `collections.namedtuple()`, etc. cannot be // marked as final. - Self::Dynamic(_) => false, - Self::DynamicNamedTuple(_) => false, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => false, } } @@ -659,6 +670,10 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.has_own_ordering_method(db), Self::Dynamic(class) => class.has_own_ordering_method(db), Self::DynamicNamedTuple(_) => false, + Self::DynamicDataclass(dataclass) => dataclass + .dataclass_params(db) + .flags(db) + .contains(DataclassFlags::ORDER), } } @@ -666,7 +681,23 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn as_static(self) -> Option> { match self { Self::Static(class) => Some(class), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => None, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => None, + } + } + + /// Returns the dynamic dataclass definition if this is one. + pub(crate) fn as_dynamic_dataclass(self) -> Option> { + match self { + Self::DynamicDataclass(dataclass) => Some(dataclass), + Self::Static(_) | Self::Dynamic(_) | Self::DynamicNamedTuple(_) => None, + } + } + + /// Returns the dynamic class definition (from `type()` call) if this is one. + pub(crate) fn as_dynamic(self) -> Option> { + match self { + Self::Dynamic(class) => Some(class), + Self::Static(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => None, } } @@ -676,6 +707,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => Some(class.definition(db)), Self::Dynamic(class) => class.definition(db), Self::DynamicNamedTuple(namedtuple) => namedtuple.definition(db), + Self::DynamicDataclass(dataclass) => dataclass.definition(db), } } @@ -684,13 +716,14 @@ impl<'db> ClassLiteral<'db> { /// For static classes, returns `TypeDefinition::StaticClass`. /// For dynamic classes, returns `TypeDefinition::DynamicClass` if a definition is available. pub(crate) fn type_definition(self, db: &'db dyn Db) -> Option> { - match self { - Self::Static(class) => Some(TypeDefinition::StaticClass(class.definition(db))), - Self::Dynamic(class) => class.definition(db).map(TypeDefinition::DynamicClass), - Self::DynamicNamedTuple(namedtuple) => { - namedtuple.definition(db).map(TypeDefinition::DynamicClass) - } - } + let definition = match self { + Self::Dynamic(class) => class.definition(db), + Self::DynamicNamedTuple(namedtuple) => namedtuple.definition(db), + Self::DynamicDataclass(dataclass) => dataclass.definition(db), + Self::Static(class) => return Some(TypeDefinition::StaticClass(class.definition(db))), + }; + + definition.map(TypeDefinition::DynamicClass) } /// Returns the qualified name of this class. @@ -707,6 +740,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.header_span(db), Self::Dynamic(class) => class.header_span(db), Self::DynamicNamedTuple(namedtuple) => namedtuple.header_span(db), + Self::DynamicDataclass(dataclass) => dataclass.header_span(db), } } @@ -734,6 +768,22 @@ impl<'db> ClassLiteral<'db> { // Dynamic namedtuples define `__slots__ = ()`, but `__slots__` must be // non-empty for a class to be a disjoint base. Self::DynamicNamedTuple(_) => None, + // Dynamic dataclasses can define `__slots__` if `slots=True`. + // However, empty slots (dataclass with no fields) are not disjoint bases. + Self::DynamicDataclass(dataclass) => { + let has_slots = dataclass + .dataclass_params(db) + .flags(db) + .contains(DataclassFlags::SLOTS); + let has_fields = !dataclass.fields(db).is_empty(); + if has_slots && has_fields { + Some(DisjointBase::due_to_dunder_slots( + ClassLiteral::DynamicDataclass(dataclass), + )) + } else { + None + } + } } } @@ -741,7 +791,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn to_non_generic_instance(self, db: &'db dyn Db) -> Type<'db> { match self { Self::Static(class) => class.to_non_generic_instance(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => { + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => { Type::instance(db, ClassType::NonGeneric(self)) } } @@ -764,7 +814,9 @@ impl<'db> ClassLiteral<'db> { ) -> ClassType<'db> { match self { Self::Static(class) => class.apply_specialization(db, f), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => { + ClassType::NonGeneric(self) + } } } @@ -779,6 +831,7 @@ impl<'db> ClassLiteral<'db> { Self::Static(class) => class.instance_member(db, specialization, name), Self::Dynamic(class) => class.instance_member(db, name), Self::DynamicNamedTuple(namedtuple) => namedtuple.instance_member(db, name), + Self::DynamicDataclass(dataclass) => dataclass.instance_member(db, name), } } @@ -786,7 +839,9 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn top_materialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.top_materialization(db), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => { + ClassType::NonGeneric(self) + } } } @@ -800,7 +855,9 @@ impl<'db> ClassLiteral<'db> { ) -> PlaceAndQualifiers<'db> { match self { Self::Static(class) => class.typed_dict_member(db, specialization, name, policy), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => Place::Undefined.into(), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => { + Place::Undefined.into() + } } } @@ -815,7 +872,7 @@ impl<'db> ClassLiteral<'db> { Self::Dynamic(class) => { Self::Dynamic(class.with_dataclass_params(db, dataclass_params)) } - Self::DynamicNamedTuple(_) => self, + Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => self, } } } @@ -838,6 +895,12 @@ impl<'db> From> for ClassLiteral<'db> { } } +impl<'db> From> for ClassLiteral<'db> { + fn from(literal: DynamicDataclassLiteral<'db>) -> Self { + ClassLiteral::DynamicDataclass(literal) + } +} + /// Represents a class type, which might be a non-generic class, or a specialization of a generic /// class. #[derive( @@ -930,7 +993,11 @@ impl<'db> ClassType<'db> { ) -> Option<(StaticClassLiteral<'db>, Option>)> { match self { Self::NonGeneric(ClassLiteral::Static(class)) => Some((class, None)), - Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => None, + Self::NonGeneric( + ClassLiteral::Dynamic(_) + | ClassLiteral::DynamicNamedTuple(_) + | ClassLiteral::DynamicDataclass(_), + ) => None, Self::Generic(generic) => Some((generic.origin(db), Some(generic.specialization(db)))), } } @@ -944,7 +1011,11 @@ impl<'db> ClassType<'db> { ) -> Option<(StaticClassLiteral<'db>, Option>)> { match self { Self::NonGeneric(ClassLiteral::Static(class)) => Some((class, None)), - Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => None, + Self::NonGeneric( + ClassLiteral::Dynamic(_) + | ClassLiteral::DynamicNamedTuple(_) + | ClassLiteral::DynamicDataclass(_), + ) => None, Self::Generic(generic) => Some(( generic.origin(db), Some( @@ -1461,6 +1532,9 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { return namedtuple.own_class_member(db, name); } + Self::NonGeneric(ClassLiteral::DynamicDataclass(dataclass)) => { + return dataclass.own_class_member(db, name); + } Self::NonGeneric(ClassLiteral::Static(class)) => (class, None), Self::Generic(generic) => (generic.origin(db), Some(generic.specialization(db))), }; @@ -1754,6 +1828,9 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { namedtuple.instance_member(db, name) } + Self::NonGeneric(ClassLiteral::DynamicDataclass(dataclass)) => { + dataclass.instance_member(db, name) + } Self::NonGeneric(ClassLiteral::Static(class)) => { if class.is_typed_dict(db) { return Place::Undefined.into(); @@ -1785,6 +1862,9 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { namedtuple.own_instance_member(db, name) } + Self::NonGeneric(ClassLiteral::DynamicDataclass(dataclass)) => { + dataclass.own_instance_member(db, name) + } Self::NonGeneric(ClassLiteral::Static(class_literal)) => { class_literal.own_instance_member(db, name) } @@ -2090,9 +2170,11 @@ impl<'db> VarianceInferable<'db> for ClassType<'db> { fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarVariance { match self { Self::NonGeneric(ClassLiteral::Static(class)) => class.variance_of(db, typevar), - Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => { - TypeVarVariance::Bivariant - } + Self::NonGeneric( + ClassLiteral::Dynamic(_) + | ClassLiteral::DynamicNamedTuple(_) + | ClassLiteral::DynamicDataclass(_), + ) => TypeVarVariance::Bivariant, Self::Generic(generic) => generic.variance_of(db, typevar), } } @@ -2346,6 +2428,13 @@ impl<'db> StaticClassLiteral<'db> { } // Dynamic namedtuples don't define their own ordering methods. ClassLiteral::DynamicNamedTuple(_) => {} + // Dynamic dataclasses can have ordering methods if order=True. + ClassLiteral::DynamicDataclass(dataclass) => { + let member = dataclass.own_class_member(db, name); + if let Some(ty) = member.ignore_possibly_undefined() { + return Some(ty); + } + } } } } @@ -2802,7 +2891,7 @@ impl<'db> StaticClassLiteral<'db> { .filter_map(ClassBase::into_class) .any(|base| match base.class_literal(db) { ClassLiteral::DynamicNamedTuple(_) => true, - ClassLiteral::Dynamic(_) => false, + ClassLiteral::Dynamic(_) | ClassLiteral::DynamicDataclass(_) => false, ClassLiteral::Static(class) => class .explicit_bases(db) .contains(&Type::SpecialForm(SpecialFormType::NamedTuple)), @@ -2870,6 +2959,17 @@ impl<'db> StaticClassLiteral<'db> { (dataclass_params, transformer_params) } + /// Returns the combined dataclass flags from both `dataclass_params` and `transformer_params`. + fn combined_dataclass_flags( + self, + db: &'db dyn Db, + field_policy: CodeGeneratorKind<'db>, + ) -> DataclassFlags { + let (dataclass_params, transformer_params) = self.merged_dataclass_params(db, field_policy); + dataclass_params.map_or(DataclassFlags::empty(), |p| p.flags(db)) + | transformer_params.map_or(DataclassFlags::empty(), |p| p.flags(db)) + } + /// Checks if the given dataclass parameter flag is set for this class. /// This checks both the `dataclass_params` and `transformer_params`. fn has_dataclass_param( @@ -2878,9 +2978,8 @@ impl<'db> StaticClassLiteral<'db> { field_policy: CodeGeneratorKind<'db>, param: DataclassFlags, ) -> bool { - let (dataclass_params, transformer_params) = self.merged_dataclass_params(db, field_policy); - dataclass_params.is_some_and(|params| params.flags(db).contains(param)) - || transformer_params.is_some_and(|params| params.flags(db).contains(param)) + self.combined_dataclass_flags(db, field_policy) + .contains(param) } /// Return the explicit `metaclass` of this class, if one is defined. @@ -3281,6 +3380,8 @@ impl<'db> StaticClassLiteral<'db> { let field_policy = CodeGeneratorKind::from_class(db, self.into(), specialization)?; + let has_dataclass_param = |param| self.has_dataclass_param(db, field_policy, param); + let instance_ty = Type::instance(db, self.apply_optional_specialization(db, specialization)); @@ -3457,74 +3558,40 @@ impl<'db> StaticClassLiteral<'db> { specialization.map(|s| s.generic_context(db)), ) } - (CodeGeneratorKind::DataclassLike(_), "__lt__" | "__le__" | "__gt__" | "__ge__") => { - if !self.has_dataclass_param(db, field_policy, DataclassFlags::ORDER) { - return None; - } - - let signature = Signature::new( - Parameters::new( - db, - [ - Parameter::positional_or_keyword(Name::new_static("self")) - // TODO: could be `Self`. - .with_annotated_type(instance_ty), - Parameter::positional_or_keyword(Name::new_static("other")) - // TODO: could be `Self`. - .with_annotated_type(instance_ty), - ], - ), - KnownClass::Bool.to_instance(db), - ); - - Some(Type::function_like_callable(db, signature)) - } - (CodeGeneratorKind::DataclassLike(_), "__hash__") => { - let unsafe_hash = - self.has_dataclass_param(db, field_policy, DataclassFlags::UNSAFE_HASH); - let frozen = self.has_dataclass_param(db, field_policy, DataclassFlags::FROZEN); - let eq = self.has_dataclass_param(db, field_policy, DataclassFlags::EQ); - - if unsafe_hash || (frozen && eq) { - let signature = Signature::new( - Parameters::new( - db, - [Parameter::positional_or_keyword(Name::new_static("self")) - .with_annotated_type(instance_ty)], - ), - KnownClass::Int.to_instance(db), - ); - - Some(Type::function_like_callable(db, signature)) - } else if eq && !frozen { - Some(Type::none(db)) - } else { - // No `__hash__` is generated, fall back to `object.__hash__` - None - } - } + ( + CodeGeneratorKind::DataclassLike(_), + "__lt__" | "__le__" | "__gt__" | "__ge__" | "__hash__", + ) => synthesize_dataclass_dunder_method( + db, + name, + instance_ty, + self.combined_dataclass_flags(db, field_policy), + ), (CodeGeneratorKind::DataclassLike(_), "__match_args__") if Program::get(db).python_version(db) >= PythonVersion::PY310 => { - if !self.has_dataclass_param(db, field_policy, DataclassFlags::MATCH_ARGS) { - return None; - } - - let kw_only_default = - self.has_dataclass_param(db, field_policy, DataclassFlags::KW_ONLY); - + let kw_only_default = has_dataclass_param(DataclassFlags::KW_ONLY); let fields = self.fields(db, specialization, field_policy); - let match_args = fields - .iter() - .filter(|(_, field)| { - if let FieldKind::Dataclass { init, kw_only, .. } = &field.kind { - *init && !kw_only.unwrap_or(kw_only_default) - } else { - false - } - }) - .map(|(name, _)| Type::string_literal(db, name)); - Some(Type::heterogeneous_tuple(db, match_args)) + let fields_iter = fields.iter().filter_map(|(name, field)| { + if let FieldKind::Dataclass { init, kw_only, .. } = &field.kind { + Some(DataclassFieldInfo { + name: name.clone(), + ty: field.declared_ty, + default_ty: None, + init: *init, + kw_only: kw_only.unwrap_or(kw_only_default), + }) + } else { + None + } + }); + synthesize_dataclass_class_member( + db, + name, + instance_ty, + self.combined_dataclass_flags(db, field_policy), + fields_iter, + ) } (CodeGeneratorKind::DataclassLike(_), "__weakref__") if Program::get(db).python_version(db) >= PythonVersion::PY311 => @@ -3573,23 +3640,12 @@ impl<'db> StaticClassLiteral<'db> { signature_from_fields(vec![self_parameter], instance_ty) } (CodeGeneratorKind::DataclassLike(_), "__setattr__") => { - if self.has_dataclass_param(db, field_policy, DataclassFlags::FROZEN) { - let signature = Signature::new( - Parameters::new( - db, - [ - Parameter::positional_or_keyword(Name::new_static("self")) - .with_annotated_type(instance_ty), - Parameter::positional_or_keyword(Name::new_static("name")), - Parameter::positional_or_keyword(Name::new_static("value")), - ], - ), - Type::Never, - ); - - return Some(Type::function_like_callable(db, signature)); - } - None + synthesize_dataclass_dunder_method( + db, + name, + instance_ty, + self.combined_dataclass_flags(db, field_policy), + ) } (CodeGeneratorKind::DataclassLike(_), "__slots__") if Program::get(db).python_version(db) >= PythonVersion::PY310 => @@ -4206,7 +4262,7 @@ impl<'db> StaticClassLiteral<'db> { kw_only_sentinel_field_seen = true; } - // If no explicit kw_only setting and we've seen KW_ONLY sentinel, mark as keyword-only + // If no explicit `kw_only` setting and we've seen `KW_ONLY` sentinel, mark as keyword-only if kw_only_sentinel_field_seen { if let FieldKind::Dataclass { kw_only: ref mut kw @ None, @@ -4217,8 +4273,8 @@ impl<'db> StaticClassLiteral<'db> { } } - // Resolve the kw_only to the class-level default. This ensures that when fields - // are inherited by child classes, they use their defining class's kw_only default. + // Resolve the `kw_only` to the class-level default. This ensures that when fields + // are inherited by child classes, they use their defining class's `kw_only` default. if let FieldKind::Dataclass { kw_only: ref mut kw @ None, .. @@ -4991,7 +5047,9 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> { fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarVariance { match self { Self::Static(class) => class.variance_of(db, typevar), - Self::Dynamic(_) | Self::DynamicNamedTuple(_) => TypeVarVariance::Bivariant, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) | Self::DynamicDataclass(_) => { + TypeVarVariance::Bivariant + } } } } @@ -5323,7 +5381,7 @@ impl<'db> DynamicClassLiteral<'db> { /// an error (duplicate bases or C3 linearization failure). #[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] pub(crate) fn try_mro(self, db: &'db dyn Db) -> Result, DynamicMroError<'db>> { - Mro::of_dynamic_class(db, self) + Mro::of_dynamic(db, self.into()) } /// Return `Some()` if this dynamic class is known to be a [`DisjointBase`]. @@ -5415,6 +5473,158 @@ fn create_field_property<'db>(db: &'db dyn Db, field_ty: Type<'db>) -> Type<'db> Type::PropertyInstance(property) } +/// Field information for synthesizing dataclass methods. +#[derive(Debug, Clone)] +struct DataclassFieldInfo<'db> { + /// The field name (or alias if provided). + name: Name, + /// The declared type of the field. + ty: Type<'db>, + /// The default value type, if any. + default_ty: Option>, + /// Whether this field should be included in `__init__`. + init: bool, + /// Whether this field is keyword-only. + kw_only: bool, +} + +/// Synthesize a dataclass class member given the dataclass flags, instance type, and fields. +fn synthesize_dataclass_class_member<'db>( + db: &'db dyn Db, + name: &str, + instance_ty: Type<'db>, + flags: DataclassFlags, + fields: impl Iterator>, +) -> Option> { + match name { + "__init__" if flags.contains(DataclassFlags::INIT) => { + let mut parameters = vec![ + Parameter::positional_or_keyword(Name::new_static("self")) + .with_annotated_type(instance_ty), + ]; + + for field in fields { + if !field.init { + continue; + } + let mut param = if field.kw_only { + Parameter::keyword_only(field.name) + } else { + Parameter::positional_or_keyword(field.name) + }; + param = param.with_annotated_type(field.ty); + if let Some(default) = field.default_ty { + param = param.with_default_type(default); + } + parameters.push(param); + } + + let signature = Signature::new(Parameters::new(db, parameters), Type::none(db)); + Some(Type::function_like_callable(db, signature)) + } + "__match_args__" if flags.contains(DataclassFlags::MATCH_ARGS) => { + // __match_args__ includes only fields that are in __init__ and not keyword-only + let match_args = fields + .filter(|field| field.init && !field.kw_only) + .map(|field| Type::string_literal(db, &field.name)); + Some(Type::heterogeneous_tuple(db, match_args)) + } + _ => synthesize_dataclass_dunder_method(db, name, instance_ty, flags), + } +} + +/// Synthesize a dataclass dunder method that doesn't require field information. +fn synthesize_dataclass_dunder_method<'db>( + db: &'db dyn Db, + name: &str, + instance_ty: Type<'db>, + flags: DataclassFlags, +) -> Option> { + match name { + "__setattr__" if flags.contains(DataclassFlags::FROZEN) => { + // Frozen dataclasses have `__setattr__` that returns `Never` (immutable). + let signature = Signature::new( + Parameters::new( + db, + [ + Parameter::positional_or_keyword(Name::new_static("self")) + .with_annotated_type(instance_ty), + Parameter::positional_or_keyword(Name::new_static("name")), + Parameter::positional_or_keyword(Name::new_static("value")), + ], + ), + Type::Never, + ); + Some(Type::function_like_callable(db, signature)) + } + "__lt__" | "__le__" | "__gt__" | "__ge__" if flags.contains(DataclassFlags::ORDER) => { + // Ordering methods: (self, other: Self) -> bool + let signature = Signature::new( + Parameters::new( + db, + [ + Parameter::positional_or_keyword(Name::new_static("self")) + // TODO: could be `Self`. + .with_annotated_type(instance_ty), + Parameter::positional_or_keyword(Name::new_static("other")) + // TODO: could be `Self`. + .with_annotated_type(instance_ty), + ], + ), + KnownClass::Bool.to_instance(db), + ); + Some(Type::function_like_callable(db, signature)) + } + "__hash__" => { + let has_hash = flags.contains(DataclassFlags::UNSAFE_HASH) + || (flags.contains(DataclassFlags::FROZEN) && flags.contains(DataclassFlags::EQ)); + if has_hash { + let signature = Signature::new( + Parameters::new( + db, + [Parameter::positional_or_keyword(Name::new_static("self")) + .with_annotated_type(instance_ty)], + ), + KnownClass::Int.to_instance(db), + ); + Some(Type::function_like_callable(db, signature)) + } else if flags.contains(DataclassFlags::EQ) && !flags.contains(DataclassFlags::FROZEN) + { + // eq=True without frozen=True sets __hash__ to None + Some(Type::none(db)) + } else { + // No __hash__ is generated, fall back to object.__hash__ + None + } + } + "__eq__" if flags.contains(DataclassFlags::EQ) => { + // __eq__(self, other: object) -> bool + let signature = Signature::new( + Parameters::new( + db, + [ + Parameter::positional_or_keyword(Name::new_static("self")) + .with_annotated_type(instance_ty), + Parameter::positional_or_keyword(Name::new_static("other")) + .with_annotated_type(KnownClass::Object.to_instance(db)), + ], + ), + KnownClass::Bool.to_instance(db), + ); + Some(Type::function_like_callable(db, signature)) + } + "__dataclass_fields__" => { + // __dataclass_fields__: dict[str, Field[Any]] + let field_any = KnownClass::Field.to_specialized_instance(db, &[Type::any()]); + Some( + KnownClass::Dict + .to_specialized_instance(db, &[KnownClass::Str.to_instance(db), field_any]), + ) + } + _ => None, + } +} + /// Synthesize a namedtuple class member given the field information. /// /// This is used by both `DynamicNamedTupleLiteral` and `StaticClassLiteral` (for declarative @@ -5988,6 +6198,514 @@ impl<'db> NamedTupleSpec<'db> { impl get_size2::GetSize for NamedTupleSpec<'_> {} +/// A single field in a dynamic `make_dataclass` class. +#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +pub struct DataclassFieldSpec<'db> { + /// The field name. + pub name: Name, + /// The field type. + pub ty: Type<'db>, + /// The default value type, if any. + pub default_ty: Option>, + /// Whether this field should be included in `__init__`. + pub init: bool, + /// Whether this field is keyword-only. + pub kw_only: Option, + /// The alias name for this field (for use in `__init__`), if any. + pub alias: Option, +} + +/// A specification describing the fields and bases of a dynamic `make_dataclass` class. +/// +/// # Ordering +/// +/// Ordering is based on the spec's salsa-assigned id and not on its values. +/// The id may change between runs, or when the spec was garbage collected and recreated. +#[salsa::interned(debug, heap_size = ruff_memory_usage::heap_size)] +#[derive(PartialOrd, Ord)] +pub struct DataclassSpec<'db> { + /// The fields with their full metadata. + #[returns(deref)] + pub(crate) fields: Box<[DataclassFieldSpec<'db>]>, + + /// Whether the fields are known statically. + pub(crate) has_known_fields: bool, + + /// The base classes (from the `bases` keyword argument). + #[returns(deref)] + pub(crate) bases: Box<[ClassBase<'db>]>, +} + +impl<'db> DataclassSpec<'db> { + /// Create a [`DataclassSpec`] with the given fields and bases. + pub(crate) fn known( + db: &'db dyn Db, + fields: Box<[DataclassFieldSpec<'db>]>, + bases: Box<[ClassBase<'db>]>, + ) -> Self { + Self::new(db, fields, true, bases) + } + + /// Create a [`DataclassSpec`] that indicates a dataclass has unknown fields. + pub(crate) fn unknown(db: &'db dyn Db) -> Self { + Self::new(db, Box::default(), false, Box::default()) + } + + pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { + let fields: Box<_> = self + .fields(db) + .iter() + .map(|field| DataclassFieldSpec { + name: field.name.clone(), + ty: field.ty.normalized_impl(db, visitor), + default_ty: field.default_ty.map(|d| d.normalized_impl(db, visitor)), + init: field.init, + kw_only: field.kw_only, + alias: field.alias.clone(), + }) + .collect(); + + let bases: Box<_> = self + .bases(db) + .iter() + .map(|base| base.normalized_impl(db, visitor)) + .collect(); + + Self::new(db, fields, self.has_known_fields(db), bases) + } + + pub(crate) fn recursive_type_normalized_impl( + self, + db: &'db dyn Db, + div: Type<'db>, + nested: bool, + ) -> Option { + let fields = self + .fields(db) + .iter() + .map(|field| { + let normalized_ty = if nested { + field.ty.recursive_type_normalized_impl(db, div, nested)? + } else { + field + .ty + .recursive_type_normalized_impl(db, div, nested) + .unwrap_or(div) + }; + let normalized_default = match field.default_ty { + Some(d) => Some(d.recursive_type_normalized_impl(db, div, nested)?), + None => None, + }; + Some(DataclassFieldSpec { + name: field.name.clone(), + ty: normalized_ty, + default_ty: normalized_default, + init: field.init, + kw_only: field.kw_only, + alias: field.alias.clone(), + }) + }) + .collect::>>()?; + + let bases = self + .bases(db) + .iter() + .map(|base| base.recursive_type_normalized_impl(db, div, nested)) + .collect::>>()?; + + Some(Self::new(db, fields, self.has_known_fields(db), bases)) + } +} + +impl get_size2::GetSize for DataclassSpec<'_> {} + +/// Anchor for identifying a dynamic `make_dataclass` class literal. +/// +/// This enum provides stable identity for `DynamicDataclassLiteral` instances: +/// - For assigned calls, the `Definition` uniquely identifies the class. +/// The spec is computed lazily to support forward references. +/// - For dangling calls, a relative offset and eagerly-computed spec provide stable identity. +#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +pub enum DynamicDataclassAnchor<'db> { + /// We're dealing with a `make_dataclass()` call that's assigned to a variable. + /// + /// The `Definition` uniquely identifies this class. The `make_dataclass()` + /// call expression is the `value` of the assignment, so we can get its + /// range from the definition. + /// + /// The spec is NOT stored here - it is computed lazily via `deferred_spec()` + /// to support forward references and recursive types in field type annotations. + Definition(Definition<'db>), + + /// We're dealing with a `make_dataclass()` call that is "dangling" + /// (not assigned to a variable). + /// + /// The offset is relative to the enclosing scope's anchor node index. + /// For module scope, this is equivalent to an absolute index (anchor is 0). + /// + /// Dangling calls always store the spec eagerly because all class bases + /// are deferred in their entirety during type inference. + ScopeOffset { + scope: ScopeId<'db>, + offset: u32, + spec: DataclassSpec<'db>, + }, +} + +/// A dataclass created via the functional form `make_dataclass(name, fields, ...)`. +/// +/// For example: +/// ```python +/// from dataclasses import make_dataclass +/// Point = make_dataclass("Point", [("x", int), ("y", int)]) +/// ``` +/// +/// The type of `Point` would be `type[Point]` where `Point` is a `DynamicDataclassLiteral`. +#[salsa::interned(debug, heap_size = ruff_memory_usage::heap_size)] +#[derive(PartialOrd, Ord)] +pub struct DynamicDataclassLiteral<'db> { + /// The name of the dataclass (from the first argument). + #[returns(ref)] + pub name: Name, + + /// The dataclass parameters (init, repr, eq, order, etc.) + pub dataclass_params: DataclassParams<'db>, + + /// The anchor for this dynamic dataclass, providing stable identity. + /// + /// - `Definition`: The call is assigned to a variable. The spec is + /// computed lazily to support forward references and recursive types. + /// - `ScopeOffset`: The call is "dangling" (not assigned). The spec is + /// stored eagerly since class bases are fully deferred during inference. + #[returns(ref)] + pub anchor: DynamicDataclassAnchor<'db>, +} + +impl get_size2::GetSize for DynamicDataclassLiteral<'_> {} + +fn dynamic_dataclass_spec_cycle_initial<'db>( + db: &'db dyn Db, + _id: salsa::Id, + _definition: Definition<'db>, +) -> DataclassSpec<'db> { + DataclassSpec::unknown(db) +} + +// The return type must match the tracked function's return type for cycle_initial. +#[expect(clippy::unnecessary_wraps)] +fn dynamic_dataclass_mro_cycle_initial<'db>( + db: &'db dyn Db, + _id: salsa::Id, + self_: DynamicDataclassLiteral<'db>, +) -> Result, DynamicMroError<'db>> { + Ok(Mro::from_error( + db, + ClassType::NonGeneric(ClassLiteral::DynamicDataclass(self_)), + )) +} + +#[salsa::tracked] +impl<'db> DynamicDataclassLiteral<'db> { + /// Returns the definition where this dataclass is created, if it was assigned to a variable. + pub(crate) fn definition(self, db: &'db dyn Db) -> Option> { + match self.anchor(db) { + DynamicDataclassAnchor::Definition(definition) => Some(*definition), + DynamicDataclassAnchor::ScopeOffset { .. } => None, + } + } + + /// Returns the scope in which this dynamic class was created. + pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { + match self.anchor(db) { + DynamicDataclassAnchor::Definition(definition) => definition.scope(db), + DynamicDataclassAnchor::ScopeOffset { scope, .. } => *scope, + } + } + + /// Returns an instance type for this dynamic dataclass. + pub(crate) fn to_instance(self, db: &'db dyn Db) -> Type<'db> { + Type::instance(db, ClassType::NonGeneric(self.into())) + } + + /// Returns the range of the `make_dataclass` call expression. + pub(crate) fn header_range(self, db: &'db dyn Db) -> TextRange { + let scope = self.scope(db); + let file = scope.file(db); + let module = parsed_module(db, file).load(db); + + match self.anchor(db) { + DynamicDataclassAnchor::Definition(definition) => { + // For definitions, get the range from the definition's value. + // The make_dataclass call is the value of the assignment. + definition + .kind(db) + .value(&module) + .expect( + "DynamicDataclassAnchor::Definition should only be used for assignments", + ) + .range() + } + DynamicDataclassAnchor::ScopeOffset { offset, .. } => { + // For dangling calls, compute the absolute index from the offset. + let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("anchor should not be NodeIndex::NONE"); + let absolute_index = NodeIndex::from(anchor_u32 + *offset); + + // Get the node and return its range. + let node: &ast::ExprCall = module + .get_by_index(absolute_index) + .try_into() + .expect("scope offset should point to ExprCall"); + node.range() + } + } + } + + /// Returns a [`Span`] pointing to the `make_dataclass` call expression. + pub(super) fn header_span(self, db: &'db dyn Db) -> Span { + Span::from(self.scope(db).file(db)).with_range(self.header_range(db)) + } + + /// Returns the spec for this dynamic dataclass. + /// + /// For assigned calls, this is computed lazily via a tracked query to support + /// forward references and recursive types. For dangling calls, the spec is + /// stored eagerly in the anchor. + fn spec(self, db: &'db dyn Db) -> DataclassSpec<'db> { + /// Lazily evaluate the spec for an assigned `make_dataclass` call. + /// + /// This allows the field types and bases to be evaluated in the correct + /// scope context, supporting forward references and recursive types. + #[salsa::tracked( + cycle_initial = dynamic_dataclass_spec_cycle_initial, + heap_size = ruff_memory_usage::heap_size + )] + fn deferred_spec<'db>(db: &'db dyn Db, definition: Definition<'db>) -> DataclassSpec<'db> { + let module = parsed_module(db, definition.file(db)).load(db); + let node = definition + .kind(db) + .value(&module) + .expect("Expected `make_dataclass` definition to be an assignment") + .as_call_expr() + .expect("Expected `make_dataclass` definition r.h.s. to be a call expression"); + + // Look for DataclassSpec in the inferred type of the fields argument. + // The fields argument is at index 1 (after the name argument). + if node.arguments.args.len() >= 2 + && let Type::KnownInstance(KnownInstanceType::DataclassSpec(spec)) = + definition_expression_type(db, definition, &node.arguments.args[1]) + { + return spec; + } + + DataclassSpec::unknown(db) + } + + match self.anchor(db) { + DynamicDataclassAnchor::Definition(definition) => deferred_spec(db, *definition), + DynamicDataclassAnchor::ScopeOffset { spec, .. } => *spec, + } + } + + /// Returns the fields for this dynamic dataclass. + pub(crate) fn fields(self, db: &'db dyn Db) -> &'db [DataclassFieldSpec<'db>] { + self.spec(db).fields(db) + } + + /// Returns whether the fields are known statically. + pub(crate) fn has_known_fields(self, db: &'db dyn Db) -> bool { + self.spec(db).has_known_fields(db) + } + + /// Returns the base classes for this dynamic dataclass. + pub(crate) fn bases(self, db: &'db dyn Db) -> &'db [ClassBase<'db>] { + self.spec(db).bases(db) + } + + /// Try to compute the MRO for this dynamic dataclass. + /// + /// Returns `Ok(Mro)` if successful, or `Err(DynamicMroError)` if there's + /// an error (duplicate bases or C3 linearization failure). + #[salsa::tracked( + returns(ref), + heap_size = ruff_memory_usage::heap_size, + cycle_initial = dynamic_dataclass_mro_cycle_initial + )] + pub(crate) fn try_mro(self, db: &'db dyn Db) -> Result, DynamicMroError<'db>> { + Mro::of_dynamic(db, self.into()) + } + + /// Returns an iterator over the MRO. + pub(crate) fn iter_mro(self, db: &'db dyn Db) -> MroIterator<'db> { + MroIterator::new(db, ClassLiteral::DynamicDataclass(self), None) + } + + /// Get the metaclass of this dynamic dataclass. + /// + /// Derives the metaclass from base classes. If no bases or no special metaclass, + /// defaults to `type`. + pub(crate) fn metaclass(self, db: &'db dyn Db) -> Type<'db> { + let bases = self.bases(db); + + if bases.is_empty() { + return KnownClass::Type.to_class_literal(db); + } + + // Use the most derived metaclass from the bases. + // For simplicity, we just use the first base's metaclass as the candidate + // and check if subsequent bases have more derived metaclasses. + let mut candidate = bases[0].metaclass(db); + + for base in bases.iter().skip(1) { + let base_metaclass = base.metaclass(db); + + let Some(candidate_class) = candidate.to_class_type(db) else { + continue; + }; + let Some(base_metaclass_class) = base_metaclass.to_class_type(db) else { + continue; + }; + + // If base's metaclass is more derived, use it. + if base_metaclass_class.is_subclass_of(db, candidate_class) { + candidate = base_metaclass; + } + // Otherwise keep the current candidate (we don't handle conflicts here) + } + + candidate + } + + /// Look up an instance member defined directly on this class (not inherited). + /// + /// For dynamic dataclasses, instance members are the field names. + /// If fields are unknown (dynamic), returns `Any` for any attribute. + pub(super) fn own_instance_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { + for field in self.fields(db) { + if field.name.as_str() == name { + return Member::definitely_declared(field.ty); + } + } + + if !self.has_known_fields(db) { + return Member::definitely_declared(Type::any()); + } + + Member::unbound() + } + + /// Look up an instance member by name (including superclasses). + pub(crate) fn instance_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { + // First check own instance members. + let result = self.own_instance_member(db, name); + if !result.is_undefined() { + return result.inner; + } + + // Check base classes for inherited instance members. + for base in self.bases(db) { + if let ClassBase::Class(class) = base { + let member = class.instance_member(db, name); + if !member.place.is_undefined() { + return member; + } + } + } + + // Fall back to object for other attributes. + KnownClass::Object.to_instance(db).instance_member(db, name) + } + + /// Look up a class-level member by name. + pub(crate) fn class_member( + self, + db: &'db dyn Db, + name: &str, + policy: MemberLookupPolicy, + ) -> PlaceAndQualifiers<'db> { + let result = MroLookup::new(db, self.iter_mro(db)).class_member( + name, policy, None, // No inherited generic context. + false, // Dynamic dataclasses are never `object`. + ); + + let result = match result { + ClassMemberResult::Done(result) => result.finalize(db), + ClassMemberResult::TypedDict => { + // Simplified `TypedDict` handling without type mapping. + KnownClass::TypedDictFallback + .to_class_literal(db) + .find_name_in_mro_with_policy(db, name, policy) + .expect("Will return Some() when called on class literal") + } + }; + + // If fields are unknown (dynamic) and the attribute wasn't found, + // return `Any` instead of failing. + if !self.has_known_fields(db) && result.place.is_undefined() { + return Place::bound(Type::any()).into(); + } + + result + } + + /// Look up a class-level member defined directly on this class (not inherited). + /// + /// This only checks synthesized members and field properties, without falling + /// back to object or other base classes. + pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { + if let Some(ty) = self.synthesized_class_member(db, name) { + return Member::definitely_declared(ty); + } + + Member::default() + } + + /// Generate synthesized class members for dataclasses. + fn synthesized_class_member(self, db: &'db dyn Db, name: &str) -> Option> { + let instance_ty = self.to_instance(db); + let params = self.dataclass_params(db); + let flags = params.flags(db); + + // When fields are unknown, handle constructors specially. + // We need to handle both `__new__` and `__init__` to avoid falling back + // to `object.__new__` which only accepts one argument. + if !self.has_known_fields(db) { + match name { + "__new__" | "__init__" => { + let signature = Signature::new(Parameters::gradual_form(), instance_ty); + return Some(Type::function_like_callable(db, signature)); + } + _ => {} + } + } + + // Handle `__weakref__` for `slots=True`, `weakref_slot=True` (Python 3.11+) + if name == "__weakref__" + && Program::get(db).python_version(db) >= PythonVersion::PY311 + && flags.contains(DataclassFlags::WEAKREF_SLOT) + && flags.contains(DataclassFlags::SLOTS) + { + // This could probably be `weakref | None`, but it does not seem important enough to + // model it precisely. + return Some(UnionType::from_elements(db, [Type::any(), Type::none(db)])); + } + + // Use per-field metadata from the spec, falling back to class-level `kw_only` flag. + let kw_only_default = flags.contains(DataclassFlags::KW_ONLY); + let fields_iter = self.fields(db).iter().map(|field| DataclassFieldInfo { + name: field.alias.clone().unwrap_or_else(|| field.name.clone()), + ty: field.ty, + default_ty: field.default_ty, + init: field.init, + kw_only: field.kw_only.unwrap_or(kw_only_default), + }); + + synthesize_dataclass_class_member(db, name, instance_ty, flags, fields_iter) + } +} + /// Performs member lookups over an MRO (Method Resolution Order). /// /// This struct encapsulates the shared logic for looking up class and instance @@ -6269,6 +6987,11 @@ impl<'db> QualifiedClassName<'db> { let scope = namedtuple.scope(self.db); (scope.file(self.db), scope.file_scope_id(self.db), 0) } + ClassLiteral::DynamicDataclass(dataclass) => { + // Dynamic dataclasses don't have a body scope; start from the enclosing scope. + let scope = dataclass.scope(self.db); + (scope.file(self.db), scope.file_scope_id(self.db), 0) + } }; let module_ast = parsed_module(self.db, file).load(self.db); diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 3111a0890e2c4..ad8118980f095 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -194,6 +194,7 @@ impl<'db> ClassBase<'db> { | KnownInstanceType::Literal(_) | KnownInstanceType::LiteralStringAlias(_) | KnownInstanceType::NamedTupleSpec(_) + | KnownInstanceType::DataclassSpec(_) // A class inheriting from a newtype would make intuitive sense, but newtype // wrappers are just identity callables at runtime, so this sort of inheritance // doesn't work and isn't allowed. diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index ab42742fda901..bf99acebbde17 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -906,11 +906,11 @@ declare_lint! { declare_lint! { /// ## What it does - /// Checks for dynamic class definitions (using `type()`) that have bases - /// which are unsupported by ty. + /// Checks for dynamic class definitions that have bases which are unsupported by ty. /// /// This is equivalent to [`unsupported-base`] but applies to classes created - /// via `type()` rather than `class` statements. + /// dynamically via `type()`, `make_dataclass()`, or similar functions rather + /// than `class` statements. /// /// ## Why is this bad? /// If a dynamically created class has a base that is an unsupported type @@ -920,7 +920,7 @@ declare_lint! { /// /// ## Default level /// This rule is disabled by default because it will not cause a runtime error, - /// and may be noisy on codebases that use `type()` in highly dynamic ways. + /// and may be noisy on codebases that use dynamic class creation in highly dynamic ways. /// /// ## Examples /// ```python diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 07b118ffeb352..663b4e62d3bd6 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -2732,6 +2732,7 @@ impl<'db> FmtDetailed<'db> for DisplayKnownInstanceRepr<'db> { f.write_str("'>") } KnownInstanceType::NamedTupleSpec(_) => f.write_str("NamedTupleSpec"), + KnownInstanceType::DataclassSpec(_) => f.write_str("DataclassSpec"), } } } diff --git a/crates/ty_python_semantic/src/types/enums.rs b/crates/ty_python_semantic/src/types/enums.rs index 793521911f2f9..803b8790f4bbd 100644 --- a/crates/ty_python_semantic/src/types/enums.rs +++ b/crates/ty_python_semantic/src/types/enums.rs @@ -69,7 +69,7 @@ pub(crate) fn enum_metadata<'db>( // ``` return None; } - ClassLiteral::DynamicNamedTuple(..) => return None, + ClassLiteral::DynamicNamedTuple(..) | ClassLiteral::DynamicDataclass(..) => return None, }; // This is a fast path to avoid traversing the MRO of known classes diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 18aa4815dedcd..1e56a26662495 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1414,6 +1414,8 @@ pub enum KnownFunction { Dataclass, /// `dataclasses.field` Field, + /// `dataclasses.make_dataclass` + MakeDataclass, /// `functools.total_ordering` TotalOrdering, @@ -1503,7 +1505,7 @@ impl KnownFunction { Self::AsyncContextManager => { matches!(module, KnownModule::Contextlib) } - Self::Dataclass | Self::Field => { + Self::Dataclass | Self::Field | Self::MakeDataclass => { matches!(module, KnownModule::Dataclasses) } Self::TotalOrdering => module.is_functools(), @@ -2068,7 +2070,9 @@ pub(crate) mod tests { KnownFunction::AsyncContextManager => KnownModule::Contextlib, - KnownFunction::Dataclass | KnownFunction::Field => KnownModule::Dataclasses, + KnownFunction::Dataclass | KnownFunction::Field | KnownFunction::MakeDataclass => { + KnownModule::Dataclasses + } KnownFunction::GetattrStatic => KnownModule::Inspect, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 3b2ba44f43de4..d4f6055656c06 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -56,9 +56,10 @@ use crate::subscript::{PyIndex, PySlice}; use crate::types::call::bind::{CallableDescription, MatchingOverloadIndex}; use crate::types::call::{Argument, Binding, Bindings, CallArguments, CallError, CallErrorKind}; use crate::types::class::{ - ClassLiteral, CodeGeneratorKind, DynamicClassAnchor, DynamicClassLiteral, - DynamicMetaclassConflict, DynamicNamedTupleAnchor, DynamicNamedTupleLiteral, FieldKind, - MetaclassErrorKind, MethodDecorator, NamedTupleField, NamedTupleSpec, + ClassLiteral, CodeGeneratorKind, DataclassFieldSpec, DataclassSpec, DynamicClassAnchor, + DynamicClassLiteral, DynamicDataclassAnchor, DynamicDataclassLiteral, DynamicMetaclassConflict, + DynamicNamedTupleAnchor, DynamicNamedTupleLiteral, FieldKind, MetaclassErrorKind, + MethodDecorator, NamedTupleField, NamedTupleSpec, }; use crate::types::context::{InNoTypeCheck, InferContext}; use crate::types::cyclic::CycleDetector; @@ -107,7 +108,7 @@ use crate::types::generics::{ }; use crate::types::infer::nearest_enclosing_function; use crate::types::instance::SliceLiteral; -use crate::types::mro::{DynamicMroErrorKind, StaticMroErrorKind}; +use crate::types::mro::{DynamicMroError, DynamicMroErrorKind, Mro, StaticMroErrorKind}; use crate::types::newtype::NewType; use crate::types::subclass_of::SubclassOfInner; use crate::types::tuple::{Tuple, TupleLength, TupleSpec, TupleSpecBuilder, TupleType}; @@ -118,15 +119,16 @@ use crate::types::typed_dict::{ use crate::types::visitor::any_over_type; use crate::types::{ BoundTypeVarIdentity, BoundTypeVarInstance, CallDunderError, CallableBinding, CallableType, - CallableTypeKind, ClassType, DataclassParams, DynamicType, InternedType, IntersectionBuilder, - IntersectionType, KnownClass, KnownInstanceType, KnownUnion, LintDiagnosticGuard, - MemberLookupPolicy, MetaclassCandidate, PEP695TypeAliasType, ParamSpecAttrKind, Parameter, - ParameterForm, Parameters, Signature, SpecialFormType, StaticClassLiteral, SubclassOfType, - TrackedConstraintSet, Truthiness, Type, TypeAliasType, TypeAndQualifiers, TypeContext, - TypeQualifiers, TypeVarBoundOrConstraints, TypeVarBoundOrConstraintsEvaluation, - TypeVarDefaultEvaluation, TypeVarIdentity, TypeVarInstance, TypeVarKind, TypeVarVariance, - TypedDictType, UnionBuilder, UnionType, UnionTypeInstance, binding_type, - definition_expression_type, infer_complete_scope_types, infer_scope_types, todo_type, + CallableTypeKind, ClassType, DataclassFlags, DataclassParams, DynamicType, InternedType, + IntersectionBuilder, IntersectionType, KnownClass, KnownInstanceType, KnownUnion, + LintDiagnosticGuard, MemberLookupPolicy, MetaclassCandidate, PEP695TypeAliasType, + ParamSpecAttrKind, Parameter, ParameterForm, Parameters, Signature, SpecialFormType, + StaticClassLiteral, SubclassOfType, TrackedConstraintSet, Truthiness, Type, TypeAliasType, + TypeAndQualifiers, TypeContext, TypeQualifiers, TypeVarBoundOrConstraints, + TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation, TypeVarIdentity, + TypeVarInstance, TypeVarKind, TypeVarVariance, TypedDictType, UnionBuilder, UnionType, + UnionTypeInstance, binding_type, definition_expression_type, infer_complete_scope_types, + infer_scope_types, todo_type, }; use crate::types::{CallableTypes, overrides}; use crate::types::{ClassBase, add_inferred_python_version_hint_to_diagnostic}; @@ -610,6 +612,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if self.db().should_check_file(self.file()) { self.check_static_class_definitions(); + self.check_dynamic_class_definitions(); self.check_overloaded_functions(node); self.check_type_guard_definitions(); } @@ -1294,6 +1297,173 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + /// Check dynamic class definitions (from `type()` and `make_dataclass()` calls). + /// + /// This is called after the scope has been fully inferred to avoid expensive + /// Salsa cycle detection when forward references are present. + fn check_dynamic_class_definitions(&self) { + let db = self.db(); + let module = self.module(); + + // Track definitions we've already checked to avoid emitting duplicate diagnostics. + // The same DynamicClassLiteral type can appear for multiple expressions (e.g., both + // the call expression and the assignment target). + let mut checked_definitions: FxHashSet> = FxHashSet::default(); + + // Find all dynamic class definitions in expressions (both type() and make_dataclass()). + // We iterate through expressions because for scope-level inference, bindings from + // individual definition inferences are not accumulated in self.bindings. + for ty in self.expressions.values() { + let Some(class_literal) = ty.as_class_literal() else { + continue; + }; + + // Only check definition-bound calls (those with a Definition anchor). + // Dangling calls are checked eagerly during inference. + + // Check DynamicClassLiteral (from type() calls) + if let Some(dynamic_class) = class_literal.as_dynamic() + && let Some(def) = dynamic_class.definition(db) + && checked_definitions.insert(def) + { + // Try to extract the bases tuple AST nodes for per-base diagnostic annotations. + let base_nodes = Self::extract_type_call_bases_nodes(db, def, module); + self.check_dynamic_class_mro( + dynamic_class.try_mro(db), + dynamic_class.name(db), + dynamic_class.bases(db), + dynamic_class.header_range(db), + base_nodes.as_deref(), + ); + } + + // Check DynamicDataclassLiteral (from make_dataclass() calls) + if let Some(dataclass) = class_literal.as_dynamic_dataclass() + && let Some(def) = dataclass.definition(db) + && checked_definitions.insert(def) + { + // Try to extract the bases tuple AST nodes for per-base diagnostic annotations. + let base_nodes = Self::extract_make_dataclass_bases_nodes(db, def, module); + self.check_dynamic_class_mro( + dataclass.try_mro(db), + dataclass.name(db), + dataclass.bases(db), + dataclass.header_range(db), + base_nodes.as_deref(), + ); + } + } + } + + /// Extract the bases tuple element nodes from a `type()` call definition. + /// Returns `Some(nodes)` if the definition is an assignment with a `type()` call + /// where the bases argument is a tuple literal. + fn extract_type_call_bases_nodes( + db: &'db dyn Db, + def: Definition<'db>, + module: &ParsedModuleRef, + ) -> Option> { + let DefinitionKind::Assignment(assignment) = def.kind(db) else { + return None; + }; + let ast::Expr::Call(call_expr) = assignment.value(module) else { + return None; + }; + // The second argument to type() is the bases tuple + let bases_arg = call_expr.arguments.args.get(1)?; + let ast::Expr::Tuple(tuple) = bases_arg else { + return None; + }; + Some(tuple.elts.clone()) + } + + /// Extract the bases tuple element nodes from a `make_dataclass()` call definition. + /// Returns `Some(nodes)` if the definition is an assignment with a `make_dataclass()` call + /// where the bases keyword argument is a tuple literal. + fn extract_make_dataclass_bases_nodes( + db: &'db dyn Db, + def: Definition<'db>, + module: &ParsedModuleRef, + ) -> Option> { + let DefinitionKind::Assignment(assignment) = def.kind(db) else { + return None; + }; + let ast::Expr::Call(call_expr) = assignment.value(module) else { + return None; + }; + // Find the `bases` keyword argument + for keyword in &call_expr.arguments.keywords { + if keyword.arg.as_ref().is_some_and(|arg| arg.id == "bases") { + if let ast::Expr::Tuple(tuple) = &keyword.value { + return Some(tuple.elts.clone()); + } + } + } + None + } + + /// Check MRO result and emit diagnostics for errors. + /// Shared helper for both `type()` and `make_dataclass()` calls. + fn check_dynamic_class_mro( + &self, + mro_result: &Result, DynamicMroError<'db>>, + class_name: &Name, + bases: &[ClassBase<'db>], + header_range: TextRange, + base_nodes: Option<&[ast::Expr]>, + ) { + let db = self.db(); + + match mro_result { + Err(error) => match error.reason() { + DynamicMroErrorKind::DuplicateBases(duplicates) => { + if let Some(builder) = self.context.report_lint(&DUPLICATE_BASE, header_range) { + builder.into_diagnostic(format_args!( + "Duplicate base class{maybe_s} {dupes} in class `{class_name}`", + maybe_s = if duplicates.len() == 1 { "" } else { "es" }, + dupes = duplicates + .iter() + .map(|base: &ClassBase<'_>| base.display(db)) + .join(", "), + )); + } + } + DynamicMroErrorKind::UnresolvableMro => { + if let Some(builder) = self.context.report_lint(&INCONSISTENT_MRO, header_range) + { + builder.into_diagnostic(format_args!( + "Cannot create a consistent method resolution order (MRO) \ + for class `{class_name}` with bases `[{}]`", + bases.iter().map(|base| base.display(db)).join(", ") + )); + } + } + }, + Ok(_) => { + // MRO succeeded, check for instance-layout-conflict. + // Compute disjoint bases from the stored bases. + let mut disjoint_bases = IncompatibleBases::default(); + for (idx, base) in bases.iter().enumerate() { + if let ClassBase::Class(class_type) = base { + if let Some(disjoint_base) = class_type.nearest_disjoint_base(db) { + disjoint_bases.insert(disjoint_base, idx, class_type.class_literal(db)); + } + } + } + + disjoint_bases.remove_redundant_entries(db); + if disjoint_bases.len() > 1 { + report_instance_layout_conflict( + &self.context, + header_range, + base_nodes, + &disjoint_bases, + ); + } + } + } + } + /// Check that a `@final` class does not have unimplemented abstract methods. /// /// A final class cannot be subclassed, so if it inherits abstract methods without @@ -5671,6 +5841,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Some(definition), namedtuple_kind, ) + } else if callable_type + .as_function_literal() + .is_some_and(|f| f.is_known(self.db(), KnownFunction::MakeDataclass)) + { + self.infer_make_dataclass_call_expression(call_expr, Some(definition)) } else { match callable_type .as_class_literal() @@ -6253,6 +6428,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_typing_namedtuple_fields(&arguments.args[1]); return; } + if func_ty + .as_function_literal() + .is_some_and(|f| f.is_known(self.db(), KnownFunction::MakeDataclass)) + { + // The `fields` and `bases` arguments are deferred for `make_dataclass`; + // other arguments are inferred eagerly. + self.infer_make_dataclass_deferred(arguments); + return; + } let known_class = func_ty .as_class_literal() .and_then(|cls| cls.known(self.db())); @@ -6561,47 +6745,56 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { None, ); - // Check for MRO errors. - 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!( - "Duplicate base class{maybe_s} {dupes} in class `{class}`", - maybe_s = if duplicates.len() == 1 { "" } else { "es" }, - dupes = duplicates - .iter() - .map(|base: &ClassBase<'_>| base.display(db)) - .join(", "), - class = dynamic_class.name(db), - )); + // For definition-bound calls (assigned to a variable), defer MRO checks until after + // the scope is fully inferred via `check_dynamic_class_definitions()`. This avoids + // expensive Salsa cycle detection when forward references like + // `X = type("X", (Base,), {"attr": X})` are present. + // For dangling calls, check eagerly since they can't have recursive references. + if definition.is_none() { + 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!( + "Duplicate base class{maybe_s} {dupes} in class `{class}`", + maybe_s = if duplicates.len() == 1 { "" } else { "es" }, + dupes = duplicates + .iter() + .map(|base: &ClassBase<'_>| base.display(db)) + .join(", "), + class = dynamic_class.name(db), + )); + } } - } - DynamicMroErrorKind::UnresolvableMro => { - if let Some(builder) = self.context.report_lint(&INCONSISTENT_MRO, call_expr) { - builder.into_diagnostic(format_args!( - "Cannot create a consistent method resolution order (MRO) \ + DynamicMroErrorKind::UnresolvableMro => { + if let Some(builder) = + self.context.report_lint(&INCONSISTENT_MRO, call_expr) + { + builder.into_diagnostic(format_args!( + "Cannot create a consistent method resolution order (MRO) \ for class `{}` with bases `[{}]`", - dynamic_class.name(db), - dynamic_class - .bases(db) - .iter() - .map(|base| base.display(db)) - .join(", ") - )); + dynamic_class.name(db), + dynamic_class + .bases(db) + .iter() + .map(|base| base.display(db)) + .join(", ") + )); + } + } + }, + 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, + ); } - } - }, - 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, - ); } } } @@ -7348,24 +7541,577 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - /// Extract base classes from the second argument of a `type()` call. + /// Infer a `dataclasses.make_dataclass(cls_name, fields, ...)` call. + /// + /// This method *does not* call `infer_expression` on the object being called; + /// it is assumed that the type for this AST node has already been inferred before this method is called. + fn infer_make_dataclass_call_expression( + &mut self, + call_expr: &ast::ExprCall, + definition: Option>, + ) -> Type<'db> { + let db = self.db(); + + let ast::Arguments { + args, + keywords, + range: _, + node_index: _, + } = &call_expr.arguments; + + let has_starred = args.iter().any(ast::Expr::is_starred_expr); + let has_double_starred = keywords.iter().any(|kw| kw.arg.is_none()); + + // Need at least `cls_name` and `fields`. + let [name_arg, fields_arg, rest @ ..] = &**args else { + for arg in args { + self.infer_expression(arg, TypeContext::default()); + } + for kw in keywords { + self.infer_expression(&kw.value, TypeContext::default()); + } + + // Report missing argument diagnostic if we can statically determine the arguments. + if !has_starred && !has_double_starred { + let missing = if args.is_empty() { + "`cls_name` and `fields`" + } else { + "`fields`" + }; + if let Some(builder) = self.context.report_lint(&MISSING_ARGUMENT, call_expr) { + builder.into_diagnostic(format_args!( + "No argument{} provided for required parameter{} {missing} of function `make_dataclass`", + if args.is_empty() { "s" } else { "" }, + if args.is_empty() { "s" } else { "" } + )); + } + } + + // Fall back to type[Unknown] + return SubclassOfType::subclass_of_unknown(); + }; + + let name_type = self.infer_expression(name_arg, TypeContext::default()); + + for arg in rest { + self.infer_expression(arg, TypeContext::default()); + } + + // If any argument is a starred expression or any keyword is a double-starred expression, + // we can't statically determine the arguments, so fall back to type[Unknown]. + if has_starred || has_double_starred { + // Infer fields_arg since we won't process it via infer_make_dataclass_fields. + self.infer_expression(fields_arg, TypeContext::default()); + for kw in keywords { + self.infer_expression(&kw.value, TypeContext::default()); + } + return SubclassOfType::subclass_of_unknown(); + } + + // Check for excess positional arguments (only `cls_name` and `fields` are positional). + if !rest.is_empty() { + if let Some(builder) = self + .context + .report_lint(&TOO_MANY_POSITIONAL_ARGUMENTS, &rest[0]) + { + builder.into_diagnostic(format_args!( + "Too many positional arguments to function `make_dataclass`: expected 2, got {}", + args.len() + )); + } + } + + // Parse keyword arguments to extract dataclass parameters. + let mut dataclass_flags = DataclassFlags::default(); + let mut bases_arg: Option<(&ast::Expr, Type<'db>)> = None; + let bool_type = KnownClass::Bool.to_instance(db); + + for kw in keywords { + let kw_type = self.infer_expression(&kw.value, TypeContext::default()); + + let Some(arg) = &kw.arg else { + continue; + }; + match arg.id.as_str() { + "bases" => { + // Validate that bases is a tuple. At runtime, `make_dataclass` requires a tuple + // (not list or other iterable). + // + // We use `tuple[object, ...]` rather than `tuple[type, ...]` because special + // forms like `TypedDict`, `Protocol`, and `Generic` are not subtypes of `type`. + // Using `tuple[type, ...]` would emit a generic type error here, but we prefer + // to let `extract_dynamic_bases` emit more specific diagnostics. + let tuple_of_objects = + Type::homogeneous_tuple(db, KnownClass::Object.to_instance(db)); + if !kw_type.is_assignable_to(db, tuple_of_objects) { + if let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, &kw.value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `bases` of `make_dataclass()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `tuple`, found `{}`", + kw_type.display(db) + )); + } + } + bases_arg = Some((&kw.value, kw_type)); + } + "namespace" => { + // Emit diagnostic for invalid types (not `dict | None`). + let dict_type = + KnownClass::Dict.to_specialized_instance(db, &[Type::any(), Type::any()]); + let valid_type = UnionType::from_elements(db, [dict_type, Type::none(db)]); + if !kw_type.is_assignable_to(db, valid_type) { + if let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, &kw.value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `namespace` of `make_dataclass()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `dict | None`, found `{}`", + kw_type.display(db) + )); + } + } + } + "module" => { + // Emit diagnostic for invalid types (not `str | None`). + let valid_type = UnionType::from_elements( + db, + [KnownClass::Str.to_instance(db), Type::none(db)], + ); + if !kw_type.is_assignable_to(db, valid_type) { + if let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, &kw.value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `module` of `make_dataclass()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `str | None`, found `{}`", + kw_type.display(db) + )); + } + } + } + // All boolean parameters share the same type validation logic. + param @ ("init" | "repr" | "eq" | "order" | "unsafe_hash" | "frozen" + | "match_args" | "kw_only" | "slots" | "weakref_slot") => { + if !kw_type.is_assignable_to(db, bool_type) { + if let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, &kw.value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `{param}` of `make_dataclass()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `bool`, found `{}`", + kw_type.display(db) + )); + } + } + // Update flags: params defaulting to true use is_always_false/remove, + // params defaulting to false use is_always_true/insert. + match param { + "init" if kw_type.bool(db).is_always_false() => { + dataclass_flags.remove(DataclassFlags::INIT); + } + "repr" if kw_type.bool(db).is_always_false() => { + dataclass_flags.remove(DataclassFlags::REPR); + } + "eq" if kw_type.bool(db).is_always_false() => { + dataclass_flags.remove(DataclassFlags::EQ); + } + "match_args" if kw_type.bool(db).is_always_false() => { + dataclass_flags.remove(DataclassFlags::MATCH_ARGS); + } + "order" if kw_type.bool(db).is_always_true() => { + dataclass_flags.insert(DataclassFlags::ORDER); + } + "unsafe_hash" if kw_type.bool(db).is_always_true() => { + dataclass_flags.insert(DataclassFlags::UNSAFE_HASH); + } + "frozen" if kw_type.bool(db).is_always_true() => { + dataclass_flags.insert(DataclassFlags::FROZEN); + } + "kw_only" if kw_type.bool(db).is_always_true() => { + dataclass_flags.insert(DataclassFlags::KW_ONLY); + } + "slots" if kw_type.bool(db).is_always_true() => { + dataclass_flags.insert(DataclassFlags::SLOTS); + } + "weakref_slot" if kw_type.bool(db).is_always_true() => { + dataclass_flags.insert(DataclassFlags::WEAKREF_SLOT); + } + _ => {} + } + } + unknown_kwarg => { + if let Some(builder) = self.context.report_lint(&UNKNOWN_ARGUMENT, kw) { + builder.into_diagnostic(format_args!( + "Argument `{unknown_kwarg}` does not match any known parameter of function `make_dataclass`", + )); + } + } + } + } + + let dataclass_params = DataclassParams::from_flags(db, dataclass_flags); + + let name = if let Type::StringLiteral(literal) = name_type { + Name::new(literal.value(db)) + } else { + // Name is not a string literal; use `` like we do for `type(...)` calls. + if !name_type.is_assignable_to(db, KnownClass::Str.to_instance(db)) + && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `cls_name` of `make_dataclass()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `str`, found `{}`", + name_type.display(db) + )); + } + Name::new_static("") + }; + + let scope = self.scope(); + + // Create the anchor for identifying this dynamic dataclass. + // For assigned calls, we defer field/base evaluation to support forward references. + // For dangling calls, we compute the spec eagerly. + let (anchor, disjoint_bases): (DynamicDataclassAnchor<'db>, IncompatibleBases<'db>) = + match definition { + Some(def) => { + // Assigned call: defer field/base evaluation to support forward references + // and recursive types. + self.deferred.insert(def, self.multi_inference_state); + ( + DynamicDataclassAnchor::Definition(def), + IncompatibleBases::default(), + ) + } + None => { + // Dangling call: compute spec eagerly since class bases are fully deferred + // during type inference. + let call_node_index = call_expr.node_index.load(); + let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("scope anchor should not be NodeIndex::NONE"); + let call_u32 = call_node_index + .as_u32() + .expect("call node should not be NodeIndex::NONE"); + + // Extract bases from the `bases` keyword argument. + let (bases, disjoint_bases): (Box<[ClassBase<'db>]>, IncompatibleBases<'db>) = + if let Some((bases_node, bases_type)) = bases_arg { + let (bases, disjoint) = self.extract_dynamic_bases( + bases_node, + bases_type, + &name, + DynamicClassKind::MakeDataclass, + ); + (bases.unwrap_or_default(), disjoint) + } else { + (Box::default(), IncompatibleBases::default()) + }; + + // Compute the spec eagerly for dangling calls. + let spec = self.infer_make_dataclass_fields(fields_arg, bases); + + ( + DynamicDataclassAnchor::ScopeOffset { + scope, + offset: call_u32 - anchor_u32, + spec, + }, + disjoint_bases, + ) + } + }; + + let dataclass = DynamicDataclassLiteral::new(db, name, dataclass_params, anchor); + + // For definition-bound calls (assigned to a variable), defer MRO checks until after + // the scope is fully inferred via `check_dynamic_dataclass_definitions()`. This avoids + // expensive Salsa cycle detection when forward references like + // `X = make_dataclass("X", [("field", X | None)])` are present. + // For dangling calls, check eagerly since they can't have recursive references. + if definition.is_none() { + self.check_dynamic_dataclass_mro(dataclass, call_expr, disjoint_bases, bases_arg); + } + + Type::ClassLiteral(ClassLiteral::DynamicDataclass(dataclass)) + } + + /// Check MRO and instance layout conflicts for a dynamic dataclass. + /// Used for eager checking of dangling calls. + fn check_dynamic_dataclass_mro( + &self, + dataclass: DynamicDataclassLiteral<'db>, + call_expr: &ast::ExprCall, + mut disjoint_bases: IncompatibleBases<'db>, + bases_arg: Option<(&ast::Expr, Type<'db>)>, + ) { + let db = self.db(); + + match dataclass.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!( + "Duplicate base class{maybe_s} {dupes} in class `{class}`", + maybe_s = if duplicates.len() == 1 { "" } else { "es" }, + dupes = duplicates + .iter() + .map(|base: &ClassBase<'_>| base.display(db)) + .join(", "), + class = dataclass.name(db), + )); + } + } + DynamicMroErrorKind::UnresolvableMro => { + if let Some(builder) = self.context.report_lint(&INCONSISTENT_MRO, call_expr) { + builder.into_diagnostic(format_args!( + "Cannot create a consistent method resolution order (MRO) \ + for class `{}` with bases `[{}]`", + dataclass.name(db), + dataclass + .bases(db) + .iter() + .map(|base| base.display(db)) + .join(", ") + )); + } + } + }, + Ok(_) => { + // MRO succeeded, check for instance-layout-conflict. + disjoint_bases.remove_redundant_entries(db); + if disjoint_bases.len() > 1 { + let bases_arg_node = bases_arg.map(|(node, _)| node); + report_instance_layout_conflict( + &self.context, + dataclass.header_range(db), + bases_arg_node.and_then(|n| n.as_tuple_expr().map(|t| t.elts.as_slice())), + &disjoint_bases, + ); + } + } + } + } + + /// Infer deferred field and base types for a `make_dataclass()` assignment. + /// + /// This is called during deferred evaluation to process forward references + /// and recursive types in field type annotations and base classes. + fn infer_make_dataclass_deferred(&mut self, arguments: &ast::Arguments) { + // Need at least the fields argument. + if arguments.args.len() < 2 { + return; + } + + let db = self.db(); + let name_arg = &arguments.args[0]; + let fields_arg = &arguments.args[1]; + + // Extract the class name from the first argument. + // The name was already inferred in the eager phase, so use try_expression_type. + let name_type = self + .try_expression_type(name_arg) + .unwrap_or_else(|| self.infer_expression(name_arg, TypeContext::default())); + let name = if let Type::StringLiteral(literal) = name_type { + Name::new(literal.value(db)) + } else { + Name::new_static("") + }; + + // Extract bases from the `bases` keyword argument. + let bases: Box<[ClassBase<'db>]> = if let Some(bases_kw) = arguments.find_keyword("bases") { + let bases_type = self.infer_expression(&bases_kw.value, TypeContext::default()); + let (bases, _) = self.extract_dynamic_bases( + &bases_kw.value, + bases_type, + &name, + DynamicClassKind::MakeDataclass, + ); + bases.unwrap_or_default() + } else { + Box::default() + }; + + // Infer fields with proper handling of string annotations. + self.infer_make_dataclass_fields(fields_arg, bases); + } + + /// Infer fields from a `make_dataclass` fields argument. + /// + /// This method properly handles string annotations as forward references by using + /// `infer_type_expression` instead of `expression_type().in_type_expression()`. + /// + /// Returns a `DataclassSpec` containing the fields. The spec is also stored as the + /// expression type of the fields argument so it can be retrieved during deferred evaluation. + fn infer_make_dataclass_fields( + &mut self, + fields_arg: &ast::Expr, + bases: Box<[ClassBase<'db>]>, + ) -> DataclassSpec<'db> { + let db = self.db(); + + // Get the elements from the list or tuple literal. + let elements: &[ast::Expr] = match fields_arg { + ast::Expr::List(list) => &list.elts, + ast::Expr::Tuple(tuple) => &tuple.elts, + _ => { + // Not a list/tuple literal - return unknown spec. + // For dynamic fields, we don't store DataclassSpec since the expression + // already has a type from the variable inference. + self.infer_expression(fields_arg, TypeContext::default()); + return DataclassSpec::unknown(db); + } + }; + + let mut fields = Vec::with_capacity(elements.len()); + + for elt in elements { + // Field can be a string literal (just the name, type defaults to Any). + if let ast::Expr::StringLiteral(string_lit) = elt { + let name = Name::new(string_lit.value.to_str()); + fields.push(DataclassFieldSpec { + name, + ty: Type::any(), + default_ty: None, + init: true, + kw_only: None, + alias: None, + }); + self.store_expression_type( + elt, + Type::string_literal(db, string_lit.value.to_str()), + ); + continue; + } + + // Field can be a tuple of (name, type) or (name, type, field). + let field_elements: &[ast::Expr] = match elt { + ast::Expr::Tuple(tuple) => &tuple.elts, + ast::Expr::List(list) => &list.elts, + _ => { + // Invalid field spec - skip it + self.infer_expression(elt, TypeContext::default()); + continue; + } + }; + + match field_elements { + [name_expr, type_expr] => { + // (name, type) + let name_ty = self.infer_expression(name_expr, TypeContext::default()); + // Use infer_type_expression to properly handle string annotations + let field_ty = self.infer_type_expression(type_expr); + + if let Some(name_lit) = name_ty.as_string_literal() { + let field_name = Name::new(name_lit.value(db)); + fields.push(DataclassFieldSpec { + name: field_name, + ty: field_ty, + default_ty: None, + init: true, + kw_only: None, + alias: None, + }); + } + // Store the tuple type + self.store_expression_type( + elt, + Type::heterogeneous_tuple(db, [name_ty, field_ty]), + ); + } + [name_expr, type_expr, default_expr] => { + // (name, type, default_or_field) + let name_ty = self.infer_expression(name_expr, TypeContext::default()); + // Use infer_type_expression to properly handle string annotations + let field_ty = self.infer_type_expression(type_expr); + let default_ty_value = + self.infer_expression(default_expr, TypeContext::default()); + + if let Some(name_lit) = name_ty.as_string_literal() { + let field_name = Name::new(name_lit.value(db)); + // Extract field properties from Field object, or use default values. + let (default_ty, init, kw_only, alias) = + if let Type::KnownInstance(KnownInstanceType::Field(field)) = + default_ty_value + { + ( + field.default_type(db), + field.init(db), + field.kw_only(db), + field.alias(db).map(Name::new), + ) + } else { + (Some(default_ty_value), true, None, None) + }; + fields.push(DataclassFieldSpec { + name: field_name, + ty: field_ty, + default_ty, + init, + kw_only, + alias, + }); + } + // Store the tuple type + self.store_expression_type( + elt, + Type::heterogeneous_tuple(db, [name_ty, field_ty, default_ty_value]), + ); + } + _ => { + // Invalid tuple length - infer all elements but don't create a field + for expr in field_elements { + self.infer_expression(expr, TypeContext::default()); + } + } + } + } + + let spec = DataclassSpec::known(db, fields.into_boxed_slice(), bases); + self.store_expression_type( + fields_arg, + Type::KnownInstance(KnownInstanceType::DataclassSpec(spec)), + ); + spec + } + + /// Extract base classes from a dynamic class definition. /// /// 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( + /// + /// This is the shared implementation used by both `type()` and `make_dataclass()`. + fn extract_dynamic_bases( &mut self, bases_node: &ast::Expr, bases_type: Type<'db>, - name: &ast::name::Name, - ) -> (Box<[ClassBase<'db>]>, IncompatibleBases<'db>) { + name: &Name, + kind: DynamicClassKind, + ) -> (Option]>>, IncompatibleBases<'db>) { let db = self.db(); // Get AST nodes for base expressions (for diagnostics). let bases_tuple_elts = bases_node.as_tuple_expr().map(|t| t.elts.as_slice()); - // We use a placeholder class literal for try_from_type (the subclass parameter is only - // used for Protocol/TypedDict detection which doesn't apply here). + // We use a placeholder class literal for `try_from_type`. The `subclass` parameter is used + // for special forms like `NamedTuple` that need the defining class's fields, but for + // dynamic classes we don't have a static class to reference. Using `object` as a placeholder + // is safe because we explicitly reject Protocol/TypedDict/Generic bases below, and NamedTuple + // would produce incorrect results anyway (dynamic classes can't properly inherit from it). let placeholder_class: ClassLiteral<'db> = KnownClass::Object.try_to_class_literal(db).unwrap().into(); @@ -7386,7 +8132,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .and_then(|elts| elts.get(idx)) .unwrap_or(bases_node); - // First try the standard conversion. + // Try the standard conversion. if let Some(class_base) = ClassBase::try_from_type(db, *base, placeholder_class) { @@ -7397,26 +8143,29 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(builder) = self.context.report_lint(&INVALID_BASE, diagnostic_node) { - let mut diagnostic = builder.into_diagnostic( - "Invalid base for class created via `type()`", - ); + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid base for class created via `{}`", + kind.function_name() + )); diagnostic.set_primary_message(format_args!( "Has type `{}`", base.display(db) )); match class_base { ClassBase::Generic => { - diagnostic.info( - "Classes created via `type()` cannot be generic", - ); + diagnostic.info(format_args!( + "Classes created via `{}` cannot be generic", + kind.function_name() + )); diagnostic.info(format_args!( "Consider using `class {name}(Generic[...]): ...` instead" )); } ClassBase::TypedDict => { - diagnostic.info( - "Classes created via `type()` cannot be TypedDicts", - ); + diagnostic.info(format_args!( + "Classes created via `{}` cannot be TypedDicts", + kind.function_name() + )); diagnostic.info(format_args!( "Consider using `TypedDict(\"{name}\", {{}})` instead" )); @@ -7431,16 +8180,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .context .report_lint(&UNSUPPORTED_DYNAMIC_BASE, diagnostic_node) { - let mut diagnostic = builder.into_diagnostic( - "Unsupported base for class created via `type()`", - ); + let mut diagnostic = builder.into_diagnostic(format_args!( + "Unsupported base for class created via `{}`", + kind.function_name() + )); diagnostic.set_primary_message(format_args!( "Has type `{}`", base.display(db) )); - diagnostic.info( - "Classes created via `type()` cannot be protocols", - ); + diagnostic.info(format_args!( + "Classes created via `{}` cannot be protocols", + kind.function_name() + )); diagnostic.info(format_args!( "Consider using `class {name}(Protocol): ...` instead" )); @@ -7463,7 +8214,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } // Enum subclasses require the EnumMeta metaclass, which - // expects special dict attributes that `type()` doesn't provide. + // expects special dict attributes that dynamic class creation doesn't provide. if let Some((static_class, _)) = class_type.static_class_literal(db) { @@ -7473,15 +8224,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .report_lint(&INVALID_BASE, diagnostic_node) { let mut diagnostic = builder.into_diagnostic( - "Invalid base for class created via `type()`", + format_args!( + "Invalid base for class created via `{}`", + kind.function_name() + ), ); diagnostic.set_primary_message(format_args!( "Has type `{}`", base.display(db) )); - diagnostic.info( - "Creating an enum class via `type()` is not supported", - ); + diagnostic.info(format_args!( + "Creating an enum class via `{}` is not supported", + kind.function_name() + )); diagnostic.info(format_args!( "Consider using `Enum(\"{name}\", [])` instead" )); @@ -7549,27 +8304,48 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ClassBase::unknown() }) .collect() - }) - .unwrap_or_else(|| { - if !bases_type.is_assignable_to( - db, - Type::homogeneous_tuple(db, KnownClass::Type.to_instance(db)), - ) && let Some(builder) = - self.context.report_lint(&INVALID_ARGUMENT_TYPE, bases_node) - { - let mut diagnostic = builder - .into_diagnostic("Invalid argument to parameter 2 (`bases`) of `type()`"); - diagnostic.set_primary_message(format_args!( - "Expected `tuple[type, ...]`, found `{}`", - bases_type.display(db) - )); - } - Box::from([ClassBase::unknown()]) }); (bases, disjoint_bases) } + /// Extract base classes from the second argument of a `type()` call. + /// + /// 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>]>, IncompatibleBases<'db>) { + let db = self.db(); + + let (bases, disjoint_bases) = + self.extract_dynamic_bases(bases_node, bases_type, name, DynamicClassKind::Type); + + let bases = bases.unwrap_or_else(|| { + // The bases argument is not a fixed-length tuple, so we can't extract the bases. + // Check if it's at least assignable to `tuple[type, ...]` and emit an error if not. + if !bases_type.is_assignable_to( + db, + Type::homogeneous_tuple(db, KnownClass::Type.to_instance(db)), + ) && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, bases_node) + { + let mut diagnostic = builder + .into_diagnostic("Invalid argument to parameter 2 (`bases`) of `type()`"); + diagnostic.set_primary_message(format_args!( + "Expected `tuple[type, ...]`, found `{}`", + bases_type.display(db) + )); + } + Box::from([ClassBase::unknown()]) + }); + + (bases, disjoint_bases) + } + fn infer_annotated_assignment_statement(&mut self, assignment: &ast::StmtAnnAssign) { if assignment.target.is_name_expr() { self.infer_definition(assignment); @@ -10773,6 +11549,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return self.infer_namedtuple_call_expression(call_expression, None, namedtuple_kind); } + // Handle `dataclasses.make_dataclass(cls_name, fields, ...)`. + if callable_type + .as_function_literal() + .is_some_and(|f| f.is_known(self.db(), KnownFunction::MakeDataclass)) + { + return self.infer_make_dataclass_call_expression(call_expression, None); + } + // We don't call `Type::try_call`, because we want to perform type inference on the // arguments after matching them to parameters, but before checking that the argument types // are assignable to any parameter annotations. @@ -15978,3 +16762,22 @@ impl std::fmt::Display for NamedTupleKind { }) } } + +/// The kind of dynamic class being created. +#[derive(Copy, Clone, Debug)] +enum DynamicClassKind { + /// Created via `type(name, bases, dict)`. + Type, + /// Created via `dataclasses.make_dataclass(...)`. + MakeDataclass, +} + +impl DynamicClassKind { + /// Returns the function name for use in diagnostic messages. + const fn function_name(self) -> &'static str { + match self { + Self::Type => "type()", + Self::MakeDataclass => "make_dataclass()", + } + } +} diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index d4b21684ce53b..05828e1fad49c 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1076,6 +1076,15 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } Type::unknown() } + KnownInstanceType::DataclassSpec(_) => { + self.infer_type_expression(&subscript.slice); + if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { + builder.into_diagnostic(format_args!( + "`make_dataclass` specs cannot be specialized", + )); + } + Type::unknown() + } }, Type::Dynamic(DynamicType::UnknownGeneric(_)) => { self.infer_explicit_type_alias_specialization(subscript, value_ty, true) diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 43d681b4afc7a..35d9e084b609a 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -39,10 +39,9 @@ impl<'db> Type<'db> { // Dynamic classes created via `type()` don't have special instance types. // TODO: When we add functional TypedDict support, this branch should check // for TypedDict and return `Type::typed_dict(class)` for that case. - ClassLiteral::Dynamic(_) => { - Type::NominalInstance(NominalInstanceType(NominalInstanceInner::NonTuple(class))) - } - ClassLiteral::DynamicNamedTuple(_) => { + ClassLiteral::Dynamic(_) + | ClassLiteral::DynamicNamedTuple(_) + | ClassLiteral::DynamicDataclass(_) => { Type::NominalInstance(NominalInstanceType(NominalInstanceInner::NonTuple(class))) } ClassLiteral::Static(class_literal) => { diff --git a/crates/ty_python_semantic/src/types/mro.rs b/crates/ty_python_semantic/src/types/mro.rs index 0c838ec0c3261..5a1372f14fd71 100644 --- a/crates/ty_python_semantic/src/types/mro.rs +++ b/crates/ty_python_semantic/src/types/mro.rs @@ -5,11 +5,11 @@ use indexmap::IndexMap; use rustc_hash::{FxBuildHasher, FxHashSet}; use crate::Db; +use crate::types::class::{DynamicClassLiteral, DynamicDataclassLiteral}; use crate::types::class_base::ClassBase; use crate::types::generics::Specialization; use crate::types::{ - ClassLiteral, ClassType, DynamicClassLiteral, KnownInstanceType, SpecialFormType, - StaticClassLiteral, Type, + ClassLiteral, ClassType, KnownInstanceType, SpecialFormType, StaticClassLiteral, Type, }; /// The inferred method resolution order of a given class. @@ -320,14 +320,15 @@ impl<'db> Mro<'db> { ]) } - /// Attempt to resolve the MRO of a dynamic class (created via `type(name, bases, dict)`). + /// Attempt to resolve the MRO of a dynamic class literal with explicit bases. /// + /// Works for both `type(name, bases, dict)` and `make_dataclass(...)`. /// Uses C3 linearization when possible, returning an error if the MRO cannot be resolved. - pub(super) fn of_dynamic_class( + pub(super) fn of_dynamic( db: &'db dyn Db, - dynamic: DynamicClassLiteral<'db>, + literal: DynamicLiteralWithBases<'db>, ) -> Result> { - let bases = dynamic.bases(db); + let bases = literal.bases(db); // Check for duplicate bases first, but skip dynamic bases like `Unknown` or `Any`. let mut seen = FxHashSet::default(); @@ -343,7 +344,7 @@ impl<'db> Mro<'db> { if !duplicates.is_empty() { return Err( DynamicMroErrorKind::DuplicateBases(duplicates.into_boxed_slice()) - .into_error(db, dynamic), + .into_error(db, literal), ); } @@ -376,7 +377,7 @@ impl<'db> Mro<'db> { match mro_bases { Some(mro) => { - let mut result = vec![ClassBase::Class(ClassType::NonGeneric(dynamic.into()))]; + let mut result = vec![ClassBase::Class(ClassType::NonGeneric(literal.into()))]; result.extend(mro); Ok(Self::from(result)) } @@ -384,24 +385,24 @@ impl<'db> Mro<'db> { // C3 merge failed. If there are dynamic bases, use the fallback MRO. // Otherwise, report an error. if has_dynamic_bases { - Ok(Self::dynamic_fallback(db, dynamic)) + Ok(Self::dynamic_fallback(db, literal)) } else { - Err(DynamicMroErrorKind::UnresolvableMro.into_error(db, dynamic)) + Err(DynamicMroErrorKind::UnresolvableMro.into_error(db, literal)) } } } } - /// Compute a fallback MRO for a dynamic class when `of_dynamic_class` fails. + /// Compute a fallback MRO for a dynamic class when `of_dynamic` fails. /// /// Iterates over base MROs sequentially with deduplication. - pub(super) fn dynamic_fallback(db: &'db dyn Db, dynamic: DynamicClassLiteral<'db>) -> Self { - let self_base = ClassBase::Class(ClassType::NonGeneric(dynamic.into())); + pub(super) fn dynamic_fallback(db: &'db dyn Db, literal: DynamicLiteralWithBases<'db>) -> Self { + let self_base = ClassBase::Class(ClassType::NonGeneric(literal.into())); let mut result = vec![self_base]; let mut seen = FxHashSet::default(); seen.insert(self_base); - for base in dynamic.bases(db) { + for base in literal.bases(db) { for item in base.mro(db, None) { if seen.insert(item) { result.push(item); @@ -503,6 +504,9 @@ impl<'db> MroIterator<'db> { ClassLiteral::DynamicNamedTuple(literal) => { ClassBase::Class(ClassType::NonGeneric(literal.into())) } + ClassLiteral::DynamicDataclass(literal) => { + ClassBase::Class(ClassType::NonGeneric(literal.into())) + } } } @@ -532,6 +536,14 @@ impl<'db> MroIterator<'db> { full_mro_iter.next(); full_mro_iter } + ClassLiteral::DynamicDataclass(literal) => { + let mut full_mro_iter = match literal.try_mro(self.db) { + Ok(mro) => mro.iter(), + Err(error) => error.fallback_mro().iter(), + }; + full_mro_iter.next(); + full_mro_iter + } }) } } @@ -713,11 +725,52 @@ impl<'db> DynamicMroErrorKind<'db> { fn into_error( self, db: &'db dyn Db, - class_literal: DynamicClassLiteral<'db>, + literal: DynamicLiteralWithBases<'db>, ) -> DynamicMroError<'db> { DynamicMroError { kind: self, - fallback_mro: Mro::dynamic_fallback(db, class_literal), + fallback_mro: Mro::dynamic_fallback(db, literal), + } + } +} + +/// Dynamic class literals that have explicit bases and use C3 linearization for MRO. +/// +/// This enum distinguishes dynamic classes created via `type(name, bases, dict)` or +/// `make_dataclass(...)` from other dynamic types like `NamedTuple` which have +/// fixed MRO structures. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub(super) enum DynamicLiteralWithBases<'db> { + Class(DynamicClassLiteral<'db>), + Dataclass(DynamicDataclassLiteral<'db>), +} + +impl<'db> DynamicLiteralWithBases<'db> { + fn bases(self, db: &'db dyn Db) -> &'db [ClassBase<'db>] { + match self { + Self::Class(class) => class.bases(db), + Self::Dataclass(class) => class.bases(db), + } + } +} + +impl<'db> From> for DynamicLiteralWithBases<'db> { + fn from(literal: DynamicClassLiteral<'db>) -> Self { + Self::Class(literal) + } +} + +impl<'db> From> for DynamicLiteralWithBases<'db> { + fn from(literal: DynamicDataclassLiteral<'db>) -> Self { + Self::Dataclass(literal) + } +} + +impl<'db> From> for ClassLiteral<'db> { + fn from(literal: DynamicLiteralWithBases<'db>) -> Self { + match literal { + DynamicLiteralWithBases::Class(class) => class.into(), + DynamicLiteralWithBases::Dataclass(class) => class.into(), } } } diff --git a/ty.schema.json b/ty.schema.json index 498c4880e3c59..7759bc85e5c17 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1222,7 +1222,7 @@ }, "unsupported-dynamic-base": { "title": "detects dynamic class bases that are unsupported as ty could not feasibly calculate the class's MRO", - "description": "## What it does\nChecks for dynamic class definitions (using `type()`) that have bases\nwhich are unsupported by ty.\n\nThis is equivalent to [`unsupported-base`] but applies to classes created\nvia `type()` rather than `class` statements.\n\n## Why is this bad?\nIf a dynamically created class has a base that is an unsupported type\nsuch as `type[T]`, ty will not be able to resolve the\n[method resolution order] (MRO) for the class. This may lead to an inferior\nunderstanding of your codebase and unpredictable type-checking behavior.\n\n## Default level\nThis rule is disabled by default because it will not cause a runtime error,\nand may be noisy on codebases that use `type()` in highly dynamic ways.\n\n## Examples\n```python\ndef factory(base: type[Base]) -> type:\n # `base` has type `type[Base]`, not `type[Base]` itself\n return type(\"Dynamic\", (base,), {}) # error: [unsupported-dynamic-base]\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order\n[`unsupported-base`]: https://docs.astral.sh/ty/rules/unsupported-base", + "description": "## What it does\nChecks for dynamic class definitions that have bases which are unsupported by ty.\n\nThis is equivalent to [`unsupported-base`] but applies to classes created\ndynamically via `type()`, `make_dataclass()`, or similar functions rather\nthan `class` statements.\n\n## Why is this bad?\nIf a dynamically created class has a base that is an unsupported type\nsuch as `type[T]`, ty will not be able to resolve the\n[method resolution order] (MRO) for the class. This may lead to an inferior\nunderstanding of your codebase and unpredictable type-checking behavior.\n\n## Default level\nThis rule is disabled by default because it will not cause a runtime error,\nand may be noisy on codebases that use dynamic class creation in highly dynamic ways.\n\n## Examples\n```python\ndef factory(base: type[Base]) -> type:\n # `base` has type `type[Base]`, not `type[Base]` itself\n return type(\"Dynamic\", (base,), {}) # error: [unsupported-dynamic-base]\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order\n[`unsupported-base`]: https://docs.astral.sh/ty/rules/unsupported-base", "default": "ignore", "oneOf": [ {