From 388dad241e7cd5ea70f1940cb93eb468587e1850 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 14 Sep 2022 13:14:05 +0300 Subject: [PATCH 1/7] Add initial `LiteralString` support --- mypy/messages.py | 2 + mypy/nodes.py | 7 -- mypy/subtypes.py | 9 ++ mypy/typeanal.py | 10 ++ mypy/types.py | 32 +++++- mypy/typestate.py | 2 + test-data/unit/check-literal-string.test | 104 ++++++++++++++++++ test-data/unit/fine-grained.test | 26 +++++ test-data/unit/fixtures/literal_string.pyi | 55 +++++++++ test-data/unit/lib-stub/typing_extensions.pyi | 2 + 10 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 test-data/unit/check-literal-string.test create mode 100644 test-data/unit/fixtures/literal_string.pyi diff --git a/mypy/messages.py b/mypy/messages.py index 6e7aa164ac91..f4908876e297 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -2212,6 +2212,8 @@ def format_literal_value(typ: LiteralType) -> str: if itype.extra_attrs and itype.extra_attrs.mod_name and module_names: return f"{base_str} {itype.extra_attrs.mod_name}" return base_str + elif itype.type.fullname == "builtins.str" and itype.literal_string: + return "LiteralString" if verbosity >= 2 or (fullnames and itype.type.fullname in fullnames): base_str = itype.type.fullname else: diff --git a/mypy/nodes.py b/mypy/nodes.py index 21d33b03e447..15dc1b93fe88 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -138,8 +138,6 @@ def get_column(self) -> int: "typing.DefaultDict": "collections.defaultdict", "typing.Deque": "collections.deque", "typing.OrderedDict": "collections.OrderedDict", - # HACK: a lie in lieu of actual support for PEP 675 - "typing.LiteralString": "builtins.str", } # This keeps track of the oldest supported Python version where the corresponding @@ -154,15 +152,12 @@ def get_column(self) -> int: "typing.DefaultDict": (2, 7), "typing.Deque": (2, 7), "typing.OrderedDict": (3, 7), - "typing.LiteralString": (3, 11), } # This keeps track of aliases in `typing_extensions`, which we treat specially. typing_extensions_aliases: Final = { # See: https://github.com/python/mypy/issues/11528 "typing_extensions.OrderedDict": "collections.OrderedDict", - # HACK: a lie in lieu of actual support for PEP 675 - "typing_extensions.LiteralString": "builtins.str", } reverse_builtin_aliases: Final = { @@ -176,8 +171,6 @@ def get_column(self) -> int: _nongen_builtins.update((name, alias) for alias, name in type_aliases.items()) # Drop OrderedDict from this for backward compatibility del _nongen_builtins["collections.OrderedDict"] -# HACK: consequence of hackily treating LiteralString as an alias for str -del _nongen_builtins["builtins.str"] def get_nongen_builtins(python_version: tuple[int, int]) -> dict[str, str]: diff --git a/mypy/subtypes.py b/mypy/subtypes.py index bc35b1a4d683..b3fb620adc5f 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -463,7 +463,16 @@ def visit_instance(self, left: Instance) -> bool: # and we can't have circular promotions. if left.type.alt_promote is right.type: return True + rname = right.type.fullname + + # Check `LiteralString` special case: + if rname == "builtins.str" and right.literal_string: + return left.literal_string or ( + left.last_known_value is not None + and isinstance(left.last_known_value.value, str) + ) + # Always try a nominal check if possible, # there might be errors that a user wants to silence *once*. # NamedTuples are a special case, because `NamedTuple` is not listed diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 37f00841562f..ec7db58338fa 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -574,6 +574,16 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ self.fail('"Unpack" is not supported yet, use --enable-incomplete-features', t) return AnyType(TypeOfAny.from_error) return UnpackType(self.anal_type(t.args[0]), line=t.line, column=t.column) + elif fullname in ("typing.LiteralString", "typing_extensions.LiteralString"): + inst = self.named_type("builtins.str") + if not self.options.enable_incomplete_features: + self.fail( + '"LiteralString" is not fully supported yet, use --enable-incomplete-features', + t, + ) + else: + inst.literal_string = True + return inst return None def get_omitted_any(self, typ: Type, fullname: str | None = None) -> AnyType: diff --git a/mypy/types.py b/mypy/types.py index d82b511f7d5a..9f6087ce9e90 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -1203,7 +1203,16 @@ class Instance(ProperType): fallbacks for all "non-special" (like UninhabitedType, ErasedType etc) types. """ - __slots__ = ("type", "args", "invalid", "type_ref", "last_known_value", "_hash", "extra_attrs") + __slots__ = ( + "type", + "args", + "invalid", + "type_ref", + "last_known_value", + "_hash", + "extra_attrs", + "literal_string", + ) def __init__( self, @@ -1214,6 +1223,7 @@ def __init__( *, last_known_value: LiteralType | None = None, extra_attrs: ExtraAttrs | None = None, + literal_string: bool = False, ) -> None: super().__init__(line, column) self.type = typ @@ -1276,6 +1286,9 @@ def __init__( # to be "short-lived", we don't serialize it, and even don't store as variable type. self.extra_attrs = extra_attrs + # Is set to true when `LiteralString` type is used. + self.literal_string = literal_string + def accept(self, visitor: TypeVisitor[T]) -> T: return visitor.visit_instance(self) @@ -1292,6 +1305,7 @@ def __eq__(self, other: object) -> bool: and self.args == other.args and self.last_known_value == other.last_known_value and self.extra_attrs == other.extra_attrs + and self.literal_string == self.literal_string ) def serialize(self) -> JsonDict | str: @@ -1304,6 +1318,7 @@ def serialize(self) -> JsonDict | str: data["args"] = [arg.serialize() for arg in self.args] if self.last_known_value is not None: data["last_known_value"] = self.last_known_value.serialize() + data["literal_string"] = self.literal_string return data @classmethod @@ -1322,6 +1337,7 @@ def deserialize(cls, data: JsonDict | str) -> Instance: inst.type_ref = data["type_ref"] # Will be fixed up by fixup.py later. if "last_known_value" in data: inst.last_known_value = LiteralType.deserialize(data["last_known_value"]) + inst.literal_string = data["literal_string"] return inst def copy_modified( @@ -1329,15 +1345,19 @@ def copy_modified( *, args: Bogus[list[Type]] = _dummy, last_known_value: Bogus[LiteralType | None] = _dummy, + literal_string: Bogus[bool] = _dummy, ) -> Instance: new = Instance( self.type, args if args is not _dummy else self.args, self.line, self.column, - last_known_value=last_known_value - if last_known_value is not _dummy - else self.last_known_value, + last_known_value=( + last_known_value if last_known_value is not _dummy else self.last_known_value + ), + literal_string=( + literal_string if literal_string is not _dummy else self.literal_string + ), ) # We intentionally don't copy the extra_attrs here, so they will be erased. new.can_be_true = self.can_be_true @@ -2446,6 +2466,10 @@ def __init__( self.fallback = fallback self._hash = -1 # Cached hash value + # Make sure `LiteralString` will just work with `Literal['...']` types: + if self.fallback.type.fullname == "builtins.str" and isinstance(self.value, str): + self.fallback.literal_string = True + def can_be_false_default(self) -> bool: return not self.value diff --git a/mypy/typestate.py b/mypy/typestate.py index a5d65c4b4ea3..ba68dc15389f 100644 --- a/mypy/typestate.py +++ b/mypy/typestate.py @@ -141,6 +141,8 @@ def is_cached_subtype_check(kind: SubtypeKind, left: Instance, right: Instance) # will be an unbounded number of potential types to cache, # making caching less effective. return False + if left.literal_string or right.literal_string: + return False info = right.type cache = TypeState._subtype_caches.get(info) if cache is None: diff --git a/test-data/unit/check-literal-string.test b/test-data/unit/check-literal-string.test new file mode 100644 index 000000000000..0050af4f61df --- /dev/null +++ b/test-data/unit/check-literal-string.test @@ -0,0 +1,104 @@ +-- LiteralString tests +-- See https://peps.python.org/pep-0675/ + +[case testLiteralStringInference] +from typing_extensions import LiteralString + +x: LiteralString = 'a' +raw_str: str = x # Ok, can be narrowed + +some_str: str +y: LiteralString = some_str # E: Incompatible types in assignment (expression has type "str", variable has type "LiteralString") + +z: LiteralString = 1 # E: Incompatible types in assignment (expression has type "int", variable has type "LiteralString") +[builtins fixtures/literal_string.pyi] + + +[case testLiteralTypeAndLiteralString] +from typing_extensions import LiteralString, Literal + +l1: Literal['a'] +l2: Literal[1] + +ls1: LiteralString = l1 +ls2: LiteralString = l2 # E: Incompatible types in assignment (expression has type "Literal[1]", variable has type "LiteralString") + +ls3: LiteralString +l3: Literal['a'] = ls3 # E: Incompatible types in assignment (expression has type "LiteralString", variable has type "Literal['a']") + +def expects_literal_string(x: LiteralString): ... +def expects_literal_a(x: Literal['a']): ... + +expects_literal_string(l1) +expects_literal_string(ls1) + +expects_literal_a(l1) +expects_literal_a(ls1) # E: Argument 1 to "expects_literal_a" has incompatible type "LiteralString"; expected "Literal['a']" +[builtins fixtures/literal_string.pyi] + + +[case testLiteralStringFallbackToString] +from typing_extensions import LiteralString +def expects_literal_string(x: LiteralString): ... + +x: LiteralString +expects_literal_string(x.format(1)) # E: Argument 1 to "expects_literal_string" has incompatible type "str"; expected "LiteralString" +[builtins fixtures/literal_string.pyi] + + +-- TODO: this is not supported yet +-- All cases here must pass +-- But, we need literal type math for this +[case testLiteralStringTypeMath-skip] +from typing_extensions import LiteralString +def expects_literal_string(x: LiteralString): ... + +expects_literal_string('a') +expects_literal_string('a' + 'b') +expects_literal_string('a' * 2) +[builtins fixtures/literal_string.pyi] + + +[case testLiteralStringBoundTypeVar] +from typing_extensions import LiteralString +from typing import TypeVar + +T = TypeVar('T', bound=LiteralString) +def expects_literal_string(x: T): ... + +expects_literal_string('a') + +x: LiteralString +y: str +expects_literal_string(x) +expects_literal_string(y) # E: Value of type variable "T" of "expects_literal_string" cannot be "str" +[builtins fixtures/literal_string.pyi] + + +[case testLiteralStringAsMethodSig] +from typing_extensions import LiteralString + +class Base: + def method1(self, arg: LiteralString) -> str: ... + def method2(self, arg: str) -> LiteralString: ... + def method3(self, arg: LiteralString) -> LiteralString: ... + def method4(self, arg: str) -> str: ... + +class Correct(Base): + def method1(self, arg: str) -> LiteralString: ... + def method3(self, arg: str) -> LiteralString: ... + def method4(self, arg: str) -> LiteralString: ... + +class Wrong(Base): + def method2(self, arg: LiteralString) -> str: ... + def method3(self, arg: str) -> str: ... + def method4(self, arg: LiteralString) -> LiteralString: ... +[out] +main:15: error: Return type "str" of "method2" incompatible with return type "LiteralString" in supertype "Base" +main:16: error: Return type "str" of "method3" incompatible with return type "LiteralString" in supertype "Base" +main:17: error: Signature of "method4" incompatible with supertype "Base" +main:17: note: Superclass: +main:17: note: def method4(self, arg: str) -> str +main:17: note: Subclass: +main:17: note: def method4(self, arg: LiteralString) -> LiteralString +[builtins fixtures/literal_string.pyi] diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index 9d8857301425..55b05a4c1102 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -10031,3 +10031,29 @@ class C(B): ... == == main.py:4: note: Revealed type is "def () -> builtins.str" + +[case testLiteralStringCache] +import main +[file main.py] +from typing_extensions import LiteralString + +def some(x: LiteralString) -> LiteralString: + return x + +x: LiteralString = 'a' +some(x) +some('b') +[file main.py.2] +from typing_extensions import LiteralString + +def some(x: LiteralString) -> str: + return x + +x: str +some(x) # error +some('b') +[builtins fixtures/tuple.pyi] +[out] +== +== +main.py:7: error: Argument 1 to "some" has incompatible type "str"; expected "LiteralString" diff --git a/test-data/unit/fixtures/literal_string.pyi b/test-data/unit/fixtures/literal_string.pyi new file mode 100644 index 000000000000..8d937247ebbf --- /dev/null +++ b/test-data/unit/fixtures/literal_string.pyi @@ -0,0 +1,55 @@ +# Builtins stub used in tuple-related test cases. + +from typing import Iterable, Iterator, TypeVar, Generic, Sequence, Any, overload, Tuple, Type + +T = TypeVar("T") +Tco = TypeVar('Tco', covariant=True) + +class object: + def __init__(self) -> None: pass + +class type: + def __init__(self, *a: object) -> None: pass + def __call__(self, *a: object) -> object: pass +class tuple(Sequence[Tco], Generic[Tco]): + def __new__(cls: Type[T], iterable: Iterable[Tco] = ...) -> T: ... + def __iter__(self) -> Iterator[Tco]: pass + def __contains__(self, item: object) -> bool: pass + @overload + def __getitem__(self, x: int) -> Tco: pass + @overload + def __getitem__(self, x: slice) -> Tuple[Tco, ...]: ... + def __mul__(self, n: int) -> Tuple[Tco, ...]: pass + def __rmul__(self, n: int) -> Tuple[Tco, ...]: pass + def __add__(self, x: Tuple[Tco, ...]) -> Tuple[Tco, ...]: pass + def count(self, obj: object) -> int: pass +class function: pass +class ellipsis: pass +class classmethod: pass + +# We need int and slice for indexing tuples. +class int: + def __neg__(self) -> 'int': pass +class float: pass +class slice: pass +class bool(int): pass +class str: + def __add__(self, __other: str) -> str: pass + def __mul__(self, __num: int) -> str: pass + def format(self, *args: Any) -> str: pass +class bytes: pass +class unicode: pass + +class list(Sequence[T], Generic[T]): + @overload + def __getitem__(self, i: int) -> T: ... + @overload + def __getitem__(self, s: slice) -> list[T]: ... + def __contains__(self, item: object) -> bool: ... + def __iter__(self) -> Iterator[T]: ... + +def isinstance(x: object, t: type) -> bool: pass + +def sum(iterable: Iterable[T], start: T = None) -> T: pass + +class BaseException: pass diff --git a/test-data/unit/lib-stub/typing_extensions.pyi b/test-data/unit/lib-stub/typing_extensions.pyi index b82b73d49a71..cfa1569556e9 100644 --- a/test-data/unit/lib-stub/typing_extensions.pyi +++ b/test-data/unit/lib-stub/typing_extensions.pyi @@ -33,6 +33,8 @@ Never: _SpecialForm TypeVarTuple: _SpecialForm Unpack: _SpecialForm +LiteralString: _SpecialForm + # Fallback type for all typed dicts (does not exist at runtime). class _TypedDict(Mapping[str, object]): # Needed to make this class non-abstract. It is explicitly declared abstract in From 8b6dffbbcbf83621f696ba628c1c3267dccf1599 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 14 Sep 2022 15:35:04 +0300 Subject: [PATCH 2/7] Introduce implicit and explicit LiteralStrings --- mypy/checkexpr.py | 4 +- mypy/erasetype.py | 4 +- mypy/subtypes.py | 5 +- mypy/type_visitor.py | 5 +- mypy/typeanal.py | 3 +- mypy/types.py | 22 +++++-- test-data/unit/check-literal-string.test | 73 ++++++++++++++++++++-- test-data/unit/fixtures/literal_string.pyi | 2 + 8 files changed, 99 insertions(+), 19 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index a07a1a1c9258..e25282c400f3 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -145,6 +145,7 @@ TypeOfAny, TypeType, TypeVarType, + TypeOfLiteralString, UninhabitedType, UnionType, flatten_nested_unions, @@ -2724,7 +2725,8 @@ def infer_literal_expr_type(self, value: LiteralValue, fallback_name: str) -> Ty return typ.copy_modified( last_known_value=LiteralType( value=value, fallback=typ, line=typ.line, column=typ.column - ) + ), + literal_string=TypeOfLiteralString.implicit, ) def concat_tuples(self, left: TupleType, right: TupleType) -> TupleType: diff --git a/mypy/erasetype.py b/mypy/erasetype.py index 89c07186f44a..ecf2e61236de 100644 --- a/mypy/erasetype.py +++ b/mypy/erasetype.py @@ -192,7 +192,9 @@ class LastKnownValueEraser(TypeTranslator): def visit_instance(self, t: Instance) -> Type: if not t.last_known_value and not t.args: return t - return t.copy_modified(args=[a.accept(self) for a in t.args], last_known_value=None) + return t.copy_modified( + args=[a.accept(self) for a in t.args], last_known_value=None + ) def visit_type_alias_type(self, t: TypeAliasType) -> Type: # Type aliases can't contain literal values, because they are diff --git a/mypy/subtypes.py b/mypy/subtypes.py index b3fb620adc5f..1d29968e1dc0 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -54,6 +54,7 @@ TypeVarType, TypeVisitor, UnboundType, + TypeOfLiteralString, UninhabitedType, UnionType, UnpackType, @@ -467,7 +468,7 @@ def visit_instance(self, left: Instance) -> bool: rname = right.type.fullname # Check `LiteralString` special case: - if rname == "builtins.str" and right.literal_string: + if rname == "builtins.str" and right.literal_string == TypeOfLiteralString.explicit and left.type.fullname == rname: return left.literal_string or ( left.last_known_value is not None and isinstance(left.last_known_value.value, str) @@ -782,6 +783,8 @@ def visit_typeddict_type(self, left: TypedDictType) -> bool: def visit_literal_type(self, left: LiteralType) -> bool: if isinstance(self.right, LiteralType): return left == self.right + elif isinstance(self.right, Instance) and self.right.literal_string: + return isinstance(left.value, str) else: return self._is_subtype(left.fallback, self.right) diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index fe404cda0bec..f9d8c39ff4d0 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -211,11 +211,8 @@ def visit_instance(self, t: Instance) -> Type: raw_last_known_value = t.last_known_value.accept(self) assert isinstance(raw_last_known_value, LiteralType) # type: ignore[misc] last_known_value = raw_last_known_value - return Instance( - typ=t.type, + return t.copy_modified( args=self.translate_types(t.args), - line=t.line, - column=t.column, last_known_value=last_known_value, ) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index ec7db58338fa..92ea936f61d1 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -78,6 +78,7 @@ TypeType, TypeVarLikeType, TypeVarTupleType, + TypeOfLiteralString, TypeVarType, UnboundType, UninhabitedType, @@ -582,7 +583,7 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ t, ) else: - inst.literal_string = True + inst.literal_string = TypeOfLiteralString.explicit return inst return None diff --git a/mypy/types.py b/mypy/types.py index 9f6087ce9e90..613d551781ea 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -1193,6 +1193,15 @@ def copy(self) -> ExtraAttrs: return ExtraAttrs(self.attrs.copy(), self.immutable.copy(), self.mod_name) +class TypeOfLiteralString: + """Used to specify what kind of `LiteralString` are we dealing with.""" + + __slots__ = () + + explicit: Final = 1 + implicit: Final = 2 + + class Instance(ProperType): """An instance type of form C[T1, ..., Tn]. @@ -1223,7 +1232,7 @@ def __init__( *, last_known_value: LiteralType | None = None, extra_attrs: ExtraAttrs | None = None, - literal_string: bool = False, + literal_string: int | None = None, ) -> None: super().__init__(line, column) self.type = typ @@ -1286,7 +1295,9 @@ def __init__( # to be "short-lived", we don't serialize it, and even don't store as variable type. self.extra_attrs = extra_attrs - # Is set to true when `LiteralString` type is used. + # Is set to `1` when explicit `LiteralString` type is used. + # Is set to `2` when implicit `LiteralString` is used, like `'a'` + # Is `None` by default. self.literal_string = literal_string def accept(self, visitor: TypeVisitor[T]) -> T: @@ -2466,10 +2477,6 @@ def __init__( self.fallback = fallback self._hash = -1 # Cached hash value - # Make sure `LiteralString` will just work with `Literal['...']` types: - if self.fallback.type.fullname == "builtins.str" and isinstance(self.value, str): - self.fallback.literal_string = True - def can_be_false_default(self) -> bool: return not self.value @@ -2925,6 +2932,9 @@ def visit_instance(self, t: Instance) -> str: else: s = t.type.fullname or t.type.name or "" + if t.literal_string == TypeOfLiteralString.explicit: + s = 'LiteralString' + if t.args: if t.type.fullname == "builtins.tuple": assert len(t.args) == 1 diff --git a/test-data/unit/check-literal-string.test b/test-data/unit/check-literal-string.test index 0050af4f61df..b20ad9de4377 100644 --- a/test-data/unit/check-literal-string.test +++ b/test-data/unit/check-literal-string.test @@ -5,7 +5,9 @@ from typing_extensions import LiteralString x: LiteralString = 'a' +reveal_type(x) # N: Revealed type is "LiteralString" raw_str: str = x # Ok, can be narrowed +reveal_type(raw_str) # N: Revealed type is "builtins.str" some_str: str y: LiteralString = some_str # E: Incompatible types in assignment (expression has type "str", variable has type "LiteralString") @@ -14,6 +16,30 @@ z: LiteralString = 1 # E: Incompatible types in assignment (expression has type [builtins fixtures/literal_string.pyi] +[case testLiteralStringAndFString] +from typing_extensions import LiteralString +x: LiteralString = f'Value: {1}' # E: Incompatible types in assignment (expression has type "str", variable has type "LiteralString") +[builtins fixtures/literal_string.pyi] + + +[case testLiteralStringAndStrImplicitTypes] +reveal_type('a') # N: Revealed type is "Literal['a']?" + +x = 'a' +reveal_type(x) # N: Revealed type is "builtins.str" +x = f'Value: {x}' +reveal_type(x) # N: Revealed type is "builtins.str" + +if int(): + cond = 'abc' # Literal +else: + cond = ','.join(['a', 'b']) # Dynamic + +literal_names = ["native"] +literal_names.append(str()) +[builtins fixtures/literal_string.pyi] + + [case testLiteralTypeAndLiteralString] from typing_extensions import LiteralString, Literal @@ -94,11 +120,48 @@ class Wrong(Base): def method3(self, arg: str) -> str: ... def method4(self, arg: LiteralString) -> LiteralString: ... [out] +main:15: error: Argument 1 of "method2" is incompatible with supertype "Base"; supertype defines the argument type as "str" +main:15: note: This violates the Liskov substitution principle +main:15: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides main:15: error: Return type "str" of "method2" incompatible with return type "LiteralString" in supertype "Base" main:16: error: Return type "str" of "method3" incompatible with return type "LiteralString" in supertype "Base" -main:17: error: Signature of "method4" incompatible with supertype "Base" -main:17: note: Superclass: -main:17: note: def method4(self, arg: str) -> str -main:17: note: Subclass: -main:17: note: def method4(self, arg: LiteralString) -> LiteralString +main:17: error: Argument 1 of "method4" is incompatible with supertype "Base"; supertype defines the argument type as "str" +main:17: note: This violates the Liskov substitution principle +main:17: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides [builtins fixtures/literal_string.pyi] + + +[case testLiteralStringInsideList] +from typing_extensions import LiteralString +from typing import List + +literal_names: List[LiteralString] = ["native", "literal"] +literal_names.append(str()) # E: Argument 1 to "append" of "list" has incompatible type "str"; expected "LiteralString" + +literal_names2: List[LiteralString] = ["literal", str()] # E: List item 1 has incompatible type "str"; expected "LiteralString" +[builtins fixtures/list.pyi] + +[case testLiteralStringInsideTuple] +from typing_extensions import LiteralString +from typing import Tuple + +literal_names: Tuple[LiteralString, ...] = ("native", "literal") +literal_names2: Tuple[LiteralString, ...] = ("literal", str()) # E: Incompatible types in assignment (expression has type "Tuple[LiteralString, str]", variable has type "Tuple[LiteralString, ...]") +[builtins fixtures/tuple.pyi] + +[case testLiteralStringInsideSet] +from typing_extensions import LiteralString +from typing import Set + +literal_names: Set[LiteralString] = {"native", "literal"} +literal_names2: Set[LiteralString] = {"literal", str()} # E: Argument 2 to has incompatible type "str"; expected "LiteralString" +[builtins fixtures/set.pyi] + +[case testLiteralStringInsideDict] +from typing_extensions import LiteralString +from typing import Dict + +literal_names: Dict[LiteralString, LiteralString] = {"native": "literal"} +literal_names2: Dict[LiteralString, int] = {"literal": 1, str(): 2} # E: Dict entry 1 has incompatible type "str": "int"; expected "LiteralString": "int" +literal_names3: Dict[int, LiteralString] = {2: str(), 1: "literal"} # E: Dict entry 0 has incompatible type "int": "str"; expected "int": "LiteralString" +[builtins fixtures/dict.pyi] diff --git a/test-data/unit/fixtures/literal_string.pyi b/test-data/unit/fixtures/literal_string.pyi index 8d937247ebbf..082c82ad9f60 100644 --- a/test-data/unit/fixtures/literal_string.pyi +++ b/test-data/unit/fixtures/literal_string.pyi @@ -37,6 +37,7 @@ class str: def __add__(self, __other: str) -> str: pass def __mul__(self, __num: int) -> str: pass def format(self, *args: Any) -> str: pass + def join(self, __items: Iterable[str]) -> str: pass class bytes: pass class unicode: pass @@ -47,6 +48,7 @@ class list(Sequence[T], Generic[T]): def __getitem__(self, s: slice) -> list[T]: ... def __contains__(self, item: object) -> bool: ... def __iter__(self) -> Iterator[T]: ... + def append(self, __item: T) -> None: ... def isinstance(x: object, t: type) -> bool: pass From 07b65c5a9062115ef51a6284b3f84ecc61988299 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 14 Sep 2022 16:31:35 +0300 Subject: [PATCH 3/7] Fix CI --- mypy/checkexpr.py | 2 +- mypy/erasetype.py | 4 +--- mypy/nodes.py | 2 +- mypy/subtypes.py | 19 ++++++++++++++----- mypy/type_visitor.py | 3 +-- mypy/typeanal.py | 2 +- mypy/types.py | 4 ++-- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index e25282c400f3..581b6e4fa1bd 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -143,9 +143,9 @@ Type, TypedDictType, TypeOfAny, + TypeOfLiteralString, TypeType, TypeVarType, - TypeOfLiteralString, UninhabitedType, UnionType, flatten_nested_unions, diff --git a/mypy/erasetype.py b/mypy/erasetype.py index ecf2e61236de..89c07186f44a 100644 --- a/mypy/erasetype.py +++ b/mypy/erasetype.py @@ -192,9 +192,7 @@ class LastKnownValueEraser(TypeTranslator): def visit_instance(self, t: Instance) -> Type: if not t.last_known_value and not t.args: return t - return t.copy_modified( - args=[a.accept(self) for a in t.args], last_known_value=None - ) + return t.copy_modified(args=[a.accept(self) for a in t.args], last_known_value=None) def visit_type_alias_type(self, t: TypeAliasType) -> Type: # Type aliases can't contain literal values, because they are diff --git a/mypy/nodes.py b/mypy/nodes.py index 15dc1b93fe88..16d0fc8382bf 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -157,7 +157,7 @@ def get_column(self) -> int: # This keeps track of aliases in `typing_extensions`, which we treat specially. typing_extensions_aliases: Final = { # See: https://github.com/python/mypy/issues/11528 - "typing_extensions.OrderedDict": "collections.OrderedDict", + "typing_extensions.OrderedDict": "collections.OrderedDict" } reverse_builtin_aliases: Final = { diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 1d29968e1dc0..21faf5ed9c4c 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -49,12 +49,12 @@ TypeAliasType, TypedDictType, TypeOfAny, + TypeOfLiteralString, TypeType, TypeVarTupleType, TypeVarType, TypeVisitor, UnboundType, - TypeOfLiteralString, UninhabitedType, UnionType, UnpackType, @@ -468,8 +468,12 @@ def visit_instance(self, left: Instance) -> bool: rname = right.type.fullname # Check `LiteralString` special case: - if rname == "builtins.str" and right.literal_string == TypeOfLiteralString.explicit and left.type.fullname == rname: - return left.literal_string or ( + if ( + rname == "builtins.str" + and right.literal_string == TypeOfLiteralString.explicit + and left.type.fullname == rname + ): + return left.literal_string is not None or ( left.last_known_value is not None and isinstance(left.last_known_value.value, str) ) @@ -783,8 +787,13 @@ def visit_typeddict_type(self, left: TypedDictType) -> bool: def visit_literal_type(self, left: LiteralType) -> bool: if isinstance(self.right, LiteralType): return left == self.right - elif isinstance(self.right, Instance) and self.right.literal_string: - return isinstance(left.value, str) + elif ( + isinstance(left.value, str) + and isinstance(self.right, Instance) + and self.right.type.fullname == "builtins.str" + and self.right.literal_string is not None + ): + return True else: return self._is_subtype(left.fallback, self.right) diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index f9d8c39ff4d0..143f150c9771 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -212,8 +212,7 @@ def visit_instance(self, t: Instance) -> Type: assert isinstance(raw_last_known_value, LiteralType) # type: ignore[misc] last_known_value = raw_last_known_value return t.copy_modified( - args=self.translate_types(t.args), - last_known_value=last_known_value, + args=self.translate_types(t.args), last_known_value=last_known_value ) def visit_type_var(self, t: TypeVarType) -> Type: diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 92ea936f61d1..1866486a7b8c 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -74,11 +74,11 @@ TypedDictType, TypeList, TypeOfAny, + TypeOfLiteralString, TypeQuery, TypeType, TypeVarLikeType, TypeVarTupleType, - TypeOfLiteralString, TypeVarType, UnboundType, UninhabitedType, diff --git a/mypy/types.py b/mypy/types.py index 613d551781ea..ca3b6a658d2a 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -1356,7 +1356,7 @@ def copy_modified( *, args: Bogus[list[Type]] = _dummy, last_known_value: Bogus[LiteralType | None] = _dummy, - literal_string: Bogus[bool] = _dummy, + literal_string: Bogus[int | None] = _dummy, ) -> Instance: new = Instance( self.type, @@ -2933,7 +2933,7 @@ def visit_instance(self, t: Instance) -> str: s = t.type.fullname or t.type.name or "" if t.literal_string == TypeOfLiteralString.explicit: - s = 'LiteralString' + s = "LiteralString" if t.args: if t.type.fullname == "builtins.tuple": From 7079ece119f7707ebd8a1e213b0da016695012eb Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 14 Sep 2022 19:44:10 +0300 Subject: [PATCH 4/7] Fix error message --- mypy/messages.py | 6 +++- mypy/semanal.py | 3 +- mypy/typeanal.py | 3 +- mypy/types.py | 2 ++ test-data/unit/check-literal-string.test | 43 +++++++++++++++++++++++- 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index f4908876e297..4f5926017ac0 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -79,6 +79,7 @@ TypeAliasType, TypedDictType, TypeOfAny, + TypeOfLiteralString, TypeType, TypeVarType, UnboundType, @@ -2212,7 +2213,10 @@ def format_literal_value(typ: LiteralType) -> str: if itype.extra_attrs and itype.extra_attrs.mod_name and module_names: return f"{base_str} {itype.extra_attrs.mod_name}" return base_str - elif itype.type.fullname == "builtins.str" and itype.literal_string: + elif ( + itype.type.fullname == "builtins.str" + and itype.literal_string == TypeOfLiteralString.explicit + ): return "LiteralString" if verbosity >= 2 or (fullnames and itype.type.fullname in fullnames): base_str = itype.type.fullname diff --git a/mypy/semanal.py b/mypy/semanal.py index 0c7ec43dd793..0c1d54c4cf3a 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -233,6 +233,7 @@ ASSERT_TYPE_NAMES, FINAL_DECORATOR_NAMES, FINAL_TYPE_NAMES, + LITERAL_STRING_NAMES, NEVER_NAMES, OVERLOAD_NAMES, PROTOCOL_NAMES, @@ -2655,7 +2656,7 @@ def is_type_ref(self, rv: Expression, bare: bool = False) -> bool: if bare: # These three are valid even if bare, for example # A = Tuple is just equivalent to A = Tuple[Any, ...]. - valid_refs = {"typing.Any", "typing.Tuple", "typing.Callable"} + valid_refs = {"typing.Any", "typing.Tuple", "typing.Callable", *LITERAL_STRING_NAMES} else: valid_refs = type_constructors diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 1866486a7b8c..bd591fdac41a 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -45,6 +45,7 @@ from mypy.types import ( ANNOTATED_TYPE_NAMES, FINAL_TYPE_NAMES, + LITERAL_STRING_NAMES, LITERAL_TYPE_NAMES, NEVER_NAMES, TYPE_ALIAS_NAMES, @@ -575,7 +576,7 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ self.fail('"Unpack" is not supported yet, use --enable-incomplete-features', t) return AnyType(TypeOfAny.from_error) return UnpackType(self.anal_type(t.args[0]), line=t.line, column=t.column) - elif fullname in ("typing.LiteralString", "typing_extensions.LiteralString"): + elif fullname in LITERAL_STRING_NAMES: inst = self.named_type("builtins.str") if not self.options.enable_incomplete_features: self.fail( diff --git a/mypy/types.py b/mypy/types.py index ca3b6a658d2a..6005f55efd41 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -133,6 +133,8 @@ OVERLOAD_NAMES: Final = ("typing.overload", "typing_extensions.overload") +LITERAL_STRING_NAMES: Final = ("typing.LiteralString", "typing_extensions.LiteralString") + # Attributes that can optionally be defined in the body of a subclass of # enum.Enum but are removed from the class __dict__ by EnumMeta. ENUM_REMOVED_PROPS: Final = ("_ignore_", "_order_", "__order__") diff --git a/test-data/unit/check-literal-string.test b/test-data/unit/check-literal-string.test index b20ad9de4377..2694cf02382f 100644 --- a/test-data/unit/check-literal-string.test +++ b/test-data/unit/check-literal-string.test @@ -36,6 +36,7 @@ else: cond = ','.join(['a', 'b']) # Dynamic literal_names = ["native"] +reveal_type(literal_names) # N: Revealed type is "builtins.list[builtins.str]" literal_names.append(str()) [builtins fixtures/literal_string.pyi] @@ -131,6 +132,46 @@ main:17: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#inco [builtins fixtures/literal_string.pyi] +[case testLiteralStringErrorMessages] +from typing_extensions import LiteralString + +class Base: + x: int +class Sub(Base): + x = 'a' # E: Incompatible types in assignment (expression has type "str", base class "Base" defined the type as "int") +class ExcplicitSub1(Base): + x: LiteralString = 'a' # E: Incompatible types in assignment (expression has type "LiteralString", base class "Base" defined the type as "int") +class ExcplicitSub2(Base): + x: LiteralString # E: Incompatible types in assignment (expression has type "LiteralString", base class "Base" defined the type as "int") + +def accepts_int(arg: int): ... +accepts_int('b') # E: Argument 1 to "accepts_int" has incompatible type "str"; expected "int" +ls1: LiteralString +ls2: LiteralString = 'a' +accepts_int(ls1) # E: Argument 1 to "accepts_int" has incompatible type "LiteralString"; expected "int" +accepts_int(ls2) # E: Argument 1 to "accepts_int" has incompatible type "LiteralString"; expected "int" +[builtins fixtures/literal_string.pyi] + + +[case testConditionalLiteralString] +from typing_extensions import LiteralString +if int(): + cond: LiteralString = 'abc' +else: + cond = ','.join(['a', 'b']) # E: Incompatible types in assignment (expression has type "str", variable has type "LiteralString") +[builtins fixtures/literal_string.pyi] + + +[case testUnionOfLiteralString] +from typing_extensions import LiteralString +from typing import Union + +x: Union[str, LiteralString] = 'a' +y: Union[str, LiteralString] = str() +z: Union[str, LiteralString] = 1 # E: Incompatible types in assignment (expression has type "int", variable has type "Union[str, LiteralString]") +[builtins fixtures/literal_string.pyi] + + [case testLiteralStringInsideList] from typing_extensions import LiteralString from typing import List @@ -146,7 +187,7 @@ from typing_extensions import LiteralString from typing import Tuple literal_names: Tuple[LiteralString, ...] = ("native", "literal") -literal_names2: Tuple[LiteralString, ...] = ("literal", str()) # E: Incompatible types in assignment (expression has type "Tuple[LiteralString, str]", variable has type "Tuple[LiteralString, ...]") +literal_names2: Tuple[LiteralString, ...] = ("literal", str()) # E: Incompatible types in assignment (expression has type "Tuple[str, str]", variable has type "Tuple[LiteralString, ...]") [builtins fixtures/tuple.pyi] [case testLiteralStringInsideSet] From 395e3e81aeeb0aebd8392a6ac60fa45319ad34eb Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 14 Sep 2022 20:17:41 +0300 Subject: [PATCH 5/7] Test type aliases --- mypy/typeanal.py | 13 +++++-------- test-data/unit/check-literal-string.test | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index bd591fdac41a..21830a637fea 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -578,13 +578,7 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ return UnpackType(self.anal_type(t.args[0]), line=t.line, column=t.column) elif fullname in LITERAL_STRING_NAMES: inst = self.named_type("builtins.str") - if not self.options.enable_incomplete_features: - self.fail( - '"LiteralString" is not fully supported yet, use --enable-incomplete-features', - t, - ) - else: - inst.literal_string = TypeOfLiteralString.explicit + inst.literal_string = TypeOfLiteralString.explicit return inst return None @@ -1599,7 +1593,10 @@ def expand_type_alias( assert isinstance(node.target, Instance) # type: ignore[misc] # Note: this is the only case where we use an eager expansion. See more info about # no_args aliases like L = List in the docstring for TypeAlias class. - return Instance(node.target.type, [], line=ctx.line, column=ctx.column) + inst = node.target.copy_modified(args=[]) + inst.line = ctx.line + inst.column = ctx.column + return inst return TypeAliasType(node, [], line=ctx.line, column=ctx.column) if ( exp_len == 0 diff --git a/test-data/unit/check-literal-string.test b/test-data/unit/check-literal-string.test index 2694cf02382f..618131ab979c 100644 --- a/test-data/unit/check-literal-string.test +++ b/test-data/unit/check-literal-string.test @@ -172,6 +172,27 @@ z: Union[str, LiteralString] = 1 # E: Incompatible types in assignment (express [builtins fixtures/literal_string.pyi] +[case testTypeAliasOfLiteralString] +from typing_extensions import LiteralString, TypeAlias + +LS1: TypeAlias = LiteralString +LS2 = LiteralString + +def ls1(arg: LS1) -> LS1: ... +def ls2(arg: LS2) -> LS2: ... + +reveal_type(ls1('abc')) # N: Revealed type is "LiteralString" +reveal_type(ls2("abc")) # N: Revealed type is "LiteralString" + +x: LiteralString +reveal_type(ls1(x)) # N: Revealed type is "LiteralString" +reveal_type(ls2(x)) # N: Revealed type is "LiteralString" +y: str +ls1(y) # E: Argument 1 to "ls1" has incompatible type "str"; expected "LiteralString" +ls2(y) # E: Argument 1 to "ls2" has incompatible type "str"; expected "LiteralString" +[builtins fixtures/literal_string.pyi] + + [case testLiteralStringInsideList] from typing_extensions import LiteralString from typing import List From 1675a35c49ed320dd7d74a978dfc1605ac0f092d Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 14 Sep 2022 20:52:59 +0300 Subject: [PATCH 6/7] Fix tests --- test-data/unit/check-type-aliases.test | 4 ++-- test-data/unit/fine-grained.test | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test-data/unit/check-type-aliases.test b/test-data/unit/check-type-aliases.test index 2849a226727b..dcad48cb5c3c 100644 --- a/test-data/unit/check-type-aliases.test +++ b/test-data/unit/check-type-aliases.test @@ -760,8 +760,8 @@ from typing import LiteralString as tpLS from typing_extensions import LiteralString as tpxLS def f(a: tpLS, b: tpxLS) -> None: - reveal_type(a) # N: Revealed type is "builtins.str" - reveal_type(b) # N: Revealed type is "builtins.str" + reveal_type(a) # N: Revealed type is "LiteralString" + reveal_type(b) # N: Revealed type is "LiteralString" # This isn't the correct behaviour, but should unblock use of LiteralString in typeshed f("asdf", "asdf") diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index 55b05a4c1102..5731b4e0af2f 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -10055,5 +10055,4 @@ some('b') [builtins fixtures/tuple.pyi] [out] == -== main.py:7: error: Argument 1 to "some" has incompatible type "str"; expected "LiteralString" From 5631847a63621dd6a0e0be9c2ccc22e953b24036 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Wed, 14 Sep 2022 21:19:52 +0300 Subject: [PATCH 7/7] More tests --- test-data/unit/check-literal-string.test | 21 +++++++++++++++++++++ test-data/unit/check-type-aliases.test | 6 ------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/test-data/unit/check-literal-string.test b/test-data/unit/check-literal-string.test index 618131ab979c..056b72ec6cf2 100644 --- a/test-data/unit/check-literal-string.test +++ b/test-data/unit/check-literal-string.test @@ -193,6 +193,27 @@ ls2(y) # E: Argument 1 to "ls2" has incompatible type "str"; expected "LiteralS [builtins fixtures/literal_string.pyi] +[case testOverloadsWithLiteralString] +from over import some + +x: str +y: str +reveal_type(some('a', 'b')) # N: Revealed type is "LiteralString" +reveal_type(some(x, y)) # N: Revealed type is "builtins.str" +reveal_type(some('a', y)) # N: Revealed type is "builtins.str" +reveal_type(some(x, 'b')) # N: Revealed type is "builtins.str" + +[file over.pyi] +from typing_extensions import LiteralString +from typing import Union, overload + +@overload +def some(x: LiteralString, y: LiteralString) -> LiteralString: ... +@overload +def some(x: str, y: str) -> str: ... +[builtins fixtures/literal_string.pyi] + + [case testLiteralStringInsideList] from typing_extensions import LiteralString from typing import List diff --git a/test-data/unit/check-type-aliases.test b/test-data/unit/check-type-aliases.test index dcad48cb5c3c..53e830c8fc8c 100644 --- a/test-data/unit/check-type-aliases.test +++ b/test-data/unit/check-type-aliases.test @@ -762,12 +762,6 @@ from typing_extensions import LiteralString as tpxLS def f(a: tpLS, b: tpxLS) -> None: reveal_type(a) # N: Revealed type is "LiteralString" reveal_type(b) # N: Revealed type is "LiteralString" - -# This isn't the correct behaviour, but should unblock use of LiteralString in typeshed -f("asdf", "asdf") -string: str -f(string, string) - [builtins fixtures/tuple.pyi] [typing fixtures/typing-medium.pyi]