diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b06f181746c35..e414c538f4589 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -226,6 +226,10 @@ jobs: - name: Setup tox environment run: | tox run -e ${{ matrix.toxenv }} --notest + - name: Install local librt in tox environment + if: ${{ !matrix.test_mypyc && matrix.toxenv == 'py' }} + # Use local version of librt so that fixes not yet in a PyPI release are available. + run: tox exec -e ${{ matrix.toxenv }} -- pip install -U mypyc/lib-rt - name: Test run: tox run -e ${{ matrix.toxenv }} --skip-pkg-install -- ${{ matrix.tox_extra_args }} continue-on-error: ${{ matrix.allow_failure == 'true' }} diff --git a/mypy/cache.py b/mypy/cache.py index ebe36e8940b81..013a286fae2c8 100644 --- a/mypy/cache.py +++ b/mypy/cache.py @@ -48,7 +48,10 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any, Final, TypeAlias as _TypeAlias +from typing import TYPE_CHECKING, Any, Final, TypeAlias as _TypeAlias + +if TYPE_CHECKING: + from mypy.types import SentinelValue from librt.internal import ( ReadBuffer as ReadBuffer, @@ -69,7 +72,7 @@ from mypy_extensions import u8 # High-level cache layout format -CACHE_VERSION: Final = 10 +CACHE_VERSION: Final = 11 # Type used internally to represent errors: # (path, line, column, end_line, end_column, severity, message, code) @@ -308,6 +311,7 @@ def read(cls, data: ReadBuffer) -> CacheMetaEx | None: LITERAL_BYTES: Final[Tag] = 5 LITERAL_FLOAT: Final[Tag] = 6 LITERAL_COMPLEX: Final[Tag] = 7 +LITERAL_SENTINEL: Final[Tag] = 8 # Collections. LIST_GEN: Final[Tag] = 20 @@ -328,7 +332,7 @@ def read(cls, data: ReadBuffer) -> CacheMetaEx | None: END_TAG: Final[Tag] = 255 -def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float: +def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float | SentinelValue: if tag == LITERAL_INT: return read_int_bare(data) elif tag == LITERAL_STR: @@ -339,12 +343,18 @@ def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float: return True elif tag == LITERAL_FLOAT: return read_float_bare(data) + elif tag == LITERAL_SENTINEL: + from mypy.types import SentinelValue as _SentinelValue + + return _SentinelValue(read_str_bare(data), read_str_bare(data)) assert False, f"Unknown literal tag {tag}" # There is an intentional asymmetry between read and write for literals because # None and/or complex values are only allowed in some contexts but not in others. -def write_literal(data: WriteBuffer, value: int | str | bool | float | complex | None) -> None: +def write_literal( + data: WriteBuffer, value: int | str | bool | float | complex | SentinelValue | None +) -> None: if isinstance(value, bool): write_bool(data, value) elif isinstance(value, int): @@ -360,8 +370,12 @@ def write_literal(data: WriteBuffer, value: int | str | bool | float | complex | write_tag(data, LITERAL_COMPLEX) write_float_bare(data, value.real) write_float_bare(data, value.imag) - else: + elif value is None: write_tag(data, LITERAL_NONE) + else: + write_tag(data, LITERAL_SENTINEL) + write_str_bare(data, value.fullname) + write_str_bare(data, value.name) def read_int(data: ReadBuffer) -> int: diff --git a/mypy/messages.py b/mypy/messages.py index ffac78201a6cd..b58c9e7ac4b6c 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -2783,6 +2783,8 @@ def format_literal_value(typ: LiteralType) -> str: modifier += "=" items.append(f"{item_name!r}{modifier}: {format(item_type)}") return f"TypedDict({{{', '.join(items)}}})" + elif isinstance(typ, LiteralType) and typ.is_sentinel_literal(): + return format_literal_value(typ) elif isinstance(typ, LiteralType): return f"Literal[{format_literal_value(typ)}]" elif isinstance(typ, UnionType): @@ -2790,6 +2792,9 @@ def format_literal_value(typ: LiteralType) -> str: if not isinstance(typ, UnionType): return format(typ) literal_items, union_items = separate_union_literals(typ) + sentinel_items = [item for item in literal_items if item.is_sentinel_literal()] + literal_items = [item for item in literal_items if not item.is_sentinel_literal()] + union_items = [*sentinel_items, *union_items] # Coalesce multiple Literal[] members. This also changes output order. # If there's just one Literal item, retain the original ordering. diff --git a/mypy/nodes.py b/mypy/nodes.py index e2ea348d2df11..cbeda29ec74a2 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -1416,6 +1416,7 @@ def is_dynamic(self) -> bool: "from_module_getattr", "has_explicit_value", "allow_incompatible_override", + "is_sentinel", ] @@ -1454,6 +1455,7 @@ class Var(SymbolNode): "allow_incompatible_override", "invalid_partial_type", "is_argument", + "is_sentinel", ) __match_args__ = ("name", "type", "final_value") @@ -1516,6 +1518,8 @@ def __init__(self, name: str, type: mypy.types.Type | None = None) -> None: self.invalid_partial_type = False # Is it a variable symbol for a function argument? self.is_argument = False + # Was this variable created by PEP 661 sentinel()/Sentinel() syntax? + self.is_sentinel = False @property def name(self) -> str: @@ -1598,6 +1602,7 @@ def write(self, data: WriteBuffer) -> None: self.from_module_getattr, self.has_explicit_value, self.allow_incompatible_override, + self.is_sentinel, ], ) write_literal(data, self.final_value) @@ -1635,12 +1640,15 @@ def read(cls, data: ReadBuffer) -> Var: v.from_module_getattr, v.has_explicit_value, v.allow_incompatible_override, - ) = read_flags(data, num_flags=19) + v.is_sentinel, + ) = read_flags(data, num_flags=20) tag = read_tag(data) if tag == LITERAL_COMPLEX: v.final_value = complex(read_float_bare(data), read_float_bare(data)) elif tag != LITERAL_NONE: - v.final_value = read_literal(data, tag) + val = read_literal(data, tag) + assert not isinstance(val, mypy.types.SentinelValue) + v.final_value = val assert read_tag(data) == END_TAG return v diff --git a/mypy/semanal.py b/mypy/semanal.py index e010273b0781f..dbfff326083d1 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -271,6 +271,7 @@ OVERRIDE_DECORATOR_NAMES, PROTOCOL_NAMES, REVEAL_TYPE_NAMES, + SENTINEL_TYPE_NAMES, TPDICT_NAMES, TYPE_ALIAS_NAMES, TYPE_CHECK_ONLY_NAMES, @@ -289,6 +290,7 @@ ParamSpecType, PlaceholderType, ProperType, + SentinelValue, TrivialSyntheticTypeTranslator, TupleType, Type, @@ -3377,9 +3379,15 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None: # may be set to True while there were still placeholders due to forward refs. s.is_alias_def = False + sentinel_definition = self.is_sentinel_declaration(s) + # OK, this is a regular assignment, perform the necessary analysis steps. s.is_final_def = self.unwrap_final(s) + if sentinel_definition: + s.is_final_def = True self.analyze_lvalues(s) + if sentinel_definition: + self.setup_sentinel_var(s) self.check_final_implicit_def(s) self.store_final_status(s) self.check_classvar(s) @@ -3392,6 +3400,51 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None: self.process__deletable__(s) self.process__slots__(s) + def is_sentinel_declaration(self, s: AssignmentStmt) -> bool: + """Does this assignment define a PEP 661 sentinel singleton?""" + if self.is_func_scope() or s.unanalyzed_type is not None: + return False + if len(s.lvalues) != 1 or not isinstance(s.lvalues[0], NameExpr): + return False + if not isinstance(s.rvalue, CallExpr): + return False + call = s.rvalue + if not isinstance(call.callee, RefExpr): + return False + if call.callee.fullname not in SENTINEL_TYPE_NAMES: + return False + if not call.args or call.arg_kinds[0] != ARG_POS or not isinstance(call.args[0], StrExpr): + return False + return True + + def setup_sentinel_var(self, s: AssignmentStmt) -> None: + lvalue = s.lvalues[0] + assert isinstance(lvalue, NameExpr) + if not isinstance(lvalue.node, Var): + return + var = lvalue.node + var.is_sentinel = True + typ = self.sentinel_type_for_var(var, s.rvalue) + if typ is not None: + s.type = typ + + def sentinel_type_for_var(self, var: Var, rvalue: Expression) -> Instance | None: + assert isinstance(rvalue, CallExpr) + callee = rvalue.callee + assert isinstance(callee, RefExpr) + typ = self.named_type_or_none(callee.fullname) + if typ is None: + return None + name = f"{self.type.name}.{var.name}" if self.type is not None else var.name + return typ.copy_modified( + last_known_value=LiteralType( + SentinelValue(var.fullname, name), + fallback=typ, + line=rvalue.line, + column=rvalue.column, + ) + ) + def analyze_identity_global_assignment(self, s: AssignmentStmt) -> bool: """Special case 'X = X' in global scope. @@ -3556,6 +3609,8 @@ def is_type_ref(self, rv: Expression, bare: bool = False) -> bool: # Assignment color = Color['RED'] defines a variable, not an alias. return not rv.node.is_enum if isinstance(rv.node, Var): + if rv.node.is_sentinel: + return True return rv.node.fullname in NEVER_NAMES if isinstance(rv, NameExpr): @@ -4763,6 +4818,7 @@ def store_declared_types(self, lvalue: Lvalue, typ: Type) -> None: var.is_final and isinstance(typ, Instance) and typ.last_known_value + and not isinstance(typ.last_known_value.value, SentinelValue) and (not self.type or not self.type.is_enum) ): var.final_value = typ.last_known_value.value diff --git a/mypy/test/testtypes.py b/mypy/test/testtypes.py index 6de922cb7f234..b287e82b3d4af 100644 --- a/mypy/test/testtypes.py +++ b/mypy/test/testtypes.py @@ -5,6 +5,9 @@ import re from unittest import TestCase, skipUnless +from librt.internal import ReadBuffer, WriteBuffer + +from mypy.cache import read_tag from mypy.erasetype import erase_type, remove_instance_last_known_values from mypy.indirection import TypeIndirectionVisitor from mypy.join import join_types @@ -30,6 +33,7 @@ from mypy.test.typefixture import InterfaceTypeFixture, TypeFixture from mypy.typeops import false_only, make_simplified_union, true_only from mypy.types import ( + LITERAL_TYPE, AnyType, CallableType, Instance, @@ -37,6 +41,7 @@ NoneType, Overloaded, ProperType, + SentinelValue, TupleType, Type, TypedDictType, @@ -66,6 +71,25 @@ def setUp(self) -> None: def test_any(self) -> None: assert_equal(str(AnyType(TypeOfAny.special_form)), "Any") + def test_sentinel_literal_json_roundtrip(self) -> None: + literal = LiteralType(SentinelValue("__main__.MISSING", "MISSING"), self.fx.a) + assert_equal(str(literal), "MISSING") + data = literal.serialize() + assert isinstance(data, dict) + roundtrip = LiteralType.deserialize(data) + self.assertEqual(roundtrip.value, literal.value) + self.assertEqual(roundtrip.fallback.type_ref, self.fx.a.type.fullname) + + def test_sentinel_literal_ff_roundtrip(self) -> None: + literal = LiteralType(SentinelValue("__main__.MISSING", "MISSING"), self.fx.a) + data = WriteBuffer() + literal.write(data) + buffer = ReadBuffer(data.getvalue()) + assert read_tag(buffer) == LITERAL_TYPE + roundtrip = LiteralType.read(buffer) + self.assertEqual(roundtrip.value, literal.value) + self.assertEqual(roundtrip.fallback.type_ref, self.fx.a.type.fullname) + def test_simple_unbound_type(self) -> None: u = UnboundType("Foo") assert_equal(str(u), "Foo?") diff --git a/mypy/typeanal.py b/mypy/typeanal.py index ff3c8bd2816e1..cb7080134b5ef 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1050,6 +1050,16 @@ def analyze_unbound_type_without_type_info( column=t.column, ) + if isinstance(sym.node, Var) and sym.node.is_sentinel: + typ = get_proper_type(sym.node.type) + if isinstance(typ, Instance) and typ.last_known_value is not None: + return LiteralType( + value=typ.last_known_value.value, + fallback=typ.last_known_value.fallback, + line=t.line, + column=t.column, + ) + # None of the above options worked. We parse the args (if there are any) # to make sure there are no remaining semanal-only types, then give up. t = t.copy_modified(args=self.anal_array(t.args)) diff --git a/mypy/typeops.py b/mypy/typeops.py index 78448c60927f9..48efe3532a1c0 100644 --- a/mypy/typeops.py +++ b/mypy/typeops.py @@ -1058,7 +1058,7 @@ def is_singleton_identity_type(typ: ProperType) -> bool: or (typ.type.fullname in NOT_IMPLEMENTED_TYPE_NAMES) ) if isinstance(typ, LiteralType): - return typ.is_enum_literal() or isinstance(typ.value, bool) + return typ.is_enum_literal() or typ.is_sentinel_literal() or isinstance(typ.value, bool) if isinstance(typ, TypeType) and isinstance(typ.item, Instance) and typ.item.type.is_final: return True if isinstance(typ, FunctionLike) and typ.is_type_obj() and typ.type_object().is_final: diff --git a/mypy/types.py b/mypy/types.py index da420d1c012d2..7a1470964d251 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -67,6 +67,7 @@ JsonDict: _TypeAlias = dict[str, Any] + # The set of all valid expressions that can currently be contained # inside of a Literal[...]. # @@ -97,7 +98,12 @@ # # Note: Float values are only used internally. They are not accepted within # Literal[...]. -LiteralValue: _TypeAlias = int | str | bool | float +class SentinelValue(NamedTuple): + fullname: str + name: str + + +LiteralValue: _TypeAlias = int | str | bool | float | SentinelValue TUPLE_NAMES: Final = ("builtins.tuple", "typing.Tuple") @@ -112,6 +118,12 @@ "typing_extensions.TypeVarTuple", ) +SENTINEL_TYPE_NAMES: Final = ( + "builtins.sentinel", + "typing_extensions.sentinel", + "typing_extensions.Sentinel", +) + TYPED_NAMEDTUPLE_NAMES: Final = ("typing.NamedTuple", "typing_extensions.NamedTuple") # Supported names of TypedDict type constructors. @@ -3342,11 +3354,15 @@ def __init__( # almost no test cases where we would redundantly compute # `can_be_false`/`can_be_true`. def can_be_false_default(self) -> bool: + if isinstance(self.value, SentinelValue): + return False if self.fallback.type.is_enum: return self.fallback.can_be_false return not self.value def can_be_true_default(self) -> bool: + if isinstance(self.value, SentinelValue): + return True if self.fallback.type.is_enum: return self.fallback.can_be_true return bool(self.value) @@ -3367,6 +3383,9 @@ def __eq__(self, other: object) -> bool: def is_enum_literal(self) -> bool: return self.fallback.type.is_enum + def is_sentinel_literal(self) -> bool: + return isinstance(self.value, SentinelValue) + def value_repr(self) -> str: """Returns the string representation of the underlying type. @@ -3374,6 +3393,9 @@ def value_repr(self) -> str: except it includes some additional logic to correctly handle cases where the value is a string, byte string, a unicode string, or an enum. """ + if isinstance(self.value, SentinelValue): + return self.value.name + raw = repr(self.value) fallback_name = self.fallback.type.fullname @@ -3392,16 +3414,19 @@ def value_repr(self) -> str: return raw def serialize(self) -> JsonDict | str: - return { - ".class": "LiteralType", - "value": self.value, - "fallback": self.fallback.serialize(), - } + value: LiteralValue | JsonDict = self.value + if isinstance(value, SentinelValue): + value = {".class": "SentinelValue", "fullname": value.fullname, "name": value.name} + return {".class": "LiteralType", "value": value, "fallback": self.fallback.serialize()} @classmethod def deserialize(cls, data: JsonDict) -> LiteralType: assert data[".class"] == "LiteralType" - return LiteralType(value=data["value"], fallback=Instance.deserialize(data["fallback"])) + value = data["value"] + if isinstance(value, dict): + assert value[".class"] == "SentinelValue" + value = SentinelValue(value["fullname"], value["name"]) + return LiteralType(value=value, fallback=Instance.deserialize(data["fallback"])) def write(self, data: WriteBuffer) -> None: write_tag(data, LITERAL_TYPE) @@ -3414,7 +3439,8 @@ def read(cls, data: ReadBuffer) -> LiteralType: assert read_tag(data) == INSTANCE fallback = Instance.read(data) tag = read_tag(data) - ret = LiteralType(read_literal(data, tag), fallback) + value = read_literal(data, tag) + ret = LiteralType(value, fallback) assert read_tag(data) == END_TAG return ret @@ -4077,6 +4103,8 @@ def visit_raw_expression_type(self, t: RawExpressionType, /) -> str: return repr(t.literal_value) def visit_literal_type(self, t: LiteralType, /) -> str: + if isinstance(t.value, SentinelValue): + return t.value_repr() return f"Literal[{t.value_repr()}]" def visit_union_type(self, t: UnionType, /) -> str: diff --git a/mypyc/lib-rt/internal/librt_internal.c b/mypyc/lib-rt/internal/librt_internal.c index 04de7610736c0..dc811efbabad1 100644 --- a/mypyc/lib-rt/internal/librt_internal.c +++ b/mypyc/lib-rt/internal/librt_internal.c @@ -930,6 +930,7 @@ write_tag(PyObject *self, PyObject *const *args, size_t nargs) { #define LITERAL_BYTES 5 #define LITERAL_FLOAT 6 #define LITERAL_COMPLEX 7 +#define LITERAL_SENTINEL 8 // Supported builtin collections. #define LIST_GEN 20 @@ -1161,6 +1162,11 @@ _skip_object(PyObject *data, uint8_t tag) { return _skip(data, 8); if (tag == LITERAL_COMPLEX) return _skip(data, 16); + if (tag == LITERAL_SENTINEL) { + if (unlikely(_skip_str_bytes(data) == CPY_NONE_ERROR)) + return CPY_NONE_ERROR; + return _skip_str_bytes(data); + } PyErr_Format(PyExc_ValueError, "Unsupported tag: %d", tag); return CPY_NONE_ERROR; } diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index 7722cf26ca910..59eded082d532 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -2845,7 +2845,7 @@ from mypy_extensions import u8 from librt.internal import ( ReadBuffer, WriteBuffer, write_bool, read_bool, write_str, read_str, write_float, read_float, write_int, read_int, write_tag, read_tag, write_bytes, read_bytes, - cache_version, + cache_version, extract_symbol, ) from testutil import assertRaises @@ -3063,6 +3063,16 @@ def test_buffer_str_size() -> None: b = ReadBuffer(b.getvalue()) assert read_str(b) == s +def test_extract_symbol_sentinel_literal() -> None: + data = WriteBuffer() + write_tag(data, 8) # LITERAL_SENTINEL + write_str(data, "__main__.MISSING") + write_str(data, "MISSING") + write_tag(data, 255) # END_TAG + + payload = data.getvalue() + assert extract_symbol(ReadBuffer(payload)) == payload + [file driver.py] from native import * @@ -3077,6 +3087,7 @@ test_buffer_str_size() test_buffer_int_powers() test_positive_long_int_serialized_bytes() test_negative_long_int_serialized_bytes() +test_extract_symbol_sentinel_literal() def test_buffer_basic_interpreted() -> None: b = WriteBuffer() diff --git a/test-data/unit/check-sentinels.test b/test-data/unit/check-sentinels.test new file mode 100644 index 0000000000000..ca4768a4d4aab --- /dev/null +++ b/test-data/unit/check-sentinels.test @@ -0,0 +1,155 @@ +[case testSentinelTypeExpressionsAndNarrowing] +from typing_extensions import Sentinel, assert_type, sentinel + +MISSING = Sentinel("", repr="missing") +SPECIAL = sentinel("SPECIAL") + +class Cls: + IN_CLASS = Sentinel("Cls.IN_CLASS") + +def func2(x: int | MISSING | SPECIAL = MISSING) -> None: + if x is MISSING: + assert_type(x, MISSING) + else: + assert_type(x, int | SPECIAL) + +def func3(x: int | Cls.IN_CLASS = Cls.IN_CLASS) -> None: + if x is Cls.IN_CLASS: + assert_type(x, Cls.IN_CLASS) + else: + assert_type(x, int) + +func2(1) +func2(MISSING) +func2(SPECIAL) +func2(Cls.IN_CLASS) # E: Argument 1 to "func2" has incompatible type "Cls.IN_CLASS"; expected "int | MISSING | SPECIAL" + +func3(1) +func3(MISSING) # E: Argument 1 to "func3" has incompatible type "MISSING"; expected "int | Cls.IN_CLASS" +func3(Cls.IN_CLASS) +[builtins fixtures/tuple.pyi] + +[case testSentinelEqualityNarrowingCurrentBehavior] +from typing_extensions import Sentinel, assert_type + +MISSING = Sentinel("MISSING") +SPECIAL = Sentinel("SPECIAL") + +def func(x: int | MISSING | SPECIAL) -> None: + # We could reasonably do narrowing here, but currently we're pretty conservative + # about narrowing on ==. + if x == MISSING: + assert_type(x, int | MISSING | SPECIAL) + else: + assert_type(x, int | MISSING | SPECIAL) +[builtins fixtures/tuple.pyi] + +[case testSentinelSameReprDistinctTypes] +from typing_extensions import Sentinel + +FIRST = Sentinel("DUPLICATE") +SECOND = Sentinel("DUPLICATE") + +def takes_first(x: FIRST) -> None: ... + +takes_first(FIRST) +takes_first(SECOND) # E: Argument 1 to "takes_first" has incompatible type "SECOND"; expected "FIRST" +[builtins fixtures/tuple.pyi] + +[case testSentinelFunctionLocalNotTypeExpression] +from typing_extensions import Sentinel + +def outer() -> None: + LOCAL = Sentinel("LOCAL") + def inner(x: LOCAL) -> None: ... # E: Variable "LOCAL" is not valid as a type \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases +[builtins fixtures/tuple.pyi] + +[case testSentinelImplicitlyFinal] +from typing_extensions import Sentinel + +MISSING = Sentinel("MISSING") +MISSING = Sentinel("OTHER") # E: Cannot redefine an existing name as final +[builtins fixtures/tuple.pyi] + +[case testSentinelImplicitlyFinalWithLaterReference] +from typing_extensions import Sentinel + +_SENTINEL = Sentinel("_SENTINEL") + +def func(x: int | _SENTINEL = _SENTINEL) -> int: + if x is _SENTINEL: + return 0 + return x +[builtins fixtures/tuple.pyi] + +[case testBuiltinsSentinelTypeExpression] +# flags: --python-version 3.15 +from typing import assert_type + +MISSING = sentinel("MISSING") + +def func(x: int | MISSING = MISSING) -> None: + if x is not MISSING: + assert_type(x, int) + else: + assert_type(x, MISSING) + +[builtins fixtures/sentinel.pyi] + +[case testPrivateSentinelNameCanBeReusedAcrossModules] +from typing_extensions import Sentinel + +_SENTINEL = Sentinel("_SENTINEL") + +[file other.py] +from typing_extensions import Sentinel + +_SENTINEL = Sentinel("_SENTINEL") +[builtins fixtures/tuple.pyi] + +[case testPrivateSentinelNameCanBeReusedAfterStarImport] +from other import * +from typing_extensions import Sentinel + +_SENTINEL = Sentinel("_SENTINEL") + +[file other.py] +from typing_extensions import Sentinel + +_SENTINEL = Sentinel("_SENTINEL") +[builtins fixtures/tuple.pyi] + +[case testPrivateSentinelNameCanBeReusedAfterStarImportWithAll] +from other import * +from typing_extensions import Sentinel + +_SENTINEL = Sentinel("_SENTINEL") + +[file other.py] +from typing_extensions import Sentinel + +_SENTINEL = Sentinel("_SENTINEL") +__all__ = ("public",) +public = 1 +[builtins fixtures/tuple.pyi] + +[case testIsinstanceSentinel] +# flags: --python-version 3.15 +from typing import assert_type +from typing_extensions import Sentinel + +BUILTIN = sentinel("BUILTIN") +TYPEXT = Sentinel("TYPEXT") + +def func(x: int | BUILTIN | TYPEXT) -> None: + if isinstance(x, sentinel): + assert_type(x, BUILTIN) + else: + assert_type(x, int | TYPEXT) + if isinstance(x, Sentinel): + assert_type(x, TYPEXT) + else: + assert_type(x, int) + +[builtins fixtures/sentinel.pyi] diff --git a/test-data/unit/fixtures/sentinel.pyi b/test-data/unit/fixtures/sentinel.pyi new file mode 100644 index 0000000000000..690efb0df32ac --- /dev/null +++ b/test-data/unit/fixtures/sentinel.pyi @@ -0,0 +1,24 @@ +# Builtins stub used in sentinel-related test cases. + +from typing import Self + +class object: + def __init__(self) -> None: pass + def __new__(cls) -> Self: ... + +class type: pass +class function: + __name__: str +class ellipsis: pass + +class int: pass +class bool(int): pass +class str: pass + +class sentinel: + def __init__(self, name: str, /) -> None: ... + +class dict: pass +class tuple: pass + +def isinstance(x: object, t: type) -> bool: pass diff --git a/test-data/unit/lib-stub/typing_extensions.pyi b/test-data/unit/lib-stub/typing_extensions.pyi index 43b5ef3a09501..6d614d8f1b915 100644 --- a/test-data/unit/lib-stub/typing_extensions.pyi +++ b/test-data/unit/lib-stub/typing_extensions.pyi @@ -42,6 +42,11 @@ TypeGuard: _SpecialForm TypeIs: _SpecialForm Never: _SpecialForm +class Sentinel: + def __init__(self, name: str, repr: str | None = None) -> None: ... + def __eq__(self, other: object) -> bool: ... +sentinel = Sentinel + TypeVarTuple: _SpecialForm Unpack: _SpecialForm Required: _SpecialForm