diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 5a3f80c9e6fec1..35bfee13b96432 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -3489,7 +3489,7 @@ the variable they are assigned to. Constructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`, -`TypedDict`, and `TypeAliasType` all take a name argument that is +`TypedDict`, `TypeAliasType`, and `Sentinel` all take a name argument that is normally expected to match the assigned variable. A mismatch is usually a typo and makes later diagnostics harder to understand. @@ -3504,12 +3504,13 @@ continue understanding the resulting type. ```python from typing import NewType, ParamSpec, TypeVar -from typing_extensions import TypedDict +from typing_extensions import Sentinel, TypedDict T = TypeVar("U") # error: [mismatched-type-name] P = ParamSpec("Q") # error: [mismatched-type-name] UserId = NewType("Id", int) # error: [mismatched-type-name] Movie = TypedDict("Film", {"title": str}) # error: [mismatched-type-name] +Missing = Sentinel("NotGiven") # error: [mismatched-type-name] ``` ## `missing-argument` diff --git a/crates/ty_python_semantic/resources/lint_docs/mismatched-type-name.md b/crates/ty_python_semantic/resources/lint_docs/mismatched-type-name.md index 3bdd62559cac42..4ae384fd92381c 100644 --- a/crates/ty_python_semantic/resources/lint_docs/mismatched-type-name.md +++ b/crates/ty_python_semantic/resources/lint_docs/mismatched-type-name.md @@ -6,7 +6,7 @@ the variable they are assigned to. ## Why is this bad? Constructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`, -`TypedDict`, and `TypeAliasType` all take a name argument that is +`TypedDict`, `TypeAliasType`, and `Sentinel` all take a name argument that is normally expected to match the assigned variable. A mismatch is usually a typo and makes later diagnostics harder to understand. @@ -19,10 +19,11 @@ continue understanding the resulting type. ```python from typing import NewType, ParamSpec, TypeVar -from typing_extensions import TypedDict +from typing_extensions import Sentinel, TypedDict T = TypeVar("U") # error: [mismatched-type-name] P = ParamSpec("Q") # error: [mismatched-type-name] UserId = NewType("Id", int) # error: [mismatched-type-name] Movie = TypedDict("Film", {"title": str}) # error: [mismatched-type-name] +Missing = Sentinel("NotGiven") # error: [mismatched-type-name] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/sentinels.md b/crates/ty_python_semantic/resources/mdtest/sentinels.md index fe54fb8b50a9b9..e4c8657dd841fb 100644 --- a/crates/ty_python_semantic/resources/mdtest/sentinels.md +++ b/crates/ty_python_semantic/resources/mdtest/sentinels.md @@ -70,6 +70,18 @@ def reverse_negative_check(x: int | MISSING | OTHER) -> None: reveal_type(x) # revealed: MISSING ``` +Sentinel names must match their assigned variables, including when the constructor has an alias or +an explicit representation: + +```py +from typing_extensions import Sentinel as SentinelAlias + +MISMATCHED = Sentinel("OTHER") # error: [mismatched-type-name] +MISMATCHED_WITH_POSITIONAL_REPR = Sentinel("OTHER", "other") # error: [mismatched-type-name] +MISMATCHED_WITH_KEYWORD_REPR = Sentinel("OTHER", repr="other") # error: [mismatched-type-name] +ALIASED_MISMATCHED = SentinelAlias("OTHER") # error: [mismatched-type-name] +``` + Sentinel objects are always truthy, expose the standard sentinel metadata attributes, and are rejected as class bases: @@ -88,6 +100,16 @@ Sentinels declared in class scope can also be used in type expressions: ```py class C: MARKER = Sentinel("C.MARKER") + UNQUALIFIED = Sentinel("UNQUALIFIED") + WRONG_CLASS = Sentinel("Other.WRONG_CLASS") # error: [mismatched-type-name] + WRONG_NAME = Sentinel("C.OTHER") # error: [mismatched-type-name] + + class Nested: + MARKER = Sentinel("C.Nested.MARKER") + UNQUALIFIED = Sentinel("UNQUALIFIED") + PARTIALLY_QUALIFIED = Sentinel("Nested.PARTIALLY_QUALIFIED") # error: [mismatched-type-name] + WRONG_CLASS = Sentinel("Other.Nested.WRONG_CLASS") # error: [mismatched-type-name] + WRONG_NAME = Sentinel("C.Nested.OTHER") # error: [mismatched-type-name] def accepts_marker(x: C.MARKER) -> None: ... @@ -210,6 +232,18 @@ def reverse_negative_check(x: int | MISSING | OTHER) -> None: reveal_type(x) # revealed: MISSING ``` +Sentinel names must match their assigned variables, including when the builtin is imported under an +alias or given an explicit representation: + +```py +from builtins import sentinel as sentinel_alias + +MISMATCHED = sentinel("OTHER") # error: [mismatched-type-name] +MISMATCHED_WITH_POSITIONAL_REPR = sentinel("OTHER", "other") # error: [mismatched-type-name] +MISMATCHED_WITH_KEYWORD_REPR = sentinel("OTHER", repr="other") # error: [mismatched-type-name] +ALIASED_MISMATCHED = sentinel_alias("OTHER") # error: [mismatched-type-name] +``` + Sentinel objects are always truthy, expose the standard sentinel metadata attributes, and are rejected as class bases: @@ -228,6 +262,16 @@ Sentinels declared in class scope can also be used in type expressions: ```py class C: MARKER = sentinel("C.MARKER") + UNQUALIFIED = sentinel("UNQUALIFIED") + WRONG_CLASS = sentinel("Other.WRONG_CLASS") # error: [mismatched-type-name] + WRONG_NAME = sentinel("C.OTHER") # error: [mismatched-type-name] + + class Nested: + MARKER = sentinel("C.Nested.MARKER") + UNQUALIFIED = sentinel("UNQUALIFIED") + PARTIALLY_QUALIFIED = sentinel("Nested.PARTIALLY_QUALIFIED") # error: [mismatched-type-name] + WRONG_CLASS = sentinel("Other.Nested.WRONG_CLASS") # error: [mismatched-type-name] + WRONG_NAME = sentinel("C.Nested.OTHER") # error: [mismatched-type-name] def accepts_marker(x: C.MARKER) -> None: ... @@ -285,6 +329,7 @@ UNKNOWN_KEYWORD = sentinel("UNKNOWN_KEYWORD", unknown=NAME) # error: [unknown-a import typing_extensions EXTENSIONS_MISSING = typing_extensions.Sentinel("EXTENSIONS_MISSING") +EXTENSIONS_MISMATCHED = typing_extensions.Sentinel("OTHER") # error: [mismatched-type-name] def f(x: int | EXTENSIONS_MISSING): ... diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 6cccb9f29bf41c..a00ece4921b5b2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3479,21 +3479,61 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return None; } - let Some(repr_arg) = repr_arg else { - return Some(Type::KnownInstance(KnownInstanceType::Sentinel( - SentinelInstance::new(self.db(), target_name, definition), - ))); - }; - - if !matches!(repr_arg, ast::Expr::StringLiteral(_)) && !repr_arg.is_none_literal_expr() { + if repr_arg.is_some_and(|repr_arg| { + !matches!(repr_arg, ast::Expr::StringLiteral(_)) && !repr_arg.is_none_literal_expr() + }) { return None; } + let name_arg_ty = self.infer_expression(name_arg, TypeContext::default()); + let name = name_arg_ty.as_string_literal()?.value(self.db()); + + if !self.sentinel_name_matches_target(name, target_name) { + report_mismatched_type_name( + &self.context, + name_arg, + KnownClass::Sentinel.name(self.db()), + target_name, + Some(name), + name_arg_ty, + ); + } + Some(Type::KnownInstance(KnownInstanceType::Sentinel( SentinelInstance::new(self.db(), target_name, definition), ))) } + /// Sentinel names can be unqualified or include their exact enclosing class path. + fn sentinel_name_matches_target(&self, name: &str, target_name: &Name) -> bool { + if name == target_name.as_str() { + return true; + } + + let mut name_components = name.rsplit('.'); + + if name_components.next() != Some(target_name.as_str()) { + return false; + } + + for (_, scope) in self + .index + .ancestor_scopes(self.scope.file_scope_id(self.db())) + { + match scope.node() { + NodeWithScopeKind::Class(class) => { + if name_components.next() != Some(class.node(self.module()).name.as_str()) { + return false; + } + } + NodeWithScopeKind::Module => return name_components.next().is_none(), + _ => return false, + } + } + + false + } + fn sentinel_definition_scope_is_supported(&self) -> bool { let db = self.db(); let mut scope_id = self.scope.file_scope_id(db); diff --git a/ty.schema.json b/ty.schema.json index be1fa4be1c305a..52362b1fbd17ca 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1109,7 +1109,7 @@ }, "mismatched-type-name": { "title": "detects functional typing definitions whose declared name does not match the assigned variable", - "description": "## What it does\n\nChecks for functional typing definitions whose declared name does not match\nthe variable they are assigned to.\n\n## Why is this bad?\n\nConstructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`,\n`TypedDict`, and `TypeAliasType` all take a name argument that is\nnormally expected to match the assigned variable. A mismatch is usually a\ntypo and makes later diagnostics harder to understand.\n\n## Default level\n\nThis rule is a warning by default because ty can usually recover and\ncontinue understanding the resulting type.\n\n## Examples\n\n```python\nfrom typing import NewType, ParamSpec, TypeVar\nfrom typing_extensions import TypedDict\n\nT = TypeVar(\"U\") # error: [mismatched-type-name]\nP = ParamSpec(\"Q\") # error: [mismatched-type-name]\nUserId = NewType(\"Id\", int) # error: [mismatched-type-name]\nMovie = TypedDict(\"Film\", {\"title\": str}) # error: [mismatched-type-name]\n```", + "description": "## What it does\n\nChecks for functional typing definitions whose declared name does not match\nthe variable they are assigned to.\n\n## Why is this bad?\n\nConstructors like `TypeVar`, `ParamSpec`, `NewType`, `NamedTuple`,\n`TypedDict`, `TypeAliasType`, and `Sentinel` all take a name argument that is\nnormally expected to match the assigned variable. A mismatch is usually a\ntypo and makes later diagnostics harder to understand.\n\n## Default level\n\nThis rule is a warning by default because ty can usually recover and\ncontinue understanding the resulting type.\n\n## Examples\n\n```python\nfrom typing import NewType, ParamSpec, TypeVar\nfrom typing_extensions import Sentinel, TypedDict\n\nT = TypeVar(\"U\") # error: [mismatched-type-name]\nP = ParamSpec(\"Q\") # error: [mismatched-type-name]\nUserId = NewType(\"Id\", int) # error: [mismatched-type-name]\nMovie = TypedDict(\"Film\", {\"title\": str}) # error: [mismatched-type-name]\nMissing = Sentinel(\"NotGiven\") # error: [mismatched-type-name]\n```", "default": "warn", "oneOf": [ {