Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/ty/docs/rules.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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]
```
45 changes: 45 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/sentinels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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: ...

Expand Down Expand Up @@ -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:

Expand All @@ -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: ...

Expand Down Expand Up @@ -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): ...

Expand Down
54 changes: 47 additions & 7 deletions crates/ty_python_semantic/src/types/infer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion ty.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading