From ff8281b3f6f5f7da6960de58da74dadf68ec59dd Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Mon, 12 Jan 2026 18:27:46 -0500 Subject: [PATCH 01/12] Add --disallow-str-iteration flag --- docs/source/command_line.rst | 13 +++++++++++++ docs/source/config_file.rst | 8 ++++++++ mypy/checker.py | 17 +++++++++++++++++ mypy/main.py | 8 ++++++++ mypy/messages.py | 7 +++++++ mypy/options.py | 4 ++++ mypy/subtypes.py | 8 ++++++++ mypy/typeshed/stdlib/builtins.pyi | 2 ++ test-data/unit/check-flags.test | 31 +++++++++++++++++++++++++++++++ test-data/unit/fixtures/dict.pyi | 3 +++ 10 files changed, 101 insertions(+) diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index b5081f113f91..00f9b6589f65 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -774,6 +774,19 @@ of the above sections. f(memoryview(b"")) # Ok +.. option:: --disallow-str-iteration + + Disallow iterating over ``str`` values. + This also rejects using ``str`` where an ``Iterable[str]`` or ``Sequence[str]`` is expected. + To iterate over characters, call ``iter`` on the string explicitly. + + .. code-block:: python + + s = "hello" + for ch in s: # error: Iterating over "str" is disallowed + print(ch) + + .. option:: --extra-checks This flag enables additional checks that are technically correct but may be diff --git a/docs/source/config_file.rst b/docs/source/config_file.rst index 77f952471007..41c15536230d 100644 --- a/docs/source/config_file.rst +++ b/docs/source/config_file.rst @@ -852,6 +852,14 @@ section of the command line docs. Disable treating ``bytearray`` and ``memoryview`` as subtypes of ``bytes``. This will be enabled by default in *mypy 2.0*. +.. confval:: disallow_str_iteration + + :type: boolean + :default: False + + Disallow iterating over ``str`` values. + This also rejects using ``str`` where an ``Iterable[str]`` or ``Sequence[str]`` is expected. + .. confval:: strict :type: boolean diff --git a/mypy/checker.py b/mypy/checker.py index 008becdd3483..f9a36358a5a8 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -5378,6 +5378,11 @@ def analyze_iterable_item_type_without_expression( echk = self.expr_checker iterable: Type iterable = get_proper_type(type) + if self.options.disallow_str_iteration and self.is_str_iteration_type(iterable): + self.msg.str_iteration_disallowed(context) + item_type = self.named_type("builtins.str") + iterator_type = self.named_generic_type("typing.Iterator", [item_type]) + return iterator_type, item_type iterator = echk.check_method_call_by_name("__iter__", iterable, [], [], context)[0] if ( @@ -5390,6 +5395,18 @@ def analyze_iterable_item_type_without_expression( iterable = echk.check_method_call_by_name("__next__", iterator, [], [], context)[0] return iterator, iterable + def is_str_iteration_type(self, typ: Type) -> bool: + typ = get_proper_type(typ) + if isinstance(typ, LiteralType): + return isinstance(typ.value, str) + if isinstance(typ, Instance): + return typ.type.fullname == "builtins.str" + if isinstance(typ, UnionType): + return any(self.is_str_iteration_type(item) for item in typ.relevant_items()) + if isinstance(typ, TypeVarType): + return self.is_str_iteration_type(typ.upper_bound) + return False + def analyze_range_native_int_type(self, expr: Expression) -> Type | None: """Try to infer native int item type from arguments to range(...). diff --git a/mypy/main.py b/mypy/main.py index 926e72515d95..c94a23dc79a2 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -937,6 +937,14 @@ def add_invertible_flag( group=strictness_group, ) + add_invertible_flag( + "--disallow-str-iteration", + default=False, + strict_flag=False, + help="Disallow iterating over str instances", + group=strictness_group, + ) + add_invertible_flag( "--extra-checks", default=False, diff --git a/mypy/messages.py b/mypy/messages.py index 5863b8719b95..5f42f911e83b 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1136,6 +1136,13 @@ def wrong_number_values_to_unpack( def unpacking_strings_disallowed(self, context: Context) -> None: self.fail("Unpacking a string is disallowed", context, code=codes.STR_UNPACK) + def str_iteration_disallowed(self, context: Context) -> None: + self.fail('Iterating over "str" is disallowed', context) + self.note( + "This is because --disallow-str-iteration is enabled", + context, + ) + def type_not_iterable(self, type: Type, context: Context) -> None: self.fail(f"{format_type(type, self.options)} object is not iterable", context) diff --git a/mypy/options.py b/mypy/options.py index cb5088af7e79..641a06ff74b6 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -37,6 +37,7 @@ class BuildType: "disallow_any_unimported", "disallow_incomplete_defs", "disallow_subclassing_any", + "disallow_str_iteration", "disallow_untyped_calls", "disallow_untyped_decorators", "disallow_untyped_defs", @@ -238,6 +239,9 @@ def __init__(self) -> None: # Disable treating bytearray and memoryview as subtypes of bytes self.strict_bytes = False + # Disallow iterating over str instances or using them as Sequence[T] + self.disallow_str_iteration = False + # Deprecated, use extra_checks instead. self.strict_concatenate = False diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 350d57a7e4ad..218b6e24001d 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -479,6 +479,14 @@ def visit_instance(self, left: Instance) -> bool: # dynamic base classes correctly, see #5456. return not isinstance(self.right, NoneType) right = self.right + if ( + self.options + and self.options.disallow_str_iteration + and left.type.fullname == "builtins.str" + and isinstance(right, Instance) + and right.type.fullname in ("typing.Sequence", "collections.abc.Sequence") + ): + return False if isinstance(right, TupleType) and right.partial_fallback.type.is_enum: return self._is_subtype(left, mypy.typeops.tuple_fallback(right)) if isinstance(right, TupleType): diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index bd425ff3c212..61a28e3a90e1 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -1455,6 +1455,8 @@ def input(prompt: object = "", /) -> str: ... class _GetItemIterable(Protocol[_T_co]): def __getitem__(self, i: int, /) -> _T_co: ... +@overload +def iter(object: str, /) -> Iterable[str]: ... @overload def iter(object: SupportsIter[_SupportsNextT_co], /) -> _SupportsNextT_co: ... @overload diff --git a/test-data/unit/check-flags.test b/test-data/unit/check-flags.test index 8d18c699e628..0d3e0054f39a 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2451,6 +2451,37 @@ f(bytearray(b"asdf")) # E: Argument 1 to "f" has incompatible type "bytearray"; f(memoryview(b"asdf")) # E: Argument 1 to "f" has incompatible type "memoryview"; expected "bytes" [builtins fixtures/primitives.pyi] +[case testDisallowStrIteration] +# flags: --disallow-str-iteration --python-version 3.12 +from typing import Iterable, Sequence + +s = "hello" +for ch in s: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled + reveal_type(ch) # N: Revealed type is "builtins.str" +[x for x in s] # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled + +def takes_seq(x: Sequence[int]) -> None: ... +def takes_seq_str(x: Sequence[str]) -> None: ... +takes_seq_str(s) # E: Argument 1 to "takes_seq_str" has incompatible type "str"; expected "Sequence[str]" + +def takes_iter_str(x: Iterable[str]) -> None: ... +takes_iter_str(s) # E: Argument 1 to "takes_iter_str" has incompatible type "str"; expected "Iterable[str]" + +seq: Sequence[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Sequence[str]") +iterable: Iterable[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Iterable[str]") + +def takes_maybe_seq(x: str | Sequence[str]) -> None: + for ch in x: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled + reveal_type(ch) # N: Revealed type is "builtins.str" + +def takes_bound[T: str](x: T) -> None: + for ch in x: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled + reveal_type(ch) # N: Revealed type is "builtins.str" + +[case testIterStrOverload] +reveal_type(iter("foo")) # N: Revealed type is "typing.Iterable[builtins.str]" +[builtins fixtures/dict.pyi] + [case testNoCrashFollowImportsForStubs] # flags: --config-file tmp/mypy.ini {**{"x": "y"}} diff --git a/test-data/unit/fixtures/dict.pyi b/test-data/unit/fixtures/dict.pyi index ed2287511161..8fde41357a3d 100644 --- a/test-data/unit/fixtures/dict.pyi +++ b/test-data/unit/fixtures/dict.pyi @@ -61,4 +61,7 @@ class ellipsis: pass class BaseException: pass def isinstance(x: object, t: Union[type, Tuple[type, ...]]) -> bool: pass +@overload +def iter(__iterable: str) -> Iterable[str]: pass +@overload def iter(__iterable: Iterable[T]) -> Iterator[T]: pass From 9844f81dc1ab93a7ffc257d650ad1f18c7f06a57 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:19:26 +0000 Subject: [PATCH 02/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/messages.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 5f42f911e83b..cfd8a83772ee 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1138,10 +1138,7 @@ def unpacking_strings_disallowed(self, context: Context) -> None: def str_iteration_disallowed(self, context: Context) -> None: self.fail('Iterating over "str" is disallowed', context) - self.note( - "This is because --disallow-str-iteration is enabled", - context, - ) + self.note("This is because --disallow-str-iteration is enabled", context) def type_not_iterable(self, type: Type, context: Context) -> None: self.fail(f"{format_type(type, self.options)} object is not iterable", context) From 115c1b7cf3eebbd6fb19ad29707d4235143aafb0 Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Tue, 13 Jan 2026 12:03:51 -0500 Subject: [PATCH 03/12] address feedback --- mypy/checker.py | 5 +- mypy/subtypes.py | 13 ++++- test-data/unit/check-flags.test | 35 +++++++++--- test-data/unit/fixtures/str-iter.pyi | 52 +++++++++++++++++ test-data/unit/fixtures/typing-str-iter.pyi | 62 +++++++++++++++++++++ test-data/unit/lib-stub/_typeshed.pyi | 5 ++ 6 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 test-data/unit/fixtures/str-iter.pyi create mode 100644 test-data/unit/fixtures/typing-str-iter.pyi diff --git a/mypy/checker.py b/mypy/checker.py index f9a36358a5a8..796110f7f8ec 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -5378,11 +5378,10 @@ def analyze_iterable_item_type_without_expression( echk = self.expr_checker iterable: Type iterable = get_proper_type(type) + if self.options.disallow_str_iteration and self.is_str_iteration_type(iterable): self.msg.str_iteration_disallowed(context) - item_type = self.named_type("builtins.str") - iterator_type = self.named_generic_type("typing.Iterator", [item_type]) - return iterator_type, item_type + iterator = echk.check_method_call_by_name("__iter__", iterable, [], [], context)[0] if ( diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 218b6e24001d..33982f70f474 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -484,7 +484,18 @@ def visit_instance(self, left: Instance) -> bool: and self.options.disallow_str_iteration and left.type.fullname == "builtins.str" and isinstance(right, Instance) - and right.type.fullname in ("typing.Sequence", "collections.abc.Sequence") + and right.type.fullname + in ( + "collections.abc.Collection", + "collections.abc.Iterable", + "collections.abc.Reversible", + "collections.abc.Sequence", + "typing.Collection", + "typing.Iterable", + "typing.Reversible", + "typing.Sequence", + "_typeshed.SupportsLenAndGetItem", + ) ): return False if isinstance(right, TupleType) and right.partial_fallback.type.is_enum: diff --git a/test-data/unit/check-flags.test b/test-data/unit/check-flags.test index 0d3e0054f39a..7b1266917b50 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2452,33 +2452,50 @@ f(memoryview(b"asdf")) # E: Argument 1 to "f" has incompatible type "memoryview [builtins fixtures/primitives.pyi] [case testDisallowStrIteration] -# flags: --disallow-str-iteration --python-version 3.12 -from typing import Iterable, Sequence +# flags: --disallow-str-iteration +from typing import Collection, Iterable, Reversible, Sequence, TypeVar + +def takes_str(x: str): + for ch in x: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled + reveal_type(ch) # N: Revealed type is "builtins.str" + [ch for ch in x] # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled s = "hello" -for ch in s: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled - reveal_type(ch) # N: Revealed type is "builtins.str" -[x for x in s] # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled -def takes_seq(x: Sequence[int]) -> None: ... def takes_seq_str(x: Sequence[str]) -> None: ... takes_seq_str(s) # E: Argument 1 to "takes_seq_str" has incompatible type "str"; expected "Sequence[str]" def takes_iter_str(x: Iterable[str]) -> None: ... takes_iter_str(s) # E: Argument 1 to "takes_iter_str" has incompatible type "str"; expected "Iterable[str]" +def takes_collection_str(x: Collection[str]) -> None: ... +takes_collection_str(s) # E: Argument 1 to "takes_collection_str" has incompatible type "str"; expected "Collection[str]" + +def takes_reversible_str(x: Reversible[str]) -> None: ... +takes_reversible_str(s) # E: Argument 1 to "takes_reversible_str" has incompatible type "str"; expected "Reversible[str]" + seq: Sequence[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Sequence[str]") iterable: Iterable[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Iterable[str]") +collection: Collection[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Collection[str]") +reversible: Reversible[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Reversible[str]") -def takes_maybe_seq(x: str | Sequence[str]) -> None: +def takes_maybe_seq(x: "str | Sequence[int]") -> None: for ch in x: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled - reveal_type(ch) # N: Revealed type is "builtins.str" + reveal_type(ch) # N: Revealed type is "builtins.str | builtins.int" + +T = TypeVar('T', bound=str) -def takes_bound[T: str](x: T) -> None: +def takes_str_upper_bound(x: T) -> None: for ch in x: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled reveal_type(ch) # N: Revealed type is "builtins.str" +reveal_type(reversed(s)) # N: Revealed type is "builtins.reversed[builtins.str]" # E: Argument 1 to "reversed" has incompatible type "str"; expected "Reversible[str]" + +[builtins fixtures/str-iter.pyi] +[typing fixtures/typing-str-iter.pyi] + [case testIterStrOverload] +# flags: --disallow-str-iteration reveal_type(iter("foo")) # N: Revealed type is "typing.Iterable[builtins.str]" [builtins fixtures/dict.pyi] diff --git a/test-data/unit/fixtures/str-iter.pyi b/test-data/unit/fixtures/str-iter.pyi new file mode 100644 index 000000000000..ac0822e708e1 --- /dev/null +++ b/test-data/unit/fixtures/str-iter.pyi @@ -0,0 +1,52 @@ +# Builtins stub used in disallow-str-iteration tests. + + +from _typeshed import SupportsLenAndGetItem +from typing import Generic, Iterator, Sequence, Reversible, TypeVar, overload + +_T = TypeVar("_T") + +class object: + def __init__(self) -> None: pass + +class type: pass +class int: pass +class bool(int): pass +class ellipsis: pass +class slice: pass + +class str: + def __iter__(self) -> Iterator[str]: pass + def __len__(self) -> int: pass + def __contains__(self, item: object) -> bool: pass + def __reversed__(self) -> Iterator[str]: pass + def __getitem__(self, i: int) -> str: pass + +class list(Sequence[_T], Generic[_T]): + def __iter__(self) -> Iterator[_T]: pass + def __len__(self) -> int: pass + def __contains__(self, item: object) -> bool: pass + def __reversed__(self) -> Iterator[_T]: pass + @overload + def __getitem__(self, i: int, /) -> _T: ... + @overload + def __getitem__(self, s: slice, /) -> list[_T]: ... + +class tuple(Sequence[_T], Generic[_T]): + def __iter__(self) -> Iterator[_T]: pass + def __len__(self) -> int: pass + def __contains__(self, item: object) -> bool: pass + def __reversed__(self) -> Iterator[_T]: pass + @overload + def __getitem__(self, i: int, /) -> _T: ... + @overload + def __getitem__(self, s: slice, /) -> list[_T]: ... + +class dict: pass + +class reversed(Iterator[_T]): + @overload + def __new__(cls, sequence: Reversible[_T], /) -> Iterator[_T]: ... # type: ignore[misc] + @overload + def __new__(cls, sequence: SupportsLenAndGetItem[_T], /) -> Iterator[_T]: ... # type: ignore[misc] + def __next__(self) -> _T: ... diff --git a/test-data/unit/fixtures/typing-str-iter.pyi b/test-data/unit/fixtures/typing-str-iter.pyi new file mode 100644 index 000000000000..c01b2df66d0c --- /dev/null +++ b/test-data/unit/fixtures/typing-str-iter.pyi @@ -0,0 +1,62 @@ +# Minimal typing fixture for disallow-str-iteration tests. + +from abc import ABCMeta, abstractmethod + +Any = object() +TypeVar = 0 +Generic = 0 +Protocol = 0 +overload = 0 + +_T = TypeVar("_T") +_KT = TypeVar("_KT") +_T_co = TypeVar("_T_co", covariant=True) +_VT_co = TypeVar("_VT_co", covariant=True) # Value type covariant containers. +_TC = TypeVar("_TC", bound=type[object]) + +@runtime_checkable +class Iterable(Protocol[_T_co]): + @abstractmethod + def __iter__(self) -> Iterator[_T_co]: ... + +@runtime_checkable +class Iterator(Iterable[_T_co], Protocol[_T_co]): + @abstractmethod + def __next__(self) -> _T_co: ... + def __iter__(self) -> Iterator[_T_co]: ... + +@runtime_checkable +class Reversible(Iterable[_T_co], Protocol[_T_co]): + @abstractmethod + def __reversed__(self) -> Iterator[_T_co]: ... + +@runtime_checkable +class Container(Protocol[_T_co]): + # This is generic more on vibes than anything else + @abstractmethod + def __contains__(self, x: object, /) -> bool: ... + +@runtime_checkable +class Collection(Iterable[_T_co], Container[_T_co], Protocol[_T_co]): + # Implement Sized (but don't have it as a base class). + @abstractmethod + def __len__(self) -> int: ... + +class Sequence(Reversible[_T_co], Collection[_T_co]): + @overload + @abstractmethod + def __getitem__(self, index: int) -> _T_co: ... + @overload + @abstractmethod + def __getitem__(self, index: slice) -> Sequence[_T_co]: ... + def __contains__(self, value: object) -> bool: ... + def __iter__(self) -> Iterator[_T_co]: ... + def __reversed__(self) -> Iterator[_T_co]: ... + +class Mapping(Collection[_KT], Generic[_KT, _VT_co]): + @abstractmethod + def __getitem__(self, key: _KT, /) -> _VT_co: ... + def __contains__(self, key: object, /) -> bool: ... + +def runtime_checkable(cls: _TC) -> _TC: + return cls diff --git a/test-data/unit/lib-stub/_typeshed.pyi b/test-data/unit/lib-stub/_typeshed.pyi index 054ad0ec0c46..47eae1aeaf5b 100644 --- a/test-data/unit/lib-stub/_typeshed.pyi +++ b/test-data/unit/lib-stub/_typeshed.pyi @@ -2,7 +2,12 @@ from typing import Protocol, TypeVar, Iterable _KT = TypeVar("_KT") _VT_co = TypeVar("_VT_co", covariant=True) +_T_co = TypeVar("_T_co", covariant=True) class SupportsKeysAndGetItem(Protocol[_KT, _VT_co]): def keys(self) -> Iterable[_KT]: pass def __getitem__(self, __key: _KT) -> _VT_co: pass + +class SupportsLenAndGetItem(Protocol[_T_co]): + def __len__(self) -> int: ... + def __getitem__(self, k: int, /) -> _T_co: ... From 168405902751bd5e71086ac655c2bab0e10d1375 Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Tue, 13 Jan 2026 15:01:32 -0500 Subject: [PATCH 04/12] actually commit the patch + more adjustments --- ...0001-Add-explicit-overload-for-iter-of-str.patch | 13 +++++++++++++ mypy/messages.py | 6 ++++++ mypy/typeshed/stdlib/builtins.pyi | 2 +- test-data/unit/lib-stub/_typeshed.pyi | 4 ++-- 4 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 misc/typeshed_patches/0001-Add-explicit-overload-for-iter-of-str.patch diff --git a/misc/typeshed_patches/0001-Add-explicit-overload-for-iter-of-str.patch b/misc/typeshed_patches/0001-Add-explicit-overload-for-iter-of-str.patch new file mode 100644 index 000000000000..f5683e4e62eb --- /dev/null +++ b/misc/typeshed_patches/0001-Add-explicit-overload-for-iter-of-str.patch @@ -0,0 +1,13 @@ +diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi +index bd425ff3c..61a28e3a9 100644 +--- a/mypy/typeshed/stdlib/builtins.pyi ++++ b/mypy/typeshed/stdlib/builtins.pyi +@@ -1455,6 +1455,8 @@ def input(prompt: object = "", /) -> str: ... + class _GetItemIterable(Protocol[_T_co]): + def __getitem__(self, i: int, /) -> _T_co: ... + ++@overload ++def iter(object: str, /) -> Iterator[str]: ... + @overload + def iter(object: SupportsIter[_SupportsNextT_co], /) -> _SupportsNextT_co: ... + @overload diff --git a/mypy/messages.py b/mypy/messages.py index cfd8a83772ee..05e490c042d2 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -3126,6 +3126,12 @@ def get_conflict_protocol_types( Return them as a list of ('member', 'got', 'expected', 'is_lvalue'). """ assert right.type.is_protocol + + if left.type.fullname == "builtins.str" and right.type.fullname in ("collections.abc.Collection", "typing.Collection"): + # `str` doesn't conform to the `Collection` protocol, but we don't want to show that as the reason for the error. + assert options.disallow_str_iteration + return [] + conflicts: list[tuple[str, Type, Type, bool]] = [] for member in right.type.protocol_members: if member in ("__init__", "__new__"): diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index 61a28e3a90e1..e8b8676627d1 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -1456,7 +1456,7 @@ class _GetItemIterable(Protocol[_T_co]): def __getitem__(self, i: int, /) -> _T_co: ... @overload -def iter(object: str, /) -> Iterable[str]: ... +def iter(object: str, /) -> Iterator[str]: ... @overload def iter(object: SupportsIter[_SupportsNextT_co], /) -> _SupportsNextT_co: ... @overload diff --git a/test-data/unit/lib-stub/_typeshed.pyi b/test-data/unit/lib-stub/_typeshed.pyi index 47eae1aeaf5b..87736007a36a 100644 --- a/test-data/unit/lib-stub/_typeshed.pyi +++ b/test-data/unit/lib-stub/_typeshed.pyi @@ -9,5 +9,5 @@ class SupportsKeysAndGetItem(Protocol[_KT, _VT_co]): def __getitem__(self, __key: _KT) -> _VT_co: pass class SupportsLenAndGetItem(Protocol[_T_co]): - def __len__(self) -> int: ... - def __getitem__(self, k: int, /) -> _T_co: ... + def __len__(self) -> int: pass + def __getitem__(self, k: int, /) -> _T_co: pass From eb08cfe6d25d412c2c1ce6b5b8bf32f200a6e997 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:33:16 +0000 Subject: [PATCH 05/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/messages.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mypy/messages.py b/mypy/messages.py index 05e490c042d2..8ffe16c7e4ab 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -3127,7 +3127,10 @@ def get_conflict_protocol_types( """ assert right.type.is_protocol - if left.type.fullname == "builtins.str" and right.type.fullname in ("collections.abc.Collection", "typing.Collection"): + if left.type.fullname == "builtins.str" and right.type.fullname in ( + "collections.abc.Collection", + "typing.Collection", + ): # `str` doesn't conform to the `Collection` protocol, but we don't want to show that as the reason for the error. assert options.disallow_str_iteration return [] From 4b0149aad1bc64e1f670dd38a1f9a3b447c26d03 Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Tue, 13 Jan 2026 16:11:19 -0500 Subject: [PATCH 06/12] address more feedback + undo reversible --- docs/source/command_line.rst | 2 ++ mypy/checker.py | 4 +-- mypy/messages.py | 23 +++++++------- mypy/subtypes.py | 12 ++++---- test-data/unit/check-flags.test | 33 ++++++++++++++++----- test-data/unit/fixtures/str-iter.pyi | 13 +------- test-data/unit/fixtures/typing-str-iter.pyi | 8 +---- test-data/unit/lib-stub/_typeshed.pyi | 5 ---- 8 files changed, 49 insertions(+), 51 deletions(-) diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index 00f9b6589f65..996ee8c42aeb 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -786,6 +786,8 @@ of the above sections. for ch in s: # error: Iterating over "str" is disallowed print(ch) + for ch in iter(s): # OK + print(ch) .. option:: --extra-checks diff --git a/mypy/checker.py b/mypy/checker.py index 796110f7f8ec..483cd2ddcbe8 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -5380,7 +5380,7 @@ def analyze_iterable_item_type_without_expression( iterable = get_proper_type(type) if self.options.disallow_str_iteration and self.is_str_iteration_type(iterable): - self.msg.str_iteration_disallowed(context) + self.msg.str_iteration_disallowed(context, iterable) iterator = echk.check_method_call_by_name("__iter__", iterable, [], [], context)[0] @@ -5399,7 +5399,7 @@ def is_str_iteration_type(self, typ: Type) -> bool: if isinstance(typ, LiteralType): return isinstance(typ.value, str) if isinstance(typ, Instance): - return typ.type.fullname == "builtins.str" + return is_proper_subtype(typ, self.named_type("builtins.str")) if isinstance(typ, UnionType): return any(self.is_str_iteration_type(item) for item in typ.relevant_items()) if isinstance(typ, TypeVarType): diff --git a/mypy/messages.py b/mypy/messages.py index 8ffe16c7e4ab..eb114678c7a1 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1136,8 +1136,8 @@ def wrong_number_values_to_unpack( def unpacking_strings_disallowed(self, context: Context) -> None: self.fail("Unpacking a string is disallowed", context, code=codes.STR_UNPACK) - def str_iteration_disallowed(self, context: Context) -> None: - self.fail('Iterating over "str" is disallowed', context) + def str_iteration_disallowed(self, context: Context, str_type: Type) -> None: + self.fail(f'Iterating over {format_type(str_type, self.options)} is disallowed', context) self.note("This is because --disallow-str-iteration is enabled", context) def type_not_iterable(self, type: Type, context: Context) -> None: @@ -2214,6 +2214,17 @@ def report_protocol_problems( conflict_types = get_conflict_protocol_types( subtype, supertype, class_obj=class_obj, options=self.options ) + + if ( + subtype.type.has_base("builtins.str") and + supertype.type.has_base("typing.Container") + ): + # `str` doesn't properly conform to the `Container` protocol, but we don't want to show that as the reason for the error. + conflict_types = [ + conflict_type for conflict_type in conflict_types + if conflict_type[0] != "__contains__" + ] + if conflict_types and ( not is_subtype(subtype, erase_type(supertype), options=self.options) or not subtype.type.defn.type_vars @@ -3127,14 +3138,6 @@ def get_conflict_protocol_types( """ assert right.type.is_protocol - if left.type.fullname == "builtins.str" and right.type.fullname in ( - "collections.abc.Collection", - "typing.Collection", - ): - # `str` doesn't conform to the `Collection` protocol, but we don't want to show that as the reason for the error. - assert options.disallow_str_iteration - return [] - conflicts: list[tuple[str, Type, Type, bool]] = [] for member in right.type.protocol_members: if member in ("__init__", "__new__"): diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 33982f70f474..78cdc2654d0b 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -479,23 +479,21 @@ def visit_instance(self, left: Instance) -> bool: # dynamic base classes correctly, see #5456. return not isinstance(self.right, NoneType) right = self.right + if ( self.options and self.options.disallow_str_iteration - and left.type.fullname == "builtins.str" + and left.type.has_base("builtins.str") and isinstance(right, Instance) - and right.type.fullname - in ( + and not right.type.has_base("builtins.str") + and any(right.type.has_base(base) for base in ( "collections.abc.Collection", "collections.abc.Iterable", - "collections.abc.Reversible", "collections.abc.Sequence", "typing.Collection", "typing.Iterable", - "typing.Reversible", "typing.Sequence", - "_typeshed.SupportsLenAndGetItem", - ) + )) ): return False if isinstance(right, TupleType) and right.partial_fallback.type.is_enum: diff --git a/test-data/unit/check-flags.test b/test-data/unit/check-flags.test index 7b1266917b50..b7e0ae8d1a66 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2453,7 +2453,8 @@ f(memoryview(b"asdf")) # E: Argument 1 to "f" has incompatible type "memoryview [case testDisallowStrIteration] # flags: --disallow-str-iteration -from typing import Collection, Iterable, Reversible, Sequence, TypeVar +from abc import abstractmethod +from typing import Collection, Container, Iterable, Protocol, Sequence, TypeVar def takes_str(x: str): for ch in x: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled @@ -2471,25 +2472,41 @@ takes_iter_str(s) # E: Argument 1 to "takes_iter_str" has incompatible type "st def takes_collection_str(x: Collection[str]) -> None: ... takes_collection_str(s) # E: Argument 1 to "takes_collection_str" has incompatible type "str"; expected "Collection[str]" -def takes_reversible_str(x: Reversible[str]) -> None: ... -takes_reversible_str(s) # E: Argument 1 to "takes_reversible_str" has incompatible type "str"; expected "Reversible[str]" - seq: Sequence[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Sequence[str]") iterable: Iterable[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Iterable[str]") collection: Collection[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Collection[str]") -reversible: Reversible[str] = s # E: Incompatible types in assignment (expression has type "str", variable has type "Reversible[str]") def takes_maybe_seq(x: "str | Sequence[int]") -> None: - for ch in x: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled + for ch in x: # E: Iterating over "str | Sequence[int]" is disallowed # N: This is because --disallow-str-iteration is enabled reveal_type(ch) # N: Revealed type is "builtins.str | builtins.int" T = TypeVar('T', bound=str) +_T_co = TypeVar('_T_co', covariant=True) def takes_str_upper_bound(x: T) -> None: - for ch in x: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled + for ch in x: # E: Iterating over "T" is disallowed # N: This is because --disallow-str-iteration is enabled reveal_type(ch) # N: Revealed type is "builtins.str" -reveal_type(reversed(s)) # N: Revealed type is "builtins.reversed[builtins.str]" # E: Argument 1 to "reversed" has incompatible type "str"; expected "Reversible[str]" +class StrSubclass(str): + def __contains__(self, x: object) -> bool: ... + +def takes_str_subclass(x: StrSubclass): + for ch in x: # E: Iterating over "StrSubclass" is disallowed # N: This is because --disallow-str-iteration is enabled + reveal_type(ch) # N: Revealed type is "builtins.str" + +class CollectionSubclass(Collection[_T_co], Protocol[_T_co]): + @abstractmethod + def __missing_impl__(self): ... + +def takes_collection_subclass(x: CollectionSubclass[str]) -> None: ... + +takes_collection_subclass(s) # E: Argument 1 to "takes_collection_subclass" has incompatible type "str"; expected "CollectionSubclass[str]" \ + # N: "str" is missing following "CollectionSubclass" protocol member: \ + # N: __missing_impl__ + +takes_collection_subclass(StrSubclass()) # E: Argument 1 to "takes_collection_subclass" has incompatible type "StrSubclass"; expected "CollectionSubclass[str]" \ + # N: "StrSubclass" is missing following "CollectionSubclass" protocol member: \ + # N: __missing_impl__ [builtins fixtures/str-iter.pyi] [typing fixtures/typing-str-iter.pyi] diff --git a/test-data/unit/fixtures/str-iter.pyi b/test-data/unit/fixtures/str-iter.pyi index ac0822e708e1..8617b4c45624 100644 --- a/test-data/unit/fixtures/str-iter.pyi +++ b/test-data/unit/fixtures/str-iter.pyi @@ -1,8 +1,7 @@ # Builtins stub used in disallow-str-iteration tests. -from _typeshed import SupportsLenAndGetItem -from typing import Generic, Iterator, Sequence, Reversible, TypeVar, overload +from typing import Generic, Iterator, Sequence, TypeVar, overload _T = TypeVar("_T") @@ -19,14 +18,12 @@ class str: def __iter__(self) -> Iterator[str]: pass def __len__(self) -> int: pass def __contains__(self, item: object) -> bool: pass - def __reversed__(self) -> Iterator[str]: pass def __getitem__(self, i: int) -> str: pass class list(Sequence[_T], Generic[_T]): def __iter__(self) -> Iterator[_T]: pass def __len__(self) -> int: pass def __contains__(self, item: object) -> bool: pass - def __reversed__(self) -> Iterator[_T]: pass @overload def __getitem__(self, i: int, /) -> _T: ... @overload @@ -36,17 +33,9 @@ class tuple(Sequence[_T], Generic[_T]): def __iter__(self) -> Iterator[_T]: pass def __len__(self) -> int: pass def __contains__(self, item: object) -> bool: pass - def __reversed__(self) -> Iterator[_T]: pass @overload def __getitem__(self, i: int, /) -> _T: ... @overload def __getitem__(self, s: slice, /) -> list[_T]: ... class dict: pass - -class reversed(Iterator[_T]): - @overload - def __new__(cls, sequence: Reversible[_T], /) -> Iterator[_T]: ... # type: ignore[misc] - @overload - def __new__(cls, sequence: SupportsLenAndGetItem[_T], /) -> Iterator[_T]: ... # type: ignore[misc] - def __next__(self) -> _T: ... diff --git a/test-data/unit/fixtures/typing-str-iter.pyi b/test-data/unit/fixtures/typing-str-iter.pyi index c01b2df66d0c..af758e48961d 100644 --- a/test-data/unit/fixtures/typing-str-iter.pyi +++ b/test-data/unit/fixtures/typing-str-iter.pyi @@ -25,11 +25,6 @@ class Iterator(Iterable[_T_co], Protocol[_T_co]): def __next__(self) -> _T_co: ... def __iter__(self) -> Iterator[_T_co]: ... -@runtime_checkable -class Reversible(Iterable[_T_co], Protocol[_T_co]): - @abstractmethod - def __reversed__(self) -> Iterator[_T_co]: ... - @runtime_checkable class Container(Protocol[_T_co]): # This is generic more on vibes than anything else @@ -42,7 +37,7 @@ class Collection(Iterable[_T_co], Container[_T_co], Protocol[_T_co]): @abstractmethod def __len__(self) -> int: ... -class Sequence(Reversible[_T_co], Collection[_T_co]): +class Sequence(Collection[_T_co]): @overload @abstractmethod def __getitem__(self, index: int) -> _T_co: ... @@ -51,7 +46,6 @@ class Sequence(Reversible[_T_co], Collection[_T_co]): def __getitem__(self, index: slice) -> Sequence[_T_co]: ... def __contains__(self, value: object) -> bool: ... def __iter__(self) -> Iterator[_T_co]: ... - def __reversed__(self) -> Iterator[_T_co]: ... class Mapping(Collection[_KT], Generic[_KT, _VT_co]): @abstractmethod diff --git a/test-data/unit/lib-stub/_typeshed.pyi b/test-data/unit/lib-stub/_typeshed.pyi index 87736007a36a..054ad0ec0c46 100644 --- a/test-data/unit/lib-stub/_typeshed.pyi +++ b/test-data/unit/lib-stub/_typeshed.pyi @@ -2,12 +2,7 @@ from typing import Protocol, TypeVar, Iterable _KT = TypeVar("_KT") _VT_co = TypeVar("_VT_co", covariant=True) -_T_co = TypeVar("_T_co", covariant=True) class SupportsKeysAndGetItem(Protocol[_KT, _VT_co]): def keys(self) -> Iterable[_KT]: pass def __getitem__(self, __key: _KT) -> _VT_co: pass - -class SupportsLenAndGetItem(Protocol[_T_co]): - def __len__(self) -> int: pass - def __getitem__(self, k: int, /) -> _T_co: pass From b61f3400664b666c3164837eea1cc5758f53c60b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:13:54 +0000 Subject: [PATCH 07/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/messages.py | 10 ++++------ mypy/subtypes.py | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index eb114678c7a1..665ea3259841 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1137,7 +1137,7 @@ def unpacking_strings_disallowed(self, context: Context) -> None: self.fail("Unpacking a string is disallowed", context, code=codes.STR_UNPACK) def str_iteration_disallowed(self, context: Context, str_type: Type) -> None: - self.fail(f'Iterating over {format_type(str_type, self.options)} is disallowed', context) + self.fail(f"Iterating over {format_type(str_type, self.options)} is disallowed", context) self.note("This is because --disallow-str-iteration is enabled", context) def type_not_iterable(self, type: Type, context: Context) -> None: @@ -2215,13 +2215,11 @@ def report_protocol_problems( subtype, supertype, class_obj=class_obj, options=self.options ) - if ( - subtype.type.has_base("builtins.str") and - supertype.type.has_base("typing.Container") - ): + if subtype.type.has_base("builtins.str") and supertype.type.has_base("typing.Container"): # `str` doesn't properly conform to the `Container` protocol, but we don't want to show that as the reason for the error. conflict_types = [ - conflict_type for conflict_type in conflict_types + conflict_type + for conflict_type in conflict_types if conflict_type[0] != "__contains__" ] diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 78cdc2654d0b..44e5b1013532 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -486,14 +486,17 @@ def visit_instance(self, left: Instance) -> bool: and left.type.has_base("builtins.str") and isinstance(right, Instance) and not right.type.has_base("builtins.str") - and any(right.type.has_base(base) for base in ( - "collections.abc.Collection", - "collections.abc.Iterable", - "collections.abc.Sequence", - "typing.Collection", - "typing.Iterable", - "typing.Sequence", - )) + and any( + right.type.has_base(base) + for base in ( + "collections.abc.Collection", + "collections.abc.Iterable", + "collections.abc.Sequence", + "typing.Collection", + "typing.Iterable", + "typing.Sequence", + ) + ) ): return False if isinstance(right, TupleType) and right.partial_fallback.type.is_enum: From 446d5da4266b55efe357c2f576198055753c8eb8 Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Wed, 14 Jan 2026 02:16:36 -0500 Subject: [PATCH 08/12] Swap iter overloads to preserve previous error behavior --- ...001-Add-explicit-overload-for-iter-of-str.patch | 14 +++++++------- mypy/typeshed/stdlib/builtins.pyi | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/misc/typeshed_patches/0001-Add-explicit-overload-for-iter-of-str.patch b/misc/typeshed_patches/0001-Add-explicit-overload-for-iter-of-str.patch index f5683e4e62eb..d5a9a7150291 100644 --- a/misc/typeshed_patches/0001-Add-explicit-overload-for-iter-of-str.patch +++ b/misc/typeshed_patches/0001-Add-explicit-overload-for-iter-of-str.patch @@ -1,13 +1,13 @@ diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi -index bd425ff3c..61a28e3a9 100644 +index bd425ff3c..5dae75dd9 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi -@@ -1455,6 +1455,8 @@ def input(prompt: object = "", /) -> str: ... - class _GetItemIterable(Protocol[_T_co]): - def __getitem__(self, i: int, /) -> _T_co: ... - -+@overload -+def iter(object: str, /) -> Iterator[str]: ... +@@ -1458,6 +1458,8 @@ class _GetItemIterable(Protocol[_T_co]): @overload def iter(object: SupportsIter[_SupportsNextT_co], /) -> _SupportsNextT_co: ... @overload ++def iter(object: str, /) -> Iterator[str]: ... ++@overload + def iter(object: _GetItemIterable[_T], /) -> Iterator[_T]: ... + @overload + def iter(object: Callable[[], _T | None], sentinel: None, /) -> Iterator[_T]: ... diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index e8b8676627d1..5dae75dd9815 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -1455,11 +1455,11 @@ def input(prompt: object = "", /) -> str: ... class _GetItemIterable(Protocol[_T_co]): def __getitem__(self, i: int, /) -> _T_co: ... -@overload -def iter(object: str, /) -> Iterator[str]: ... @overload def iter(object: SupportsIter[_SupportsNextT_co], /) -> _SupportsNextT_co: ... @overload +def iter(object: str, /) -> Iterator[str]: ... +@overload def iter(object: _GetItemIterable[_T], /) -> Iterator[_T]: ... @overload def iter(object: Callable[[], _T | None], sentinel: None, /) -> Iterator[_T]: ... From a6c96a114cf7f489bc1ed572c3b4daa047aaa8da Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Wed, 14 Jan 2026 22:33:58 -0500 Subject: [PATCH 09/12] fix union simplification --- mypy/typeops.py | 14 ++++++++++---- test-data/unit/check-flags.test | 5 ++++- test-data/unit/fixtures/str-iter.pyi | 17 +++++++++++++---- test-data/unit/fixtures/typing-str-iter.pyi | 7 +++++++ 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/mypy/typeops.py b/mypy/typeops.py index 02e1dbda514a..f914a86a70df 100644 --- a/mypy/typeops.py +++ b/mypy/typeops.py @@ -636,7 +636,15 @@ def make_simplified_union( def _remove_redundant_union_items(items: list[Type], keep_erased: bool) -> list[Type]: - from mypy.subtypes import is_proper_subtype + from mypy.subtypes import SubtypeContext, is_proper_subtype + + subtype_context = SubtypeContext( + ignore_promotions=True, + keep_erased_types=keep_erased, + options=( + checker_state.type_checker.options if checker_state.type_checker is not None else None + ), + ) # The first pass through this loop, we check if later items are subtypes of earlier items. # The second pass through this loop, we check if earlier items are subtypes of later items @@ -685,9 +693,7 @@ def _remove_redundant_union_items(items: list[Type], keep_erased: bool) -> list[ ): continue - if is_proper_subtype( - ti, tj, keep_erased_types=keep_erased, ignore_promotions=True - ): + if is_proper_subtype(ti, tj, subtype_context=subtype_context): duplicate_index = j break if duplicate_index != -1: diff --git a/test-data/unit/check-flags.test b/test-data/unit/check-flags.test index b7e0ae8d1a66..466575497592 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2454,7 +2454,7 @@ f(memoryview(b"asdf")) # E: Argument 1 to "f" has incompatible type "memoryview [case testDisallowStrIteration] # flags: --disallow-str-iteration from abc import abstractmethod -from typing import Collection, Container, Iterable, Protocol, Sequence, TypeVar +from typing import Collection, Container, Iterable, Mapping, Protocol, Sequence, TypeVar, Union def takes_str(x: str): for ch in x: # E: Iterating over "str" is disallowed # N: This is because --disallow-str-iteration is enabled @@ -2508,6 +2508,9 @@ takes_collection_subclass(StrSubclass()) # E: Argument 1 to "takes_collection_s # N: "StrSubclass" is missing following "CollectionSubclass" protocol member: \ # N: __missing_impl__ +def repro(x: Mapping[str, Union[str, Sequence[str]]]) -> None: + x = {**x} + [builtins fixtures/str-iter.pyi] [typing fixtures/typing-str-iter.pyi] diff --git a/test-data/unit/fixtures/str-iter.pyi b/test-data/unit/fixtures/str-iter.pyi index 8617b4c45624..04059a766996 100644 --- a/test-data/unit/fixtures/str-iter.pyi +++ b/test-data/unit/fixtures/str-iter.pyi @@ -1,9 +1,11 @@ # Builtins stub used in disallow-str-iteration tests. -from typing import Generic, Iterator, Sequence, TypeVar, overload +from typing import Generic, Iterator, Mapping, Sequence, TypeVar, overload _T = TypeVar("_T") +_KT = TypeVar("_KT") +_VT = TypeVar("_VT") class object: def __init__(self) -> None: pass @@ -14,11 +16,14 @@ class bool(int): pass class ellipsis: pass class slice: pass -class str: +class str(Sequence[str]): def __iter__(self) -> Iterator[str]: pass def __len__(self) -> int: pass def __contains__(self, item: object) -> bool: pass - def __getitem__(self, i: int) -> str: pass + @overload + def __getitem__(self, i: int, /) -> str: ... + @overload + def __getitem__(self, s: slice, /) -> Sequence[str]: ... class list(Sequence[_T], Generic[_T]): def __iter__(self) -> Iterator[_T]: pass @@ -38,4 +43,8 @@ class tuple(Sequence[_T], Generic[_T]): @overload def __getitem__(self, s: slice, /) -> list[_T]: ... -class dict: pass +class dict(Mapping[_KT, _VT], Generic[_KT, _VT]): + def __iter__(self) -> Iterator[_KT]: pass + def __len__(self) -> int: pass + def __contains__(self, item: object) -> bool: pass + def __getitem__(self, key: _KT) -> _VT: pass diff --git a/test-data/unit/fixtures/typing-str-iter.pyi b/test-data/unit/fixtures/typing-str-iter.pyi index af758e48961d..f0fb8aa57cfb 100644 --- a/test-data/unit/fixtures/typing-str-iter.pyi +++ b/test-data/unit/fixtures/typing-str-iter.pyi @@ -1,16 +1,19 @@ # Minimal typing fixture for disallow-str-iteration tests. +import _typeshed from abc import ABCMeta, abstractmethod Any = object() TypeVar = 0 Generic = 0 Protocol = 0 +Union = 0 overload = 0 _T = TypeVar("_T") _KT = TypeVar("_KT") _T_co = TypeVar("_T_co", covariant=True) +_KT_co = TypeVar("_KT_co", covariant=True) # Key type covariant containers. _VT_co = TypeVar("_VT_co", covariant=True) # Value type covariant containers. _TC = TypeVar("_TC", bound=type[object]) @@ -47,10 +50,14 @@ class Sequence(Collection[_T_co]): def __contains__(self, value: object) -> bool: ... def __iter__(self) -> Iterator[_T_co]: ... +class KeysView(Protocol[_KT_co]): + def __iter__(self) -> Iterator[_KT_co]: ... + class Mapping(Collection[_KT], Generic[_KT, _VT_co]): @abstractmethod def __getitem__(self, key: _KT, /) -> _VT_co: ... def __contains__(self, key: object, /) -> bool: ... + def keys(self) -> KeysView[_KT]: ... def runtime_checkable(cls: _TC) -> _TC: return cls From d4d5ce718e40a470e251e2a4ca86a36d585eb9f2 Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Thu, 15 Jan 2026 03:09:32 -0500 Subject: [PATCH 10/12] refactor disallow_str_iteration checks and add check to is_overlapping_types --- mypy/checker.py | 17 ++++++++++--- mypy/disallow_str_iteration_state.py | 25 +++++++++++++++++++ mypy/meet.py | 8 +++++++ mypy/subtypes.py | 36 ++++++++++++++++------------ mypy/typeops.py | 14 ++++------- test-data/unit/check-flags.test | 8 ++++++- test-data/unit/fixtures/str-iter.pyi | 2 ++ 7 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 mypy/disallow_str_iteration_state.py diff --git a/mypy/checker.py b/mypy/checker.py index 483cd2ddcbe8..6b0ff826fabf 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -155,6 +155,7 @@ from mypy.semanal_enum import ENUM_BASES, ENUM_SPECIAL_PROPS from mypy.semanal_shared import SemanticAnalyzerCoreInterface from mypy.sharedparse import BINARY_MAGIC_METHODS +from mypy.disallow_str_iteration_state import disallow_str_iteration_state from mypy.state import state from mypy.subtypes import ( find_member, @@ -513,7 +514,11 @@ def check_first_pass(self) -> None: Deferred functions will be processed by check_second_pass(). """ self.recurse_into_functions = True - with state.strict_optional_set(self.options.strict_optional), checker_state.set(self): + with ( + state.strict_optional_set(self.options.strict_optional), + disallow_str_iteration_state.set(self.options.disallow_str_iteration), + checker_state.set(self), + ): self.errors.set_file( self.path, self.tree.fullname, scope=self.tscope, options=self.options ) @@ -558,7 +563,11 @@ def check_second_pass( """ self.allow_constructor_cache = allow_constructor_cache self.recurse_into_functions = True - with state.strict_optional_set(self.options.strict_optional), checker_state.set(self): + with ( + state.strict_optional_set(self.options.strict_optional), + disallow_str_iteration_state.set(self.options.disallow_str_iteration), + checker_state.set(self), + ): if not todo and not self.deferred_nodes: return False self.errors.set_file( @@ -5379,7 +5388,9 @@ def analyze_iterable_item_type_without_expression( iterable: Type iterable = get_proper_type(type) - if self.options.disallow_str_iteration and self.is_str_iteration_type(iterable): + if disallow_str_iteration_state.disallow_str_iteration and self.is_str_iteration_type( + iterable + ): self.msg.str_iteration_disallowed(context, iterable) iterator = echk.check_method_call_by_name("__iter__", iterable, [], [], context)[0] diff --git a/mypy/disallow_str_iteration_state.py b/mypy/disallow_str_iteration_state.py new file mode 100644 index 000000000000..930243b00af1 --- /dev/null +++ b/mypy/disallow_str_iteration_state.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Final + + +class DisallowStrIterationState: + # Wrap this in a class since it's faster that using a module-level attribute. + + def __init__(self, disallow_str_iteration: bool) -> None: + # Value varies by file being processed + self.disallow_str_iteration = disallow_str_iteration + + @contextmanager + def set(self, value: bool) -> Iterator[None]: + saved = self.disallow_str_iteration + self.disallow_str_iteration = value + try: + yield + finally: + self.disallow_str_iteration = saved + + +disallow_str_iteration_state: Final = DisallowStrIterationState(disallow_str_iteration=False) diff --git a/mypy/meet.py b/mypy/meet.py index 365544d4584f..eca9864b5c5f 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -5,6 +5,7 @@ from mypy import join from mypy.erasetype import erase_type from mypy.maptype import map_instance_to_supertype +from mypy.disallow_str_iteration_state import disallow_str_iteration_state from mypy.state import state from mypy.subtypes import ( are_parameters_compatible, @@ -14,6 +15,7 @@ is_proper_subtype, is_same_type, is_subtype, + is_subtype_relation_ignored_to_disallow_str_iteration, ) from mypy.typeops import is_recursive_pair, make_simplified_union, tuple_fallback from mypy.types import ( @@ -596,6 +598,12 @@ def _type_object_overlap(left: Type, right: Type) -> bool: if right.type.fullname == "builtins.int" and left.type.fullname in MYPYC_NATIVE_INT_NAMES: return True + if disallow_str_iteration_state.disallow_str_iteration: + if is_subtype_relation_ignored_to_disallow_str_iteration(left, right): + return False + elif is_subtype_relation_ignored_to_disallow_str_iteration(right, left): + return False + # Two unrelated types cannot be partially overlapping: they're disjoint. if left.type.has_base(right.type.fullname): left = map_instance_to_supertype(left, right.type) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 44e5b1013532..ed5147c14a50 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -34,6 +34,7 @@ Var, ) from mypy.options import Options +from mypy.disallow_str_iteration_state import disallow_str_iteration_state from mypy.state import state from mypy.types import ( MYPYC_NATIVE_INT_NAMES, @@ -481,22 +482,9 @@ def visit_instance(self, left: Instance) -> bool: right = self.right if ( - self.options - and self.options.disallow_str_iteration - and left.type.has_base("builtins.str") + disallow_str_iteration_state.disallow_str_iteration and isinstance(right, Instance) - and not right.type.has_base("builtins.str") - and any( - right.type.has_base(base) - for base in ( - "collections.abc.Collection", - "collections.abc.Iterable", - "collections.abc.Sequence", - "typing.Collection", - "typing.Iterable", - "typing.Sequence", - ) - ) + and is_subtype_relation_ignored_to_disallow_str_iteration(left, right) ): return False if isinstance(right, TupleType) and right.partial_fallback.type.is_enum: @@ -2331,3 +2319,21 @@ def is_erased_instance(t: Instance) -> bool: elif not isinstance(get_proper_type(arg), AnyType): return False return True + + +def is_subtype_relation_ignored_to_disallow_str_iteration(left: Instance, right: Instance) -> bool: + return ( + left.type.has_base("builtins.str") + and not right.type.has_base("builtins.str") + and any( + right.type.has_base(base) + for base in ( + "collections.abc.Collection", + "collections.abc.Iterable", + "collections.abc.Sequence", + "typing.Collection", + "typing.Iterable", + "typing.Sequence", + ) + ) + ) diff --git a/mypy/typeops.py b/mypy/typeops.py index f914a86a70df..02e1dbda514a 100644 --- a/mypy/typeops.py +++ b/mypy/typeops.py @@ -636,15 +636,7 @@ def make_simplified_union( def _remove_redundant_union_items(items: list[Type], keep_erased: bool) -> list[Type]: - from mypy.subtypes import SubtypeContext, is_proper_subtype - - subtype_context = SubtypeContext( - ignore_promotions=True, - keep_erased_types=keep_erased, - options=( - checker_state.type_checker.options if checker_state.type_checker is not None else None - ), - ) + from mypy.subtypes import is_proper_subtype # The first pass through this loop, we check if later items are subtypes of earlier items. # The second pass through this loop, we check if earlier items are subtypes of later items @@ -693,7 +685,9 @@ def _remove_redundant_union_items(items: list[Type], keep_erased: bool) -> list[ ): continue - if is_proper_subtype(ti, tj, subtype_context=subtype_context): + if is_proper_subtype( + ti, tj, keep_erased_types=keep_erased, ignore_promotions=True + ): duplicate_index = j break if duplicate_index != -1: diff --git a/test-data/unit/check-flags.test b/test-data/unit/check-flags.test index 466575497592..72999c88a69e 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2508,9 +2508,15 @@ takes_collection_subclass(StrSubclass()) # E: Argument 1 to "takes_collection_s # N: "StrSubclass" is missing following "CollectionSubclass" protocol member: \ # N: __missing_impl__ -def repro(x: Mapping[str, Union[str, Sequence[str]]]) -> None: +def dict_unpacking_unaffected_by_union_simplification(x: Mapping[str, Union[str, Sequence[str]]]) -> None: x = {**x} +def narrowing(x: "str | Sequence[str]"): + if isinstance(x, str): + reveal_type(x) # N: Revealed type is "builtins.str" + else: + reveal_type(x) # N: Revealed type is "typing.Sequence[builtins.str]" + [builtins fixtures/str-iter.pyi] [typing fixtures/typing-str-iter.pyi] diff --git a/test-data/unit/fixtures/str-iter.pyi b/test-data/unit/fixtures/str-iter.pyi index 04059a766996..49c61bad65d2 100644 --- a/test-data/unit/fixtures/str-iter.pyi +++ b/test-data/unit/fixtures/str-iter.pyi @@ -48,3 +48,5 @@ class dict(Mapping[_KT, _VT], Generic[_KT, _VT]): def __len__(self) -> int: pass def __contains__(self, item: object) -> bool: pass def __getitem__(self, key: _KT) -> _VT: pass + +def isinstance(x: object, t: type) -> bool: pass From 41bb4d31ad94face4527aa19370fca3d942d9033 Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Wed, 14 Jan 2026 16:37:01 -0500 Subject: [PATCH 11/12] experiment enabling by default --- mypy/main.py | 2 +- mypy/options.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mypy/main.py b/mypy/main.py index c94a23dc79a2..c46ae3d07edb 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -939,7 +939,7 @@ def add_invertible_flag( add_invertible_flag( "--disallow-str-iteration", - default=False, + default=True, strict_flag=False, help="Disallow iterating over str instances", group=strictness_group, diff --git a/mypy/options.py b/mypy/options.py index 641a06ff74b6..044bacea13f3 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -240,7 +240,7 @@ def __init__(self) -> None: self.strict_bytes = False # Disallow iterating over str instances or using them as Sequence[T] - self.disallow_str_iteration = False + self.disallow_str_iteration = True # Deprecated, use extra_checks instead. self.strict_concatenate = False From 8edc2d83c0938479e33a3a8c0b4c8b3f641eef11 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:03:03 +0000 Subject: [PATCH 12/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/checker.py | 2 +- mypy/meet.py | 2 +- mypy/subtypes.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 6b0ff826fabf..17b39b22563e 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -32,6 +32,7 @@ ) from mypy.checkpattern import PatternChecker from mypy.constraints import SUPERTYPE_OF +from mypy.disallow_str_iteration_state import disallow_str_iteration_state from mypy.erasetype import erase_type, erase_typevars, remove_instance_last_known_values from mypy.errorcodes import TYPE_VAR, UNUSED_AWAITABLE, UNUSED_COROUTINE, ErrorCode from mypy.errors import ( @@ -155,7 +156,6 @@ from mypy.semanal_enum import ENUM_BASES, ENUM_SPECIAL_PROPS from mypy.semanal_shared import SemanticAnalyzerCoreInterface from mypy.sharedparse import BINARY_MAGIC_METHODS -from mypy.disallow_str_iteration_state import disallow_str_iteration_state from mypy.state import state from mypy.subtypes import ( find_member, diff --git a/mypy/meet.py b/mypy/meet.py index eca9864b5c5f..92f549915678 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -3,9 +3,9 @@ from collections.abc import Callable from mypy import join +from mypy.disallow_str_iteration_state import disallow_str_iteration_state from mypy.erasetype import erase_type from mypy.maptype import map_instance_to_supertype -from mypy.disallow_str_iteration_state import disallow_str_iteration_state from mypy.state import state from mypy.subtypes import ( are_parameters_compatible, diff --git a/mypy/subtypes.py b/mypy/subtypes.py index ed5147c14a50..8b7c357edfbd 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -8,6 +8,7 @@ import mypy.constraints import mypy.typeops from mypy.checker_state import checker_state +from mypy.disallow_str_iteration_state import disallow_str_iteration_state from mypy.erasetype import erase_type from mypy.expandtype import ( expand_self_type, @@ -34,7 +35,6 @@ Var, ) from mypy.options import Options -from mypy.disallow_str_iteration_state import disallow_str_iteration_state from mypy.state import state from mypy.types import ( MYPYC_NATIVE_INT_NAMES,