diff --git a/crates/ty/docs/environment.md b/crates/ty/docs/environment.md index 30935a8a09..c4552f2e58 100644 --- a/crates/ty/docs/environment.md +++ b/crates/ty/docs/environment.md @@ -33,6 +33,10 @@ when necessary, e.g. to watch for file system changes or a dedicated UI thread. ty also reads the following externally defined environment variables: +### `CONDA_DEFAULT_ENV` + +Used to determine if an active Conda environment is the base environment or not. + ### `CONDA_PREFIX` Used to detect an activated Conda environment location. diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index cd207b21f3..3043c84eaa 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -36,7 +36,7 @@ def test(): -> "int": Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20call-non-callable) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L109) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L110) **What it does** @@ -58,7 +58,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20conflicting-argument-forms) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L153) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L154) **What it does** @@ -88,7 +88,7 @@ f(int) # error Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20conflicting-declarations) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L179) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L180) **What it does** @@ -117,7 +117,7 @@ a = 1 Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20conflicting-metaclass) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L204) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L205) **What it does** @@ -147,7 +147,7 @@ class C(A, B): ... Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20cyclic-class-definition) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L230) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L231) **What it does** @@ -177,7 +177,7 @@ class B(A): ... Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20duplicate-base) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L295) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L296) **What it does** @@ -202,7 +202,7 @@ class B(A, A): ... Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20duplicate-kw-only) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L316) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L317) **What it does** @@ -306,7 +306,7 @@ def test(): -> "Literal[5]": Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20inconsistent-mro) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L519) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L520) **What it does** @@ -334,7 +334,7 @@ class C(A, B): ... Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20index-out-of-bounds) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L543) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L544) **What it does** @@ -358,7 +358,7 @@ t[3] # IndexError: tuple index out of range Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20instance-layout-conflict) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L348) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L349) **What it does** @@ -445,7 +445,7 @@ an atypical memory layout. Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-argument-type) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L588) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L589) **What it does** @@ -470,7 +470,7 @@ func("foo") # error: [invalid-argument-type] Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-assignment) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L628) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L629) **What it does** @@ -496,7 +496,7 @@ a: int = '' Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-attribute-access) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1662) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1663) **What it does** @@ -528,7 +528,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-await) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L650) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L651) **What it does** @@ -562,7 +562,7 @@ asyncio.run(main()) Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-base) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L680) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L681) **What it does** @@ -584,7 +584,7 @@ class A(42): ... # error: [invalid-base] Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-context-manager) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L731) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L732) **What it does** @@ -609,7 +609,7 @@ with 1: Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-declaration) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L752) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L753) **What it does** @@ -636,7 +636,7 @@ a: str Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-exception-caught) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L775) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L776) **What it does** @@ -678,7 +678,7 @@ except ZeroDivisionError: Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-generic-class) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L811) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L812) **What it does** @@ -709,7 +709,7 @@ class C[U](Generic[T]): ... Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-key) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L563) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L564) **What it does** @@ -738,7 +738,7 @@ alice["height"] # KeyError: 'height' Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-legacy-type-variable) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L837) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L838) **What it does** @@ -771,7 +771,7 @@ def f(t: TypeVar("U")): ... Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-metaclass) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L886) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L887) **What it does** @@ -803,7 +803,7 @@ class B(metaclass=f): ... Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-named-tuple) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L493) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L494) **What it does** @@ -833,7 +833,7 @@ TypeError: can only inherit from a NamedTuple type and Generic Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-overload) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L913) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L914) **What it does** @@ -881,7 +881,7 @@ def foo(x: int) -> int: ... Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-parameter-default) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L956) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L957) **What it does** @@ -905,7 +905,7 @@ def f(a: int = ''): ... Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-protocol) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L430) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L431) **What it does** @@ -937,7 +937,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-raise) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L976) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L977) Checks for `raise` statements that raise non-exceptions or use invalid @@ -984,7 +984,7 @@ def g(): Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-return-type) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L609) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L610) **What it does** @@ -1007,7 +1007,7 @@ def func() -> int: Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-super-argument) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1019) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1020) **What it does** @@ -1061,7 +1061,7 @@ TODO #14889 Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-alias-type) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L865) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L866) **What it does** @@ -1086,7 +1086,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-checking-constant) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1058) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1059) **What it does** @@ -1114,7 +1114,7 @@ TYPE_CHECKING = '' Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-form) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1082) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1083) **What it does** @@ -1142,7 +1142,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-guard-call) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1134) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1135) **What it does** @@ -1174,7 +1174,7 @@ f(10) # Error Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-guard-definition) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1106) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1107) **What it does** @@ -1206,7 +1206,7 @@ class C: Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-variable-constraints) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1162) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1163) **What it does** @@ -1239,7 +1239,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20missing-argument) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1191) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1192) **What it does** @@ -1257,12 +1257,43 @@ def func(x: int): ... func() # TypeError: func() missing 1 required positional argument: 'x' ``` +## `missing-typed-dict-key` + + +Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · +[Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20missing-typed-dict-key) · +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1762) + + +**What it does** + +Detects missing required keys in `TypedDict` constructor calls. + +**Why is this bad?** + +`TypedDict` requires all non-optional keys to be provided during construction. +Missing items can lead to a `KeyError` at runtime. + +**Example** + +```python +from typing import TypedDict + +class Person(TypedDict): + name: str + age: int + +alice: Person = {"name": "Alice"} # missing required key 'age' + +alice["age"] # KeyError +``` + ## `no-matching-overload` Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20no-matching-overload) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1210) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1211) **What it does** @@ -1289,7 +1320,7 @@ func("string") # error: [no-matching-overload] Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20non-subscriptable) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1233) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1234) **What it does** @@ -1311,7 +1342,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20not-iterable) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1251) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1252) **What it does** @@ -1335,7 +1366,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20parameter-already-assigned) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1302) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1303) **What it does** @@ -1389,7 +1420,7 @@ def test(): -> "int": Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20static-assert-error) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1638) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1639) **What it does** @@ -1417,7 +1448,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20subclass-of-final-class) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1393) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1394) **What it does** @@ -1444,7 +1475,7 @@ class B(A): ... # Error raised here Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20too-many-positional-arguments) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1438) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1439) **What it does** @@ -1469,7 +1500,7 @@ f("foo") # Error raised here Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20type-assertion-failure) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1416) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1417) **What it does** @@ -1495,7 +1526,7 @@ def _(x: int): Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unavailable-implicit-super-arguments) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1459) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1460) **What it does** @@ -1539,7 +1570,7 @@ class A: Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unknown-argument) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1516) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1517) **What it does** @@ -1564,7 +1595,7 @@ f(x=1, y=2) # Error raised here Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-attribute) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1537) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1538) **What it does** @@ -1590,7 +1621,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-import) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1559) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1560) **What it does** @@ -1613,7 +1644,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-reference) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1578) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1579) **What it does** @@ -1636,7 +1667,7 @@ print(x) # NameError: name 'x' is not defined Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unsupported-bool-conversion) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1271) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1272) **What it does** @@ -1671,7 +1702,7 @@ b1 < b2 < b1 # exception raised here Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unsupported-operator) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1597) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1598) **What it does** @@ -1697,7 +1728,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' Default level: [`error`](../rules.md#rule-levels "This lint has a default level of 'error'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20zero-stepsize-in-slice) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1619) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1620) **What it does** @@ -1720,7 +1751,7 @@ l[1:10:0] # ValueError: slice step cannot be zero Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20ambiguous-protocol-member) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L458) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L459) **What it does** @@ -1759,7 +1790,7 @@ class SubProto(BaseProto, Protocol): Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20deprecated) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L274) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L275) **What it does** @@ -1812,7 +1843,7 @@ a = 20 / 0 # type: ignore Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-unbound-attribute) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1323) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1324) **What it does** @@ -1838,7 +1869,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-unbound-implicit-call) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L127) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L128) **What it does** @@ -1868,7 +1899,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-unbound-import) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1345) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1346) **What it does** @@ -1898,7 +1929,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20redundant-cast) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1690) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1691) **What it does** @@ -1923,7 +1954,7 @@ cast(int, f()) # Redundant Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20undefined-reveal) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1498) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1499) **What it does** @@ -1974,7 +2005,7 @@ a = 20 / 0 # ty: ignore[division-by-zero] Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-global) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1711) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1712) **What it does** @@ -2028,7 +2059,7 @@ def g(): Default level: [`warn`](../rules.md#rule-levels "This lint has a default level of 'warn'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unsupported-base) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L698) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L699) **What it does** @@ -2065,7 +2096,7 @@ class D(C): ... # error: [unsupported-base] Default level: [`ignore`](../rules.md#rule-levels "This lint has a default level of 'ignore'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20division-by-zero) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L256) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L257) **What it does** @@ -2087,7 +2118,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. Default level: [`ignore`](../rules.md#rule-levels "This lint has a default level of 'ignore'.") · [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-unresolved-reference) · -[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1371) +[View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1372) **What it does** diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_type_qualifiers.md b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_type_qualifiers.md index 271b6fd014..1c02eac9f0 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_type_qualifiers.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_type_qualifiers.md @@ -6,14 +6,12 @@ Several type qualifiers are unsupported by ty currently. However, we also don't errors if you use one in an annotation: ```py -from typing_extensions import Final, Required, NotRequired, ReadOnly, TypedDict +from typing_extensions import Final, ReadOnly, TypedDict X: Final = 42 Y: Final[int] = 42 class Bar(TypedDict): - x: Required[int] - y: NotRequired[str] z: ReadOnly[bytes] ``` @@ -25,23 +23,16 @@ One thing that is supported is error messages for using type qualifiers in type from typing_extensions import Final, ClassVar, Required, NotRequired, ReadOnly def _( - a: ( - Final # error: [invalid-type-form] "Type qualifier `typing.Final` is not allowed in type expressions (only in annotation expressions)" - | int - ), - b: ( - ClassVar # error: [invalid-type-form] "Type qualifier `typing.ClassVar` is not allowed in type expressions (only in annotation expressions)" - | int - ), - c: Required, # error: [invalid-type-form] "Type qualifier `typing.Required` is not allowed in type expressions (only in annotation expressions, and only with exactly one argument)" - d: NotRequired, # error: [invalid-type-form] "Type qualifier `typing.NotRequired` is not allowed in type expressions (only in annotation expressions, and only with exactly one argument)" - e: ReadOnly, # error: [invalid-type-form] "Type qualifier `typing.ReadOnly` is not allowed in type expressions (only in annotation expressions, and only with exactly one argument)" + # error: [invalid-type-form] "Type qualifier `typing.Final` is not allowed in type expressions (only in annotation expressions)" + a: Final | int, + # error: [invalid-type-form] "Type qualifier `typing.ClassVar` is not allowed in type expressions (only in annotation expressions)" + b: ClassVar | int, + # error: [invalid-type-form] "Type qualifier `typing.ReadOnly` is not allowed in type expressions (only in annotation expressions, and only with exactly one argument)" + c: ReadOnly | int, ) -> None: reveal_type(a) # revealed: Unknown | int reveal_type(b) # revealed: Unknown | int - reveal_type(c) # revealed: Unknown - reveal_type(d) # revealed: Unknown - reveal_type(e) # revealed: Unknown + reveal_type(c) # revealed: Unknown | int ``` ## Inheritance @@ -53,7 +44,5 @@ from typing_extensions import Final, ClassVar, Required, NotRequired, ReadOnly class A(Final): ... # error: [invalid-base] class B(ClassVar): ... # error: [invalid-base] -class C(Required): ... # error: [invalid-base] -class D(NotRequired): ... # error: [invalid-base] -class E(ReadOnly): ... # error: [invalid-base] +class C(ReadOnly): ... # error: [invalid-base] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index cdc8329816..19c53533a4 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -21,12 +21,10 @@ inferred based on the `TypedDict` definition: ```py alice: Person = {"name": "Alice", "age": 30} -# TODO: this should be `str` -reveal_type(alice["name"]) # revealed: Unknown -# TODO: this should be `int | None` -reveal_type(alice["age"]) # revealed: Unknown +reveal_type(alice["name"]) # revealed: str +reveal_type(alice["age"]) # revealed: int | None -# TODO: this should reveal `Unknown`, and it should emit an error +# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "non_existing"" reveal_type(alice["non_existing"]) # revealed: Unknown ``` @@ -51,23 +49,26 @@ bob.update(age=26) The construction of a `TypedDict` is checked for type correctness: ```py -# TODO: these should be errors (invalid argument type) +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`" eve1a: Person = {"name": b"Eve", "age": None} +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`" eve1b = Person(name=b"Eve", age=None) -# TODO: these should be errors (missing required key) +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" eve2a: Person = {"age": 22} +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" eve2b = Person(age=22) -# TODO: these should be errors (additional key) +# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" eve3a: Person = {"name": "Eve", "age": 25, "extra": True} +# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" eve3b = Person(name="Eve", age=25, extra=True) ``` Assignments to keys are also validated: ```py -# TODO: this should be an error +# error: [invalid-assignment] "Invalid assignment to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" alice["name"] = None # error: [invalid-assignment] "Invalid assignment to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" @@ -77,13 +78,221 @@ bob["name"] = None Assignments to non-existing keys are disallowed: ```py -# TODO: this should be an error +# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" alice["extra"] = True # error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" bob["extra"] = True ``` +## Validation of `TypedDict` construction + +```py +from typing import TypedDict + +class Person(TypedDict): + name: str + age: int | None + +class House: + owner: Person + +house = House() + +def accepts_person(p: Person) -> None: + pass +``` + +The following constructions of `Person` are all valid: + +```py +alice1: Person = {"name": "Alice", "age": 30} +Person(name="Alice", age=30) +Person({"name": "Alice", "age": 30}) + +accepts_person({"name": "Alice", "age": 30}) +house.owner = {"name": "Alice", "age": 30} +``` + +All of these are missing the required `age` field: + +```py +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +alice2: Person = {"name": "Alice"} +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +Person(name="Alice") +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +Person({"name": "Alice"}) + +# TODO: this should be an error, similar to the above +accepts_person({"name": "Alice"}) +# TODO: this should be an error, similar to the above +house.owner = {"name": "Alice"} +``` + +All of these have an invalid type for the `name` field: + +```py +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" +alice3: Person = {"name": None, "age": 30} +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" +Person(name=None, age=30) +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" +Person({"name": None, "age": 30}) + +# TODO: this should be an error, similar to the above +accepts_person({"name": None, "age": 30}) +# TODO: this should be an error, similar to the above +house.owner = {"name": None, "age": 30} +``` + +All of these have an extra field that is not defined in the `TypedDict`: + +```py +# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +alice4: Person = {"name": "Alice", "age": 30, "extra": True} +# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +Person(name="Alice", age=30, extra=True) +# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +Person({"name": "Alice", "age": 30, "extra": True}) + +# TODO: this should be an error +accepts_person({"name": "Alice", "age": 30, "extra": True}) +# TODO: this should be an error +house.owner = {"name": "Alice", "age": 30, "extra": True} +``` + +## Type ignore compatibility issues + +Users should be able to ignore TypedDict validation errors with `# type: ignore` + +```py +from typing import TypedDict + +class Person(TypedDict): + name: str + age: int + +alice_bad: Person = {"name": None} # type: ignore +Person(name=None, age=30) # type: ignore +Person(name="Alice", age=30, extra=True) # type: ignore +``` + +## Positional dictionary constructor pattern + +The positional dictionary constructor pattern (used by libraries like strawberry) should work +correctly: + +```py +from typing import TypedDict + +class User(TypedDict): + name: str + age: int + +# Valid usage - all required fields provided +user1 = User({"name": "Alice", "age": 30}) + +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `User` constructor" +user2 = User({"name": "Bob"}) + +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `User`: value of type `None`" +user3 = User({"name": None, "age": 25}) + +# error: [invalid-key] "Invalid key access on TypedDict `User`: Unknown key "extra"" +user4 = User({"name": "Charlie", "age": 30, "extra": True}) +``` + +## Optional fields with `total=False` + +By default, all fields in a `TypedDict` are required (`total=True`). You can make all fields +optional by setting `total=False`: + +```py +from typing import TypedDict + +class OptionalPerson(TypedDict, total=False): + name: str + age: int | None + +# All fields are optional with total=False +charlie = OptionalPerson() +david = OptionalPerson(name="David") +emily = OptionalPerson(age=30) +frank = OptionalPerson(name="Frank", age=25) + +# TODO: we could emit an error here, because these fields are not guaranteed to exist +reveal_type(charlie["name"]) # revealed: str +reveal_type(david["age"]) # revealed: int | None +``` + +Type validation still applies to provided fields: + +```py +# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `OptionalPerson`" +invalid = OptionalPerson(name=123) +``` + +Extra fields are still not allowed, even with `total=False`: + +```py +# error: [invalid-key] "Invalid key access on TypedDict `OptionalPerson`: Unknown key "extra"" +invalid_extra = OptionalPerson(name="George", extra=True) +``` + +## `Required` and `NotRequired` + +You can have fine-grained control over field requirements using `Required` and `NotRequired` +qualifiers, which override the class-level `total=` setting: + +```py +from typing_extensions import TypedDict, Required, NotRequired + +# total=False by default, but id is explicitly Required +class Message(TypedDict, total=False): + id: Required[int] # Always required, even though total=False + content: str # Optional due to total=False + timestamp: NotRequired[str] # Explicitly optional (redundant here) + +# total=True by default, but content is explicitly NotRequired +class User(TypedDict): + name: str # Required due to total=True (default) + email: Required[str] # Explicitly required (redundant here) + bio: NotRequired[str] # Optional despite total=True + +# Valid Message constructions +msg1 = Message(id=1) # id required, content optional +msg2 = Message(id=2, content="Hello") # both provided +msg3 = Message(id=3, timestamp="2024-01-01") # id required, timestamp optional + +# Valid User constructions +user1 = User(name="Alice", email="alice@example.com") # required fields +user2 = User(name="Bob", email="bob@example.com", bio="Developer") # with optional bio + +reveal_type(msg1["id"]) # revealed: int +reveal_type(msg1["content"]) # revealed: str +reveal_type(user1["name"]) # revealed: str +reveal_type(user1["bio"]) # revealed: str +``` + +Constructor validation respects `Required`/`NotRequired` overrides: + +```py +# error: [missing-typed-dict-key] "Missing required key 'id' in TypedDict `Message` constructor" +invalid_msg = Message(content="Hello") # Missing required id + +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `User` constructor" +# error: [missing-typed-dict-key] "Missing required key 'email' in TypedDict `User` constructor" +invalid_user = User(bio="No name provided") # Missing required name and email +``` + +Type validation still applies to all fields when provided: + +```py +# error: [invalid-argument-type] "Invalid argument to key "id" with declared type `int` on TypedDict `Message`" +invalid_type = Message(id="not-an-int", content="Hello") +``` + ## Structural assignability Assignability between `TypedDict` types is structural, that is, it is based on the presence of keys @@ -134,8 +343,7 @@ alice: Person = {"name": "Alice"} # TODO: this should be an invalid-assignment error dangerous(alice) -# TODO: this should be `str` -reveal_type(alice["name"]) # revealed: Unknown +reveal_type(alice["name"]) # revealed: str ``` ## Key-based access @@ -224,6 +432,20 @@ def _(person: Person, unknown_key: Any): person[unknown_key] = "Eve" ``` +## `ReadOnly` + +`ReadOnly` is not supported yet, but this test makes sure that we do not emit any false positive +diagnostics: + +```py +from typing_extensions import TypedDict, ReadOnly, Required + +class Person(TypedDict, total=False): + id: ReadOnly[Required[int]] + name: str + age: int | None +``` + ## Methods on `TypedDict` ```py @@ -340,10 +562,79 @@ class Employee(Person): alice: Employee = {"name": "Alice", "employee_id": 1} -# TODO: this should be an error (missing required key) +# error: [missing-typed-dict-key] "Missing required key 'employee_id' in TypedDict `Employee` constructor" eve: Employee = {"name": "Eve"} ``` +When inheriting from a `TypedDict` with a different `total` setting, inherited fields maintain their +original requirement status, while new fields follow the child class's `total` setting: + +```py +from typing import TypedDict + +# Case 1: total=True parent, total=False child +class PersonBase(TypedDict): + id: int # required (from total=True) + name: str # required (from total=True) + +class PersonOptional(PersonBase, total=False): + age: int # optional (from total=False) + email: str # optional (from total=False) + +# Inherited fields keep their original requirement status +person1 = PersonOptional(id=1, name="Alice") # Valid - id/name still required +person2 = PersonOptional(id=1, name="Alice", age=25) # Valid - age optional +person3 = PersonOptional(id=1, name="Alice", email="alice@test.com") # Valid + +# These should be errors - missing required inherited fields +# error: [missing-typed-dict-key] "Missing required key 'id' in TypedDict `PersonOptional` constructor" +person_invalid1 = PersonOptional(name="Bob") + +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `PersonOptional` constructor" +person_invalid2 = PersonOptional(id=2) + +# Case 2: total=False parent, total=True child +class PersonBaseOptional(TypedDict, total=False): + id: int # optional (from total=False) + name: str # optional (from total=False) + +class PersonRequired(PersonBaseOptional): # total=True by default + age: int # required (from total=True) + +# New fields in child are required, inherited fields stay optional +person4 = PersonRequired(age=30) # Valid - only age required, id/name optional +person5 = PersonRequired(id=1, name="Charlie", age=35) # Valid - all provided + +# This should be an error - missing required new field +# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `PersonRequired` constructor" +person_invalid3 = PersonRequired(id=3, name="David") +``` + +This also works with `Required` and `NotRequired`: + +```py +from typing_extensions import TypedDict, Required, NotRequired + +# Case 3: Mixed inheritance with Required/NotRequired +class PersonMixed(TypedDict, total=False): + id: Required[int] # required despite total=False + name: str # optional due to total=False + +class Employee(PersonMixed): # total=True by default + department: str # required due to total=True + +# id stays required (Required override), name stays optional, department is required +emp1 = Employee(id=1, department="Engineering") # Valid +emp2 = Employee(id=2, name="Eve", department="Sales") # Valid + +# Errors for missing required keys +# error: [missing-typed-dict-key] "Missing required key 'id' in TypedDict `Employee` constructor" +emp_invalid1 = Employee(department="HR") + +# error: [missing-typed-dict-key] "Missing required key 'department' in TypedDict `Employee` constructor" +emp_invalid2 = Employee(id=3) +``` + ## Generic `TypedDict` `TypedDict`s can also be generic. @@ -362,7 +653,7 @@ class TaggedData(TypedDict, Generic[T]): p1: TaggedData[int] = {"data": 42, "tag": "number"} p2: TaggedData[str] = {"data": "Hello", "tag": "text"} -# TODO: this should be an error (type mismatch) +# error: [invalid-argument-type] "Invalid argument to key "data" with declared type `int` on TypedDict `TaggedData`: value of type `Literal["not a number"]`" p3: TaggedData[int] = {"data": "not a number", "tag": "number"} ``` @@ -383,7 +674,7 @@ class TaggedData[T](TypedDict): p1: TaggedData[int] = {"data": 42, "tag": "number"} p2: TaggedData[str] = {"data": "Hello", "tag": "text"} -# TODO: this should be an error (type mismatch) +# error: [invalid-argument-type] "Invalid argument to key "data" with declared type `int` on TypedDict `TaggedData`: value of type `Literal["not a number"]`" p3: TaggedData[int] = {"data": "not a number", "tag": "number"} ``` @@ -475,4 +766,54 @@ def write_to_non_literal_string_key(person: Person, str_key: str): person[str_key] = "Alice" # error: [invalid-key] ``` +## Import aliases + +`TypedDict` can be imported with aliases and should work correctly: + +```py +from typing import TypedDict as TD +from typing_extensions import Required + +class UserWithAlias(TD, total=False): + name: Required[str] + age: int + +user_empty = UserWithAlias(name="Alice") # name is required +user_partial = UserWithAlias(name="Alice", age=30) + +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `UserWithAlias` constructor" +user_invalid = UserWithAlias(age=30) + +reveal_type(user_empty["name"]) # revealed: str +reveal_type(user_partial["age"]) # revealed: int +``` + +## Shadowing behavior + +When a local class shadows the `TypedDict` import, only the actual `TypedDict` import should be +treated as a `TypedDict`: + +```py +from typing import TypedDict as TD + +class TypedDict: + def __init__(self): + pass + +class NotActualTypedDict(TypedDict, total=True): + name: str + +class ActualTypedDict(TD, total=True): + name: str + +not_td = NotActualTypedDict() +reveal_type(not_td) # revealed: NotActualTypedDict + +# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `ActualTypedDict` constructor" +actual_td = ActualTypedDict() +actual_td = ActualTypedDict(name="Alice") +reveal_type(actual_td) # revealed: ActualTypedDict +reveal_type(actual_td["name"]) # revealed: str +``` + [`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index 41fc651dc1..56e5637e32 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -574,6 +574,16 @@ impl<'db> PlaceAndQualifiers<'db> { self.qualifiers.contains(TypeQualifiers::INIT_VAR) } + /// Returns `true` if the place has a `Required` type qualifier. + pub(crate) fn is_required(&self) -> bool { + self.qualifiers.contains(TypeQualifiers::REQUIRED) + } + + /// Returns `true` if the place has a `NotRequired` type qualifier. + pub(crate) fn is_not_required(&self) -> bool { + self.qualifiers.contains(TypeQualifiers::NOT_REQUIRED) + } + /// Returns `Some(…)` if the place is qualified with `typing.Final` without a specified type. pub(crate) fn is_bare_final(&self) -> Option { match self { diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index b1fb7dddc6..f8fbda0850 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -37,7 +37,6 @@ use crate::semantic_index::scope::ScopeId; use crate::semantic_index::{imported_modules, place_table, semantic_index}; use crate::suppression::check_suppressions; use crate::types::call::{Binding, Bindings, CallArguments, CallableBinding}; -use crate::types::class::{CodeGeneratorKind, Field}; pub(crate) use crate::types::class_base::ClassBase; use crate::types::constraints::{ Constraints, IteratorConstraintsExtension, OptionConstraintsExtension, @@ -63,10 +62,11 @@ use crate::types::mro::{Mro, MroError, MroIterator}; pub(crate) use crate::types::narrow::infer_narrowing_constraint; use crate::types::signatures::{Parameter, ParameterForm, Parameters, walk_signature}; use crate::types::tuple::TupleSpec; +pub(crate) use crate::types::typed_dict::{TypedDictParams, TypedDictType, walk_typed_dict_type}; use crate::types::variance::{TypeVarVariance, VarianceInferable}; use crate::unpack::EvaluationMode; pub use crate::util::diagnostics::add_inferred_python_version_hint_to_diagnostic; -use crate::{Db, FxOrderMap, FxOrderSet, Module, Program}; +use crate::{Db, FxOrderSet, Module, Program}; pub(crate) use class::{ClassLiteral, ClassType, GenericAlias, KnownClass}; use instance::Protocol; pub use instance::{NominalInstanceType, ProtocolInstanceType}; @@ -96,6 +96,7 @@ mod string_annotation; mod subclass_of; mod tuple; mod type_ordering; +mod typed_dict; mod unpacker; mod variance; mod visitor; @@ -1039,9 +1040,7 @@ impl<'db> Type<'db> { } pub(crate) fn typed_dict(defining_class: impl Into>) -> Self { - Self::TypedDict(TypedDictType { - defining_class: defining_class.into(), - }) + Self::TypedDict(TypedDictType::new(defining_class.into())) } #[must_use] @@ -5899,7 +5898,7 @@ impl<'db> Type<'db> { Type::AlwaysTruthy | Type::AlwaysFalsy => KnownClass::Type.to_instance(db), Type::BoundSuper(_) => KnownClass::Super.to_class_literal(db), Type::ProtocolInstance(protocol) => protocol.to_meta_type(db), - Type::TypedDict(typed_dict) => SubclassOfType::from(db, typed_dict.defining_class), + Type::TypedDict(typed_dict) => SubclassOfType::from(db, typed_dict.defining_class()), Type::TypeAlias(alias) => alias.value_type(db).to_meta_type(db), } } @@ -6366,7 +6365,7 @@ impl<'db> Type<'db> { }, Type::TypedDict(typed_dict) => { - Some(TypeDefinition::Class(typed_dict.defining_class.definition(db))) + Some(TypeDefinition::Class(typed_dict.defining_class().definition(db))) } Self::Union(_) | Self::Intersection(_) => None, @@ -6879,6 +6878,12 @@ bitflags! { const FINAL = 1 << 1; /// `dataclasses.InitVar` const INIT_VAR = 1 << 2; + /// `typing_extensions.Required` + const REQUIRED = 1 << 3; + /// `typing_extensions.NotRequired` + const NOT_REQUIRED = 1 << 4; + /// `typing_extensions.ReadOnly` + const READ_ONLY = 1 << 5; } } @@ -6894,6 +6899,8 @@ impl TypeQualifiers { Self::CLASS_VAR => "ClassVar", Self::FINAL => "Final", Self::INIT_VAR => "InitVar", + Self::REQUIRED => "Required", + Self::NOT_REQUIRED => "NotRequired", _ => { unreachable!("Only a single bit should be set when calling `TypeQualifiers::name`") } @@ -9849,43 +9856,6 @@ impl<'db> EnumLiteralType<'db> { } } -/// Type that represents the set of all inhabitants (`dict` instances) that conform to -/// a given `TypedDict` schema. -#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::Update, Hash, get_size2::GetSize)] -pub struct TypedDictType<'db> { - /// A reference to the class (inheriting from `typing.TypedDict`) that specifies the - /// schema of this `TypedDict`. - defining_class: ClassType<'db>, -} - -impl<'db> TypedDictType<'db> { - pub(crate) fn items(self, db: &'db dyn Db) -> FxOrderMap> { - let (class_literal, specialization) = self.defining_class.class_literal(db); - class_literal.fields(db, specialization, CodeGeneratorKind::TypedDict) - } - - pub(crate) fn apply_type_mapping_impl<'a>( - self, - db: &'db dyn Db, - type_mapping: &TypeMapping<'a, 'db>, - visitor: &ApplyTypeMappingVisitor<'db>, - ) -> Self { - Self { - defining_class: self - .defining_class - .apply_type_mapping_impl(db, type_mapping, visitor), - } - } -} - -fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( - db: &'db dyn Db, - typed_dict: TypedDictType<'db>, - visitor: &V, -) { - visitor.visit_type(db, typed_dict.defining_class.into()); -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum BoundSuperError<'db> { InvalidPivotClassType { diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 1c736dfc4b..58fe067963 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -27,13 +27,14 @@ use crate::types::generics::{GenericContext, Specialization, walk_specialization use crate::types::infer::nearest_enclosing_class; use crate::types::signatures::{CallableSignature, Parameter, Parameters, Signature}; use crate::types::tuple::{TupleSpec, TupleType}; +use crate::types::typed_dict::typed_dict_params_from_class_def; use crate::types::{ ApplyTypeMappingVisitor, Binding, BoundSuperError, BoundSuperType, CallableType, DataclassParams, DeprecatedInstance, HasRelationToVisitor, IsEquivalentVisitor, KnownInstanceType, ManualPEP695TypeAliasType, NormalizedVisitor, PropertyInstanceType, StringLiteralType, TypeAliasType, TypeMapping, TypeRelation, TypeVarBoundOrConstraints, - TypeVarInstance, TypeVarKind, VarianceInferable, declaration_type, infer_definition_types, - todo_type, + TypeVarInstance, TypeVarKind, TypedDictParams, VarianceInferable, declaration_type, + infer_definition_types, todo_type, }; use crate::{ Db, FxIndexMap, FxOrderSet, Program, @@ -1241,24 +1242,51 @@ impl MethodDecorator { } } +/// Kind-specific metadata for different types of fields +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FieldKind<'db> { + /// `NamedTuple` field metadata + NamedTuple { default_ty: Option> }, + /// dataclass field metadata + Dataclass { + /// The type of the default value for this field + default_ty: Option>, + /// Whether or not this field is "init-only". If this is true, it only appears in the + /// `__init__` signature, but is not accessible as a real field + init_only: bool, + /// Whether or not this field should appear in the signature of `__init__`. + init: bool, + /// Whether or not this field can only be passed as a keyword argument to `__init__`. + kw_only: Option, + }, + /// `TypedDict` field metadata + TypedDict { + /// Whether this field is required + is_required: bool, + }, +} + /// Metadata regarding a dataclass field/attribute or a `TypedDict` "item" / key-value pair. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Field<'db> { /// The declared type of the field pub(crate) declared_ty: Type<'db>, + /// Kind-specific metadata for this field + pub(crate) kind: FieldKind<'db>, +} - /// The type of the default value for this field - pub(crate) default_ty: Option>, - - /// Whether or not this field is "init-only". If this is true, it only appears in the - /// `__init__` signature, but is not accessible as a real field - pub(crate) init_only: bool, - - /// Whether or not this field should appear in the signature of `__init__`. - pub(crate) init: bool, - - /// Whether or not this field can only be passed as a keyword argument to `__init__`. - pub(crate) kw_only: Option, +impl Field<'_> { + pub(crate) const fn is_required(&self) -> bool { + match &self.kind { + FieldKind::NamedTuple { default_ty } => default_ty.is_none(), + // A dataclass field is NOT required if `default` (or `default_factory`) is set + // or if `init` has been set to `False`. + FieldKind::Dataclass { + init, default_ty, .. + } => default_ty.is_none() && *init, + FieldKind::TypedDict { is_required } => *is_required, + } + } } impl<'db> Field<'db> { @@ -1664,6 +1692,17 @@ impl<'db> ClassLiteral<'db> { .any(|base| matches!(base, ClassBase::TypedDict)) } + /// Compute `TypedDict` parameters dynamically based on MRO detection and AST parsing. + fn typed_dict_params(self, db: &'db dyn Db) -> Option { + if !self.is_typed_dict(db) { + return None; + } + + let module = parsed_module(db, self.file(db)).load(db); + let class_stmt = self.node(db, &module); + Some(typed_dict_params_from_class_def(class_stmt)) + } + /// Return the explicit `metaclass` of this class, if one is defined. /// /// ## Note @@ -1967,7 +2006,10 @@ impl<'db> ClassLiteral<'db> { } if CodeGeneratorKind::NamedTuple.matches(db, self) { - if let Some(field) = self.own_fields(db, specialization).get(name) { + if let Some(field) = self + .own_fields(db, specialization, CodeGeneratorKind::NamedTuple) + .get(name) + { let property_getter_signature = Signature::new( Parameters::new([Parameter::positional_only(Some(Name::new_static("self")))]), Some(field.declared_ty), @@ -2033,17 +2075,19 @@ impl<'db> ClassLiteral<'db> { Type::instance(db, self.apply_optional_specialization(db, specialization)); let signature_from_fields = |mut parameters: Vec<_>, return_ty: Option>| { - for ( - field_name, - field @ Field { - declared_ty: mut field_ty, - mut default_ty, - init_only: _, - init, - kw_only, - }, - ) in self.fields(db, specialization, field_policy) - { + for (field_name, field) in self.fields(db, specialization, field_policy) { + let (init, mut default_ty, kw_only) = match field.kind { + FieldKind::NamedTuple { default_ty } => (true, default_ty, None), + FieldKind::Dataclass { + init, + default_ty, + kw_only, + .. + } => (init, default_ty, kw_only), + FieldKind::TypedDict { .. } => continue, + }; + let mut field_ty = field.declared_ty; + if name == "__init__" && !init { // Skip fields with `init=False` continue; @@ -2351,7 +2395,7 @@ impl<'db> ClassLiteral<'db> { if field_policy == CodeGeneratorKind::NamedTuple { // NamedTuples do not allow multiple inheritance, so it is sufficient to enumerate the // fields of this class only. - return self.own_fields(db, specialization); + return self.own_fields(db, specialization, field_policy); } let matching_classes_in_mro: Vec<_> = self @@ -2374,7 +2418,7 @@ impl<'db> ClassLiteral<'db> { matching_classes_in_mro .into_iter() .rev() - .flat_map(|(class, specialization)| class.own_fields(db, specialization)) + .flat_map(|(class, specialization)| class.own_fields(db, specialization, field_policy)) // We collect into a FxOrderMap here to deduplicate attributes .collect() } @@ -2394,6 +2438,7 @@ impl<'db> ClassLiteral<'db> { self, db: &'db dyn Db, specialization: Option>, + field_policy: CodeGeneratorKind, ) -> FxOrderMap> { let mut attributes = FxOrderMap::default(); @@ -2401,7 +2446,10 @@ impl<'db> ClassLiteral<'db> { let table = place_table(db, class_body_scope); let use_def = use_def_map(db, class_body_scope); + + let typed_dict_params = self.typed_dict_params(db); let mut kw_only_sentinel_field_seen = false; + for (symbol_id, declarations) in use_def.all_end_of_scope_symbol_declarations() { // Here, we exclude all declarations that are not annotated assignments. We need this because // things like function definitions and nested classes would otherwise be considered dataclass @@ -2456,12 +2504,34 @@ impl<'db> ClassLiteral<'db> { } } + let kind = match field_policy { + CodeGeneratorKind::NamedTuple => FieldKind::NamedTuple { default_ty }, + CodeGeneratorKind::DataclassLike => FieldKind::Dataclass { + default_ty, + init_only: attr.is_init_var(), + init, + kw_only, + }, + CodeGeneratorKind::TypedDict => { + let is_required = if attr.is_required() { + // Explicit Required[T] annotation - always required + true + } else if attr.is_not_required() { + // Explicit NotRequired[T] annotation - never required + false + } else { + // No explicit qualifier - use class default (`total` parameter) + typed_dict_params + .expect("TypedDictParams should be available for CodeGeneratorKind::TypedDict") + .contains(TypedDictParams::TOTAL) + }; + FieldKind::TypedDict { is_required } + } + }; + let mut field = Field { declared_ty: attr_ty.apply_optional_specialization(db, specialization), - default_ty, - init_only: attr.is_init_var(), - init, - kw_only, + kind, }; // Check if this is a KW_ONLY sentinel and mark subsequent fields as keyword-only @@ -2470,8 +2540,14 @@ impl<'db> ClassLiteral<'db> { } // If no explicit kw_only setting and we've seen KW_ONLY sentinel, mark as keyword-only - if field.kw_only.is_none() && kw_only_sentinel_field_seen { - field.kw_only = Some(true); + if kw_only_sentinel_field_seen { + if let FieldKind::Dataclass { + kw_only: ref mut kw @ None, + .. + } = field.kind + { + *kw = Some(true); + } } attributes.insert(symbol.name().clone(), field); diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index eeb83a3cbb..08eda64f09 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -1,4 +1,5 @@ use crate::Db; +use crate::types::class::CodeGeneratorKind; use crate::types::generics::Specialization; use crate::types::tuple::TupleType; use crate::types::{ @@ -206,7 +207,7 @@ impl<'db> ClassBase<'db> { SpecialFormType::Generic => Some(Self::Generic), SpecialFormType::NamedTuple => { - let fields = subclass.own_fields(db, None); + let fields = subclass.own_fields(db, None, CodeGeneratorKind::NamedTuple); Self::try_from_type( db, TupleType::heterogeneous( diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 4930dd4f9e..bc2fcfb2e3 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -96,6 +96,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&INVALID_ATTRIBUTE_ACCESS); registry.register_lint(&REDUNDANT_CAST); registry.register_lint(&UNRESOLVED_GLOBAL); + registry.register_lint(&MISSING_TYPED_DICT_KEY); // String annotations registry.register_lint(&BYTE_STRING_TYPE_ANNOTATION); @@ -1758,6 +1759,33 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// Detects missing required keys in `TypedDict` constructor calls. + /// + /// ## Why is this bad? + /// `TypedDict` requires all non-optional keys to be provided during construction. + /// Missing items can lead to a `KeyError` at runtime. + /// + /// ## Example + /// ```python + /// from typing import TypedDict + /// + /// class Person(TypedDict): + /// name: str + /// age: int + /// + /// alice: Person = {"name": "Alice"} # missing required key 'age' + /// + /// alice["age"] # KeyError + /// ``` + pub(crate) static MISSING_TYPED_DICT_KEY = { + summary: "detects missing required keys in `TypedDict` constructors", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + /// A collection of type check diagnostics. #[derive(Default, Eq, PartialEq, get_size2::GetSize)] pub struct TypeCheckDiagnostics { @@ -2761,18 +2789,18 @@ fn report_invalid_base<'ctx, 'db>( pub(crate) fn report_invalid_key_on_typed_dict<'db>( context: &InferContext<'db, '_>, - value_node: AnyNodeRef, - slice_node: AnyNodeRef, - value_ty: Type<'db>, - slice_ty: Type<'db>, + typed_dict_node: AnyNodeRef, + key_node: AnyNodeRef, + typed_dict_ty: Type<'db>, + key_ty: Type<'db>, items: &FxOrderMap>, ) { let db = context.db(); - if let Some(builder) = context.report_lint(&INVALID_KEY, slice_node) { - match slice_ty { + if let Some(builder) = context.report_lint(&INVALID_KEY, key_node) { + match key_ty { Type::StringLiteral(key) => { let key = key.value(db); - let typed_dict_name = value_ty.display(db); + let typed_dict_name = typed_dict_ty.display(db); let mut diagnostic = builder.into_diagnostic(format_args!( "Invalid key access on TypedDict `{typed_dict_name}`", @@ -2780,7 +2808,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>( diagnostic.annotate( context - .secondary(value_node) + .secondary(typed_dict_node) .message(format_args!("TypedDict `{typed_dict_name}`")), ); @@ -2799,8 +2827,8 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>( } _ => builder.into_diagnostic(format_args!( "TypedDict `{}` cannot be indexed with a key of type `{}`", - value_ty.display(db), - slice_ty.display(db), + typed_dict_ty.display(db), + key_ty.display(db), )), }; } @@ -2860,6 +2888,21 @@ pub(super) fn report_namedtuple_field_without_default_after_field_with_default<' } } +pub(crate) fn report_missing_typed_dict_key<'db>( + context: &InferContext<'db, '_>, + constructor_node: AnyNodeRef, + typed_dict_ty: Type<'db>, + missing_field: &str, +) { + let db = context.db(); + if let Some(builder) = context.report_lint(&MISSING_TYPED_DICT_KEY, constructor_node) { + let typed_dict_name = typed_dict_ty.display(db); + builder.into_diagnostic(format_args!( + "Missing required key '{missing_field}' in TypedDict `{typed_dict_name}` constructor", + )); + } +} + /// This function receives an unresolved `from foo import bar` import, /// where `foo` can be resolved to a module but that module does not /// have a `bar` member or submodule. diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index dd8412f4cf..b8ec4bd61a 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -315,7 +315,7 @@ impl Display for DisplayRepresentation<'_> { } f.write_str("]") } - Type::TypedDict(typed_dict) => f.write_str(typed_dict.defining_class.name(self.db)), + Type::TypedDict(typed_dict) => f.write_str(typed_dict.defining_class().name(self.db)), Type::TypeAlias(alias) => f.write_str(alias.name(self.db)), } } diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 9b8b43103e..43c1abed21 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -90,7 +90,7 @@ use crate::semantic_index::{ ApplicableConstraints, EnclosingSnapshotResult, SemanticIndex, place_table, semantic_index, }; use crate::types::call::{Binding, Bindings, CallArguments, CallError, CallErrorKind}; -use crate::types::class::{CodeGeneratorKind, MetaclassErrorKind}; +use crate::types::class::{CodeGeneratorKind, FieldKind, MetaclassErrorKind}; use crate::types::diagnostic::{ self, CALL_NON_CALLABLE, CONFLICTING_DECLARATIONS, CONFLICTING_METACLASS, CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, DUPLICATE_KW_ONLY, INCONSISTENT_MRO, @@ -117,6 +117,10 @@ use crate::types::instance::SliceLiteral; use crate::types::mro::MroErrorKind; use crate::types::signatures::{CallableSignature, Signature}; use crate::types::tuple::{Tuple, TupleSpec, TupleSpecBuilder, TupleType}; +use crate::types::typed_dict::{ + TypedDictAssignmentKind, validate_typed_dict_constructor, validate_typed_dict_dict_literal, + validate_typed_dict_key_assignment, +}; use crate::types::unpacker::{UnpackResult, Unpacker}; use crate::types::{ CallDunderError, CallableType, ClassLiteral, ClassType, DataclassParams, DynamicType, @@ -1118,8 +1122,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if is_named_tuple { let mut field_with_default_encountered = None; - for (field_name, field) in class.own_fields(self.db(), None) { - if field.default_ty.is_some() { + for (field_name, field) in + class.own_fields(self.db(), None, CodeGeneratorKind::NamedTuple) + { + if matches!( + field.kind, + FieldKind::NamedTuple { + default_ty: Some(_) + } + ) { field_with_default_encountered = Some(field_name); } else if let Some(field_with_default) = field_with_default_encountered.as_ref() { @@ -3804,47 +3815,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(typed_dict) = value_ty.into_typed_dict() { if let Some(key) = slice_ty.into_string_literal() { let key = key.value(self.db()); - let items = typed_dict.items(self.db()); - if let Some((_, item)) = - items.iter().find(|(name, _)| *name == key) - { - if let Some(builder) = - context.report_lint(&INVALID_ASSIGNMENT, rhs) - { - let mut diagnostic = builder.into_diagnostic(format_args!( - "Invalid assignment to key \"{key}\" with declared type `{}` on TypedDict `{value_d}`", - item.declared_ty.display(db), - )); - - diagnostic.set_primary_message(format_args!( - "value of type `{assigned_d}`" - )); - - diagnostic.annotate( - self.context - .secondary(value.as_ref()) - .message(format_args!("TypedDict `{value_d}`")), - ); - - diagnostic.annotate( - self.context.secondary(slice.as_ref()).message( - format_args!( - "key has declared type `{}`", - item.declared_ty.display(db), - ), - ), - ); - } - } else { - report_invalid_key_on_typed_dict( - &self.context, - value.as_ref().into(), - slice.as_ref().into(), - value_ty, - slice_ty, - &items, - ); - } + validate_typed_dict_key_assignment( + &self.context, + typed_dict, + key, + assigned_ty, + value.as_ref(), + slice.as_ref(), + rhs, + TypedDictAssignmentKind::Subscript, + ); } else { // Check if the key has a valid type. We only allow string literals, a union of string literals, // or a dynamic type like `Any`. We can do this by checking assignability to `LiteralString`, @@ -4695,7 +4675,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(value) = value { let inferred_ty = self.infer_maybe_standalone_expression(value); - let inferred_ty = if target + let mut inferred_ty = if target .as_name_expr() .is_some_and(|name| &name.id == "TYPE_CHECKING") { @@ -4705,6 +4685,25 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } else { inferred_ty }; + + // Validate `TypedDict` dictionary literal assignments + if let Some(typed_dict) = declared.inner_type().into_typed_dict() { + if let Some(dict_expr) = value.as_dict_expr() { + validate_typed_dict_dict_literal( + &self.context, + typed_dict, + dict_expr, + target.into(), + |expr| self.expression_type(expr), + ); + + // Override the inferred type of the dict literal to be the `TypedDict` type + // This ensures that the dict literal gets the correct type for key access + let typed_dict_type = Type::TypedDict(typed_dict); + inferred_ty = typed_dict_type; + } + } + self.add_declaration_with_binding( target.into(), definition, @@ -6269,6 +6268,22 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .match_parameters(&call_arguments); self.infer_argument_types(arguments, &mut call_arguments, &bindings.argument_forms); + // Validate `TypedDict` constructor calls after argument type inference + if let Some(class_literal) = callable_type.into_class_literal() { + if class_literal.is_typed_dict(self.db()) { + let typed_dict_type = Type::typed_dict(ClassType::NonGeneric(class_literal)); + if let Some(typed_dict) = typed_dict_type.into_typed_dict() { + validate_typed_dict_constructor( + &self.context, + typed_dict, + arguments, + func.as_ref().into(), + |expr| self.expression_type(expr), + ); + } + } + } + let mut bindings = match bindings.check_types(self.db(), &call_arguments) { Ok(bindings) => bindings, Err(CallError(_, bindings)) => { @@ -9422,6 +9437,15 @@ impl<'db> TypeInferenceBuilder<'db, '_> { Type::SpecialForm(SpecialFormType::Final) => { TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::FINAL) } + Type::SpecialForm(SpecialFormType::Required) => { + TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::REQUIRED) + } + Type::SpecialForm(SpecialFormType::NotRequired) => { + TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::NOT_REQUIRED) + } + Type::SpecialForm(SpecialFormType::ReadOnly) => { + TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::READ_ONLY) + } Type::ClassLiteral(class) if class.is_known(self.db(), KnownClass::InitVar) => { @@ -9497,7 +9521,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } } Type::SpecialForm( - type_qualifier @ (SpecialFormType::ClassVar | SpecialFormType::Final), + type_qualifier @ (SpecialFormType::ClassVar + | SpecialFormType::Final + | SpecialFormType::Required + | SpecialFormType::NotRequired + | SpecialFormType::ReadOnly), ) => { let arguments = if let ast::Expr::Tuple(tuple) = slice { &*tuple.elts @@ -9516,6 +9544,15 @@ impl<'db> TypeInferenceBuilder<'db, '_> { SpecialFormType::Final => { type_and_qualifiers.add_qualifier(TypeQualifiers::FINAL); } + SpecialFormType::Required => { + type_and_qualifiers.add_qualifier(TypeQualifiers::REQUIRED); + } + SpecialFormType::NotRequired => { + type_and_qualifiers.add_qualifier(TypeQualifiers::NOT_REQUIRED); + } + SpecialFormType::ReadOnly => { + type_and_qualifiers.add_qualifier(TypeQualifiers::READ_ONLY); + } _ => unreachable!(), } type_and_qualifiers @@ -10802,15 +10839,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { KnownClass::Deque, ), - SpecialFormType::ReadOnly => { - self.infer_type_expression(arguments_slice); - todo_type!("`ReadOnly[]` type qualifier") - } - SpecialFormType::NotRequired => { - self.infer_type_expression(arguments_slice); - todo_type!("`NotRequired[]` type qualifier") - } - SpecialFormType::ClassVar | SpecialFormType::Final => { + SpecialFormType::ClassVar + | SpecialFormType::Final + | SpecialFormType::Required + | SpecialFormType::NotRequired + | SpecialFormType::ReadOnly => { if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { let diag = builder.into_diagnostic(format_args!( "Type qualifier `{special_form}` is not allowed in type expressions \ @@ -10820,10 +10853,6 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } self.infer_type_expression(arguments_slice) } - SpecialFormType::Required => { - self.infer_type_expression(arguments_slice); - todo_type!("`Required[]` type qualifier") - } SpecialFormType::TypeIs => match arguments_slice { ast::Expr::Tuple(_) => { self.infer_type_expression(arguments_slice); diff --git a/crates/ty_python_semantic/src/types/type_ordering.rs b/crates/ty_python_semantic/src/types/type_ordering.rs index 21612c3614..d1262802e9 100644 --- a/crates/ty_python_semantic/src/types/type_ordering.rs +++ b/crates/ty_python_semantic/src/types/type_ordering.rs @@ -245,7 +245,7 @@ pub(super) fn union_or_intersection_elements_ordering<'db>( } (Type::TypedDict(left), Type::TypedDict(right)) => { - left.defining_class.cmp(&right.defining_class) + left.defining_class().cmp(&right.defining_class()) } (Type::TypedDict(_), _) => Ordering::Less, (_, Type::TypedDict(_)) => Ordering::Greater, diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs new file mode 100644 index 0000000000..f6b03a225a --- /dev/null +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -0,0 +1,361 @@ +use bitflags::bitflags; +use ruff_python_ast::Arguments; +use ruff_python_ast::{self as ast, AnyNodeRef, StmtClassDef, name::Name}; + +use super::class::{ClassType, CodeGeneratorKind, Field}; +use super::context::InferContext; +use super::diagnostic::{ + INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, report_invalid_key_on_typed_dict, + report_missing_typed_dict_key, +}; +use super::{ApplyTypeMappingVisitor, Type, TypeMapping, visitor}; +use crate::{Db, FxOrderMap}; + +use ordermap::OrderSet; + +bitflags! { + /// Used for `TypedDict` class parameters. + /// Keeps track of the keyword arguments that were passed-in during class definition. + /// (see https://typing.python.org/en/latest/spec/typeddict.html) + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct TypedDictParams: u8 { + /// Whether keys are required by default (`total=True`) + const TOTAL = 1 << 0; + } +} + +impl get_size2::GetSize for TypedDictParams {} + +impl Default for TypedDictParams { + fn default() -> Self { + Self::TOTAL + } +} + +/// Type that represents the set of all inhabitants (`dict` instances) that conform to +/// a given `TypedDict` schema. +#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::Update, Hash, get_size2::GetSize)] +pub struct TypedDictType<'db> { + /// A reference to the class (inheriting from `typing.TypedDict`) that specifies the + /// schema of this `TypedDict`. + defining_class: ClassType<'db>, +} + +impl<'db> TypedDictType<'db> { + pub(crate) fn new(defining_class: ClassType<'db>) -> Self { + Self { defining_class } + } + + pub(crate) fn defining_class(self) -> ClassType<'db> { + self.defining_class + } + + pub(crate) fn items(self, db: &'db dyn Db) -> FxOrderMap> { + let (class_literal, specialization) = self.defining_class.class_literal(db); + class_literal.fields(db, specialization, CodeGeneratorKind::TypedDict) + } + + pub(crate) fn apply_type_mapping_impl<'a>( + self, + db: &'db dyn Db, + type_mapping: &TypeMapping<'a, 'db>, + visitor: &ApplyTypeMappingVisitor<'db>, + ) -> Self { + Self { + defining_class: self + .defining_class + .apply_type_mapping_impl(db, type_mapping, visitor), + } + } +} + +pub(crate) fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( + db: &'db dyn Db, + typed_dict: TypedDictType<'db>, + visitor: &V, +) { + visitor.visit_type(db, typed_dict.defining_class.into()); +} + +pub(super) fn typed_dict_params_from_class_def(class_stmt: &StmtClassDef) -> TypedDictParams { + let mut typed_dict_params = TypedDictParams::default(); + + // Check for `total` keyword argument in the class definition + // Note that it is fine to only check for Boolean literals here + // (https://typing.python.org/en/latest/spec/typeddict.html#totality) + if let Some(arguments) = &class_stmt.arguments { + for keyword in &arguments.keywords { + if keyword.arg.as_deref() == Some("total") + && matches!( + &keyword.value, + ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value: false, .. }) + ) + { + typed_dict_params.remove(TypedDictParams::TOTAL); + } + } + } + + typed_dict_params +} + +#[derive(Debug, Clone, Copy)] +pub(super) enum TypedDictAssignmentKind { + /// For subscript assignments like `d["key"] = value` + Subscript, + /// For constructor arguments like `MyTypedDict(key=value)` + Constructor, +} + +impl TypedDictAssignmentKind { + fn diagnostic_name(self) -> &'static str { + match self { + Self::Subscript => "assignment", + Self::Constructor => "argument", + } + } + + fn diagnostic_type(self) -> &'static crate::lint::LintMetadata { + match self { + Self::Subscript => &INVALID_ASSIGNMENT, + Self::Constructor => &INVALID_ARGUMENT_TYPE, + } + } +} + +/// Validates assignment of a value to a specific key on a `TypedDict`. +/// Returns true if the assignment is valid, false otherwise. +#[allow(clippy::too_many_arguments)] +pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>( + context: &InferContext<'db, 'ast>, + typed_dict: TypedDictType<'db>, + key: &str, + value_ty: Type<'db>, + typed_dict_node: impl Into>, + key_node: impl Into>, + value_node: impl Into>, + assignment_kind: TypedDictAssignmentKind, +) -> bool { + let db = context.db(); + let items = typed_dict.items(db); + + // Check if key exists in `TypedDict` + let Some((_, item)) = items.iter().find(|(name, _)| *name == key) else { + report_invalid_key_on_typed_dict( + context, + typed_dict_node.into(), + key_node.into(), + Type::TypedDict(typed_dict), + Type::string_literal(db, key), + &items, + ); + return false; + }; + + // Key exists, check if value type is assignable to declared type + if value_ty.is_assignable_to(db, item.declared_ty) { + return true; + } + + // Invalid assignment - emit diagnostic + if let Some(builder) = context.report_lint(assignment_kind.diagnostic_type(), value_node.into()) + { + let typed_dict_ty = Type::TypedDict(typed_dict); + let typed_dict_d = typed_dict_ty.display(db); + let value_d = value_ty.display(db); + let item_type_d = item.declared_ty.display(db); + + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid {} to key \"{key}\" with declared type `{item_type_d}` on TypedDict `{typed_dict_d}`", + assignment_kind.diagnostic_name(), + )); + + diagnostic.set_primary_message(format_args!("value of type `{value_d}`")); + + diagnostic.annotate( + context + .secondary(typed_dict_node.into()) + .message(format_args!("TypedDict `{typed_dict_d}`")), + ); + + diagnostic.annotate( + context + .secondary(key_node.into()) + .message(format_args!("key has declared type `{item_type_d}`")), + ); + } + + false +} + +/// Validates that all required keys are provided in a `TypedDict` construction. +/// Reports errors for any keys that are required but not provided. +pub(super) fn validate_typed_dict_required_keys<'db, 'ast>( + context: &InferContext<'db, 'ast>, + typed_dict: TypedDictType<'db>, + provided_keys: &OrderSet<&str>, + error_node: AnyNodeRef<'ast>, +) { + let db = context.db(); + let items = typed_dict.items(db); + + let required_keys: OrderSet<&str> = items + .iter() + .filter_map(|(key_name, field)| field.is_required().then_some(key_name.as_str())) + .collect(); + + for missing_key in required_keys.difference(provided_keys) { + report_missing_typed_dict_key( + context, + error_node, + Type::TypedDict(typed_dict), + missing_key, + ); + } +} + +pub(super) fn validate_typed_dict_constructor<'db, 'ast>( + context: &InferContext<'db, 'ast>, + typed_dict: TypedDictType<'db>, + arguments: &'ast Arguments, + error_node: AnyNodeRef<'ast>, + expression_type_fn: impl Fn(&ast::Expr) -> Type<'db>, +) { + let has_positional_dict = arguments.args.len() == 1 && arguments.args[0].is_dict_expr(); + + let provided_keys = if has_positional_dict { + validate_from_dict_literal( + context, + typed_dict, + arguments, + error_node, + &expression_type_fn, + ) + } else { + validate_from_keywords( + context, + typed_dict, + arguments, + error_node, + &expression_type_fn, + ) + }; + + validate_typed_dict_required_keys(context, typed_dict, &provided_keys, error_node); +} + +/// Validates a `TypedDict` constructor call with a single positional dictionary argument +/// e.g. `Person({"name": "Alice", "age": 30})` +fn validate_from_dict_literal<'db, 'ast>( + context: &InferContext<'db, 'ast>, + typed_dict: TypedDictType<'db>, + arguments: &'ast Arguments, + error_node: AnyNodeRef<'ast>, + expression_type_fn: &impl Fn(&ast::Expr) -> Type<'db>, +) -> OrderSet<&'ast str> { + let mut provided_keys = OrderSet::new(); + + if let ast::Expr::Dict(dict_expr) = &arguments.args[0] { + // Validate dict entries + for dict_item in &dict_expr.items { + if let Some(ref key_expr) = dict_item.key { + if let ast::Expr::StringLiteral(ast::ExprStringLiteral { + value: key_value, .. + }) = key_expr + { + let key_str = key_value.to_str(); + provided_keys.insert(key_str); + + // Get the already-inferred argument type + let value_type = expression_type_fn(&dict_item.value); + validate_typed_dict_key_assignment( + context, + typed_dict, + key_str, + value_type, + error_node, + key_expr, + &dict_item.value, + TypedDictAssignmentKind::Constructor, + ); + } + } + } + } + + provided_keys +} + +/// Validates a `TypedDict` constructor call with keywords +/// e.g. `Person(name="Alice", age=30)` +fn validate_from_keywords<'db, 'ast>( + context: &InferContext<'db, 'ast>, + typed_dict: TypedDictType<'db>, + arguments: &'ast Arguments, + error_node: AnyNodeRef<'ast>, + expression_type_fn: &impl Fn(&ast::Expr) -> Type<'db>, +) -> OrderSet<&'ast str> { + let provided_keys: OrderSet<&str> = arguments + .keywords + .iter() + .filter_map(|kw| kw.arg.as_ref().map(|arg| arg.id.as_str())) + .collect(); + + // Validate that each key is assigned a type that is compatible with the keys's value type + for keyword in &arguments.keywords { + if let Some(arg_name) = &keyword.arg { + // Get the already-inferred argument type + let arg_type = expression_type_fn(&keyword.value); + validate_typed_dict_key_assignment( + context, + typed_dict, + arg_name.as_str(), + arg_type, + error_node, + keyword, + &keyword.value, + TypedDictAssignmentKind::Constructor, + ); + } + } + + provided_keys +} + +/// Validates a `TypedDict` dictionary literal assignment +/// e.g. `person: Person = {"name": "Alice", "age": 30}` +pub(super) fn validate_typed_dict_dict_literal<'db, 'ast>( + context: &InferContext<'db, 'ast>, + typed_dict: TypedDictType<'db>, + dict_expr: &'ast ast::ExprDict, + error_node: AnyNodeRef<'ast>, + expression_type_fn: impl Fn(&ast::Expr) -> Type<'db>, +) -> OrderSet<&'ast str> { + let mut provided_keys = OrderSet::new(); + + // Validate each key-value pair in the dictionary literal + for item in &dict_expr.items { + if let Some(key_expr) = &item.key { + if let ast::Expr::StringLiteral(key_literal) = key_expr { + let key_str = key_literal.value.to_str(); + provided_keys.insert(key_str); + + let value_type = expression_type_fn(&item.value); + validate_typed_dict_key_assignment( + context, + typed_dict, + key_str, + value_type, + error_node, + key_expr, + &item.value, + TypedDictAssignmentKind::Constructor, + ); + } + } + } + + validate_typed_dict_required_keys(context, typed_dict, &provided_keys, error_node); + + provided_keys +} diff --git a/ty.schema.json b/ty.schema.json index c8b9668506..6b55406977 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -721,6 +721,16 @@ } ] }, + "missing-typed-dict-key": { + "title": "detects missing required keys in `TypedDict` constructors", + "description": "## What it does\nDetects missing required keys in `TypedDict` constructor calls.\n\n## Why is this bad?\n`TypedDict` requires all non-optional keys to be provided during construction.\nMissing items can lead to a `KeyError` at runtime.\n\n## Example\n```python\nfrom typing import TypedDict\n\nclass Person(TypedDict):\n name: str\n age: int\n\nalice: Person = {\"name\": \"Alice\"} # missing required key 'age'\n\nalice[\"age\"] # KeyError\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "no-matching-overload": { "title": "detects calls that do not match any overload", "description": "## What it does\nChecks for calls to an overloaded function that do not match any of the overloads.\n\n## Why is this bad?\nFailing to provide the correct arguments to one of the overloads will raise a `TypeError`\nat runtime.\n\n## Examples\n```python\n@overload\ndef func(x: int): ...\n@overload\ndef func(x: bool): ...\nfunc(\"string\") # error: [no-matching-overload]\n```",